diff --git a/build/.moduleignore b/build/.moduleignore index d1a8a227d958c8..409c588290d67a 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -219,6 +219,10 @@ zone.js/dist/** @github/copilot/prebuilds/** @github/copilot/clipboard/** @github/copilot/ripgrep/** +@github/copilot/pvrecorder/** +@github/copilot/foundry-local-sdk/** +@github/copilot/mxc-bin/** +@github/copilot/sharp/** @github/copilot/**/keytar.node # @github/copilot platform binaries - not needed diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 1e96fbe898dc61..758f487d6f5966 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -100,6 +100,7 @@ steps: - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - script: | set -e + export COPILOT_CLI_UI_SMOKE=1 APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) APP_NAME="`ls $APP_ROOT | head -n 1`" npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" diff --git a/build/azure-pipelines/linux/steps/product-build-linux-test.yml b/build/azure-pipelines/linux/steps/product-build-linux-test.yml index 03ee0fe4e7abcf..d4156148baeadf 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-test.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-test.yml @@ -112,7 +112,10 @@ steps: condition: succeededOrFailed() - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - - script: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" + - script: | + set -e + export COPILOT_CLI_UI_SMOKE=1 + npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" timeoutInMinutes: 20 displayName: πŸ§ͺ Run smoke tests (Electron) diff --git a/build/azure-pipelines/win32/steps/product-build-win32-test.yml b/build/azure-pipelines/win32/steps/product-build-win32-test.yml index 0d9dcb8c7f9689..744e1b3345f762 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-test.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-test.yml @@ -125,7 +125,9 @@ steps: condition: succeededOrFailed() - ${{ if eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true) }}: - - powershell: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\test\VSCode-win32-$(VSCODE_ARCH)" + - powershell: | + $env:COPILOT_CLI_UI_SMOKE = '1' + npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\test\VSCode-win32-$(VSCODE_ARCH)" displayName: πŸ§ͺ Run smoke tests (Electron) timeoutInMinutes: 20 diff --git a/build/darwin/verify-macho.ts b/build/darwin/verify-macho.ts index bbd834d8a28570..dc87a2917e184d 100644 --- a/build/darwin/verify-macho.ts +++ b/build/darwin/verify-macho.ts @@ -40,6 +40,10 @@ const FILES_TO_SKIP = [ // ripgrep-universal: single-arch binaries in per-platform directories '**/node_modules/@vscode/ripgrep-universal/bin/darwin-*/**', '**/node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/darwin-*/**', + // MXC SDK ships per-arch native binaries under bin/; the package + // includes both arm64 and x64 trees regardless of host arch. + '**/node_modules/@microsoft/mxc-sdk/bin/**', + '**/node_modules.asar.unpacked/@microsoft/mxc-sdk/bin/**', ]; function isFileSkipped(file: string): boolean { diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index ef8c094ef89542..ed96d535f952b1 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -29,7 +29,7 @@ import * as cp from 'child_process'; import log from 'fancy-log'; import buildfile from './buildfile.ts'; import { fetchUrls, fetchGithub } from './lib/fetch.ts'; -import { getCopilotExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; const rcedit = promisify(rceditCallback); @@ -333,11 +333,13 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN const productionDependencies = getProductionDependencies(REMOTE_FOLDER); const dependenciesSrc = productionDependencies.map(d => path.relative(REPO_ROOT, d)).map(d => [`${d}/**`, `!${d}/**/{test,tests}/**`, `!${d}/.bin/**`]).flat(); - const deps = gulp.src(dependenciesSrc, { base: 'remote', dot: true }) + const cleanedDeps = gulp.src(dependenciesSrc, { base: 'remote', dot: true }) // filter out unnecessary files, no source maps in server build .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) - .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); + const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch, 'remote/node_modules'), { base: 'remote', dot: true, allowEmpty: true }); + const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(filter(getRipgrepExcludeFilter(platform, arch))) .pipe(jsFilter) diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 6e98a0794be2e2..88c66de650e800 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -28,7 +28,7 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; -import { getCopilotExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +import { getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; import globCallback from 'glob'; @@ -328,10 +328,12 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d depFilterPattern.push('!**/*.{js,css}.map'); } - const deps = gulp.src(dependenciesSrc, { base: '.', dot: true }) + const cleanedDeps = gulp.src(dependenciesSrc, { base: '.', dot: true }) .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) - .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))); + const copilotRuntimePrebuilds = gulp.src(getCopilotRuntimePrebuildFiles(platform, arch), { base: '.', dot: true, allowEmpty: true }); + const deps = es.merge(cleanedDeps, copilotRuntimePrebuilds) .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(filter(getRipgrepExcludeFilter(platform, arch))) .pipe(jsFilter) diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts index fea2af81745e44..31960a55313070 100644 --- a/build/lib/copilot.ts +++ b/build/lib/copilot.ts @@ -83,13 +83,30 @@ export function getCopilotExcludeFilter(platform: string, arch: string): string[ const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); // Strip wrong-architecture @github/copilot-{platform} packages. - // All copilot prebuilds are stripped by .moduleignore; the copilot CLI SDK - // resolves `node-pty` from VS Code's own node_modules via `hostRequire`. const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); return ['**', ...excludes]; } +/** + * Returns the public @github/copilot-sdk runtime native addon files that must + * survive app/remote packaging for the target platform. + * + * .moduleignore strips @github/copilot/prebuilds/** globally because the + * internal extension SDK uses a copied sdk/prebuilds layout. Agent Host uses + * the public SDK, whose runtime addon loader expects runtime.node in the root + * prebuilds layout. + */ +export function getCopilotRuntimePrebuildFiles(platform: string, arch: string, nodeModulesRoot = 'node_modules'): string[] { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const targetPlatformArch = `${nodePlatform}-${nodeArch}`; + const prebuildDir = path.posix.join(nodeModulesRoot, '@github', 'copilot', 'prebuilds', targetPlatformArch); + + return [ + path.posix.join(prebuildDir, 'runtime.node'), + ]; +} + /** * Materializes the copilot CLI ripgrep shim directly inside the built-in copilot extension. * diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index e6247b9a1a4fab..b65c77cc0cd830 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ use base64::{engine::general_purpose as b64, Engine as _}; -use futures::{stream::FuturesUnordered, StreamExt}; +use futures::{stream::FuturesUnordered, FutureExt, StreamExt}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, str::FromStr, + sync::Arc, time::Duration, }; use sysinfo::Pid; @@ -49,6 +50,7 @@ use crate::{ make_singleton_server, start_singleton_server, BroadcastLogSink, SingletonServerArgs, }, AuthRequired, Next, ServeStreamParams, ServiceContainer, ServiceManager, + SharedActiveAgentHost, }, util::{ app_lock::AppMutex, @@ -148,24 +150,27 @@ pub async fn command_shell(ctx: CommandContext, args: CommandShellArgs) -> Resul shutdown_reqs.push(ShutdownRequest::ParentProcessKilled(p)); } - // Ensure a per-machine agent host supervisor is running on the remote - // (the SSH/`command-shell` entry point) so the renderer that connects - // to the spawned VS Code server can reach the agent host via the - // `agentHostProxy` IPC channel. Best-effort: if the supervisor can't - // be started we still serve the stream so editing / extension host - // keep working β€” the renderer will just see "Unknown channel: - // agentHostProxy". - let agent_host_bridge = match ensure_supervisor_running(&ctx.paths, &ctx.log).await { - Ok(a) => Some(a), - Err(e) => { - warning!( - ctx.log, - "Could not start agent host supervisor; the renderer will not be able to reach it: {}", - e - ); - None + // Kick off the agent host supervisor in the background. The supervisor + // is what lets the renderer reach the agent host via the + // `agentHostProxy` IPC channel on the spawned VS Code server. We do + // NOT await it here β€” `command-shell` needs to start listening + // immediately. `handle_serve` awaits the shared future on demand and + // mixes the bridge endpoint into the per-request `code_server_args`. + // On failure the renderer just won't see `agentHostProxy`; editing + // and the extension host still work. + let active_agent_host: SharedActiveAgentHost = { + let paths = ctx.paths.clone(); + let log = ctx.log.clone(); + async move { + ensure_supervisor_running(&paths, &log) + .await + .map(Arc::new) + .map_err(Arc::new) } + .boxed() + .shared() }; + tokio::spawn(active_agent_host.clone()); let mut params = ServeStreamParams { log: ctx.log, @@ -177,14 +182,11 @@ pub async fn command_shell(ctx: CommandContext, args: CommandShellArgs) -> Resul .unwrap_or(AuthRequired::VSDA), exit_barrier: ShutdownRequest::create_rx(shutdown_reqs), code_server_args: (&ctx.args).into(), + active_agent_host: Some(active_agent_host), }; args.server_args.apply_to(&mut params.code_server_args); - if let Some(a) = &agent_host_bridge { - a.apply_to_bridge(&mut params.code_server_args); - } - let mut listener: Box = match (args.on_port.first(), &args.on_host, args.on_socket) { (_, _, true) => { diff --git a/cli/src/tunnels.rs b/cli/src/tunnels.rs index e25628d37cef6d..76b27899635a4a 100644 --- a/cli/src/tunnels.rs +++ b/cli/src/tunnels.rs @@ -37,7 +37,9 @@ mod service_windows; mod socket_signal; mod wsl_detect; -pub use control_server::{serve, serve_stream, AuthRequired, Next, ServeStreamParams}; +pub use control_server::{ + serve, serve_stream, AuthRequired, Next, ServeStreamParams, SharedActiveAgentHost, +}; pub use nosleep::SleepInhibitor; pub use service::{ create_service_manager, ServiceContainer, ServiceManager, SERVICE_LOG_FILE_NAME, diff --git a/cli/src/tunnels/control_server.rs b/cli/src/tunnels/control_server.rs index 2af42422bae6fe..41419274101750 100644 --- a/cli/src/tunnels/control_server.rs +++ b/cli/src/tunnels/control_server.rs @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ use crate::async_pipe::get_socket_rw_stream; -use crate::commands::agent_host::ensure_supervisor_running; +use crate::commands::agent_host::{ensure_supervisor_running, ActiveAgentHost}; use crate::constants::{AGENT_HOST_PORT, CONTROL_PORT, PRODUCT_NAME_LONG}; use crate::log; use crate::msgpack_rpc::{new_msgpack_rpc, start_msgpack_rpc, MsgPackCodec, MsgPackSerializer}; @@ -27,6 +27,7 @@ use crate::util::machine::kill_pid; use crate::util::os::os_release; use crate::util::sync::{new_barrier, Barrier, BarrierOpener}; +use futures::future::{BoxFuture, Shared}; use futures::stream::FuturesUnordered; use futures::FutureExt; use std::collections::HashMap; @@ -70,6 +71,14 @@ use super::socket_signal::{ type HttpRequestsMap = Arc>>; type CodeServerCell = Arc>>; +/// Shared, cloneable future that resolves once the agent host supervisor +/// is up. We kick it off from `serve()` so the tunnel can start accepting +/// connections immediately and only block on the supervisor in places +/// that actually need it (currently `handle_serve` and the agent-host +/// port forwarder). +pub type SharedActiveAgentHost = + Shared, Arc>>>; + struct HandlerContext { /// Log handle for the server log: log::Logger, @@ -95,6 +104,11 @@ struct HandlerContext { http: Arc, /// requests being served by the client http_requests: HttpRequestsMap, + /// Shared handle to the background `ensure_supervisor_running` task, + /// awaited in `handle_serve` to mix the bridge info into the spawned + /// server's args. `None` for callers (e.g. `command-shell`) that + /// already applied the bridge eagerly. + active_agent_host: Option, } /// Handler auth state. @@ -186,27 +200,31 @@ pub async fn serve( let (tx, mut rx) = mpsc::channel::(4); let (exit_barrier, signal_exit) = new_barrier(); - // Make sure an agent host supervisor is running on this machine before - // we start advertising the `agent-host` port over the tunnel. The - // supervisor is the only process that binds the user-facing TCP - // listener and owns the canonical lockfile; we never spawn an - // in-process sidecar here. If one is already live we reuse it; if - // not, we daemonize a fresh supervisor and consume the resulting - // lockfile. - let active_agent_host = ensure_supervisor_running(launcher_paths, log).await?; - - // Thread the active agent-host endpoint into the args used when spawning - // VS Code servers for renderer clients. The server uses these to register - // its `agentHostProxy` IPC channel so the renderer can reach the agent - // host over the existing renderer↔server connection. Note: this does NOT - // ask the spawned server to start its own agent host (that path uses - // `--agent-host-port` / `--agent-host-path` instead), it only points the - // bridge at the active agent host. - let code_server_args = { - let mut csa = code_server_args.clone(); - active_agent_host.apply_to_bridge(&mut csa); - csa + // Kick off the agent host supervisor in the background. The supervisor + // is the only process that binds the user-facing TCP listener and owns + // the canonical lockfile; we never spawn an in-process sidecar here. + // We deliberately do NOT await this here β€” the tunnel needs to start + // accepting connections immediately. Consumers that need the + // supervisor's endpoint (currently `handle_serve` for the + // `agentHostProxy` bridge, and the `agent-host` port forwarder below) + // await this shared future when they actually need it. Driving a + // clone with `tokio::spawn` ensures the work makes progress even if no + // one is currently awaiting it. + let active_agent_host: SharedActiveAgentHost = { + let launcher_paths = launcher_paths.clone(); + let log = log.clone(); + async move { + ensure_supervisor_running(&launcher_paths, &log) + .await + .map(Arc::new) + .map_err(Arc::new) + } + .boxed() + .shared() }; + tokio::spawn(active_agent_host.clone()); + + let code_server_args = code_server_args.clone(); if !code_server_args.install_extensions.is_empty() { info!( @@ -254,17 +272,26 @@ pub async fn serve( forwarding.process(w, &mut tunnel).await; }, Some(socket) = agent_host_port.recv() => { - let host = active_agent_host.dial_host().to_string(); - let port = active_agent_host.port; - let token = active_agent_host.token.clone(); let log = log.clone(); + let active_agent_host = active_agent_host.clone(); tokio::spawn(async move { + let active = match active_agent_host.await { + Ok(a) => a, + Err(e) => { + warning!( + log, + "Cannot forward agent-host tunnel connection; supervisor unavailable: {}", + e + ); + return; + } + }; forward_tunnel_connection_to_existing_ah( log, socket.into_rw(), - host, - port, - token, + active.dial_host().to_string(), + active.port, + active.token.clone(), ) .await; }); @@ -287,6 +314,7 @@ pub async fn serve( let own_exit = exit_barrier.clone(); let own_code_server_args = code_server_args.clone(); let own_forwarding = forwarding.handle(); + let own_active_agent_host = active_agent_host.clone(); tokio::spawn(async move { debug!(own_log, "Serving new connection"); @@ -299,6 +327,7 @@ pub async fn serve( platform, exit_barrier: own_exit, requires_auth: AuthRequired::None, + active_agent_host: Some(own_active_agent_host), }).await; }); } @@ -321,6 +350,7 @@ pub struct ServeStreamParams { pub platform: Platform, pub requires_auth: AuthRequired, pub exit_barrier: Barrier, + pub active_agent_host: Option, } pub async fn serve_stream( @@ -352,6 +382,7 @@ fn make_socket_rpc( requires_auth: AuthRequired, platform: Platform, http_requests: HttpRequestsMap, + active_agent_host: Option, ) -> RpcDispatcher { let server_bridges = ServerMultiplexer::new(); let mut rpc = RpcBuilder::new(MsgPackSerializer {}).methods(HandlerContext { @@ -374,6 +405,7 @@ fn make_socket_rpc( http_delegated, )), http_requests, + active_agent_host, }); rpc.register_sync("ping", |_: EmptyObject, _| Ok(EmptyObject {})); @@ -553,6 +585,7 @@ async fn process_socket( code_server_args, platform, requires_auth, + active_agent_host, } = params; let (http_delegated, mut http_rx) = DelegatedSimpleHttp::new(log.clone()); @@ -571,6 +604,7 @@ async fn process_socket( requires_auth, platform, http_requests.clone(), + active_agent_host, ); { @@ -755,6 +789,21 @@ async fn handle_serve( csa.connection_token = params.connection_token.or(csa.connection_token); csa.install_extensions.extend(params.extensions); + // Mix in the agent-host bridge info now that we actually need to spawn + // the VS Code server. The supervisor was started in the background by + // `serve()`, so this only blocks if it hasn't finished yet. If it + // failed we still serve β€” the renderer just won't see `agentHostProxy`. + if let Some(ah_fut) = c.active_agent_host.clone() { + match ah_fut.await { + Ok(a) => a.apply_to_bridge(&mut csa), + Err(e) => warning!( + c.log, + "Agent host supervisor unavailable; renderer will not see agentHostProxy: {}", + e + ), + } + } + let params_raw = ServerParamsRaw { commit_id: params.commit_id, quality: params.quality, diff --git a/extensions/configuration-editing/package.json b/extensions/configuration-editing/package.json index 42c4a708c0cdc1..67e6cc0953900f 100644 --- a/extensions/configuration-editing/package.json +++ b/extensions/configuration-editing/package.json @@ -132,6 +132,14 @@ "fileMatch": "%APP_SETTINGS_HOME%/tasks.json", "url": "vscode://schemas/tasks" }, + { + "fileMatch": "%APP_SETTINGS_HOME%/chatLanguageModels.json", + "url": "vscode://schemas/language-models" + }, + { + "fileMatch": "%APP_SETTINGS_HOME%/profiles/*/chatLanguageModels.json", + "url": "vscode://schemas/language-models" + }, { "fileMatch": "%APP_SETTINGS_HOME%/snippets/*.json", "url": "vscode://schemas/snippets" diff --git a/extensions/copilot/.vscodeignore b/extensions/copilot/.vscodeignore index 640356460d354b..7a52caef60404f 100644 --- a/extensions/copilot/.vscodeignore +++ b/extensions/copilot/.vscodeignore @@ -32,8 +32,6 @@ node_modules/@github/copilot/sharp/** !node_modules/@github/copilot/sdk/definitions/*.yaml !node_modules/@github/copilot/sdk/definitions/sidekick/*.yaml !node_modules/@github/copilot/sdk/prebuilds/*/runtime.node -!node_modules/@github/copilot/sdk/prebuilds/*/icu-native.node -!node_modules/@github/copilot/sdk/prebuilds/*/win32-native.node !CHANGELOG.md !README.md !package.json diff --git a/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md b/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md new file mode 100644 index 00000000000000..816e0fb4029319 --- /dev/null +++ b/extensions/copilot/assets/prompts/chronicle-cost-tips.prompt.md @@ -0,0 +1,5 @@ +--- +name: chronicle:cost-tips +description: Get personalized tips to reduce token usage and Copilot cost +--- +Analyze my recent chat session history and give me personalized, data-grounded tips to reduce token usage and Copilot cost. Use the **chronicle** skill β€” it documents the `copilot_sessionStoreSql` tool, the session-store schema, and the Cost Tips workflow for finding expensive sessions, token-heavy patterns, and concrete habit changes. diff --git a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md index b5fe96a1b032b5..584dff31936d3e 100644 --- a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md @@ -96,6 +96,80 @@ Analysis dimensions to explore: If the session store has little data, acknowledge that and suggest features to try based on what configuration you found in the workspace. +### Cost Tips + +When the user asks for cost tips, ways to reduce token usage, or how to lower Copilot spend (e.g. `/chronicle:cost-tips`): + +The goal is **personalized, data-grounded recommendations** for reducing token usage β€” not a generic checklist. Every tip must point to a specific pattern you observed in their data. + +**Cost-relevant schema (in addition to the Database Schema section below)** + +- **Cloud DuckDB only** β€” the local SQLite store does **not** record per-event token usage and has no `events` table. If the active backend is local, gate all token queries and tell the user that real token-level analysis requires enabling cloud sync (`chat.sessionSync.enabled`). +- **events** (cloud): per-event billing β€” rows where `type = 'assistant.usage'` carry `usage_input_tokens`, `usage_output_tokens`, `usage_model`. To break spend down by agent type, JOIN `events e` to `sessions s ON s.id = e.session_id` and group by `s.agent_name`. +- **sessions.agent_name** / **agent_description** (both backends): values like `VS Code agent` (VS Code chat), `Copilot CLI`, `Copilot Coding Agent`, `Copilot Code Review`, or custom agents/subagents. Use to break spend down by agent type. +- Use `LENGTH(user_message)` on `turns` (or `LENGTH(user_content)` on `events` where `type = 'user.message'`) to find oversized pastes. + +**Step 1: Investigate cost and token patterns** + +Use `copilot_sessionStoreSql` with `action: "query"`. What to investigate depends on the active backend. + +*Cloud (DuckDB) β€” start with agent-type awareness:* + +The session store mixes session types via `sessions.agent_name` (join events to sessions on `session_id` to get the agent for any per-event analysis). Your advice is only useful if you know which agents the user actually runs, so this is the **first** thing to learn. + +- **Enumerate every agent in use.** Run e.g. `SELECT agent_name, agent_description, COUNT(*) AS n FROM sessions WHERE updated_at > now() - INTERVAL '30 days' GROUP BY 1, 2 ORDER BY n DESC` so you see the full inventory β€” official agents and any custom agents/subagents in `agent_description`. Do not assume. +- **Decide which to advise on.** Include any agent type the user can make cheaper: `VS Code agent` (VS Code chat β€” usually the dominant agent), `Copilot CLI` (interactive terminal), `Copilot Coding Agent` (autonomous cloud tasks), custom agents and subagents. **Always exclude** `agent_name = 'Copilot Code Review'` and any other agent the user does not drive interactively. +- **Tailor advice per agent.** VS Code agent tips (compaction, model picker, fresh chats, `.github/copilot-instructions.md`, custom skills/agents) look different from CLI tips (compaction, model switching, subagent delegation), Coding Agent tips (prompt scoping, smaller task framing), and custom-agent tips (slimming tool lists, narrowing prompts). + +Then drill into cost patterns (filter `events` rows by `type = 'assistant.usage'` for billable rows): + +- **Token-heavy sessions and turns** β€” sum `usage_input_tokens` and `usage_output_tokens` per session and per model from `events` where `type = 'assistant.usage'`. Which sessions burned the most tokens? Which models? +- **Input-to-output ratios** β€” when input tokens dwarf output tokens, the user is paying to re-send a bloated context every turn. Strongest signal that compaction, smaller working sets, or fresh sessions would help. +- **Model mix** β€” break down spend by `usage_model`. Are premium models being used for routine work (renames, simple edits, status checks) that a cheaper model could handle? +- **Per-turn growth** β€” within long sessions, does `usage_input_tokens` keep climbing turn-over-turn? Strong signal that compaction wasn't used. +- **Oversized pastes** β€” `LENGTH(user_content)` on `events` where `type = 'user.message'` to find user messages that should have been file references (also visible in `session_files` as repeated reads of the same path within one session). +- **Group cost breakdowns by `agent_name`** (and `agent_description` where useful) in at least one query so the user sees where their spend actually goes β€” and so you spot if a single custom agent dominates. + +*Local (SQLite) β€” no token data; use proxies:* + +- **Long sessions without compaction** β€” sessions with many turns and no rows in `checkpoints` (each `checkpoints` row is a successful compaction). `LEFT JOIN checkpoints c ON c.session_id = s.id WHERE c.session_id IS NULL` + a turn-count threshold gives prime candidates. +- **Late compaction** β€” for sessions that *do* have checkpoints, compare `checkpoints.checkpoint_number` and `created_at` against the session's turn count. A first compaction at turn 60 of an 80-turn session is far less helpful than one at turn 25. +- **Repeated large file reads** β€” in `session_files`, look for the same file read many times within one session, or across sessions. +- **Tool-call thrash** β€” sessions with many turns and repeated tool calls often indicate the agent rediscovered the same context multiple times. +- **Oversized pastes** β€” use `LENGTH(user_message)` on `turns` to find very long user messages that should have been file references. + +*Both backends:* + +- **Long-running sessions** β€” sessions with many turns or that span many hours drag a growing context window across every turn. +- **Repeated work** β€” the same file/topic showing up in many sessions, or the same agent stumbling block recurring (suggesting a custom skill, agent, or `copilot-instructions.md` entry would let the model do the work in one shot). +- **Subagent usage** β€” are heavyweight investigations being run in the main session (paying for their tokens to live in main context) when they could be delegated to a subagent that returns only a summary? + +Drill into a few of the most expensive sessions and read the actual conversation turns to understand *why* they were expensive. Don't just report aggregates β€” explain the cause. + +**Step 2: Map findings to features and habits** + +If the current workspace has a `.github/` folder, check `.github/copilot-instructions.md`, `.github/skills/`, and `.github/agents/` to see what custom configuration already exists. Do NOT look outside the workspace. Cost-relevant capabilities to keep in mind: + +- Mid-session compaction (e.g. `/compact`) to shrink the context window; for users who never compact, this is often the single biggest win. +- Model picker β€” switch to a cheaper model for routine work; check whether premium models are being used for simple tasks. +- Starting a fresh chat instead of continuing a bloated session. +- Subagents/delegation for offloading heavy research into a sub-context whose tokens don't accrete into the main session. +- Custom skills (`.github/skills/`) and custom agents (`.github/agents/`) so repeated workflows don't re-derive context each time. +- `.github/copilot-instructions.md` to encode project conventions the model otherwise has to be told every session. +- For cloud-enabled users, the Copilot usage view to inspect current premium-request spend. + +**Step 3: Provide tips** + +Give the user 3-5 specific, actionable tips. Each tip should: + +- **Be grounded in their data** β€” reference a specific session, file, model, or pattern you observed (with rough numbers when you have them: turn counts, token totals, file-read counts, etc.). +- **Be non-obvious** β€” skip basics any returning user already knows. Assume they know compaction and fresh chats exist; help them notice they're not *using* them where it would matter. +- **Quantify the win when possible** β€” "compacting around turn 30 of that 80-turn session would have shaved ~X input tokens off every subsequent turn" is far better than "consider compacting". +- **Be concrete** β€” name the workflow change, command, or config file edit. If the suggestion is a custom skill or agent, sketch what it would cover. +- **Match the agent type** β€” if a finding is specific to one `agent_name`, say so. Don't propose CLI-only fixes for findings from Coding Agent sessions, and vice versa. + +If the session store has little data (e.g., cloud store is empty, or only a handful of local sessions), say so plainly and offer 2-3 non-obvious cost-saving habits anchored in available features rather than fabricating findings. If the user is on local-only storage, end by noting that enabling `chat.sessionSync.enabled` unlocks per-event token analysis for sharper future tips. + ### Search When the user asks to search, find, or look up past sessions by keyword (e.g. `/chronicle:search `): diff --git a/extensions/copilot/chat-lib/package-lock.json b/extensions/copilot/chat-lib/package-lock.json index 515ad9ec1ab494..8fb7512d32d9ee 100644 --- a/extensions/copilot/chat-lib/package-lock.json +++ b/extensions/copilot/chat-lib/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@microsoft/tiktokenizer": "^1.0.10", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.2.19", + "@vscode/copilot-api": "^0.4.0", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.8", "@vscode/tree-sitter-wasm": "0.0.5-php.2", @@ -1321,9 +1321,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.2.19.tgz", - "integrity": "sha512-h0QR129eYTDDUBMMSIAvhEaMdXRXitqLCtIXUEnVBuDX5K7kHXrDseLeGKp2XqSvbIRRA/RqDjpleYOf+pCkiQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.4.0.tgz", + "integrity": "sha512-rFpa9xhl1HPIelTOSCvsykKzykVo1DqLkGDhWdJUuhZj4gasJ7xGlySRO9O9UYSFVgAPTkTcObhh2FoOLBF7GA==", "license": "SEE LICENSE" }, "node_modules/@vscode/l10n": { diff --git a/extensions/copilot/chat-lib/package.json b/extensions/copilot/chat-lib/package.json index b01bce2fb8f5b6..5631c5478a3266 100644 --- a/extensions/copilot/chat-lib/package.json +++ b/extensions/copilot/chat-lib/package.json @@ -16,7 +16,7 @@ "dependencies": { "@microsoft/tiktokenizer": "^1.0.10", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.2.19", + "@vscode/copilot-api": "^0.4.0", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.8", "@vscode/tree-sitter-wasm": "0.0.5-php.2", diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 3bbb3efe7d698a..cfe982fb6b5965 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.48", + "@github/copilot": "1.0.49", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -32,7 +32,7 @@ "@opentelemetry/sdk-trace-node": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.3.1", + "@vscode/copilot-api": "^0.4.0", "@vscode/extension-telemetry": "^1.5.1", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.8", @@ -2931,26 +2931,28 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.48.tgz", - "integrity": "sha512-U5SzyTEq376UU9A4Sd3TEKz+Y2nRUd90cLO4Hc1otaB8yFSy9Ur2UVGcI2/wCoodL3a39k6WbdgNzFxr0gWFRQ==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.49.tgz", + "integrity": "sha512-40Udj9uCNXaVT2XYbB93CaA7P/rWdy7DP1r088t11s0chWfm5smm9RDMNRj2KqMywwYw3xgf3ZcTFoTLy7kleA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.48", - "@github/copilot-darwin-x64": "1.0.48", - "@github/copilot-linux-arm64": "1.0.48", - "@github/copilot-linux-x64": "1.0.48", - "@github/copilot-win32-arm64": "1.0.48", - "@github/copilot-win32-x64": "1.0.48" + "@github/copilot-darwin-arm64": "1.0.49", + "@github/copilot-darwin-x64": "1.0.49", + "@github/copilot-linux-arm64": "1.0.49", + "@github/copilot-linux-x64": "1.0.49", + "@github/copilot-linuxmusl-arm64": "1.0.49", + "@github/copilot-linuxmusl-x64": "1.0.49", + "@github/copilot-win32-arm64": "1.0.49", + "@github/copilot-win32-x64": "1.0.49" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.48.tgz", - "integrity": "sha512-82MLoMQwPVVFM8EYssihFxSEPUYtZADE8rMzQ3jG9HgRg2qjQSfnHQS1mKe64dlXswZUK/onw6/8kjnW5I4pPg==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.49.tgz", + "integrity": "sha512-b/qtH1ttG7dnoEC3gLDdrI9n7f5+3LEXD2rOvpdeoxoe8lDlSpUeF4AUpfh7kUivhCKlCIRV+H3+NcRX2rexuQ==", "cpu": [ "arm64" ], @@ -2964,9 +2966,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.48.tgz", - "integrity": "sha512-1VQ5r5F0h8GwboXmZTcutqcJT+iCpPXAF27QqodmpKEvW9aYfG8g9X2kFJOzDZoX+SA3Uaka9qXdYKF2xT6Uog==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.49.tgz", + "integrity": "sha512-hHqoeCKqHttqtX3ZHj2TkAIX6jUg159tHDm7qVLccGotgz5bp6ywFxHyGYs7uwD0D90if/m+s87lXu2xAIkN9A==", "cpu": [ "x64" ], @@ -2980,9 +2982,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.48.tgz", - "integrity": "sha512-PmsGnb0DZlI+Bf53l9HM1PAHHkUcMyB4y8v/7tnC/jDOV5dGF124n0HnDNfJLOLiJGiQGodthIif6QtPaAxpeA==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.49.tgz", + "integrity": "sha512-faNys7OcjoG6g2vlmOVLgzd4pZPmi0LpZJ0pnOLW6lJ2d9Lk5KsY3aX2g/Uqdoz9oqAPg64t8NH2WPSdHPmBTg==", "cpu": [ "arm64" ], @@ -2996,9 +2998,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.48.tgz", - "integrity": "sha512-b2cc4euSlke9fYHXXsS2EL9UYbctN0h4lZvtAcKUDY+RCnpYAQOVBZK+c1R9dQrtsT6Z/yUv7PuFPSs8qdtc2Q==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.49.tgz", + "integrity": "sha512-bMqMoJ2r304yCmzZ+iv9Nf4xS4KdiqNZo+Ld7Iq9y5Rc5T+DVsrgISb9j2rBqtlOe0rdtKhwOuzSc4XP7BDcvw==", "cpu": [ "x64" ], @@ -3011,10 +3013,42 @@ "copilot-linux-x64": "copilot" } }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.49.tgz", + "integrity": "sha512-j2Ow72hiamC3yU1GQBl4WEAB9okuUxdGCs+bcYxtDSUY144F9i9U9WE8Oil3KP3Je+WLUZSf81OYsHTCM5OjbA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.49.tgz", + "integrity": "sha512-/a0iNVqXeEvvm0UyPMjW3UPl0meQSSd8SeaMYkkI2OQkYhlUrd9oaUEJzfYnBgPl37AK5+i73DFy09gSH+Efvw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.48.tgz", - "integrity": "sha512-VEEOwddtpJ3DTbXGhnK6K8im4ofl9m08q1m/K++sNvWV8wkkOSOQBTiPdyUsuU/TXAoFhb8tZMIJv+6NnMBtMw==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.49.tgz", + "integrity": "sha512-2oaOoB47i2EcM1tSO+ay2X7xF29Yc/9LFOqkGZZrdS4gTQvTD3oITQBGwdj5CR3GN9pOFxWrhUvyDf9N77AHFg==", "cpu": [ "arm64" ], @@ -3028,9 +3062,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.48", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.48.tgz", - "integrity": "sha512-93BzvXLPHTyy1gWBXQY/IWIHor4IAwZuuo7/obG80/Qa6U0WeaN9slz/FBJvrsgVNrrRfEID5Xm3At+S6Kj67Q==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.49.tgz", + "integrity": "sha512-XwoiiCV3Q9PBV1eFNAag1KnIqN/cNDoNi2B6BJUkGPJUEW3AgrOABV6cmyZ3yEKUEXMZ78JIfS9kUEmTtCAY0g==", "cpu": [ "x64" ], @@ -3916,9 +3950,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.12.5.tgz", - "integrity": "sha512-JPQZWPKQJjj7kAftdEZL0XDFfbMgXCGiUAZe0d7EhLC3QlXTlZdSckGqqRIQ2QNl0VTEZyZUvRBw6Ednw089Fw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@nodelib/fs.scandir": { @@ -6824,9 +6868,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.3.1.tgz", - "integrity": "sha512-6jLdFV0j/IZ8k9mFcnhyNKGze6q83+bdrxOZqDrMQe8gwJ2/q8+wQD1Rq9yYg+YmoDEAVhGnLB+xEqj7vKKZdQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.4.0.tgz", + "integrity": "sha512-rFpa9xhl1HPIelTOSCvsykKzykVo1DqLkGDhWdJUuhZj4gasJ7xGlySRO9O9UYSFVgAPTkTcObhh2FoOLBF7GA==", "license": "SEE LICENSE" }, "node_modules/@vscode/debugadapter": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3b48d774a9e798..31480601a415cc 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1737,6 +1737,7 @@ "vendor": "anthropic", "displayName": "Anthropic", "configuration": { + "type": "object", "properties": { "apiKey": { "type": "string", @@ -1754,6 +1755,7 @@ "vendor": "xai", "displayName": "xAI", "configuration": { + "type": "object", "properties": { "apiKey": { "type": "string", @@ -1771,6 +1773,7 @@ "vendor": "gemini", "displayName": "Google", "configuration": { + "type": "object", "properties": { "apiKey": { "type": "string", @@ -1788,6 +1791,7 @@ "vendor": "openrouter", "displayName": "OpenRouter", "configuration": { + "type": "object", "properties": { "apiKey": { "type": "string", @@ -1805,6 +1809,7 @@ "vendor": "openai", "displayName": "OpenAI", "configuration": { + "type": "object", "properties": { "apiKey": { "type": "string", @@ -6619,6 +6624,13 @@ "local" ] }, + { + "path": "./assets/prompts/chronicle-cost-tips.prompt.md", + "when": "github.copilot.sessionSearch.enabled", + "sessionTypes": [ + "local" + ] + }, { "path": "./assets/prompts/chronicle-reindex.prompt.md", "when": "github.copilot.sessionSearch.enabled", @@ -6866,7 +6878,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.48", + "@github/copilot": "1.0.49", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -6885,7 +6897,7 @@ "@opentelemetry/sdk-trace-node": "^2.5.1", "@opentelemetry/semantic-conventions": "^1.39.0", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.3.1", + "@vscode/copilot-api": "^0.4.0", "@vscode/extension-telemetry": "^1.5.1", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.8", diff --git a/extensions/copilot/script/postinstall.ts b/extensions/copilot/script/postinstall.ts index 2fc448cb63a5b4..c4327e1288b5b6 100644 --- a/extensions/copilot/script/postinstall.ts +++ b/extensions/copilot/script/postinstall.ts @@ -114,7 +114,11 @@ async function copyCopilotCliPrebuildFiles() { recursive: true, force: true, filter: (src) => { try { if (fs.statSync(src).isFile()) { - return src.endsWith('computer.node') || src.endsWith('native.node') || src.endsWith('runtime.node'); + const normalizedSrc = src.split(path.sep).join(path.posix.sep); + if (normalizedSrc.includes('/prebuilds/linuxmusl-')) { + return false; + } + return src.endsWith('computer.node') || src.endsWith('runtime.node'); } return true; } catch { diff --git a/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts index 548962097c938e..1f3e1ff17e4f43 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts @@ -398,6 +398,7 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider { "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "turn": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How many turns have been made in the conversation.", "isMeasurement": true }, "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort level" }, "reasoningSummary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning summary level" }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, diff --git a/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts index 0cb51a2bb7d981..1be258e9a61ac4 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts @@ -269,6 +269,7 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "turn": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How many turns have been made in the conversation.", "isMeasurement": true }, "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort level" }, "reasoningSummary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning summary level" }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts index 8bfe83cd8ec174..55d1be8789f7f3 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -97,9 +97,12 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { maxInputTokens: endpoint.modelMaxPromptTokens, maxOutputTokens: endpoint.maxOutputTokens, pricing: multiplier ?? (endpoint.tokenPricing ? formatPricingLabel(endpoint.tokenPricing) : undefined), - inputCost: endpoint.tokenPricing?.inputPrice, - outputCost: endpoint.tokenPricing?.outputPrice, - cacheCost: endpoint.tokenPricing?.cacheReadTokenPrice, + inputCost: endpoint.tokenPricing?.default.inputPrice, + outputCost: endpoint.tokenPricing?.default.outputPrice, + cacheCost: endpoint.tokenPricing?.default.cacheReadTokenPrice, + longContextInputCost: endpoint.tokenPricing?.longContext?.inputPrice, + longContextOutputCost: endpoint.tokenPricing?.longContext?.outputPrice, + longContextCacheCost: endpoint.tokenPricing?.longContext?.cacheReadTokenPrice, multiplierNumeric: endpoint.multiplier, priceCategory: endpoint.priceCategory, tooltip, diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts index e2343218bea4bf..9279e7106c70ac 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts @@ -56,14 +56,20 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('prebuilds', 'linux-x64', 'computer.node'), path.join('prebuilds', 'win32-arm64', 'computer.node'), path.join('prebuilds', 'win32-x64', 'computer.node'), - // icu-native and runtime native modules + // Native modules present in the raw @github/copilot package. Root + // prebuilds are stripped from the shipped extension by .vscodeignore. path.join('prebuilds', 'darwin-arm64', 'icu-native.node'), path.join('prebuilds', 'darwin-arm64', 'runtime.node'), path.join('prebuilds', 'darwin-x64', 'icu-native.node'), path.join('prebuilds', 'darwin-x64', 'runtime.node'), + path.join('prebuilds', 'linux-arm64', 'icu-native.node'), path.join('prebuilds', 'linux-arm64', 'runtime.node'), path.join('prebuilds', 'linux-x64', 'icu-native.node'), path.join('prebuilds', 'linux-x64', 'runtime.node'), + path.join('prebuilds', 'linuxmusl-arm64', 'icu-native.node'), + path.join('prebuilds', 'linuxmusl-arm64', 'runtime.node'), + path.join('prebuilds', 'linuxmusl-x64', 'icu-native.node'), + path.join('prebuilds', 'linuxmusl-x64', 'runtime.node'), path.join('prebuilds', 'win32-arm64', 'icu-native.node'), path.join('prebuilds', 'win32-arm64', 'runtime.node'), path.join('prebuilds', 'win32-arm64', 'win32-native.node'), @@ -78,23 +84,18 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('sdk', 'prebuilds', 'linux-x64', 'computer.node'), path.join('sdk', 'prebuilds', 'win32-arm64', 'computer.node'), path.join('sdk', 'prebuilds', 'win32-x64', 'computer.node'), - path.join('sdk', 'prebuilds', 'darwin-arm64', 'icu-native.node'), path.join('sdk', 'prebuilds', 'darwin-arm64', 'runtime.node'), - path.join('sdk', 'prebuilds', 'darwin-x64', 'icu-native.node'), path.join('sdk', 'prebuilds', 'darwin-x64', 'runtime.node'), path.join('sdk', 'prebuilds', 'linux-arm64', 'runtime.node'), - path.join('sdk', 'prebuilds', 'linux-x64', 'icu-native.node'), path.join('sdk', 'prebuilds', 'linux-x64', 'runtime.node'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'icu-native.node'), path.join('sdk', 'prebuilds', 'win32-arm64', 'runtime.node'), - path.join('sdk', 'prebuilds', 'win32-arm64', 'win32-native.node'), - path.join('sdk', 'prebuilds', 'win32-x64', 'icu-native.node'), path.join('sdk', 'prebuilds', 'win32-x64', 'runtime.node'), - path.join('sdk', 'prebuilds', 'win32-x64', 'win32-native.node'), path.join('ripgrep', 'bin', 'darwin-arm64', 'rg'), path.join('ripgrep', 'bin', 'darwin-x64', 'rg'), path.join('ripgrep', 'bin', 'linux-x64', 'rg'), path.join('ripgrep', 'bin', 'linux-arm64', 'rg'), + path.join('ripgrep', 'bin', 'linuxmusl-arm64', 'rg'), + path.join('ripgrep', 'bin', 'linuxmusl-x64', 'rg'), // sharp related files path.join('sharp', 'node_modules', '@img', 'sharp-wasm32', 'lib', 'sharp-wasm32.node.wasm'), // sharp related files, files copied by us. @@ -112,6 +113,7 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('pvrecorder', 'node_modules', '@picovoice', 'pvrecorder-node', 'lib', 'windows', 'arm64', 'pv_recorder.node'), // mxc-bin (Windows sandbox + WSL helpers used by the SDK's command execution). path.join('mxc-bin', 'arm64', 'lxc-exec'), + path.join('mxc-bin', 'arm64', 'mxc-exec-mac'), path.join('mxc-bin', 'arm64', 'winhttp-proxy-shim.exe'), path.join('mxc-bin', 'arm64', 'wslcsdk.dll'), path.join('mxc-bin', 'arm64', 'wxc-exec.exe'), @@ -133,7 +135,6 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('mxc-bin', 'x64', '_manifest', 'spdx_2.2', 'manifest.spdx.cose'), // parsing commands for shell. 'tree-sitter-bash.wasm', - 'tree-sitter-powershell.wasm', 'tree-sitter.wasm', 'tree-sitter-c_sharp.wasm', 'tree-sitter-c.wasm', @@ -225,4 +226,4 @@ async function findAllBinaries(dir: string): Promise { await findFilesRecursively(dir); return binaryFiles; -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index d6090dda097e75..d6e53c189c745c 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -134,7 +134,7 @@ function formatAicPrice(price: number): string { export function formatPricingLabel(pricing: IChatEndpointTokenPricing): string { return l10n.t( 'In: {0} Β· Out: {1} AICs/1M tokens', - formatAicPrice(pricing.inputPrice), - formatAicPrice(pricing.outputPrice), + formatAicPrice(pricing.default.inputPrice), + formatAicPrice(pricing.default.outputPrice), ); } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 73c377540752af..c9a73049655c8a 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -355,9 +355,12 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib family: endpoint.family, tooltip: modelTooltip, pricing: endpoint instanceof AutoChatEndpoint ? undefined : (multiplier ?? (endpoint.tokenPricing ? formatPricingLabel(endpoint.tokenPricing) : undefined)), - inputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.inputPrice, - outputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.outputPrice, - cacheCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.cacheReadTokenPrice, + inputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.default.inputPrice, + outputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.default.outputPrice, + cacheCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.default.cacheReadTokenPrice, + longContextInputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.longContext?.inputPrice, + longContextOutputCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.longContext?.outputPrice, + longContextCacheCost: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.tokenPricing?.longContext?.cacheReadTokenPrice, multiplierNumeric: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.multiplier, priceCategory: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.priceCategory, detail: modelDetail, diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index cab0bcacc4c2bc..40717a7ed8a8d7 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -44,12 +44,13 @@ import { Conversation, normalizeSummariesOnRounds, RenderedUserMessageMetadata, import { IBuildPromptContext, InternalToolReference } from '../../prompt/common/intents'; import { getRequestedToolCallIterationLimit, IContinueOnErrorConfirmation } from '../../prompt/common/specialRequestTypes'; import { ChatTelemetryBuilder } from '../../prompt/node/chatParticipantTelemetry'; +import { IntentInvocationMetadata } from '../../prompt/node/conversation'; import { IDefaultIntentRequestHandlerOptions } from '../../prompt/node/defaultIntentRequestHandler'; import { IDocumentContext } from '../../prompt/node/documentContext'; import { IBuildPromptResult, IIntent, IIntentInvocation } from '../../prompt/node/intents'; import { AgentPrompt, AgentPromptProps } from '../../prompts/node/agent/agentPrompt'; import { BackgroundSummarizationState, BackgroundSummarizer, IBackgroundSummarizationResult, shouldKickOffBackgroundSummarization } from '../../prompts/node/agent/backgroundSummarizer'; -import { BackgroundTodoDecision, BackgroundTodoProcessor } from '../../prompts/node/agent/backgroundTodoProcessor'; +import { BackgroundTodoDecision, BackgroundTodoProcessor, IBackgroundTodoExecutionContext } from '../../prompts/node/agent/backgroundTodoProcessor'; import { AgentPromptCustomizations, PromptRegistry } from '../../prompts/node/agent/promptRegistry'; import { extractSummary, SummarizationUserMessage, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder, appendTranscriptHintToSummary, computeSummarizationRoundCounts } from '../../prompts/node/agent/summarizedConversationHistory'; import { PromptRenderer, renderPromptElement } from '../../prompts/node/base/promptRenderer'; @@ -307,9 +308,11 @@ export class AgentIntent extends EditCodeIntent { // the background pass even on a normal completion. if (isBackgroundTodoAgentEnabled(this.configurationService, this.expService, request)) { const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); - if (todoProcessor) { - const turnId = conversation.getLatestTurn().id; - todoProcessor.requestFinalReview(turnId); + const currentTurn = conversation.getLatestTurn(); + const invocation = currentTurn.getMetadata(IntentInvocationMetadata)?.value; + const executionContext = invocation instanceof AgentIntentInvocation ? invocation.getBackgroundTodoExecutionContext() : undefined; + if (todoProcessor && executionContext) { + todoProcessor.requestFinalReview(currentTurn.id, executionContext); await todoProcessor.waitForCompletion(); } } @@ -451,6 +454,8 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I /** Cached model capabilities from the most recent main agent render, reused by the background summarizer. */ private _lastModelCapabilities: { enableThinking: boolean; reasoningEffort: string | undefined; enableToolSearch: boolean; enableContextEditing: boolean } | undefined; + private _backgroundTodoExecutionContext: IBackgroundTodoExecutionContext | undefined; + /** * RNG used to jitter the background-summarization trigger threshold around 0.80. * Tests may overwrite this directly (e.g. `(invocation as any)._thresholdRng = () => 0.5`). @@ -1236,6 +1241,10 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I return this.intent.getOrCreateBackgroundTodoProcessor(sessionId); } + getBackgroundTodoExecutionContext(): IBackgroundTodoExecutionContext | undefined { + return this._backgroundTodoExecutionContext; + } + /** * Kick off a background todo pass if the policy says to run. */ @@ -1256,26 +1265,29 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I return; } + const turnId = promptContext.conversation?.getLatestTurn()?.id; + const executionContext: IBackgroundTodoExecutionContext = { + instantiationService: this.instantiationService, + logService: this.logService, + toolsService: this.toolsService, + telemetryService: this.telemetryService, + promptContext, + }; + this._backgroundTodoExecutionContext = executionContext; + const { decision, reason, delta } = processor.shouldRun({ backgroundTodoAgentEnabled: isBackgroundTodoAgentEnabled(this.configurationService, this.expService, this.request), todoToolExplicitlyEnabled: isTodoToolExplicitlyEnabled(this.request), isAgentPrompt: this.prompt === AgentPrompt, promptContext, + turnId, }); this.logService.debug(`[BackgroundTodo] policy decision: ${decision} (${reason})`); - const executionContext = { - instantiationService: this.instantiationService, - logService: this.logService, - toolsService: this.toolsService, - telemetryService: this.telemetryService, - promptContext, - }; - if (decision === BackgroundTodoDecision.Wait && reason === 'processorInProgress' && delta) { // Coalesce into the queue so the latest context is not lost. - processor.requestRegularPass(delta, executionContext, token); + processor.requestRegularPass(delta, executionContext, token, turnId); return; } @@ -1283,7 +1295,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I return; } - processor.requestRegularPass(delta, executionContext, token); + processor.requestRegularPass(delta, executionContext, token, turnId); } override processResponse = undefined; diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts index d9b6e18ef7762e..f47c6bc89abebf 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts @@ -89,6 +89,16 @@ export interface IChatMLFetcherErrorData { resumeEventSeen: boolean | undefined; } +function getTurnFromBaseTelemetry(baseTelemetry: TelemetryData): number | undefined { + const turnIndex = baseTelemetry.properties.turnIndex; + if (typeof turnIndex !== 'string') { + return undefined; + } + + const parsedTurnIndex = Number(turnIndex); + return Number.isFinite(parsedTurnIndex) ? parsedTurnIndex : undefined; +} + export class ChatMLFetcherTelemetrySender { public static sendSuccessTelemetry( @@ -130,6 +140,7 @@ export class ChatMLFetcherTelemetrySender { "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the current turn request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "turn": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How many turns have been made in the conversation.", "isMeasurement": true }, "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort level" }, "reasoningSummary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning summary level" }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, @@ -207,6 +218,7 @@ export class ChatMLFetcherTelemetrySender { ...(baseTelemetry?.properties.connectivityTestErrorGitHubRequestId ? { connectivityTestErrorGitHubRequestId: baseTelemetry.properties.connectivityTestErrorGitHubRequestId } : {}), ...(baseTelemetry?.properties.retryAfterFilterCategory ? { retryAfterFilterCategory: baseTelemetry.properties.retryAfterFilterCategory } : {}), }, { + turn: getTurnFromBaseTelemetry(baseTelemetry), totalTokenMax: chatEndpointInfo?.modelMaxPromptTokens ?? -1, tokenCountMax: maxResponseTokens, promptTokenCount: chatCompletion.usage?.prompt_tokens, diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoDelta.ts b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoDelta.ts index 8cc41f29854782..69a183466f6612 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoDelta.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoDelta.ts @@ -50,10 +50,14 @@ export interface IBackgroundTodoDeltaMetadata { readonly newRoundCount: number; /** Total number of individual tool calls across new rounds. */ readonly newToolCallCount: number; - /** Number of substantive (non-excluded) tool calls in new rounds. - * Substantive = the agent did real work (reads, edits, terminal, - * searches, subagents, etc); excluded = infrastructure noise. */ + /** Number of substantive (non-excluded) tool calls across ALL new rounds + * (current turn + any unprocessed history rounds). */ readonly substantiveToolCallCount: number; + /** Number of substantive tool calls from the current turn only + * (`promptContext.toolCallRounds`). Used by the invocation policy + * so that unprocessed rounds from previous turns don't inflate the + * threshold and trigger spurious passes. */ + readonly currentTurnSubstantiveToolCallCount: number; /** True when this is the very first delta for the session (no rounds processed yet). */ readonly isInitialDelta: boolean; /** True when the delta contains only a user request and zero new rounds. */ @@ -125,6 +129,26 @@ export class BackgroundTodoDeltaTracker { } } + // Count substantive calls from current-turn rounds only so that + // unprocessed history rounds don't inflate the policy threshold. + const currentTurnRoundIds = new Set(); + for (const round of currentRounds) { + if (!this._processedRoundIds.has(round.id)) { + currentTurnRoundIds.add(round.id); + } + } + let currentTurnSubstantiveToolCallCount = 0; + for (const round of newRounds) { + if (!currentTurnRoundIds.has(round.id)) { + continue; + } + for (const call of round.toolCalls) { + if (classifyTool(call.name) === 'substantive') { + currentTurnSubstantiveToolCallCount++; + } + } + } + return { userRequest, newRounds, @@ -134,6 +158,7 @@ export class BackgroundTodoDeltaTracker { newRoundCount: newRounds.length, newToolCallCount, substantiveToolCallCount, + currentTurnSubstantiveToolCallCount, isInitialDelta, isRequestOnly: newRounds.length === 0, }, diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts index c587a39e9de0c5..3df0a713d38d7e 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoProcessor.ts @@ -85,6 +85,8 @@ export interface IBackgroundTodoPolicyInput { readonly isAgentPrompt: boolean; /** The current prompt context for delta computation. */ readonly promptContext: IBuildPromptContext; + /** ID of the current user turn, used to reset turn-scoped policy backoff. */ + readonly turnId?: string; /** Whether a todo list already exists for this session. `undefined` means unknown. */ readonly todoListExists?: boolean; } @@ -156,6 +158,8 @@ export class BackgroundTodoProcessor { /** Number of consecutive no-op passes that completed while no todos had been * created yet. Used to back off the initial-branch firing threshold. */ private _consecutiveInitialNoops: number = 0; + /** Turn ID most recently observed by policy evaluation or direct regular-pass queueing. */ + private _lastSeenTurnId: string | undefined; // ── Two-slot queue ────────────────────────────────────────── // Regular passes coalesce into one slot; final review occupies a @@ -172,11 +176,6 @@ export class BackgroundTodoProcessor { /** Turn ID for which final review has already been attempted/queued. * Prevents duplicate finalize passes within a single turn. */ private _finalReviewAttemptedTurnId: string | undefined; - /** The most recent execution context from any {@link requestRegularPass} - * call. Used by {@link requestFinalReview} to build the synthetic - * final-review delta when no explicit context is provided. */ - private _lastExecutionContext: IBackgroundTodoExecutionContext | undefined; - readonly deltaTracker = new BackgroundTodoDeltaTracker(); constructor( @@ -198,6 +197,8 @@ export class BackgroundTodoProcessor { * Callers supply only the external context they already have. */ shouldRun(input: IBackgroundTodoPolicyInput): IBackgroundTodoDecisionResult { + this._resetInitialBackoffForTurn(input.turnId); + // ── Hard gates ──────────────────────────────────────────── if (input.todoToolExplicitlyEnabled) { return { decision: BackgroundTodoDecision.Skip, reason: 'todoToolExplicitlyEnabled' }; @@ -219,7 +220,7 @@ export class BackgroundTodoProcessor { return { decision: BackgroundTodoDecision.Wait, reason: 'processorInProgress', delta }; } - const { substantiveToolCallCount, isInitialDelta, isRequestOnly } = delta.metadata; + const { currentTurnSubstantiveToolCallCount, isInitialDelta, isRequestOnly } = delta.metadata; // ── Initial request (no tool calls yet) ──────────────────── if (isRequestOnly && isInitialDelta) { @@ -249,39 +250,50 @@ export class BackgroundTodoProcessor { BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD << this._consecutiveInitialNoops, BackgroundTodoProcessor.MAX_INITIAL_BACKOFF_THRESHOLD, ); - if (substantiveToolCallCount >= effectiveThreshold) { - this._logService?.debug(`[BackgroundTodo] policy: Run (initialActivity) β€” substantive=${substantiveToolCallCount} >= effective threshold=${effectiveThreshold} (noops=${this._consecutiveInitialNoops}), rounds=${delta.metadata.newRoundCount}`); + if (currentTurnSubstantiveToolCallCount >= effectiveThreshold) { + this._logService?.debug(`[BackgroundTodo] policy: Run (initialActivity) β€” substantive=${currentTurnSubstantiveToolCallCount} >= effective threshold=${effectiveThreshold} (noops=${this._consecutiveInitialNoops}), rounds=${delta.metadata.newRoundCount}`); return { decision: BackgroundTodoDecision.Run, reason: 'initialActivity', delta }; } const reason = this._consecutiveInitialNoops > 0 ? 'initialBackoff' : 'belowThreshold'; - this._logService?.debug(`[BackgroundTodo] policy: Wait (${reason}) β€” substantive=${substantiveToolCallCount} < effective threshold=${effectiveThreshold} (noops=${this._consecutiveInitialNoops}), rounds=${delta.metadata.newRoundCount}`); + this._logService?.debug(`[BackgroundTodo] policy: Wait (${reason}) β€” substantive=${currentTurnSubstantiveToolCallCount} < effective threshold=${effectiveThreshold} (noops=${this._consecutiveInitialNoops}), rounds=${delta.metadata.newRoundCount}`); return { decision: BackgroundTodoDecision.Wait, reason, delta }; } // ── Subsequent passes (todos already exist) ───────────────── - if (substantiveToolCallCount >= BackgroundTodoProcessor.SUBSEQUENT_SUBSTANTIVE_THRESHOLD) { - this._logService?.debug(`[BackgroundTodo] policy: Run (substantiveActivity) β€” substantive=${substantiveToolCallCount} >= threshold=${BackgroundTodoProcessor.SUBSEQUENT_SUBSTANTIVE_THRESHOLD}, rounds=${delta.metadata.newRoundCount}`); + if (currentTurnSubstantiveToolCallCount >= BackgroundTodoProcessor.SUBSEQUENT_SUBSTANTIVE_THRESHOLD) { + this._logService?.debug(`[BackgroundTodo] policy: Run (substantiveActivity) β€” substantive=${currentTurnSubstantiveToolCallCount} >= threshold=${BackgroundTodoProcessor.SUBSEQUENT_SUBSTANTIVE_THRESHOLD}, rounds=${delta.metadata.newRoundCount}`); return { decision: BackgroundTodoDecision.Run, reason: 'substantiveActivity', delta }; } - this._logService?.debug(`[BackgroundTodo] policy: Wait (belowThreshold) β€” substantive=${substantiveToolCallCount}, rounds=${delta.metadata.newRoundCount}`); + this._logService?.debug(`[BackgroundTodo] policy: Wait (belowThreshold) β€” substantive=${currentTurnSubstantiveToolCallCount}, rounds=${delta.metadata.newRoundCount}`); return { decision: BackgroundTodoDecision.Wait, reason: 'belowThreshold', delta }; } + private _resetInitialBackoffForTurn(turnId: string | undefined): void { + if (turnId !== undefined && turnId !== this._lastSeenTurnId) { + this._consecutiveInitialNoops = 0; + this._lastSeenTurnId = turnId; + } + } + // ── Public queue API ──────────────────────────────────────── /** * Enqueue or coalesce a regular background pass. If a pass is already * running, the delta is stashed and will drain when the current pass - * completes. Always updates {@link _lastExecutionContext}. + * completes. + * + * @param turnId The ID of the turn that triggered this pass. Kept for direct + * queueing callers that do not evaluate {@link shouldRun} first. */ requestRegularPass( delta: IBackgroundTodoDelta, context: IBackgroundTodoExecutionContext, parentToken?: CancellationToken, + turnId?: string, ): void { - this._lastExecutionContext = context; - this._logService?.debug(`[BackgroundTodo] requestRegularPass β€” newRounds=${delta.metadata.newRoundCount}, substantive=${delta.metadata.substantiveToolCallCount}, state=${this._state}`); + this._resetInitialBackoffForTurn(turnId); + this._logService?.debug(`[BackgroundTodo] requestRegularPass β€” newRounds=${delta.metadata.newRoundCount}, substantive=${delta.metadata.substantiveToolCallCount}, state=${this._state}, turnId=${turnId}`); this._pendingRegularDelta = delta; this._pendingRegularContext = context; this._pendingRegularToken = parentToken; @@ -294,13 +306,12 @@ export class BackgroundTodoProcessor { * the processor is currently Idle, InProgress, or Failed. * * No-op when: - * - No execution context has been recorded (no prompt build happened). * - No todos have been created yet (nothing to finalize). * - Final review was already requested for the given {@link turnId}. */ - requestFinalReview(turnId: string, parentToken?: CancellationToken): void { - if (!this._hasCreatedTodos || !this._lastExecutionContext) { - this._logService?.debug(`[BackgroundTodo] final review skipped β€” hasCreatedTodos=${this._hasCreatedTodos}, hasExecutionContext=${this._lastExecutionContext !== undefined}`); + requestFinalReview(turnId: string, context: IBackgroundTodoExecutionContext, parentToken?: CancellationToken): void { + if (!this._hasCreatedTodos) { + this._logService?.debug('[BackgroundTodo] final review skipped - no todos have been created'); return; } if (this._finalReviewAttemptedTurnId === turnId) { @@ -310,7 +321,7 @@ export class BackgroundTodoProcessor { this._finalReviewAttemptedTurnId = turnId; this._logService?.debug(`[BackgroundTodo] final review requested for turn ${turnId} β€” currentState=${this._state}`); - this._pendingFinalReview = { ...this._lastExecutionContext, isFinalReview: true }; + this._pendingFinalReview = { ...context, isFinalReview: true }; this._pendingFinalReviewToken = parentToken; this._drainQueue(); } @@ -436,13 +447,14 @@ export class BackgroundTodoProcessor { // Build a synthetic delta from the full trajectory so the // finalize prompt sees every round. - const allRounds = collectAllRounds( + const allRoundsWithTurns = collectAllRounds( finalCtx.promptContext.history, finalCtx.promptContext.toolCallRounds ?? [], ); - if (allRounds.length === 0) { + if (allRoundsWithTurns.length === 0) { return; } + const allRounds = allRoundsWithTurns.map(r => r.round); let substantive = 0; for (const round of allRounds) { for (const call of round.toolCalls) { @@ -460,6 +472,7 @@ export class BackgroundTodoProcessor { newRoundCount: allRounds.length, newToolCallCount: substantive, substantiveToolCallCount: substantive, + currentTurnSubstantiveToolCallCount: substantive, isInitialDelta: false, isRequestOnly: false, }, @@ -778,7 +791,7 @@ export class BackgroundTodoProcessor { this._pendingRegularAdvanceCursor = true; this._pendingFinalReview = undefined; this._pendingFinalReviewToken = undefined; - this._lastExecutionContext = undefined; + this._lastSeenTurnId = undefined; this._finalReviewAttemptedTurnId = undefined; } } @@ -955,6 +968,9 @@ export interface IBackgroundTodoHistoryRound { readonly id: string; /** Position in the chronological list, starting at 1. */ readonly index: number; + /** 1-based turn index this round belongs to. Rounds from history turns + * precede the current turn's rounds. Used to render `` boundaries. */ + readonly turnIndex: number; /** Optional model thinking text rendered as a block in the round chunk. */ readonly thinking?: string; /** Tool calls issued during the round; excluded tools are filtered out. */ @@ -980,7 +996,7 @@ export interface IBackgroundTodoHistory { // ── Builder ───────────────────────────────────────────────────── export interface IBuildBackgroundTodoHistoryOptions { - readonly allRounds: readonly IToolCallRound[]; + readonly allRounds: readonly IToolCallRoundWithTurn[]; readonly newRoundIds: ReadonlySet; } @@ -991,7 +1007,8 @@ export function buildBackgroundTodoHistory(opts: IBuildBackgroundTodoHistoryOpti const newRounds: IBackgroundTodoHistoryRound[] = []; let index = 0; - for (const round of allRounds) { + for (const roundWithTurn of allRounds) { + const round = roundWithTurn.round; const summaries = summarizeToolCalls(round.toolCalls); const thinking = serializeThinking(round.thinking); const response = round.response.trim().length > 0 ? round.response : undefined; @@ -1005,6 +1022,7 @@ export function buildBackgroundTodoHistory(opts: IBuildBackgroundTodoHistoryOpti const historyRound: IBackgroundTodoHistoryRound = { id: round.id, index, + turnIndex: roundWithTurn.turnIndex, thinking, toolCalls: summaries, response, @@ -1117,6 +1135,33 @@ export function renderBackgroundTodoRound(round: IBackgroundTodoHistoryRound): s return lines.join('\n'); } +/** + * Render a list of rounds grouped by `turnIndex`, wrapping consecutive + * same-turn rounds inside `…` tags. This saves + * tokens compared to repeating a `turn` attribute on every ``. + */ +export function renderRoundsGroupedByTurn(rounds: readonly IBackgroundTodoHistoryRound[]): string { + if (rounds.length === 0) { + return ''; + } + const lines: string[] = []; + let currentTurn: number | undefined; + for (const round of rounds) { + if (round.turnIndex !== currentTurn) { + if (currentTurn !== undefined) { + lines.push(''); + } + lines.push(``); + currentTurn = round.turnIndex; + } + lines.push(renderBackgroundTodoRound(round)); + } + if (currentTurn !== undefined) { + lines.push(''); + } + return lines.join('\n'); +} + /** * Compute a prompt-tsx priority for a previous-context round so newer * rounds survive budget pressure ahead of older history. Values are @@ -1131,17 +1176,27 @@ export function computeRoundPriority(round: IBackgroundTodoHistoryRound, totalPr return Math.min(879, 700 + Math.min(round.index, totalPreviousRounds)); } +/** A tool-call round annotated with its 1-based turn index. */ +export interface IToolCallRoundWithTurn { + readonly round: IToolCallRound; + readonly turnIndex: number; +} + /** * Collect all tool-call rounds from history turns and current-turn rounds - * in chronological order. + * in chronological order, annotated with 1-based turn indices. */ -export function collectAllRounds(history: readonly Turn[], currentRounds: readonly IToolCallRound[]): IToolCallRound[] { - const all: IToolCallRound[] = []; - for (const turn of history) { - for (const round of turn.rounds) { - all.push(round); +export function collectAllRounds(history: readonly Turn[], currentRounds: readonly IToolCallRound[]): IToolCallRoundWithTurn[] { + const all: IToolCallRoundWithTurn[] = []; + for (let i = 0; i < history.length; i++) { + const turnIndex = i + 1; + for (const round of history[i].rounds) { + all.push({ round, turnIndex }); } } - all.push(...currentRounds); + const currentTurnIndex = history.length + 1; + for (const round of currentRounds) { + all.push({ round, turnIndex: currentTurnIndex }); + } return all; } diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx index 405fb3a555a683..2479ded1279cfd 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { BasePromptElementProps, Chunk, PrioritizedList, PromptElement, PromptSizing, SystemMessage, UserMessage } from '@vscode/prompt-tsx'; -import { computeRoundPriority, escapeForPromptTag, IBackgroundTodoHistory, IBackgroundTodoHistoryRound, renderBackgroundTodoRound } from './backgroundTodoProcessor'; +import { computeRoundPriority, escapeForPromptTag, IBackgroundTodoHistory, IBackgroundTodoHistoryRound, renderBackgroundTodoRound, renderRoundsGroupedByTurn } from './backgroundTodoProcessor'; export interface BackgroundTodoPromptProps extends BasePromptElementProps { /** Current todo list state as rendered markdown, or undefined if no todos exist yet. */ @@ -27,8 +27,15 @@ Trajectory format: - The agent trajectory is split into two sections: - contains rounds from before this background pass. They provide continuity context only β€” do not treat them as new work. - contains the rounds that happened since your previous background pass. Use these to decide whether the todo list should change. +- Each block carries an index attribute. Rounds are grouped inside wrappers. A turn is one user message plus all the agent work that follows it. When the turn number changes, a new user message was sent. Rounds from earlier turns represent completed previous interactions. - Each block may contain the agent's optional , a list (with file path or category target and an optional intent note), and a with the assistant text that followed. +Cross-turn rules: +- Work from previous turns is already finished. Their rounds are context for what was accomplished before, not new activity. +- If a todo list already exists and all rounds in belong to the same turn as the latest user message, compare the new work against the current list. Only call the tool if statuses or items need updating based on the new work in the current turn. +- Never recreate or re-emit a todo list just because previous turns' rounds are visible in . The current todo list already reflects that work. +- If the new turn's activity is trivial (e.g. a greeting, a question, or a simple acknowledgment with no substantive tool calls), do NOT update the todo list. + Do NOT call tools when: - The current todo list already accurately reflects the work: same items, same statuses, same order. This is the most common case β€” most rounds require no update. - No todo list exists yet and the task does not qualify for one (see below). @@ -109,7 +116,13 @@ Default to silence. Before calling manage_todo_list, ask yourself: "Would the up Trajectory format: - The agent trajectory is presented inside a single block containing a chronological list of blocks. Each round may contain the agent's optional , a list (with file path or category target and an optional intent note), and a with the assistant text that followed. -- This is a final review β€” reason about the entire trajectory. +- Each carries an index attribute. Rounds are grouped inside wrappers. A turn is one user message plus all the agent work that follows it. When the turn number changes, a new user message was sent. +- This is a final review β€” reason about the entire trajectory, but focus completion evidence on the current (latest) turn. + +Cross-turn rules: +- Rounds from earlier turns represent work that was already completed in previous interactions. Their outcomes should already be reflected in the current todo list. +- Only use rounds from the current (latest) turn to determine whether new items should be marked completed or in-progress. +- If the current turn had no substantive tool calls (e.g. the user just sent a greeting or asked a question), do NOT call the tool β€” the existing todo list is already accurate. Do NOT call tools when: - No todo list exists. @@ -139,14 +152,19 @@ interface PreviousContextRoundChunkProps extends BasePromptElementProps { /** * Prompt element rendering a single previous-context round as its own * Chunk so that prompt-tsx can drop older rounds independently under - * budget pressure. + * budget pressure. Each chunk is self-contained: it wraps its round + * in `` tags so that pruning any subset of rounds never produces + * unbalanced or mis-nested tags. */ class PreviousContextRoundChunk extends PromptElement { render() { const priority = computeRoundPriority(this.props.round, this.props.totalPreviousRounds); + const { round } = this.props; return ( - {renderBackgroundTodoRound(this.props.round)} + {`\n`} + {renderBackgroundTodoRound(round)} + {'\n'} ); } @@ -225,7 +243,7 @@ export class BackgroundTodoPrompt extends PromptElement {'\nUse these rounds to decide whether the todo list needs updating:\n'} - {history.newRounds.map(round => renderBackgroundTodoRound(round)).join('\n')} + {renderRoundsGroupedByTurn(history.newRounds)} {'\n'} )} diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoDelta.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoDelta.spec.ts index d9866e0971b674..481c6453ab87dc 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoDelta.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoDelta.spec.ts @@ -182,6 +182,62 @@ describe('BackgroundTodoDeltaTracker', () => { expect(delta.metadata.isRequestOnly).toBe(false); }); + // ── currentTurnSubstantiveToolCallCount ────────────────────── + + test('currentTurnSubstantiveToolCallCount counts only current-turn rounds', () => { + const tracker = new BackgroundTodoDeltaTracker(); + const historyRound: IToolCallRound = { + id: 'h1', response: '', toolInputRetry: 0, + toolCalls: [ + { name: 'read_file', arguments: '{}', id: 'tc-h1a' }, + { name: 'edit_file', arguments: '{}', id: 'tc-h1b' }, + ], + }; + const currentRound: IToolCallRound = { + id: 'c1', response: '', toolInputRetry: 0, + toolCalls: [{ name: 'read_file', arguments: '{}', id: 'tc-c1' }], + }; + const ctx = makePromptContext({ historyRounds: [[historyRound]], toolCallRounds: [currentRound] }); + const delta = tracker.peekDelta(ctx)!; + // Total substantive counts all unprocessed rounds (2 from history + 1 current) + expect(delta.metadata.substantiveToolCallCount).toBe(3); + // Current-turn only counts the current round (1) + expect(delta.metadata.currentTurnSubstantiveToolCallCount).toBe(1); + }); + + test('currentTurnSubstantiveToolCallCount is zero when all rounds are from history', () => { + const tracker = new BackgroundTodoDeltaTracker(); + const historyRound = makeRound('h1'); + const ctx = makePromptContext({ historyRounds: [[historyRound]] }); + const delta = tracker.peekDelta(ctx)!; + expect(delta.metadata.substantiveToolCallCount).toBe(1); + expect(delta.metadata.currentTurnSubstantiveToolCallCount).toBe(0); + }); + + test('currentTurnSubstantiveToolCallCount excludes already-processed current-turn rounds', () => { + const tracker = new BackgroundTodoDeltaTracker(); + const r1 = makeRound('r1'); + const ctx1 = makePromptContext({ toolCallRounds: [r1] }); + tracker.markProcessed(tracker.peekDelta(ctx1)!); + + // r1 is processed, r2 is new β€” only r2 should count + const r2 = makeRound('r2'); + const ctx2 = makePromptContext({ toolCallRounds: [r1, r2] }); + const delta = tracker.peekDelta(ctx2)!; + expect(delta.metadata.currentTurnSubstantiveToolCallCount).toBe(1); + expect(delta.metadata.substantiveToolCallCount).toBe(1); + }); + + test('currentTurnSubstantiveToolCallCount equals substantiveToolCallCount when no history', () => { + const tracker = new BackgroundTodoDeltaTracker(); + const r1 = makeRound('r1'); + const r2 = makeRound('r2'); + const ctx = makePromptContext({ toolCallRounds: [r1, r2] }); + const delta = tracker.peekDelta(ctx)!; + expect(delta.metadata.currentTurnSubstantiveToolCallCount).toBe(2); + expect(delta.metadata.substantiveToolCallCount).toBe(2); + }); + // ── Peek / commit semantics ───────────────────────────────── test('peekDelta does not advance cursor', () => { diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoHistory.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoHistory.spec.ts index 1e7b3137347287..f5ef547cab6c44 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoHistory.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoHistory.spec.ts @@ -14,7 +14,9 @@ import { extractTarget, extractToolNote, IBackgroundTodoHistoryRound, + IToolCallRoundWithTurn, renderBackgroundTodoRound, + renderRoundsGroupedByTurn, } from '../backgroundTodoProcessor'; function makeCall(name: string, args: Record = {}, id?: string): IToolCall { @@ -29,6 +31,10 @@ function makeRound(id: string, calls: IToolCall[], response = '', thinkingText?: return round; } +function wrapRound(round: IToolCallRound, turnIndex: number = 1): IToolCallRoundWithTurn { + return { round, turnIndex }; +} + describe('classifyTool', () => { test('classifies tool categories consistently', () => { expect({ @@ -116,12 +122,12 @@ describe('extractToolNote', () => { }); describe('collectAllRounds', () => { - test('combines history and current rounds in order', () => { + test('combines history and current rounds in order with turn indices', () => { const historyRound = makeRound('h1', [makeCall(ToolName.ReadFile)]); const currentRound = makeRound('c1', [makeCall(ToolName.CreateFile)]); const history = [{ rounds: [historyRound] }] as any; const result = collectAllRounds(history, [currentRound]); - expect(result.map(r => r.id)).toEqual(['h1', 'c1']); + expect(result.map(r => ({ id: r.round.id, turnIndex: r.turnIndex }))).toEqual([{ id: 'h1', turnIndex: 1 }, { id: 'c1', turnIndex: 2 }]); }); }); @@ -130,13 +136,14 @@ describe('buildBackgroundTodoHistory', () => { const r1 = makeRound('r1', [makeCall(ToolName.ReadFile, { filePath: 'src/a.ts' })], 'Read the file', 'Plan: read the file'); const r2 = makeRound('r2', [makeCall(ToolName.ReplaceString, { filePath: 'src/a.ts', explanation: 'fix typo' })], 'Done'); const result = buildBackgroundTodoHistory({ - allRounds: [r1, r2], + allRounds: [wrapRound(r1, 1), wrapRound(r2, 1)], newRoundIds: new Set(['r2']), }); expect(result.previousRounds.map(round => ({ id: round.id, index: round.index, + turnIndex: round.turnIndex, thinking: round.thinking, toolCalls: round.toolCalls, response: round.response, @@ -144,6 +151,7 @@ describe('buildBackgroundTodoHistory', () => { { id: 'r1', index: 1, + turnIndex: 1, thinking: 'Plan: read the file', toolCalls: [{ name: ToolName.ReadFile, target: 'src/a.ts', category: 'substantive' }], response: 'Read the file', @@ -153,6 +161,7 @@ describe('buildBackgroundTodoHistory', () => { expect(result.newRounds.map(round => ({ id: round.id, index: round.index, + turnIndex: round.turnIndex, thinking: round.thinking, toolCalls: round.toolCalls, response: round.response, @@ -160,6 +169,7 @@ describe('buildBackgroundTodoHistory', () => { { id: 'r2', index: 2, + turnIndex: 1, thinking: undefined, toolCalls: [{ name: ToolName.ReplaceString, target: 'src/a.ts', note: 'fix typo', category: 'substantive' }], response: 'Done', @@ -169,13 +179,13 @@ describe('buildBackgroundTodoHistory', () => { test('thinking with array text is joined and trimmed', () => { const r1 = makeRound('r1', [makeCall(ToolName.ReadFile, { filePath: 'a.ts' })], '', [' step one ', 'step two']); - const result = buildBackgroundTodoHistory({ allRounds: [r1], newRoundIds: new Set() }); + const result = buildBackgroundTodoHistory({ allRounds: [wrapRound(r1)], newRoundIds: new Set() }); expect(result.previousRounds[0].thinking).toBe('step one \nstep two'); }); test('skips entirely empty rounds', () => { const empty = makeRound('r1', [makeCall(ToolName.CoreManageTodoList)]); - const result = buildBackgroundTodoHistory({ allRounds: [empty], newRoundIds: new Set() }); + const result = buildBackgroundTodoHistory({ allRounds: [wrapRound(empty)], newRoundIds: new Set() }); expect(result.previousRounds).toHaveLength(0); expect(result.newRounds).toHaveLength(0); }); @@ -184,7 +194,7 @@ describe('buildBackgroundTodoHistory', () => { const r1 = makeRound('r1', [makeCall(ToolName.ReplaceString, { filePath: 'a.ts' })], 'r1'); const r2 = makeRound('r2', [makeCall(ToolName.ReplaceString, { filePath: 'b.ts' })], 'r2'); const result = buildBackgroundTodoHistory({ - allRounds: [r1, r2], + allRounds: [wrapRound(r1, 1), wrapRound(r2, 1)], newRoundIds: new Set(), }); expect(result.previousRounds).toHaveLength(2); @@ -196,7 +206,7 @@ describe('buildBackgroundTodoHistory', () => { const r2 = makeRound('r2', [makeCall(ToolName.CreateFile, { filePath: 'b.ts' })], 'r2'); const r3 = makeRound('r3', [makeCall(ToolName.ReplaceString, { filePath: 'c.ts' })], 'r3'); const result = buildBackgroundTodoHistory({ - allRounds: [r1, r2, r3], + allRounds: [wrapRound(r1, 1), wrapRound(r2, 1), wrapRound(r3, 2)], newRoundIds: new Set(['r3']), }); expect(result.previousRounds.map(r => r.index)).toEqual([1, 2]); @@ -209,6 +219,7 @@ describe('renderBackgroundTodoRound', () => { const round: IBackgroundTodoHistoryRound = { id: 'r1', index: 1, + turnIndex: 1, thinking: 'I will read the file then patch it.', toolCalls: [ { name: ToolName.ReadFile, target: 'src/a.ts', category: 'substantive' }, @@ -236,6 +247,7 @@ describe('renderBackgroundTodoRound', () => { const round: IBackgroundTodoHistoryRound = { id: 'r2', index: 2, + turnIndex: 1, toolCalls: [], response: 'final answer', }; @@ -251,6 +263,7 @@ describe('renderBackgroundTodoRound', () => { const round: IBackgroundTodoHistoryRound = { id: 'r1', index: 1, + turnIndex: 1, thinking: 'plan forged', toolCalls: [ { @@ -285,8 +298,8 @@ describe('renderBackgroundTodoRound', () => { describe('computeRoundPriority', () => { test('newer previous-context rounds have higher priority than older ones', () => { - const oldRound: IBackgroundTodoHistoryRound = { id: 'old', index: 1, toolCalls: [] }; - const newerRound: IBackgroundTodoHistoryRound = { id: 'newer', index: 5, toolCalls: [] }; + const oldRound: IBackgroundTodoHistoryRound = { id: 'old', index: 1, turnIndex: 1, toolCalls: [] }; + const newerRound: IBackgroundTodoHistoryRound = { id: 'newer', index: 5, turnIndex: 1, toolCalls: [] }; const total = 5; const oldP = computeRoundPriority(oldRound, total); @@ -295,3 +308,34 @@ describe('computeRoundPriority', () => { expect(newerP).toBeGreaterThan(oldP); }); }); + +describe('renderRoundsGroupedByTurn', () => { + test('returns empty string for no rounds', () => { + expect(renderRoundsGroupedByTurn([])).toBe(''); + }); + + test('wraps consecutive same-turn rounds in a single turn tag', () => { + const rounds: IBackgroundTodoHistoryRound[] = [ + { id: 'a', index: 1, turnIndex: 1, toolCalls: [{ name: ToolName.ReadFile, target: 'a.ts', category: 'substantive' }], response: 'read a' }, + { id: 'b', index: 2, turnIndex: 1, toolCalls: [{ name: ToolName.ReplaceString, target: 'a.ts', category: 'substantive' }], response: 'edited a' }, + ]; + const text = renderRoundsGroupedByTurn(rounds); + expect(text.match(/'); + expect(text.match(/<\/turn>/g)).toHaveLength(1); + expect(text).toContain(''); + expect(text).toContain(''); + }); + + test('opens a new turn tag when turnIndex changes', () => { + const rounds: IBackgroundTodoHistoryRound[] = [ + { id: 'a', index: 1, turnIndex: 1, toolCalls: [{ name: ToolName.ReadFile, target: 'a.ts', category: 'substantive' }], response: 'r1' }, + { id: 'b', index: 2, turnIndex: 2, toolCalls: [{ name: ToolName.CreateFile, target: 'b.ts', category: 'substantive' }], response: 'r2' }, + ]; + const text = renderRoundsGroupedByTurn(rounds); + expect(text.match(/'); + expect(text).toContain(''); + expect(text.match(/<\/turn>/g)).toHaveLength(2); + }); +}); diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoPolicy.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoPolicy.spec.ts index 959a1f64a4e726..abec5946d16792 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoPolicy.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoPolicy.spec.ts @@ -83,7 +83,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { test('returns Wait when processor is already InProgress', async () => { const processor = new BackgroundTodoProcessor(); - const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; + const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, currentTurnSubstantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; processor.start( { userRequest: 'old', newRounds: [makeMeaningfulRound('r0')], history: [], sessionResource: undefined, metadata: dummyMeta }, async () => { @@ -128,7 +128,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { test('skips when processor has already created todos and no new activity', async () => { const processor = new BackgroundTodoProcessor(); - const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; + const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, currentTurnSubstantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; // Simulate a successful pass processor.start( { userRequest: 'old', newRounds: [makeMeaningfulRound('r0')], history: [], sessionResource: undefined, metadata: dummyMeta }, @@ -199,7 +199,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { test('after first pass, waits until subsequent threshold is met', async () => { const processor = new BackgroundTodoProcessor(); - const dummyMeta = { newRoundCount: 1, newToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, substantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, isInitialDelta: true, isRequestOnly: false }; + const dummyMeta = { newRoundCount: 1, newToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, substantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, currentTurnSubstantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, isInitialDelta: true, isRequestOnly: false }; // Simulate a successful first pass so hasCreatedTodos becomes true. processor.start( { userRequest: 'old', newRounds: Array.from({ length: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD }, (_, i) => makeMeaningfulRound(`r${i}`)), history: [], sessionResource: undefined, metadata: dummyMeta }, @@ -227,7 +227,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { test('subsequent threshold is met by any mix of substantive calls', async () => { const processor = new BackgroundTodoProcessor(); - const dummyMeta = { newRoundCount: 1, newToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, substantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, isInitialDelta: true, isRequestOnly: false }; + const dummyMeta = { newRoundCount: 1, newToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, substantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, currentTurnSubstantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, isInitialDelta: true, isRequestOnly: false }; processor.start( { userRequest: 'old', newRounds: Array.from({ length: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD }, (_, i) => makeMeaningfulRound(`r${i}`)), history: [], sessionResource: undefined, metadata: dummyMeta }, async () => ({ outcome: 'success' }) @@ -288,7 +288,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { test('hasCreatedTodos becomes true after successful pass', async () => { const processor = new BackgroundTodoProcessor(); - const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; + const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, currentTurnSubstantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; processor.start( { userRequest: 'test', newRounds: [makeMeaningfulRound('r1')], history: [], sessionResource: undefined, metadata: dummyMeta }, async () => ({ outcome: 'success' }) @@ -299,7 +299,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { test('hasCreatedTodos stays false after noop pass', async () => { const processor = new BackgroundTodoProcessor(); - const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; + const dummyMeta = { newRoundCount: 1, newToolCallCount: 1, substantiveToolCallCount: 1, currentTurnSubstantiveToolCallCount: 1, isInitialDelta: true, isRequestOnly: false }; processor.start( { userRequest: 'test', newRounds: [makeMeaningfulRound('r1')], history: [], sessionResource: undefined, metadata: dummyMeta }, async () => ({ outcome: 'noop' }) @@ -319,6 +319,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { newRoundCount: firstBatchRounds.length, newToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, substantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, + currentTurnSubstantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, isInitialDelta: true, isRequestOnly: false, }; @@ -346,6 +347,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { newRoundCount: firstBatchRounds.length, newToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, substantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, + currentTurnSubstantiveToolCallCount: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD, isInitialDelta: true, isRequestOnly: false, }; @@ -364,6 +366,27 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { expect(result.reason).toBe('initialActivity'); }); + test('new turns reset initial backoff during policy evaluation', async () => { + const processor = new BackgroundTodoProcessor(); + const firstTurnRounds = Array.from({ length: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD }, (_, i) => makeContextRound(`turn-1-r${i}`)); + const firstTurnDecision = processor.shouldRun(makeInput({ + promptContext: makePromptContext({ toolCallRounds: firstTurnRounds }), + turnId: 'turn-1', + })); + expect(firstTurnDecision.decision).toBe(BackgroundTodoDecision.Run); + + processor.start(firstTurnDecision.delta!, async () => ({ outcome: 'noop' })); + await processor.waitForCompletion(); + + const secondTurnRounds = Array.from({ length: BackgroundTodoProcessor.INITIAL_SUBSTANTIVE_THRESHOLD }, (_, i) => makeContextRound(`turn-2-r${i}`)); + const secondTurnDecision = processor.shouldRun(makeInput({ + promptContext: makePromptContext({ toolCallRounds: secondTurnRounds }), + turnId: 'turn-2', + })); + expect(secondTurnDecision.decision).toBe(BackgroundTodoDecision.Run); + expect(secondTurnDecision.reason).toBe('initialActivity'); + }); + test('threshold is capped at MAX_INITIAL_BACKOFF_THRESHOLD and agent still monitors', async () => { const processor = new BackgroundTodoProcessor(); @@ -376,6 +399,7 @@ describe('BackgroundTodoProcessor.shouldRun (policy)', () => { newRoundCount: rounds.length, newToolCallCount: threshold, substantiveToolCallCount: threshold, + currentTurnSubstantiveToolCallCount: threshold, isInitialDelta: batchIdx === 0, isRequestOnly: false, }; diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts index b2aeeb2273ddbe..4ec18314e82ddf 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts @@ -23,6 +23,7 @@ function makeDelta(rounds: string[] = []): IBackgroundTodoDelta { newRoundCount: rounds.length, newToolCallCount: 0, substantiveToolCallCount: 0, + currentTurnSubstantiveToolCallCount: 0, isInitialDelta: true, isRequestOnly: rounds.length === 0, }, @@ -216,37 +217,25 @@ describe('BackgroundTodoProcessor', () => { // ── requestFinalReview ────────────────────────────────────── - test('requestFinalReview is a no-op when no context has been recorded', () => { - const processor = new BackgroundTodoProcessor(); - processor.requestFinalReview('turn-1'); - expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); - }); - test('requestFinalReview is a no-op when no todos have been created', async () => { const processor = new BackgroundTodoProcessor(); - // Simulate a noop pass so a context exists but hasCreatedTodos remains false + // Simulate a noop pass so hasCreatedTodos remains false. processor.start(makeDelta(['r1']), async () => ({ outcome: 'noop' })); await processor.waitForCompletion(); expect(processor.hasCreatedTodos).toBe(false); - processor.requestFinalReview('turn-1'); + processor.requestFinalReview('turn-1', makeExecutionContext(['r1'])); expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); }); test('requestFinalReview runs when processor is idle and todos exist', async () => { const processor = new BackgroundTodoProcessor(); - // Use requestRegularPass so _lastExecutionContext is recorded - processor.requestRegularPass(makeDelta(['r1']), makeExecutionContext(['r1'])); - // Force hasCreatedTodos - await processor.waitForCompletion(); - // The work threw because the mock context has no real endpoint, but - // we need hasCreatedTodos = true. Use the low-level start() for that. - processor.start(makeDelta(['r2']), async () => ({ outcome: 'success' })); + processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' })); await processor.waitForCompletion(); expect(processor.hasCreatedTodos).toBe(true); expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); // Now request final review β€” it should transition to InProgress - processor.requestFinalReview('turn-1'); + processor.requestFinalReview('turn-1', makeExecutionContext(['r1'])); expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); await processor.waitForCompletion(); }); @@ -255,20 +244,33 @@ describe('BackgroundTodoProcessor', () => { const processor = new BackgroundTodoProcessor(); processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' })); await processor.waitForCompletion(); - // Record a context - processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r2'])); - await processor.waitForCompletion(); - // First request should be accepted - processor.requestFinalReview('turn-1'); + processor.requestFinalReview('turn-1', makeExecutionContext(['r1'])); expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); await processor.waitForCompletion(); // Second request with same turn ID should be a no-op - processor.requestFinalReview('turn-1'); + processor.requestFinalReview('turn-1', makeExecutionContext(['r1'])); expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); }); + test('requestFinalReview runs with current context even when regular work last ran in another turn', async () => { + const processor = new BackgroundTodoProcessor(); + // Simulate a successful pass so hasCreatedTodos becomes true + processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + expect(processor.hasCreatedTodos).toBe(true); + + // Record context for turn-1 + processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r2']), undefined, 'turn-1'); + await processor.waitForCompletion(); + + // The final turn never queued a regular pass, but it still has a current render context. + processor.requestFinalReview('turn-2', makeExecutionContext(['turn-2-round'])); + expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); + await processor.waitForCompletion(); + }); + test('requestFinalReview drains after a regular pass completes', async () => { const logMessages: string[] = []; const telemetryEvents: string[] = []; @@ -288,7 +290,7 @@ describe('BackgroundTodoProcessor', () => { // While in progress, record context and request final review processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r1', 'r2'], { telemetryEvents })); - processor.requestFinalReview('turn-1'); + processor.requestFinalReview('turn-1', makeExecutionContext(['r1', 'r2'], { telemetryEvents })); await processor.waitForCompletion(); diff --git a/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx b/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx index bb3fb0b4d16897..9d20d6c1f2d2a4 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx @@ -443,7 +443,10 @@ export class ChatToolReferences extends PromptElement { const name = toolReference.range ? this.props.promptContext.query.slice(toolReference.range[0], toolReference.range[1]) : undefined; try { const result = await this.toolsService.invokeToolWithEndpoint(tool.name, { input: { ...toolArgs, ...internalToolArgs }, toolInvocationToken: tools.toolInvocationToken }, this.promptEndpoint, token || CancellationToken.None); - sendInvokedToolTelemetry(this.instantiationService, this.promptEndpoint, this.telemetryService, tool.name, result); + sendInvokedToolTelemetry(this.instantiationService, this.promptEndpoint, this.telemetryService, tool.name, result, { + conversationId: this.props.promptContext.conversation?.sessionId, + requestId: this.props.promptContext.requestId, + }); results.push({ name, value: result }); } catch (err) { const errResult = toolCallErrorToResult(err); diff --git a/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts b/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts index c5833fc5aad9b4..658aa81b5ee2e2 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.spec.ts @@ -8,12 +8,14 @@ import type * as vscode from 'vscode'; import { IChatHookService, type IPreToolUseHookResult } from '../../../../../platform/chat/common/chatHookService'; import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider'; +import type { IChatEndpoint } from '../../../../../platform/networking/common/networking'; import { DeferredPromise } from '../../../../../util/vs/base/common/async'; import { CancellationToken } from '../../../../../util/vs/base/common/cancellation'; import { Event } from '../../../../../util/vs/base/common/event'; import { constObservable } from '../../../../../util/vs/base/common/observable'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry'; +import { SpyingTelemetryService } from '../../../../../platform/telemetry/node/spyingTelemetryService'; import { LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes'; import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection'; import type { Conversation } from '../../../../prompt/common/conversation'; @@ -595,6 +597,8 @@ describe('ChatToolCalls (toolCalling.tsx)', () => { // of undefined (reading "supportsVision")' when a tool result contained an image. const testingServiceCollection = createExtensionUnitTestingServices(); + const spyingTelemetryService = new SpyingTelemetryService(); + testingServiceCollection.define(ITelemetryService, spyingTelemetryService); const accessor = testingServiceCollection.createTestingAccessor(); const instantiationService = accessor.get(IInstantiationService); const endpointProvider = accessor.get(IEndpointProvider); @@ -621,14 +625,28 @@ describe('ChatToolCalls (toolCalling.tsx)', () => { expect(() => { sendInvokedToolTelemetry( instantiationService, - endpoint as any, // endpoint satisfies IChatEndpoint + endpoint as IChatEndpoint, telemetryService, 'testTool', toolResult, + { conversationId: 'conversation-id', requestId: 'request-id' }, ); }).not.toThrow(); // Give async rendering a moment to complete without unhandled rejection await new Promise(resolve => setTimeout(resolve, 100)); + + const telemetryEvent = spyingTelemetryService.getEvents().telemetryServiceEvents.find(event => event.eventName === 'agent.tool.responseLength'); + expect(telemetryEvent).toMatchObject({ + properties: { + conversationId: 'conversation-id', + requestId: 'request-id', + model: endpoint.model, + toolName: 'testTool', + }, + measurements: { + tokenCount: expect.any(Number), + }, + }); }); }); diff --git a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx index 987d13418f2315..5d41940fd8420f 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx @@ -317,7 +317,10 @@ function buildToolResultElement(accessor: ServicesAccessor, props: ToolResultOpt } toolResult = await toolsService.invokeToolWithEndpoint(props.toolCall.name, invocationOptions, promptEndpoint, props.token); - sendInvokedToolTelemetry(instantiationService, promptEndpoint, telemetryService, props.toolCall.name, toolResult); + sendInvokedToolTelemetry(instantiationService, promptEndpoint, telemetryService, props.toolCall.name, toolResult, { + conversationId: promptContext.conversation?.sessionId, + requestId: props.requestId, + }); // Run hook context handling after tool execution appendHookContext(toolResult, hookResult, chatHookService, props, inputObj, promptContext); @@ -489,7 +492,12 @@ class ToolResultElement extends PromptElement { this.props.startLine - 1, 0, this.props.endLine - 1, Infinity, ); - let contents = documentSnapshot.getText(range); + const rawContents = documentSnapshot.getText(range); + let hadLongLines = false; + let contents = rawContents.split('\n').map(line => { + if (line.length > MAX_LINE_LENGTH) { + hadLongLines = true; + return line.slice(0, MAX_LINE_LENGTH) + ' [truncated]'; + } + return line; + }).join('\n'); + + if (hadLongLines) { + contents += `\n[One or more long lines were truncated at ${MAX_LINE_LENGTH} characters]\n`; + } if (this.props.truncated) { contents += `\n[File content truncated at line ${this.props.endLine}. Use ${ToolName.ReadFile} with offset/limit parameters to view more.]\n`; diff --git a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx index 5503a55c18a30f..7e8206e2e4740d 100644 --- a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx @@ -38,13 +38,17 @@ suite('ReadFile', () => { // Create a large document for testing truncation (3000 lines to exceed MAX_LINES_PER_READ) const largeContent = Array.from({ length: 3000 }, (_, i) => `line ${i + 1}`).join('\n'); const largeDoc = createTextDocumentData(URI.file('/workspace/large.ts'), largeContent, 'ts').document; + // Create a document with long lines to test per-line truncation (each line is 2500 chars) + const longLine = 'x'.repeat(2500); + const longLinesContent = `normal line\n${longLine}\nanother normal line\n${longLine}`; + const longLinesDoc = createTextDocumentData(URI.file('/workspace/longlines.ts'), longLinesContent, 'ts').document; const services = createExtensionUnitTestingServices(); services.define(IWorkspaceService, new SyncDescriptor( TestWorkspaceService, [ [URI.file('/workspace')], - [testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc], + [testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc], ] )); accessor = services.createTestingAccessor(); @@ -185,6 +189,25 @@ suite('ReadFile', () => { expect(resultString).not.toContain('line 2001'); }); + test('long lines are truncated and a notice is appended', async () => { + const toolsService = accessor.get(IToolsService); + + const input: IReadFileParamsV2 = { + filePath: '/workspace/longlines.ts' + }; + const result = await toolsService.invokeTool(ToolName.ReadFile, { input, toolInvocationToken: null as never }, CancellationToken.None); + const resultString = await toolResultToString(accessor, result); + expect(resultString).toContain('normal line'); + expect(resultString).toContain('[truncated]'); + expect(resultString).toContain('[One or more long lines were truncated at 2000 characters]'); + // The truncated line should be at most 2000 chars + ' [truncated]' = ~2012 chars, not the full 2500 + const lines = resultString.split('\n'); + const longLines = lines.filter(l => l.includes('x'.repeat(100))); + for (const l of longLines) { + expect(l.length).toBeLessThan(2500); + } + }); + test('read file with offset beyond file line count should throw error', async () => { const toolsService = accessor.get(IToolsService); diff --git a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts index 3948b299e8dd1c..c72843e7e1e6e6 100644 --- a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts @@ -480,12 +480,28 @@ describe('SessionStoreSqlTool', () => { it('chronicle slash prompts reference the chronicle skill', () => { const promptDir = path.join(copilotRoot, 'assets', 'prompts'); - const prompts = ['chronicle-tips.prompt.md', 'chronicle-standup.prompt.md', 'chronicle-search.prompt.md']; + const prompts = ['chronicle-tips.prompt.md', 'chronicle-cost-tips.prompt.md', 'chronicle-standup.prompt.md', 'chronicle-search.prompt.md']; const missing = prompts.filter(name => { const body = fs.readFileSync(path.join(promptDir, name), 'utf-8'); return !/\*\*chronicle\*\* skill/.test(body); }); expect(missing).toEqual([]); }); + + it('chronicle SKILL.md Cost Tips section carries required anchors', () => { + const skill = fs.readFileSync( + path.join(copilotRoot, 'assets', 'prompts', 'skills', 'chronicle', 'SKILL.md'), + 'utf-8', + ); + const required = [ + '### Cost Tips', + 'usage_input_tokens', 'usage_output_tokens', 'usage_model', + 'agent_name', + 'assistant.usage', + 'local SQLite', + 'chat.sessionSync.enabled', + ]; + expect(required.filter(a => !skill.includes(a))).toEqual([]); + }); }); }); diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index 371417595c5582..b77771c89311c3 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -79,11 +79,17 @@ export enum ModelSupportedEndpoint { Messages = '/v1/messages' } -export interface IModelTokenPrices { - batch_size: number; - cache_price: number; +export interface IModelTokenPriceTier { input_price: number; output_price: number; + cache_price: number; + context_max: number; +} + +export interface IModelTokenPrices { + batch_size: number; + default: IModelTokenPriceTier; + long_context?: IModelTokenPriceTier; } export interface IModelBilling { diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index f27deabedd451c..7011c5a361daf5 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/logService'; import { isAnthropicContextEditingEnabled, isExtendedCacheTtlEnabled } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, ICopilotToolCall, OptionalChatRequestParams } from '../../networking/common/fetch'; import { IFetcherService, Response } from '../../networking/common/fetcherService'; -import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride } from '../../networking/common/networking'; +import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride, ITokenPriceTier } from '../../networking/common/networking'; import { CAPIChatMessage, ChatCompletion, FinishedCompletionReason, RawMessageConversionCallback } from '../../networking/common/openai'; import { prepareChatCompletionForReturn } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; @@ -31,7 +31,7 @@ import { ITokenizerProvider } from '../../tokenizer/node/tokenizer'; import { ICAPIClientService } from '../common/capiClient'; import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; import { IDomainService } from '../common/domainService'; -import { CustomModel, IChatModelInformation, IModelTokenPrices, ModelSupportedEndpoint } from '../common/endpointProvider'; +import { CustomModel, IChatModelInformation, IModelTokenPriceTier, IModelTokenPrices, ModelSupportedEndpoint } from '../common/endpointProvider'; import { createMessagesRequestBody, processResponseFromMessagesEndpoint } from './messagesApi'; import { createResponsesRequestBody, getResponsesApiCompactionThreshold, processResponseFromChatEndpoint } from './responsesApi'; import { filterHistoryImages } from './imageLimits'; @@ -112,25 +112,56 @@ export async function defaultNonStreamChatResponseProcessor(response: Response, return AsyncIterableObject.fromArray(completions); } -const AIC_DIVISOR = 1_000_000_000; const TOKENS_PER_MILLION = 1_000_000; +/** + * Normalizes a single raw price tier into AICs per million tokens. + * + * Prices in the tiered structure (`default` / `long_context`) are already + * denominated in AIUs, so no nano-AIU conversion is needed β€” only the + * batch_size scaling is applied. + */ +function normalizePriceTier(tier: IModelTokenPriceTier, scale: number): ITokenPriceTier { + return { + inputPrice: tier.input_price * scale, + outputPrice: tier.output_price * scale, + cacheReadTokenPrice: tier.cache_price * scale, + }; +} + +function areTierPricesEqual(a: ITokenPriceTier, b: ITokenPriceTier): boolean { + return a.inputPrice === b.inputPrice + && a.outputPrice === b.outputPrice + && a.cacheReadTokenPrice === b.cacheReadTokenPrice; +} + /** * Converts raw billing token prices into normalized AICs per million tokens. * - * Raw prices are divided by {@link AIC_DIVISOR} to get AICs, then scaled - * so the result is always "per 1M tokens" regardless of the original batch_size. + * The tiered pricing structure (`default` / `long_context`) uses AIU values + * directly, scaled to per-million-token rates based on batch_size. + * + * The optional `long_context` tier is included only when its rates differ + * from the `default` tier. */ function normalizeTokenPricing(tokenPrices: IModelTokenPrices | undefined): IChatEndpointTokenPricing | undefined { if (!tokenPrices) { return undefined; } - const { batch_size, input_price, output_price, cache_price } = tokenPrices; - const scale = TOKENS_PER_MILLION / batch_size; + const scale = TOKENS_PER_MILLION / tokenPrices.batch_size; + const defaultTier = normalizePriceTier(tokenPrices.default, scale); + + let longContext: ITokenPriceTier | undefined; + if (tokenPrices.long_context) { + const lcTier = normalizePriceTier(tokenPrices.long_context, scale); + if (!areTierPricesEqual(defaultTier, lcTier)) { + longContext = lcTier; + } + } + return { - inputPrice: (input_price / AIC_DIVISOR) * scale, - outputPrice: (output_price / AIC_DIVISOR) * scale, - cacheReadTokenPrice: (cache_price / AIC_DIVISOR) * scale, + default: defaultTier, + longContext, }; } diff --git a/extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts b/extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts index 2655a705f285fa..96986ace7601f6 100644 --- a/extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts +++ b/extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts @@ -10,6 +10,10 @@ export class NullBaseOctoKitService extends BaseOctoKitService { return GitHubOutageStatus.None; } + async getCurrentAuthedUser(): Promise { + return undefined; + } + override async getCurrentAuthedUserWithToken(token: string): Promise { return { avatar_url: '', login: 'NullUser', name: 'Null User' }; } @@ -18,4 +22,4 @@ export class NullBaseOctoKitService extends BaseOctoKitService { return undefined; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 1737edccb92494..0e992bfff433c6 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -261,9 +261,9 @@ export interface ICreateEndpointBodyOptions extends IMakeChatRequestOptions { } /** - * Normalized token pricing in AICs per million tokens. + * A single tier of normalized token pricing in AICs per million tokens. */ -export interface IChatEndpointTokenPricing { +export interface ITokenPriceTier { /** Cost in AICs per million input tokens */ readonly inputPrice: number; /** Cost in AICs per million output tokens */ @@ -272,6 +272,21 @@ export interface IChatEndpointTokenPricing { readonly cacheReadTokenPrice: number; } +/** + * Normalized token pricing in AICs per million tokens, mirroring the CAPI + * tiered structure with explicit `default` and optional `longContext` tiers. + */ +export interface IChatEndpointTokenPricing { + /** Default-context tier pricing. */ + readonly default: ITokenPriceTier; + /** + * Long-context tier pricing, present only when its rates differ from the + * default tier. When absent the model either has no long-context tier or + * its prices match the default tier. + */ + readonly longContext?: ITokenPriceTier; +} + export interface IChatEndpoint extends IEndpoint { readonly maxOutputTokens: number; /** The model ID- this may change and will be `copilot-utility` for the utility (fallback) model. Use `family` to switch behavior based on model type. */ @@ -296,8 +311,8 @@ export interface IChatEndpoint extends IEndpoint { readonly restrictedToSkus?: string[]; /** * Normalized token pricing in AICs per million tokens. - * Computed from the raw billing token_prices by dividing by 1_000_000_000 - * and normalizing to per-million-token rates based on batch_size. + * Computed from the raw billing token_prices and normalized + * to per-million-token rates based on batch_size. */ readonly tokenPricing?: IChatEndpointTokenPricing; readonly priceCategory?: string; diff --git a/extensions/html-language-features/package-lock.json b/extensions/html-language-features/package-lock.json index 783eaefbc599cd..b0ab43d3955faa 100644 --- a/extensions/html-language-features/package-lock.json +++ b/extensions/html-language-features/package-lock.json @@ -139,9 +139,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@types/node": { diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index 6f245bd8ab708e..c9b1184062aca6 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -39,7 +39,7 @@ export function activateShared( const opener = new MdLinkOpener(client); const contentProvider = new MdDocumentRenderer(engine, context, cspArbiter, contributions, logger); - const previewManager = new MarkdownPreviewManager(contentProvider, logger, contributions, opener); + const previewManager = new MarkdownPreviewManager(contentProvider, logger, contributions, opener, context.workspaceState); context.subscriptions.push(previewManager); context.subscriptions.push(registerMarkdownLanguageFeatures(client, commandManager, engine)); diff --git a/extensions/markdown-language-features/src/preview/documentRenderer.ts b/extensions/markdown-language-features/src/preview/documentRenderer.ts index 85407fb81bf633..e29e37ede74567 100644 --- a/extensions/markdown-language-features/src/preview/documentRenderer.ts +++ b/extensions/markdown-language-features/src/preview/documentRenderer.ts @@ -27,7 +27,7 @@ const previewStrings = { cspAlertMessageTitle: vscode.l10n.t("Potentially unsafe or insecure content has been disabled in the Markdown preview. Change the Markdown preview security setting to allow insecure content or enable scripts"), - cspAlertMessageLabel: vscode.l10n.t("Content Disabled Security Warning") + cspAlertMessageLabel: vscode.l10n.t("Content Disabled Security Warning"), }; export interface MarkdownContentProviderOutput { @@ -44,14 +44,14 @@ export interface ImageInfo { export class MdDocumentRenderer { readonly #engine: MarkdownItEngine; - readonly #context: vscode.ExtensionContext; + readonly #context: Pick; readonly #cspArbiter: ContentSecurityPolicyArbiter; readonly #contributionProvider: MarkdownContributionProvider; readonly #logger: ILogger; constructor( engine: MarkdownItEngine, - context: vscode.ExtensionContext, + context: Pick, cspArbiter: ContentSecurityPolicyArbiter, contributionProvider: MarkdownContributionProvider, logger: ILogger @@ -139,7 +139,7 @@ export class MdDocumentRenderer { ): Promise { const innerChanges = lineChanges?.innerChanges; - // If there are inner changes, inject empty marker spans into the source text + // If there are inner changes, inject invisible marker text into the source text // before rendering. The webview uses the CSS Custom Highlight API to create // highlights between each marker pair, which works across HTML tag boundaries. const input: vscode.TextDocument | string = innerChanges?.length diff --git a/extensions/markdown-language-features/src/preview/preview.ts b/extensions/markdown-language-features/src/preview/preview.ts index 1fa1eede17a378..ebafa050daf453 100644 --- a/extensions/markdown-language-features/src/preview/preview.ts +++ b/extensions/markdown-language-features/src/preview/preview.ts @@ -79,6 +79,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { readonly #resource: vscode.Uri; readonly #webviewPanel: vscode.WebviewPanel; + readonly #isDiffView: boolean; #line: number | undefined; readonly #scrollToFragment: string | undefined; @@ -126,7 +127,8 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { this.#webviewPanel = webview; this.#resource = resource; - this.#scrollToFirstDiffChange = !startingScroll && !!delegate.getLineChanges; + this.#isDiffView = !!delegate.getLineChanges; + this.#scrollToFirstDiffChange = !startingScroll && this.#isDiffView; switch (startingScroll?.type) { case 'line': @@ -223,6 +225,10 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { return this.#resource; } + public get isDiffView(): boolean { + return this.#isDiffView; + } + public get state() { return { resource: this.#resource.toString(), @@ -519,6 +525,7 @@ export interface IManagedMarkdownPreview { readonly resource: vscode.Uri; readonly resourceColumn: vscode.ViewColumn; + readonly isDiffView: boolean; readonly onDispose: vscode.Event; readonly onDidChangeViewState: vscode.Event; @@ -666,6 +673,10 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow public get resourceColumn() { return this.#webviewPanel.viewColumn || vscode.ViewColumn.One; } + + public get isDiffView(): boolean { + return this.#preview.isDiffView; + } } interface DynamicPreviewInput { @@ -824,6 +835,10 @@ export class DynamicMarkdownPreview extends Disposable implements IManagedMarkdo return this.#resourceColumn; } + public get isDiffView(): boolean { + return this.#preview.isDiffView; + } + public reveal(viewColumn: vscode.ViewColumn) { this.#webviewPanel.reveal(viewColumn); } diff --git a/extensions/markdown-language-features/src/preview/previewManager.ts b/extensions/markdown-language-features/src/preview/previewManager.ts index f5f8bf0da5d016..81f71faf623b83 100644 --- a/extensions/markdown-language-features/src/preview/previewManager.ts +++ b/extensions/markdown-language-features/src/preview/previewManager.ts @@ -14,6 +14,7 @@ import { MdDocumentRenderer } from './documentRenderer'; import { MarkdownPreviewLineDiffProvider } from './lineDiff'; import { DynamicMarkdownPreview, IManagedMarkdownPreview, StaticMarkdownPreview } from './preview'; import { MarkdownPreviewConfigurationManager } from './previewConfig'; +import { RenderedDiffWarningManager } from './renderedDiffWarning'; import { scrollEditorToLine, StartingScrollFragment, StartingScrollLine, StartingScrollLocation } from './scrolling'; import { getVisibleLine, TopmostLineMonitor } from './topmostLineMonitor'; import type { DiffScrollSyncData, MarkdownPreviewLineChanges } from '../../types/previewMessaging'; @@ -86,12 +87,14 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview readonly #logger: ILogger; readonly #contributions: MarkdownContributionProvider; readonly #opener: MdLinkOpener; + readonly #renderedDiffWarning: RenderedDiffWarningManager; public constructor( contentProvider: MdDocumentRenderer, logger: ILogger, contributions: MarkdownContributionProvider, opener: MdLinkOpener, + workspaceState: vscode.Memento, ) { super(); @@ -99,6 +102,7 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview this.#logger = logger; this.#contributions = contributions; this.#opener = opener; + this.#renderedDiffWarning = this._register(new RenderedDiffWarningManager(workspaceState)); this._register(vscode.window.registerWebviewPanelSerializer(DynamicMarkdownPreview.viewType, this)); @@ -310,7 +314,7 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview getDiffScrollSync ); this.#registerStaticPreview(preview); - this.#activePreview = preview; + this.#setActivePreview(preview); return preview; } @@ -343,7 +347,7 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview this.#contributions, this.#opener); - this.#activePreview = preview; + this.#setActivePreview(preview); return this.#registerDynamicPreview(preview); } @@ -386,14 +390,19 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview #trackActive(preview: IManagedMarkdownPreview): void { preview.onDidChangeViewState(({ webviewPanel }) => { - this.#activePreview = webviewPanel.active ? preview : undefined; + this.#setActivePreview(webviewPanel.active ? preview : undefined); }); preview.onDispose(() => { if (this.#activePreview === preview) { - this.#activePreview = undefined; + this.#setActivePreview(undefined); } }); } + #setActivePreview(preview: IManagedMarkdownPreview | undefined): void { + this.#activePreview = preview; + this.#renderedDiffWarning.setActiveDiffPreview(!!preview?.isDiffView); + } + } diff --git a/extensions/markdown-language-features/src/preview/renderedDiffWarning.ts b/extensions/markdown-language-features/src/preview/renderedDiffWarning.ts new file mode 100644 index 00000000000000..0d85caad454be0 --- /dev/null +++ b/extensions/markdown-language-features/src/preview/renderedDiffWarning.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Disposable } from '../util/dispose'; + +const suppressedStorageKey = 'markdown.preview.renderedDiffWarning.suppressed'; +const notificationShownStorageKey = 'markdown.preview.renderedDiffWarning.notificationShown'; + +export class RenderedDiffWarningManager extends Disposable { + + readonly #workspaceState: vscode.Memento; + + #statusBarItem: vscode.StatusBarItem | undefined; + #hasActiveDiffPreview = false; + + readonly #showWarningCommandId = '_markdown.preview.showRenderedDiffWarning'; + + constructor(workspaceState: vscode.Memento) { + super(); + + this.#workspaceState = workspaceState; + + this._register(vscode.commands.registerCommand(this.#showWarningCommandId, () => { + void this.#showWarningNotification(); + })); + } + + override dispose(): void { + this.#statusBarItem?.dispose(); + this.#statusBarItem = undefined; + super.dispose(); + } + + /** + * Set whether a diff preview is currently the active editor. + * + * Drives the visibility of the status bar warning and triggers the one-time + * notification the first time the user focuses a diff preview. + */ + public setActiveDiffPreview(active: boolean): void { + if (this.#isSuppressed() || this.#hasActiveDiffPreview === active) { + return; + } + + this.#hasActiveDiffPreview = active; + this.#updateStatusBar(); + + if (active && !this.#workspaceState.get(notificationShownStorageKey, false)) { + void this.#workspaceState.update(notificationShownStorageKey, true); + void this.#showWarningNotification(); + } + } + + #updateStatusBar(): void { + if (this.#isSuppressed() || !this.#hasActiveDiffPreview) { + this.#statusBarItem?.dispose(); + this.#statusBarItem = undefined; + return; + } + + if (!this.#statusBarItem) { + this.#statusBarItem = vscode.window.createStatusBarItem('markdown.renderedDiffWarning', vscode.StatusBarAlignment.Right, 100); + this.#statusBarItem.name = vscode.l10n.t('Rendered Markdown Diff Warning'); + this.#statusBarItem.text = vscode.l10n.t('{0} Rendered Diff', '$(warning)'); + this.#statusBarItem.tooltip = vscode.l10n.t('Rendered Markdown diffs may hide important changes. Click for details.'); + this.#statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + this.#statusBarItem.command = this.#showWarningCommandId; + } + this.#statusBarItem.show(); + } + + async #showWarningNotification(): Promise { + const dontShowAgain = vscode.l10n.t("Don't Show Again"); + const selected = await vscode.window.showWarningMessage( + vscode.l10n.t('Rendered Markdown diffs may hide important changes such as formatting, whitespace, links, or HTML. Switch to the text diff if you need to review them.'), + dontShowAgain, + ); + if (selected === dontShowAgain) { + await this.#workspaceState.update(suppressedStorageKey, true); + this.#hasActiveDiffPreview = false; + this.#updateStatusBar(); + } + } + + #isSuppressed(): boolean { + return this.#workspaceState.get(suppressedStorageKey, false); + } +} diff --git a/extensions/merge-conflict/package-lock.json b/extensions/merge-conflict/package-lock.json index ac5c4de8274b3d..351e4f64dedeb1 100644 --- a/extensions/merge-conflict/package-lock.json +++ b/extensions/merge-conflict/package-lock.json @@ -137,9 +137,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@types/node": { diff --git a/extensions/simple-browser/package-lock.json b/extensions/simple-browser/package-lock.json index df8bdd20adc290..a77df6d2c90088 100644 --- a/extensions/simple-browser/package-lock.json +++ b/extensions/simple-browser/package-lock.json @@ -139,9 +139,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@types/node": { diff --git a/package-lock.json b/package-lock.json index f76a83d76b425f..5f4f13573eb45e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "1.0.39", - "@github/copilot-sdk": "^0.3.0", + "@github/copilot": "1.0.49", + "@github/copilot-sdk": "1.0.0-beta.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -20,10 +20,11 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", + "@microsoft/mxc-sdk": "0.2.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-11", - "@vscode/copilot-api": "^0.3.0", + "@vscode/copilot-api": "^0.4.0", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "^0.0.2-0", "@vscode/iconv-lite-umd": "0.7.1", @@ -1153,26 +1154,28 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz", - "integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.49.tgz", + "integrity": "sha512-40Udj9uCNXaVT2XYbB93CaA7P/rWdy7DP1r088t11s0chWfm5smm9RDMNRj2KqMywwYw3xgf3ZcTFoTLy7kleA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.39", - "@github/copilot-darwin-x64": "1.0.39", - "@github/copilot-linux-arm64": "1.0.39", - "@github/copilot-linux-x64": "1.0.39", - "@github/copilot-win32-arm64": "1.0.39", - "@github/copilot-win32-x64": "1.0.39" + "@github/copilot-darwin-arm64": "1.0.49", + "@github/copilot-darwin-x64": "1.0.49", + "@github/copilot-linux-arm64": "1.0.49", + "@github/copilot-linux-x64": "1.0.49", + "@github/copilot-linuxmusl-arm64": "1.0.49", + "@github/copilot-linuxmusl-x64": "1.0.49", + "@github/copilot-win32-arm64": "1.0.49", + "@github/copilot-win32-x64": "1.0.49" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz", - "integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.49.tgz", + "integrity": "sha512-b/qtH1ttG7dnoEC3gLDdrI9n7f5+3LEXD2rOvpdeoxoe8lDlSpUeF4AUpfh7kUivhCKlCIRV+H3+NcRX2rexuQ==", "cpu": [ "arm64" ], @@ -1186,9 +1189,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz", - "integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.49.tgz", + "integrity": "sha512-hHqoeCKqHttqtX3ZHj2TkAIX6jUg159tHDm7qVLccGotgz5bp6ywFxHyGYs7uwD0D90if/m+s87lXu2xAIkN9A==", "cpu": [ "x64" ], @@ -1202,9 +1205,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", - "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.49.tgz", + "integrity": "sha512-faNys7OcjoG6g2vlmOVLgzd4pZPmi0LpZJ0pnOLW6lJ2d9Lk5KsY3aX2g/Uqdoz9oqAPg64t8NH2WPSdHPmBTg==", "cpu": [ "arm64" ], @@ -1218,9 +1221,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", - "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.49.tgz", + "integrity": "sha512-bMqMoJ2r304yCmzZ+iv9Nf4xS4KdiqNZo+Ld7Iq9y5Rc5T+DVsrgISb9j2rBqtlOe0rdtKhwOuzSc4XP7BDcvw==", "cpu": [ "x64" ], @@ -1233,13 +1236,45 @@ "copilot-linux-x64": "copilot" } }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.49.tgz", + "integrity": "sha512-j2Ow72hiamC3yU1GQBl4WEAB9okuUxdGCs+bcYxtDSUY144F9i9U9WE8Oil3KP3Je+WLUZSf81OYsHTCM5OjbA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.49.tgz", + "integrity": "sha512-/a0iNVqXeEvvm0UyPMjW3UPl0meQSSd8SeaMYkkI2OQkYhlUrd9oaUEJzfYnBgPl37AK5+i73DFy09gSH+Efvw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, "node_modules/@github/copilot-sdk": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.3.0.tgz", - "integrity": "sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==", + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.4.tgz", + "integrity": "sha512-DcVMN2FWODxamFS9nTls8AW3QsyMnj6JDVBNRVBXaTY9kEhGHCjt8lp7sJp95/vyl52hvEb4/68Oh6SdFU9O/Q==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.36-0", + "@github/copilot": "^1.0.46", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1257,9 +1292,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz", - "integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.49.tgz", + "integrity": "sha512-2oaOoB47i2EcM1tSO+ay2X7xF29Yc/9LFOqkGZZrdS4gTQvTD3oITQBGwdj5CR3GN9pOFxWrhUvyDf9N77AHFg==", "cpu": [ "arm64" ], @@ -1273,9 +1308,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz", - "integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.49.tgz", + "integrity": "sha512-XwoiiCV3Q9PBV1eFNAag1KnIqN/cNDoNi2B6BJUkGPJUEW3AgrOABV6cmyZ3yEKUEXMZ78JIfS9kUEmTtCAY0g==", "cpu": [ "x64" ], @@ -1939,6 +1974,19 @@ "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz", "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, + "node_modules/@microsoft/mxc-sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.0.tgz", + "integrity": "sha512-xgWTV0nvIzl+IjlIhLGw++/A1eeZYORDoMLGLlDpSE8tMPWLbQIF627Xsb0pkb04MB9vtZl9P+RRNB7fwS3PXA==", + "license": "MIT", + "dependencies": { + "node-pty": "^1.2.0-beta.12", + "semver": "^7.7.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -3617,9 +3665,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.3.0.tgz", - "integrity": "sha512-H4GQKteBvjjNHWSixDyVM0r3RPYiUAmlptFqyxTeSm8baDJS4ky7qSjI+d/TLehXj1cbk4aj5ly3txN+ZfyvZA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.4.0.tgz", + "integrity": "sha512-rFpa9xhl1HPIelTOSCvsykKzykVo1DqLkGDhWdJUuhZj4gasJ7xGlySRO9O9UYSFVgAPTkTcObhh2FoOLBF7GA==", "license": "SEE LICENSE" }, "node_modules/@vscode/deviceid": { diff --git a/package.json b/package.json index 6a56b9569405b2..30f51cf65e94ff 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "1.0.39", - "@github/copilot-sdk": "^0.3.0", + "@github/copilot": "1.0.49", + "@github/copilot-sdk": "1.0.0-beta.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -97,10 +97,11 @@ "@microsoft/dev-tunnels-management": "^1.3.41", "@microsoft/dev-tunnels-ssh": "^3.12.22", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", + "@microsoft/mxc-sdk": "0.2.0", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-11", - "@vscode/copilot-api": "^0.3.0", + "@vscode/copilot-api": "^0.4.0", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "^0.0.2-0", "@vscode/iconv-lite-umd": "0.7.1", diff --git a/remote/package-lock.json b/remote/package-lock.json index 34d559ec1ba37b..7b02369510e872 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,12 +8,13 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "1.0.39", - "@github/copilot-sdk": "^0.3.0", + "@github/copilot": "1.0.49", + "@github/copilot-sdk": "1.0.0-beta.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", + "@microsoft/mxc-sdk": "0.2.0", "@parcel/watcher": "^2.5.6", - "@vscode/copilot-api": "^0.3.0", + "@vscode/copilot-api": "^0.4.0", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -56,26 +57,28 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.39.tgz", - "integrity": "sha512-AY0VPYf6QQm88wUcOav2B36iedWKBUaMegKRxxY2uIHESiU6HueEuQR/n7D3U2UdD0zLox3jFRjYbZAsr2CgkQ==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.49.tgz", + "integrity": "sha512-40Udj9uCNXaVT2XYbB93CaA7P/rWdy7DP1r088t11s0chWfm5smm9RDMNRj2KqMywwYw3xgf3ZcTFoTLy7kleA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.39", - "@github/copilot-darwin-x64": "1.0.39", - "@github/copilot-linux-arm64": "1.0.39", - "@github/copilot-linux-x64": "1.0.39", - "@github/copilot-win32-arm64": "1.0.39", - "@github/copilot-win32-x64": "1.0.39" + "@github/copilot-darwin-arm64": "1.0.49", + "@github/copilot-darwin-x64": "1.0.49", + "@github/copilot-linux-arm64": "1.0.49", + "@github/copilot-linux-x64": "1.0.49", + "@github/copilot-linuxmusl-arm64": "1.0.49", + "@github/copilot-linuxmusl-x64": "1.0.49", + "@github/copilot-win32-arm64": "1.0.49", + "@github/copilot-win32-x64": "1.0.49" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.39.tgz", - "integrity": "sha512-E8WfNL43NMzMTDDpCiYikaEmYCMAr6mz8LHrJtkaFuVXVkBr/q2NI3hAtwHFy8M11Fac/MeIe3/VEymWwwh3kw==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.49.tgz", + "integrity": "sha512-b/qtH1ttG7dnoEC3gLDdrI9n7f5+3LEXD2rOvpdeoxoe8lDlSpUeF4AUpfh7kUivhCKlCIRV+H3+NcRX2rexuQ==", "cpu": [ "arm64" ], @@ -89,9 +92,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.39.tgz", - "integrity": "sha512-0zbC4lDVX7l8Wvq+JSCMjO0xTN69nWLejTBCl3Ev5bP6P+/7wPURcUvZKoHEaXxOULQ3AGj0DwZNAsvvQkA/6Q==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.49.tgz", + "integrity": "sha512-hHqoeCKqHttqtX3ZHj2TkAIX6jUg159tHDm7qVLccGotgz5bp6ywFxHyGYs7uwD0D90if/m+s87lXu2xAIkN9A==", "cpu": [ "x64" ], @@ -105,9 +108,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.39.tgz", - "integrity": "sha512-x88FuByweJlHlAmUZXjq4JlmtqgoM57Fe7nXzQkGr2Y5wnc2EDydBzFYEOlYDSWozQreimaJIm0KEMAA5T8/Fg==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.49.tgz", + "integrity": "sha512-faNys7OcjoG6g2vlmOVLgzd4pZPmi0LpZJ0pnOLW6lJ2d9Lk5KsY3aX2g/Uqdoz9oqAPg64t8NH2WPSdHPmBTg==", "cpu": [ "arm64" ], @@ -121,9 +124,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.39.tgz", - "integrity": "sha512-ssahg8r7a0VCsHVXPRmFFXx70xNAxaTM2SZfG7qPRfFB2OM8gHrW26F2oikTklDF6D+A2MfSAMpzJLBUZbPnhw==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.49.tgz", + "integrity": "sha512-bMqMoJ2r304yCmzZ+iv9Nf4xS4KdiqNZo+Ld7Iq9y5Rc5T+DVsrgISb9j2rBqtlOe0rdtKhwOuzSc4XP7BDcvw==", "cpu": [ "x64" ], @@ -136,13 +139,45 @@ "copilot-linux-x64": "copilot" } }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.49.tgz", + "integrity": "sha512-j2Ow72hiamC3yU1GQBl4WEAB9okuUxdGCs+bcYxtDSUY144F9i9U9WE8Oil3KP3Je+WLUZSf81OYsHTCM5OjbA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.49.tgz", + "integrity": "sha512-/a0iNVqXeEvvm0UyPMjW3UPl0meQSSd8SeaMYkkI2OQkYhlUrd9oaUEJzfYnBgPl37AK5+i73DFy09gSH+Efvw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, "node_modules/@github/copilot-sdk": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.3.0.tgz", - "integrity": "sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==", + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.4.tgz", + "integrity": "sha512-DcVMN2FWODxamFS9nTls8AW3QsyMnj6JDVBNRVBXaTY9kEhGHCjt8lp7sJp95/vyl52hvEb4/68Oh6SdFU9O/Q==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.36-0", + "@github/copilot": "^1.0.46", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -160,9 +195,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.39.tgz", - "integrity": "sha512-hhBWGZQIywbp6MBxlqMX2GSmHqtUAOGwpo9b0igscecL4i0kz89QNasC+mKiN+zFEHP6I8gggOu87XPI17Io8Q==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.49.tgz", + "integrity": "sha512-2oaOoB47i2EcM1tSO+ay2X7xF29Yc/9LFOqkGZZrdS4gTQvTD3oITQBGwdj5CR3GN9pOFxWrhUvyDf9N77AHFg==", "cpu": [ "arm64" ], @@ -176,9 +211,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.39.tgz", - "integrity": "sha512-0ehlMtBiwKjmfEY3hVZggdn7qrmPMC8ueBQv/b+6UY3SMRS/M/1Y7xkOCwG84NvJsktdSsk3SlQnE2LbkTVpSA==", + "version": "1.0.49", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.49.tgz", + "integrity": "sha512-XwoiiCV3Q9PBV1eFNAag1KnIqN/cNDoNi2B6BJUkGPJUEW3AgrOABV6cmyZ3yEKUEXMZ78JIfS9kUEmTtCAY0g==", "cpu": [ "x64" ], @@ -245,6 +280,19 @@ "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz", "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, + "node_modules/@microsoft/mxc-sdk": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.2.0.tgz", + "integrity": "sha512-xgWTV0nvIzl+IjlIhLGw++/A1eeZYORDoMLGLlDpSE8tMPWLbQIF627Xsb0pkb04MB9vtZl9P+RRNB7fwS3PXA==", + "license": "MIT", + "dependencies": { + "node-pty": "^1.2.0-beta.12", + "semver": "^7.7.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -556,9 +604,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.3.0.tgz", - "integrity": "sha512-H4GQKteBvjjNHWSixDyVM0r3RPYiUAmlptFqyxTeSm8baDJS4ky7qSjI+d/TLehXj1cbk4aj5ly3txN+ZfyvZA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.4.0.tgz", + "integrity": "sha512-rFpa9xhl1HPIelTOSCvsykKzykVo1DqLkGDhWdJUuhZj4gasJ7xGlySRO9O9UYSFVgAPTkTcObhh2FoOLBF7GA==", "license": "SEE LICENSE" }, "node_modules/@vscode/deviceid": { @@ -1418,12 +1466,10 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, diff --git a/remote/package.json b/remote/package.json index 80e06e711be024..c2747d1ece36c6 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,12 +3,13 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "1.0.39", - "@github/copilot-sdk": "^0.3.0", + "@github/copilot": "1.0.49", + "@github/copilot-sdk": "1.0.0-beta.4", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", + "@microsoft/mxc-sdk": "0.2.0", "@parcel/watcher": "^2.5.6", - "@vscode/copilot-api": "^0.3.0", + "@vscode/copilot-api": "^0.4.0", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", diff --git a/scripts/chat-simulation/common/mock-llm-server.js b/scripts/chat-simulation/common/mock-llm-server.js index 524d27c981f9fb..6f19270a96e490 100644 --- a/scripts/chat-simulation/common/mock-llm-server.js +++ b/scripts/chat-simulation/common/mock-llm-server.js @@ -578,6 +578,11 @@ function handleRequest(req, res) { if (path.includes('/sessions')) { json(200, { sessions: [], total_count: 0, page_size: 20, page_number: 1 }); } + // Keep custom-agent discovery quiet during smoke tests. The extension + // expects this shape even when there are no custom agents. + else if (path.includes('/swe/custom-agents')) { + json(200, { agents: [] }); + } // /agents/swe/models β€” CCAModelsList else if (path.includes('/swe/models')) { json(200, { diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index c2837659d5fb14..07151910490748 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -126,6 +126,7 @@ display: flex; padding: 0px 8px; width: 100%; + box-sizing: border-box; } .monaco-hover .hover-row.status-bar .actions .action-container { diff --git a/src/vs/workbench/contrib/mcp/common/uriTemplate.ts b/src/vs/base/common/uriTemplate.ts similarity index 100% rename from src/vs/workbench/contrib/mcp/common/uriTemplate.ts rename to src/vs/base/common/uriTemplate.ts diff --git a/src/vs/workbench/contrib/mcp/test/common/uriTemplate.test.ts b/src/vs/base/test/common/uriTemplate.test.ts similarity index 99% rename from src/vs/workbench/contrib/mcp/test/common/uriTemplate.test.ts rename to src/vs/base/test/common/uriTemplate.test.ts index 8d6d5976f94d8b..0165f0aa4a48f1 100644 --- a/src/vs/workbench/contrib/mcp/test/common/uriTemplate.test.ts +++ b/src/vs/base/test/common/uriTemplate.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; import { UriTemplate } from '../../common/uriTemplate.js'; import * as assert from 'assert'; diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 3d52370163fdf7..8a86a911aa423b 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -141,7 +141,6 @@ import { IMeteredConnectionService } from '../../../platform/meteredConnection/c import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; import { AgentNetworkFilterService } from '../../../platform/networkFilter/common/networkFilterService.js'; -import { NullTerminalSandboxService } from '../../../platform/sandbox/common/terminalSandboxService.js'; import { ILocalGitService } from '../../../platform/git/common/localGitService.js'; import { LocalGitService } from '../../../platform/git/node/localGitService.js'; @@ -494,7 +493,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); // Playwright - const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService), new NullTerminalSandboxService())); + const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService))); const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService), agentNetworkFilterService)); this.server.registerChannel('playwright', playwrightChannel); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 2bea6c4668e8a0..a850b16a356411 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -303,6 +303,7 @@ export class MenuId { static readonly DiffEditorSelectionToolbar = new MenuId('DiffEditorSelectionToolbar'); static readonly BrowserNavigationToolbar = new MenuId('BrowserNavigationToolbar'); static readonly BrowserActionsToolbar = new MenuId('BrowserActionsToolbar'); + static readonly BrowserEmulationToolbar = new MenuId('BrowserEmulationToolbar'); static readonly AgentSessionsViewerFilterSubMenu = new MenuId('AgentSessionsViewerFilterSubMenu'); static readonly AgentSessionsContext = new MenuId('AgentSessionsContext'); static readonly AgentSessionSectionContext = new MenuId('AgentSessionSectionContext'); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 03efe2b5899792..d2cab7e4e7aecd 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -36,6 +36,9 @@ import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ip import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; +import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; +import type { InitializeResult } from '../common/state/protocol/common/commands.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -163,7 +166,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private _serverSeq = 0; private _nextClientSeq = 1; private _defaultDirectory: string | undefined; - private _completionTriggerCharacters: readonly string[] = []; + /** + * Latest `initialize` response from the host. Captured at the end of + * {@link connect} and re-captured after a soft-reconnect that pulled + * a fresh snapshot. `undefined` before the handshake completes. + */ + private _initializeResult: InitializeResult | undefined; private readonly _subscriptionManager: AgentSubscriptionManager; private readonly _onDidAction = this._register(new Emitter()); @@ -172,6 +180,20 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private readonly _onDidNotification = this._register(new Emitter()); readonly onDidNotification = this._onDidNotification.event; + /** + * Fires for every `otlp/exportLogs` notification the host sends on a + * channel this client has subscribed to. Each payload is an + * OTLP/JSON `ExportLogsServiceRequest` value verbatim; consumers + * decode it (see `iterateOtlpLogRecords`) and route the records to a + * registered logger or sink. + * + * Channel URIs are kept opaque on the wire so the same event covers + * every {@link TelemetryCapabilities.logs} URI the host advertises β€” + * subscribers should filter by `channel` if they care. + */ + private readonly _onDidReceiveOtlpLogs = this._register(new Emitter()); + readonly onDidReceiveOtlpLogs = this._onDidReceiveOtlpLogs.event; + private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; @@ -245,6 +267,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._state.kind; } + /** + * The latest `initialize` response from the host, or `undefined` if + * the handshake has not completed yet. Callers read fields they care + * about (e.g. `telemetry`, `completionTriggerCharacters`, + * `defaultDirectory`) directly off this object β€” keeping the result + * intact avoids adding a new getter every time the protocol grows. + */ + get initializeResult(): InitializeResult | undefined { + return this._initializeResult; + } + constructor( address: string, transportOrFactory: IProtocolTransport | (() => IProtocolTransport), @@ -371,7 +404,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } } - this._completionTriggerCharacters = result.completionTriggerCharacters ?? []; + this._initializeResult = result; this._updateTelemetryLevel(); this._transitionTo({ kind: AgentHostClientState.Connected }); } @@ -624,6 +657,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Subscribe to state at a URI. Returns the current state snapshot. + * + * For stateless channels (e.g. `ahp-otlp:` telemetry channels) use + * {@link subscribeStateless} β€” calling this method on a stateless + * channel rejects because the server omits `snapshot` on the + * response. */ async subscribe(resource: URI): Promise { const result = await this._sendRequest('subscribe', { channel: resource.toString() }); @@ -633,6 +671,20 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return result.snapshot; } + /** + * Subscribe to a stateless channel β€” one for which the server does + * not maintain replayable state and therefore omits `snapshot` from + * the `subscribe` response. Used today for the host's OTLP telemetry + * channels (`ahp-otlp:`). + * + * Returns once the subscription is confirmed by the server. + * Subsequent notifications on the channel arrive via the relevant + * dispatch event (e.g. {@link onDidReceiveOtlpLogs} for log records). + */ + async subscribeStateless(resource: URI): Promise { + await this._sendRequest('subscribe', { channel: resource.toString() }); + } + /** * Unsubscribe from state at a URI. */ @@ -712,7 +764,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * Empty when the remote host did not announce any. */ async getCompletionTriggerCharacters(): Promise { - return this._completionTriggerCharacters; + return this._initializeResult?.completionTriggerCharacters ?? []; } /** @@ -915,6 +967,13 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._onDidNotification.fire({ type: msg.method, ...msg.params } as INotification); break; } + case 'otlp/exportLogs': + this._onDidReceiveOtlpLogs.fire(msg.params); + break; + case 'otlp/exportTraces': + case 'otlp/exportMetrics': + // Not recorded, yet + break; default: this._logService.trace(`[RemoteAgentHostProtocol] Unhandled method: ${msg.method}`); break; diff --git a/src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts b/src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts new file mode 100644 index 00000000000000..03c96ec0103612 --- /dev/null +++ b/src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AbstractMessageLogger, LogLevel } from '../../../log/common/log.js'; + +/** + * Channel URI template advertised by an agent host that has an + * {@link OtlpLogEmitter} attached. Clients expand `{level}` to one of the + * short OTLP severity names (`trace`/`debug`/`info`/`warn`/`error`/`fatal`) + * and subscribe to the resulting concrete URI. + * + * Kept as a constant so producer (host) and consumer (workbench) cannot + * drift out of sync. + */ +export const OTLP_LOGS_CHANNEL_TEMPLATE = 'ahp-otlp://logs/{level}'; + +/** + * Scheme used by every OTLP channel URI. Lets routers tell them apart from + * `ahp-*` state channels by URI alone. + */ +export const OTLP_CHANNEL_SCHEME = 'ahp-otlp'; + +/** + * Short OTLP severity names defined by the protocol's `{level}` template + * variable. Listed in ascending order so a numeric index can act as a + * coarse "minimum severity" bucket. + */ +export const OTLP_LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; +export type OtlpLogLevelName = typeof OTLP_LOG_LEVELS[number]; + +/** + * Lowest [OTLP `SeverityNumber`](https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber) + * within each named severity band. A record is delivered when its + * `severityNumber >= levelToSeverityNumber(subscribed level)`. + */ +export function levelToSeverityNumber(level: OtlpLogLevelName): number { + switch (level) { + case 'trace': return 1; + case 'debug': return 5; + case 'info': return 9; + case 'warn': return 13; + case 'error': return 17; + case 'fatal': return 21; + } +} + +/** + * Parse one of {@link OTLP_LOG_LEVELS} from an arbitrary (case-insensitive) + * string. Returns `undefined` for anything that is not a recognised name so + * callers can decide how to handle unknown levels. + */ +export function parseOtlpLogLevel(value: string): OtlpLogLevelName | undefined { + const lower = value.toLowerCase(); + return (OTLP_LOG_LEVELS as readonly string[]).includes(lower) ? lower as OtlpLogLevelName : undefined; +} + +/** + * Map a VS Code {@link LogLevel} onto the corresponding OTLP + * `SeverityNumber` and short name. + */ +export function logLevelToOtlpSeverity(level: LogLevel): { severityNumber: number; severityText: OtlpLogLevelName } { + switch (level) { + case LogLevel.Trace: return { severityNumber: 1, severityText: 'trace' }; + case LogLevel.Debug: return { severityNumber: 5, severityText: 'debug' }; + case LogLevel.Info: return { severityNumber: 9, severityText: 'info' }; + case LogLevel.Warning: return { severityNumber: 13, severityText: 'warn' }; + case LogLevel.Error: return { severityNumber: 17, severityText: 'error' }; + case LogLevel.Off: + // `Off` is filtered out before we ever reach this function β€” but + // pick a sentinel so callers can defend if they get here anyway. + return { severityNumber: 0, severityText: 'trace' }; + } +} + +/** + * Map a VS Code {@link LogLevel} onto the matching OTLP level name used in + * the protocol's `{level}` template. Returns `undefined` for + * {@link LogLevel.Off} since "no logs" is represented by not subscribing + * at all. + */ +export function logLevelToOtlpLevelName(level: LogLevel): OtlpLogLevelName | undefined { + if (level === LogLevel.Off) { + return undefined; + } + return logLevelToOtlpSeverity(level).severityText; +} + +/** + * Reverse of {@link logLevelToOtlpSeverity}: returns the closest VS Code + * log level for a given OTLP `SeverityNumber`. Used on the client side to + * pick which `ILogger.{trace,debug,...}` call to route an incoming record + * through. + */ +export function severityNumberToLogLevel(severityNumber: number): LogLevel { + if (severityNumber >= 17) { return LogLevel.Error; } + if (severityNumber >= 13) { return LogLevel.Warning; } + if (severityNumber >= 9) { return LogLevel.Info; } + if (severityNumber >= 5) { return LogLevel.Debug; } + return LogLevel.Trace; +} + +/** + * A single log record produced by an {@link OtlpEmitterLogger}. The shape + * mirrors the relevant fields from the OTLP/JSON `LogRecord` spec but is + * kept intentionally small β€” only what we need to populate a + * spec-conformant `ExportLogsServiceRequest` envelope. + */ +export interface IOtlpLogRecord { + /** + * Time the record was produced, in nanoseconds since the Unix epoch. + * Encoded as a string because JS numbers cannot losslessly represent + * 64-bit nanosecond timestamps (this matches the OTLP/JSON wire format). + */ + readonly timeUnixNano: string; + /** OTLP `SeverityNumber` in the range 1..24 (see {@link levelToSeverityNumber}). */ + readonly severityNumber: number; + /** Short severity name (`trace`/`debug`/...) matching the protocol's `{level}` vocabulary. */ + readonly severityText: OtlpLogLevelName; + /** + * Pre-formatted log body. We send the same string the existing + * `ILogger` printed to the file logger β€” the OTLP spec models this as + * `body: { stringValue }`. + */ + readonly body: string; +} + +/** + * Connection-process-wide hub that {@link OtlpEmitterLogger} writes to and + * the protocol server reads from. Decouples log production (which happens + * via {@link ILogger}) from protocol broadcast (which needs awareness of + * connected clients and their subscribed severity). + */ +export class OtlpLogEmitter extends Disposable { + + private readonly _onDidLog = this._register(new Emitter()); + readonly onDidLog: Event = this._onDidLog.event; + + emit(record: IOtlpLogRecord): void { + this._onDidLog.fire(record); + } +} + +/** + * `AbstractMessageLogger` that converts each `log(level, message)` call + * into an {@link IOtlpLogRecord} and emits it on the shared + * {@link OtlpLogEmitter}. Designed to be installed alongside the regular + * file logger via `new LogService(primary, [otlpLogger])` so every log + * call is mirrored to OTLP subscribers without duplicating call sites. + */ +export class OtlpEmitterLogger extends AbstractMessageLogger { + + constructor( + private readonly _emitter: OtlpLogEmitter, + initialLevel: LogLevel = LogLevel.Trace, + ) { + super(); + // Default to `Trace` so the parent `LogService`'s level check is + // the single source of truth and we don't accidentally drop + // records the file logger printed. The protocol's `{level}` + // filter is applied per-subscriber later on. + this.setLevel(initialLevel); + } + + protected override log(level: LogLevel, message: string): void { + if (level === LogLevel.Off) { + return; + } + const { severityNumber, severityText } = logLevelToOtlpSeverity(level); + this._emitter.emit({ + timeUnixNano: msToUnixNano(Date.now()), + severityNumber, + severityText, + body: message, + }); + } +} + +/** + * Build an OTLP/JSON `ExportLogsServiceRequest` envelope from a single + * record. The payload is the minimum the spec allows: one `ResourceLogs` β†’ + * one `ScopeLogs` β†’ one `LogRecord`. + * + * Callers that want to batch multiple records can use + * {@link toResourceLogsPayloadBatch}. + */ +export function toResourceLogsPayload(record: IOtlpLogRecord): Record { + return toResourceLogsPayloadBatch([record]); +} + +/** + * Build an OTLP/JSON `ExportLogsServiceRequest` envelope from a batch of + * records. All records share the same `ResourceLogs`/`ScopeLogs` parent β€” + * which is fine since this emitter only ever runs inside a single agent + * host process and instrumentation scope. + */ +export function toResourceLogsPayloadBatch(records: readonly IOtlpLogRecord[]): Record { + return { + resourceLogs: [ + { + resource: { attributes: [] }, + scopeLogs: [ + { + scope: { name: 'vscode.agentHost' }, + logRecords: records.map(r => ({ + timeUnixNano: r.timeUnixNano, + observedTimeUnixNano: r.timeUnixNano, + severityNumber: r.severityNumber, + severityText: r.severityText, + body: { stringValue: r.body }, + })), + }, + ], + }, + ], + }; +} + +/** + * Walk an OTLP/JSON `ExportLogsServiceRequest` payload and yield each + * embedded `LogRecord` in a shape matching {@link IOtlpLogRecord}. Used by + * clients to decode incoming `otlp/exportLogs` notifications without + * dragging in an OpenTelemetry SDK. + * + * Anything that does not look like a log record is silently skipped β€” + * the OTLP spec gives hosts considerable freedom and we don't want a + * malformed nested object to bring down the entire batch. + */ +export function* iterateOtlpLogRecords(payload: unknown): IterableIterator { + if (!payload || typeof payload !== 'object') { + return; + } + const resourceLogs = (payload as { resourceLogs?: unknown }).resourceLogs; + if (!Array.isArray(resourceLogs)) { + return; + } + for (const resourceLog of resourceLogs) { + if (!resourceLog || typeof resourceLog !== 'object') { + continue; + } + const scopeLogs = (resourceLog as { scopeLogs?: unknown }).scopeLogs; + if (!Array.isArray(scopeLogs)) { + continue; + } + for (const scopeLog of scopeLogs) { + if (!scopeLog || typeof scopeLog !== 'object') { + continue; + } + const logRecords = (scopeLog as { logRecords?: unknown }).logRecords; + if (!Array.isArray(logRecords)) { + continue; + } + for (const raw of logRecords) { + const record = coerceLogRecord(raw); + if (record) { + yield record; + } + } + } + } +} + +function coerceLogRecord(raw: unknown): IOtlpLogRecord | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const r = raw as Record; + const severityNumber = typeof r.severityNumber === 'number' ? r.severityNumber : 0; + const severityTextRaw = typeof r.severityText === 'string' ? r.severityText.toLowerCase() : ''; + const severityText = parseOtlpLogLevel(severityTextRaw) ?? severityNameFromNumber(severityNumber); + const timeUnixNano = typeof r.timeUnixNano === 'string' + ? r.timeUnixNano + : typeof r.observedTimeUnixNano === 'string' ? r.observedTimeUnixNano : '0'; + const body = extractBody(r.body); + return { timeUnixNano, severityNumber, severityText, body }; +} + +function severityNameFromNumber(n: number): OtlpLogLevelName { + if (n >= 21) { return 'fatal'; } + if (n >= 17) { return 'error'; } + if (n >= 13) { return 'warn'; } + if (n >= 9) { return 'info'; } + if (n >= 5) { return 'debug'; } + return 'trace'; +} + +function extractBody(body: unknown): string { + if (typeof body === 'string') { + return body; + } + if (body && typeof body === 'object') { + const value = (body as { stringValue?: unknown }).stringValue; + if (typeof value === 'string') { + return value; + } + } + return ''; +} + +/** + * Convert a millisecond Unix timestamp to a string-encoded nanosecond + * Unix timestamp (the OTLP/JSON wire format). We don't have sub-millisecond + * precision on the producer side, so we just pad with `'000000'`. + */ +function msToUnixNano(ms: number): string { + // Avoid `BigInt` so this works in renderers and worker environments + // that block `bigint` in JSON serialization paths. + return `${ms}000000`; +} + +/** + * Parse an `ahp-otlp:` channel URI string and extract the `{level}` path + * segment (if present). Returns the parsed level β€” or `undefined` if the + * URI does not encode one or the encoded value is not a recognised name. + * + * The URI shape advertised by this host implementation is + * `ahp-otlp://logs/` where `` is one of {@link OTLP_LOG_LEVELS}. + */ +export function extractLevelFromOtlpLogsUri(uri: string): OtlpLogLevelName | undefined { + // Strip the scheme + authority prefix; the level is the last path + // segment. We avoid `URI.parse` here so this helper can run in + // environments that haven't pulled in the URI module (e.g. tests). + const match = /^ahp-otlp:\/\/logs\/([^/?#]+)/i.exec(uri); + if (!match) { + return undefined; + } + return parseOtlpLogLevel(match[1]); +} + +/** + * Build the concrete `ahp-otlp:` channel URI a client subscribes to for a + * given minimum severity. Used both by clients deciding which URI to send + * with `subscribe` and by hosts deciding which URI to put on outbound + * notifications. + */ +export function buildOtlpLogsChannelUri(level: OtlpLogLevelName): string { + return `ahp-otlp://logs/${level}`; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 51c56bdc81f3c6..cbb4d9e16b8df2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -ff84a6b +7c6b727 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-otlp/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-otlp/notifications.ts new file mode 100644 index 00000000000000..379c9c174d7cf6 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-otlp/notifications.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { URI } from '../common/state.js'; + +// ─── otlp/exportLogs ───────────────────────────────────────────────────────── + +/** + * Delivers a batch of OTLP log records to a client subscribed to the host's + * logs channel (advertised on `TelemetryCapabilities.logs`). + * + * The `payload` field is an OTLP/JSON `ExportLogsServiceRequest` value + * verbatim β€” i.e. an object of shape `{ resourceLogs: ResourceLogs[] }` as + * defined by [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/logs/v1/logs_service.proto). + * AHP does not redeclare the OTLP type system; clients SHOULD use an + * OpenTelemetry SDK or schema to parse it. + * + * Like all stateless-channel notifications, this is ephemeral: it is not + * replayed on reconnect. Subscribers receive only batches emitted after + * their `subscribe` succeeds. + * + * @category Telemetry Notifications + * @method otlp/exportLogs + * @direction Server β†’ Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "otlp/exportLogs", + * "params": { + * "channel": "ahp-otlp://logs", + * "payload": { "resourceLogs": [ /* OTLP/JSON ResourceLogs * / ] } + * } + * } + * ``` + */ +export interface OtlpExportLogsParams { + /** Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.logs`). */ + channel: URI; + /** + * OTLP/JSON `ExportLogsServiceRequest` value. The top-level field is + * `resourceLogs: ResourceLogs[]`; nested shapes are defined by + * opentelemetry-proto and are not redeclared here. + */ + payload: Record; +} + +// ─── otlp/exportTraces ─────────────────────────────────────────────────────── + +/** + * Delivers a batch of OTLP spans to a client subscribed to the host's + * traces channel (advertised on `TelemetryCapabilities.traces`). + * + * The `payload` field is an OTLP/JSON `ExportTraceServiceRequest` value + * verbatim β€” i.e. an object of shape `{ resourceSpans: ResourceSpans[] }` + * as defined by [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/trace/v1/trace_service.proto). + * + * @category Telemetry Notifications + * @method otlp/exportTraces + * @direction Server β†’ Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "otlp/exportTraces", + * "params": { + * "channel": "ahp-otlp://traces", + * "payload": { "resourceSpans": [ /* OTLP/JSON ResourceSpans * / ] } + * } + * } + * ``` + */ +export interface OtlpExportTracesParams { + /** Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.traces`). */ + channel: URI; + /** + * OTLP/JSON `ExportTraceServiceRequest` value. The top-level field is + * `resourceSpans: ResourceSpans[]`; nested shapes are defined by + * opentelemetry-proto and are not redeclared here. + */ + payload: Record; +} + +// ─── otlp/exportMetrics ────────────────────────────────────────────────────── + +/** + * Delivers a batch of OTLP metric data points to a client subscribed to + * the host's metrics channel (advertised on `TelemetryCapabilities.metrics`). + * + * The `payload` field is an OTLP/JSON `ExportMetricsServiceRequest` value + * verbatim β€” i.e. an object of shape `{ resourceMetrics: ResourceMetrics[] }` + * as defined by [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/metrics/v1/metrics_service.proto). + * + * @category Telemetry Notifications + * @method otlp/exportMetrics + * @direction Server β†’ Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "otlp/exportMetrics", + * "params": { + * "channel": "ahp-otlp://metrics", + * "payload": { "resourceMetrics": [ /* OTLP/JSON ResourceMetrics * / ] } + * } + * } + * ``` + */ +export interface OtlpExportMetricsParams { + /** Channel URI this notification belongs to (an `ahp-otlp:` URI advertised on `TelemetryCapabilities.metrics`). */ + channel: URI; + /** + * OTLP/JSON `ExportMetricsServiceRequest` value. The top-level field is + * `resourceMetrics: ResourceMetrics[]`; nested shapes are defined by + * opentelemetry-proto and are not redeclared here. + */ + payload: Record; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-otlp/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-otlp/state.ts new file mode 100644 index 00000000000000..54d43cfb970a81 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-otlp/state.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { URI } from '../common/state.js'; + +// ─── TelemetryCapabilities ─────────────────────────────────────────────────── + +/** + * OTLP telemetry channels the agent host emits. + * + * Each field, when present, is either a literal channel URI or an + * [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template + * a client expands and then subscribes to. Absent fields indicate the host + * does not emit that signal. + * + * Channel URIs use the `ahp-otlp:` scheme. The scheme identifies the + * protocol (OpenTelemetry over AHP) so clients can recognise the channel + * type by URI alone; the host is free to choose any authority/path that + * makes sense for its implementation. Clients MUST treat the URI as + * opaque (apart from expanding any well-known template variables defined + * below) and subscribe with the resulting concrete URI. + * + * Payloads delivered on these channels are OTLP/JSON values β€” see + * [opentelemetry-proto](https://github.com/open-telemetry/opentelemetry-proto) + * for the wire shapes (`ExportLogsServiceRequest`, + * `ExportTraceServiceRequest`, `ExportMetricsServiceRequest`). + * + * @category Telemetry + */ +export interface TelemetryCapabilities { + /** + * Channel URI (or RFC 6570 URI template) for OTLP log records + * (`otlp/exportLogs` notifications). + * + * The following template variables are defined by this protocol; any + * other variable name MUST be ignored by clients (there is no + * protocol-defined way to obtain values for unknown variables): + * + * | Variables in template | Meaning | + * | --------------------- | ------------------------------------------------------------------------------------------------------- | + * | _(none)_ | The host does not support subscriber-side severity filtering. The template is itself a subscribable URI. | + * | `{level}` | Minimum OTLP severity to deliver. Expand to one of the [OTLP `SeverityNumber`](https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber) short names (case-insensitive): `trace`, `debug`, `info`, `warn`, `error`, `fatal`. The server delivers log records whose `severityNumber` falls in the corresponding band or above. | + * + * Hosts SHOULD honour the expanded `{level}`; clients MUST still filter + * defensively in case a host ignores the parameter. Hosts that do not + * advertise `{level}` deliver all severities. + * + * Future protocol versions MAY add new well-known variables (e.g. scope + * or attribute filters). + */ + logs?: URI; + /** + * Channel URI for OTLP spans (`otlp/exportTraces` notifications). No + * template variables are defined by this protocol version. + */ + traces?: URI; + /** + * Channel URI for OTLP metric data points (`otlp/exportMetrics` + * notifications). No template variables are defined by this protocol + * version. + */ + metrics?: URI; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index 8fb808c12e8878..6a3fcf23d02543 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -8,6 +8,7 @@ import type { URI, Snapshot } from './state.js'; import type { ActionEnvelope, StateAction } from './actions.js'; +import type { TelemetryCapabilities } from '../channels-otlp/state.js'; // ─── BaseParams ────────────────────────────────────────────────────────────── @@ -101,6 +102,16 @@ export interface InitializeResult { * `'@'` or `'/'`. */ completionTriggerCharacters?: string[]; + /** + * OTLP telemetry channels the host emits, if any. Each populated field is + * either a literal `ahp-otlp:` channel URI or an RFC 6570 URI template a + * client expands before subscribing (currently only the `logs` channel + * defines a template variable, `{level}`, for subscriber-side severity + * filtering). Clients MAY ignore signals they cannot process. + * + * @see {@link /specification/telemetry-channel | Telemetry Channel} + */ + telemetry?: TelemetryCapabilities; } // ─── ping ──────────────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index 02a59fae400c43..bb487463fb9cfd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -15,6 +15,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import type { ActionEnvelope } from './actions.js'; import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams } from '../channels-root/notifications.js'; import type { AuthRequiredParams } from './notifications.js'; +import type { OtlpExportLogsParams, OtlpExportTracesParams, OtlpExportMetricsParams } from '../channels-otlp/notifications.js'; import type { AhpError } from './errors.js'; // ─── JSON-RPC Base Types ───────────────────────────────────────────────────── @@ -147,6 +148,9 @@ export interface ServerNotificationMap { 'root/sessionRemoved': { params: SessionRemovedParams }; 'root/sessionSummaryChanged': { params: SessionSummaryChangedParams }; 'auth/required': { params: AuthRequiredParams }; + 'otlp/exportLogs': { params: OtlpExportLogsParams }; + 'otlp/exportTraces': { params: OtlpExportTracesParams }; + 'otlp/exportMetrics': { params: OtlpExportMetricsParams }; } // ─── Typed Requests ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index f35b545d79a89b..3c03729b49ad01 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -8,3 +8,4 @@ export * from './common/notifications.js'; export * from './channels-root/notifications.js'; +export * from './channels-otlp/notifications.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 9235e768ad2d6d..74a7131e70f495 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -11,3 +11,4 @@ export * from './channels-root/state.js'; export * from './channels-session/state.js'; export * from './channels-terminal/state.js'; export * from './channels-changeset/state.js'; +export * from './channels-otlp/state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 5bcce39d887c85..90598453611fbe 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -146,6 +146,9 @@ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in ProtocolNotificationMe 'root/sessionRemoved': '0.1.0', 'root/sessionSummaryChanged': '0.1.0', 'auth/required': '0.1.0', + 'otlp/exportLogs': '0.2.0', + 'otlp/exportTraces': '0.2.0', + 'otlp/exportMetrics': '0.2.0', }; /** diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index e6e2176f48c8fe..d836fbb9f57a2a 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -36,6 +36,7 @@ import { getLogLevel, ILogService, isDevConsoleLogForwardingEnabled, registerDev import { LogService } from '../../log/common/logService.js'; import { LoggerService } from '../../log/node/loggerService.js'; import { LoggerChannel } from '../../log/common/logIpc.js'; +import { OtlpEmitterLogger, OtlpLogEmitter } from '../common/otlp/otlpLogEmitter.js'; import { DefaultURITransformer } from '../../../base/common/uriIpc.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; @@ -91,7 +92,14 @@ async function startAgentHost(): Promise { const loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); server.registerChannel(AgentHostIpcChannels.Logger, new LoggerChannel(loggerService, () => DefaultURITransformer)); const logger = loggerService.createLogger('agenthost', { name: localize('agentHost', "Agent Host") }); - const logService = new LogService(logger); + // OTLP log fan-out: any consumer that subscribes to the host's + // `ahp-otlp://logs/{level}` channel will receive every log record this + // `ILogService` produces, in addition to the regular file logger. The + // emitter is created here so it can be shared by every protocol + // handler instantiated below. + const otlpLogEmitter = disposables.add(new OtlpLogEmitter()); + const otlpLogger = disposables.add(new OtlpEmitterLogger(otlpLogEmitter)); + const logService = new LogService(logger, [otlpLogger]); if (!environmentService.isBuilt && isDevConsoleLogForwardingEnabled) { disposables.add(registerDevConsoleLogForwarder(logService)); } @@ -223,6 +231,7 @@ async function startAgentHost(): Promise { { defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters: agentService.completionTriggerCharacters, + otlpLogEmitter, }, clientFileSystemProvider, logService, @@ -290,6 +299,7 @@ async function startAgentHost(): Promise { instantiationService, environmentService.logsHome, logService, + otlpLogEmitter, disposables, count => connectionCountEmitter.fire(count), ).catch(err => { @@ -315,6 +325,7 @@ async function startWebSocketServer( instantiationService: IInstantiationService, logsHome: URI, logService: ILogService, + otlpLogEmitter: OtlpLogEmitter, disposables: DisposableStore, onConnectionCountChanged: (count: number) => void, ): Promise { @@ -354,6 +365,7 @@ async function startWebSocketServer( { defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters: agentService.completionTriggerCharacters, + otlpLogEmitter, }, clientFileSystemProvider, logService, diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 6e1379b90189dd..abd31cfa373f7b 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -24,9 +24,10 @@ import { localize } from '../../../nls.js'; import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; -import { getLogLevel, ILogService, NullLogService } from '../../log/common/log.js'; +import { getLogLevel, ILogService } from '../../log/common/log.js'; import { LogService } from '../../log/common/logService.js'; import { LoggerService } from '../../log/node/loggerService.js'; +import { OtlpEmitterLogger, OtlpLogEmitter } from '../common/otlp/otlpLogEmitter.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; @@ -159,16 +160,21 @@ async function main(): Promise { // Logging β€” production logging unless --quiet let logService: ILogService; let loggerService: LoggerService | undefined; + // Shared by every ProtocolServerHandler this process spawns. Always + // created (cost is negligible) so that the OTLP logs channel is + // available even in `--quiet` mode where there is no file logger. + const otlpLogEmitter = disposables.add(new OtlpLogEmitter()); + const otlpLogger = disposables.add(new OtlpEmitterLogger(otlpLogEmitter)); if (options.quiet) { - logService = new NullLogService(); + logService = disposables.add(new LogService(otlpLogger)); } else { const services = new ServiceCollection(); services.set(IProductService, productService); services.set(INativeEnvironmentService, environmentService); loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") }); - logService = disposables.add(new LogService(logger)); + logService = disposables.add(new LogService(logger, [otlpLogger])); services.set(ILogService, logService); log('Starting standalone agent host server'); } @@ -272,6 +278,7 @@ async function main(): Promise { { defaultDirectory: URI.file(os.homedir()).toString(), completionTriggerCharacters: agentService.completionTriggerCharacters, + otlpLogEmitter, }, clientFileSystemProvider, logService, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index ebbb8cd52a61d6..abb6f29b6af726 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -415,7 +415,26 @@ export class AgentService extends Disposable implements IAgentService { this._sessionToProvider.set(session.toString(), provider.id); this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); - const sessionConfig = await this._resolveCreatedSessionConfig(provider, config); + // Resolve config and seed the initial customization set in parallel so + // both are available before we register the session in the state + // manager. Seeding `state.customizations` directly (instead of + // dispatching `SessionCustomizationsChanged` after the fact) means + // the very first snapshot a subscriber sees already contains + // host/global customizations and the custom agents they contribute, + // so the agent picker doesn't have to wait for a follow-up republish + // (`RootConfigChanged`, plugin reload, or the first message's + // `setClientCustomizations`). Subsequent updates flow through the + // existing `SessionCustomizationsChanged` / `SessionCustomizationUpdated` + // actions published by `PluginController`. + const [sessionConfig, initialCustomizations] = await Promise.all([ + this._resolveCreatedSessionConfig(provider, config), + provider.getSessionCustomizations + ? provider.getSessionCustomizations(session).catch(err => { + this._logService.error('[AgentService] createSession: failed to resolve initial customizations', err); + return undefined; + }) + : Promise.resolve(undefined), + ]); // When forking, populate the new session's protocol state with // the source session's turns so the client sees the forked history. @@ -432,6 +451,9 @@ export class AgentService extends Disposable implements IAgentService { state.config = sessionConfig; state.turns = sourceTurns; state.activeClient = config.activeClient; + if (initialCustomizations && initialCustomizations.length > 0) { + state.customizations = [...initialCustomizations]; + } } else { // Provisional sessions defer the `sessionAdded` notification and // the `SessionReady` lifecycle transition until the agent fires @@ -443,6 +465,9 @@ export class AgentService extends Disposable implements IAgentService { const state = this._stateManager.createSession(summary, { emitNotification: !created.provisional }); state.config = sessionConfig; state.activeClient = config?.activeClient; + if (initialCustomizations && initialCustomizations.length > 0) { + state.customizations = [...initialCustomizations]; + } } // Persist initial config values so a subsequent `restoreSession` can // re-hydrate them. We persist the full resolved values (not just the diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md index 699ac328ff3edb..c3eec9a9b2e95c 100644 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ b/src/vs/platform/agentHost/node/claude/CONTEXT.md @@ -27,14 +27,15 @@ _Avoid_: "Anthropic proxy", "language model server". GitHub Copilot's chat completions API, accessed through `ICopilotApiService`. The terminal hop after the proxy. -**Materialization** / `ClaudeMaterializer`: -Promoting a provisional session record (created by `ClaudeAgent.createSession`, -no SDK contact yet) into a live `ClaudeAgentSession` with a bound `WarmQuery`. -Owned by `ClaudeMaterializer`: assembles the SDK `Options` bag, awaits -`IClaudeAgentSdkService.startup`, opens the per-session DB ref, and constructs -the session wrapper. `ClaudeAgent` retains sequencing, the `_sessions` map, -permission-mode resolution, the post-materialize metadata write, and the -second abort gate. +**Materialization** / `ClaudeAgentSession.materialize`: +Bringing a provisional session (created by `ClaudeAgent.createSession`, no +SDK contact yet) up to a live `ClaudeAgentSession` with a bound `WarmQuery`. +Owned by `ClaudeAgentSession.materialize(ctx)`: builds the SDK `Options` +bag via the pure helpers in `claudeSdkOptions.ts`, awaits +`IClaudeAgentSdkService.startup`, opens the per-session DB ref, constructs +the pipeline, persists the metadata overlay (skipped on `isResume`), and +attaches the rematerializer. `ClaudeAgent` retains sequencing, the +`_sessions` map, and the `onDidMaterializeSession` fan-out. _Avoid_: "session creation" (overloaded with `IAgent.createSession`). **Claude session overlay** / `ClaudeSessionMetadataStore`: diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 4b02c4b32df771..9399f1091e0915 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -5,6 +5,7 @@ import type { CCAModel } from '@vscode/copilot-api'; import type { Options, SDKSessionInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { SequencerByKey } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; @@ -19,7 +20,7 @@ import { ILogService } from '../../../log/common/log.js'; import { ISyncedCustomization } from '../../common/agentPluginManager.js'; import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; -import { clampEffortForRuntime, createClaudeThinkingLevelSchema, isClaudeEffortLevel, resolveClaudeEffort } from '../../common/claudeModelConfig.js'; +import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -28,6 +29,7 @@ import { PolicyState, ProtectedResourceMetadata, type ModelSelection, type ToolD import { CustomizationRef, isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type MessageAttachment, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; +import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { projectFromCopilotContext } from '../copilot/copilotGitProject.js'; import { ICopilotApiService } from '../shared/copilotApiService.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; @@ -35,7 +37,6 @@ import { mapSessionMessagesToTurns } from './claudeReplayMapper.js'; import { getSubagentTranscript } from './claudeSubagentResolver.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { handleCanUseTool } from './claudeCanUseTool.js'; -import { ClaudeMaterializer, type IMaterializeContext } from './claudeMaterializer.js'; import { tryParseClaudeModelId } from './claudeModelId.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle, IClaudeProxyService } from './claudeProxyService.js'; @@ -109,63 +110,10 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo // and materializer can read directly without threading callbacks // through the agent. -/** - * Phase 6: in-memory record for a provisional Claude session β€” one - * created via {@link ClaudeAgent.createSession} that has NOT yet seen - * its first {@link ClaudeAgent.sendMessage}. - * - * Holds: - * - `sessionId` / `sessionUri`: stable identifiers minted at create time. - * - `workingDirectory`: undefined when the caller didn't supply one - * (e.g. legacy `createSession({})` paths). Materialize fails fast if - * it's still missing then; until then a missing `cwd` is harmless - * because no SDK / DB / worktree work has happened. - * - `abortController`: single source of cancellation. Wired into - * {@link Options.abortController} at materialize and aborted by - * {@link ClaudeAgent.shutdown} / {@link ClaudeAgent.disposeSession} - * for provisional records; the materialize path defends against an - * abort racing `await sdk.startup()` (Q8 belt-and-suspenders). - * - `project`: the resolved {@link IAgentSessionProjectInfo} (if any), - * computed once at create time so duplicate `createSession` calls - * for the same URI return identical project metadata. - * - `model` / `config`: the `IAgentCreateSessionConfig.model` and - * `IAgentCreateSessionConfig.config` bag from `createSession`. - * Carried verbatim through to materialize so the first `query()`'s - * `Options.*` reflect the user's choices instead of SDK defaults - * (M11 / Phase 6.1 C2). The bag is `Record` because - * schema validation already happened at `resolveSessionConfig`; this - * is the post-validation runtime payload. - */ -interface IClaudeProvisionalSession { - readonly sessionId: string; - readonly sessionUri: URI; - readonly workingDirectory: URI | undefined; - readonly abortController: AbortController; - readonly project: IAgentSessionProjectInfo | undefined; - /** - * Mutable so {@link ClaudeAgent.changeModel} can update the pending - * model selection before materialize promotes the record. The first - * `sendMessage` reads this when building Options. - */ - model: ModelSelection | undefined; - readonly config: Record | undefined; - /** - * Phase 10: client-provided tool definitions registered via - * {@link ClaudeAgent.setClientTools} before the session materializes. - * Mutable for parity with {@link model} β€” `setClientTools` writes here - * pre-materialize; the snapshot is transferred into the - * {@link ClaudeAgentSession} at materialize time and flows into - * `Options.mcpServers` for the first SDK startup. - */ - clientTools: readonly ToolDefinition[] | undefined; - /** - * Phase 10: workbench `clientId` paired with the most recent - * {@link clientTools} write. Transferred onto the session's bridge at - * materialize time so the stream mapper can stamp - * `SessionToolCallStart.toolClientId`. - */ - clientId: string | undefined; -} +// Provisional session state is hosted directly on {@link ClaudeAgentSession} +// (pre-materialize fields: project, abortController, provisionalModel, +// provisionalConfig). The legacy `IClaudeProvisionalSession` map shape +// was retired in Phase 10.5 Step 3a. /** * Phase 4 skeleton {@link IAgent} provider for the Claude Agent SDK. @@ -222,20 +170,6 @@ export class ClaudeAgent extends Disposable implements IAgent { */ private readonly _sessions = this._register(new DisposableMap()); - /** - * Phase 6: pending in-memory session records. A `createSession` - * (non-fork) entry lives here until the first {@link sendMessage} - * promotes it to a real {@link ClaudeAgentSession} via - * {@link _materializeProvisional}. Each entry owns an - * {@link AbortController} that is wired into {@link Options.abortController} - * at materialize time, so {@link shutdown} can abort any in-flight - * `await sdk.startup()` cleanly. - * - * Plan section 3.3: provisional state is in-memory only β€” NO DB write, NO - * SDK contact β€” until materialize. - */ - private readonly _provisionalSessions = new Map(); - /** * Phase 6: fired once per session when {@link _materializeProvisional} * promotes a provisional record into a real {@link ClaudeAgentSession}. @@ -271,9 +205,17 @@ export class ClaudeAgent extends Disposable implements IAgent { */ private readonly _sessionSequencer = new SequencerByKey(); - private readonly _materializer: ClaudeMaterializer; private readonly _metadataStore: ClaudeSessionMetadataStore; + /** + * Unified per-session lookup. Returns the session whether it is + * still provisional or already materialized; callers branch on + * {@link ClaudeAgentSession.isPipelineReady} when behavior differs. + */ + private _findAnySession(sessionId: string): ClaudeAgentSession | undefined { + return this._sessions.get(sessionId)?.session; + } + constructor( @ILogService private readonly _logService: ILogService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @@ -281,10 +223,9 @@ export class ClaudeAgent extends Disposable implements IAgent { @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - @IInstantiationService _instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - this._materializer = _instantiationService.createInstance(ClaudeMaterializer); this._metadataStore = _instantiationService.createInstance(ClaudeSessionMetadataStore, this.id); } @@ -385,68 +326,45 @@ export class ClaudeAgent extends Disposable implements IAgent { async createSession(config: IAgentCreateSessionConfig = {}): Promise { this._ensureAuthenticated(); if (config.fork) { - // Fork moved to Phase 6.5: requires translating - // `config.fork.turnId` (a protocol turn ID) to an SDK message UUID - // via `sdk.getSessionMessages`. Phase 6's exit criteria explicitly - // scope fork out so the rest of sendMessage can land first. throw new Error('TODO: Phase 6.5: fork requires message-UUID lookup via sdk.getSessionMessages'); } - // Non-fork path: provisional. NO subprocess fork, NO worktree, NO DB - // write. Materialization happens lazily in `_materializeProvisional` - // on the first `sendMessage`; AgentService defers `sessionAdded` - // until then. const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = AgentSession.uri(this.id, sessionId); - // Idempotency: a duplicate `createSession` for the same URI (already - // materialized OR already provisional) returns the same URI without - // overwriting the existing record. This protects against a workbench - // retry collapsing a real session back into a provisional one. - const existingProvisional = this._provisionalSessions.get(sessionId); - if (existingProvisional) { - return { - session: existingProvisional.sessionUri, - workingDirectory: existingProvisional.workingDirectory, - provisional: true, - ...(existingProvisional.project ? { project: existingProvisional.project } : {}), - }; - } - if (this._sessions.has(sessionId)) { + const existing = this._findAnySession(sessionId); + if (existing) { + if (!existing.isPipelineReady) { + return { + session: existing.sessionUri, + workingDirectory: existing.workingDirectory, + provisional: true, + ...(existing.project ? { project: existing.project } : {}), + }; + } return { session: sessionUri, workingDirectory: config.workingDirectory }; } - // Resolve git project metadata when we have a cwd. Skipped when - // `workingDirectory` is undefined β€” materialize will require it, - // but a tests-only path (`createSession({})`) without a cwd is - // allowed at Phase 5/6 boundaries; failing fast here would force - // every legacy test to thread a cwd through. - // - // **Deviation from plan section 3.3 (deviation D1, ratified by review).** - // The plan called for `if (!config.workingDirectory) { throw ... }` - // at create time. We accept cwd-less calls and defer the throw to - // `_materializeProvisional` instead. Trade-off: a programmer error - // (forgetting to thread cwd) surfaces at first `sendMessage` - // rather than `createSession`. This is acceptable because: - // (a) the agent host's own callers always supply cwd via folder - // pick (`agentSideEffects.ts`) β€” the cwd-less path only exists - // for unit tests asserting protocol-only behavior; and - // (b) materialize requires cwd anyway, so the failure mode is - // bounded and visible (no silent invalid sessions). const project = config.workingDirectory ? await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService) : undefined; - this._provisionalSessions.set(sessionId, { + const permissionMode = this._resolvePermissionMode(config.config); + + const session = ClaudeAgentSession.createProvisional( sessionId, sessionUri, - workingDirectory: config.workingDirectory, - abortController: new AbortController(), + config.workingDirectory, project, - model: config.model, - config: config.config, - clientTools: undefined, - clientId: undefined, - }); + config.model, + config.config, + new PendingRequestRegistry(), + permissionMode, + this._metadataStore, + this._instantiationService, + ); + const entry = new ClaudeSessionEntry(session); + entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + this._sessions.set(sessionId, entry); return { session: sessionUri, @@ -457,118 +375,47 @@ export class ClaudeAgent extends Disposable implements IAgent { } /** - * Promote a {@link IClaudeProvisionalSession} into a real - * {@link ClaudeAgentSession}. Called from {@link sendMessage} inside - * the {@link _sessionSequencer.queue} block, so concurrent first - * sends serialize naturally β€” exactly one materialize per session. + * Promote a provisional {@link ClaudeAgentSession} into a live one. + * Called from {@link sendMessage} inside the {@link _sessionSequencer.queue} + * block, so concurrent first sends serialize naturally β€” exactly + * one materialize per session. * - * Plan section 3.4. Failure modes: - * - Missing provisional record β†’ programmer error, throws. + * Failure modes: + * - Missing session entry β†’ programmer error, throws. * - Missing proxy handle β†’ caller forgot {@link authenticate}, throws. - * - Aborted before SDK init returns β†’ {@link ClaudeMaterializer} - * disposes the {@link WarmQuery} and throws {@link CancellationError}. - * - Customization-directory persistence failure β†’ fatal: dispose the - * wrapper (aborts the SDK subprocess), drop the provisional record, - * re-throw. Avoids silent half-persisted state. + * - Aborted before SDK init returns β†’ {@link ClaudeAgentSession.materialize} + * disposes the `WarmQuery` and throws {@link CancellationError}. + * - Customization-directory persistence failure β†’ fatal: the session's + * `materialize` throws, the agent drops the entry, and the error + * propagates so the caller learns about it. * - Aborted post-metadata-write but pre-commit β†’ second abort gate - * disposes the wrapper without committing into `_sessions`. + * inside `materialize` throws so we never expose a live pipeline + * for a session the caller has already torn down. */ private async _materializeProvisional(sessionId: string): Promise { - const provisional = this._provisionalSessions.get(sessionId); - if (!provisional) { + const session = this._findAnySession(sessionId); + if (!session) { throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`); } const proxyHandle = this._ensureAuthenticated(); - // Single read of the live permissionMode (plan S3.6): used both - // for the SDK's `Options.permissionMode` and the metadata write - // below, so a `SessionConfigChanged` landing mid-materialize can't - // produce a split state where the SDK runs under one mode and the - // DB records another. - const permissionMode = readClaudePermissionMode(this._configurationService, provisional.sessionUri) - ?? this._resolvePermissionMode(provisional.config); - const canUseTool: NonNullable = (toolName, input, options) => handleCanUseTool( - { getSession: id => this._sessions.get(id)?.session, configurationService: this._configurationService }, + { getSession: id => this._findAnySession(id), configurationService: this._configurationService }, sessionId, toolName, input, options, ); - const ctx: IMaterializeContext = { - provisional, - proxyHandle, - canUseTool, - permissionModeFallback: permissionMode, - isResume: false, - }; - - const session = await this._materializer.materialize(ctx); - - // Phase 9 β€” seed the bijective state cache so a rebuild re-applies - // the user's last-chosen model/effort without losing the picker - // config. - const initialEffort = clampEffortForRuntime(resolveClaudeEffort(provisional.model)); - session.seedBijectiveState({ - model: provisional.model?.id, - effort: initialEffort, - permissionMode, - }); - - // Persist customization-directory metadata BEFORE firing the - // materialize event β€” see plan section 3.4 ordering rationale. try { - await this._metadataStore.write(provisional.sessionUri, { - customizationDirectory: provisional.workingDirectory, - model: provisional.model, - permissionMode, - }); + await session.materialize({ proxyHandle, canUseTool, isResume: false }); } catch (err) { - session.dispose(); - this._provisionalSessions.delete(sessionId); - this._logService.error(`[Claude] Failed to persist customization directory; aborting materialize`, err); + this._sessions.deleteAndDispose(sessionId); throw err; } - // Final pre-commit abort gate. The first abort gate (inside the - // materializer) only catches an abort that lands while - // `await sdk.startup()` was in flight; `_writeSessionMetadata` is a - // SECOND async boundary where a racing `disposeSession` (which does - // not await the materialize via `_disposeSequencer` because send and - // dispose use different sequencers β€” plan section 3.8 / section 6) - // can fire between the SDK init and the `_sessions.set(...)` commit. - // Without this gate, the dispose returns successfully, the provisional - // record is removed, and the materialize still completes β€” leaking a - // WarmQuery subprocess into `_sessions` that nothing else references. - // Council-review C1. - if (provisional.abortController.signal.aborted) { - session.dispose(); - this._provisionalSessions.delete(sessionId); - throw new CancellationError(); - } - - // Forward session-progress signals through the agent's emitter and - // bundle the subscription with the session in a single entry. The - // entry's `dispose()` tears down the session AND every disposable - // registered against it, so {@link disposeSession} / {@link shutdown} - // only need to dispose the entry to release everything per-session. - const entry = new ClaudeSessionEntry(session); - entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); - // Re-sync the client-tool snapshot in case `setClientTools` landed - // while we were awaiting `_sdkService.startup` / `_metadataStore.write`. - // Those updates wrote to the provisional record because the live - // session was not yet in `_sessions`; the materializer's earlier - // snapshot would otherwise be discarded along with the provisional. - // Idempotent on no-change (the diff's equality check absorbs it). - if (provisional.clientTools !== undefined) { - session.setClientTools(provisional.clientTools, provisional.clientId); - } - this._sessions.set(sessionId, entry); - this._provisionalSessions.delete(sessionId); - this._onDidMaterializeSession.fire({ - session: provisional.sessionUri, - workingDirectory: provisional.workingDirectory, - project: provisional.project, + session: session.sessionUri, + workingDirectory: session.workingDirectory, + project: session.project, }); return session; @@ -579,9 +426,8 @@ export class ClaudeAgent extends Disposable implements IAgent { * another window, or before an agent-host restart. Mirror of * `CopilotAgent._resumeSession`. Reads `workingDirectory` from the * SDK's session record and `model` / `permissionMode` from the - * metadata overlay, builds an {@link IClaudeMaterializeProvisional} - * record on the fly, and routes through - * {@link ClaudeMaterializer.materialize} with `startMode: 'resume'` + * metadata overlay, constructs a provisional {@link ClaudeAgentSession}, + * and calls {@link ClaudeAgentSession.materialize} with `isResume: true` * so the SDK reloads the existing transcript instead of minting a * fresh one. * @@ -609,75 +455,42 @@ export class ClaudeAgent extends Disposable implements IAgent { const permissionMode = readClaudePermissionMode(this._configurationService, sessionUri) ?? overlay.permissionMode ?? 'default'; - // Resolve git project metadata from the resumed cwd, same as - // createSession's non-fork path. Best-effort: a failure (no - // repo, git CLI missing, etc.) downgrades to `undefined` rather - // than blocking the resume. let project: IAgentSessionProjectInfo | undefined; try { project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); } catch (err) { this._logService.warn(`[Claude:${sessionId}] project resolution failed during resume; continuing without project`, err); } - const abortController = new AbortController(); - const provisional: IClaudeProvisionalSession = { + + const session = ClaudeAgentSession.createProvisional( sessionId, sessionUri, workingDirectory, - abortController, project, - model: overlay.model, - config: undefined, - clientTools: undefined, - clientId: undefined, - }; - // Register the resume provisional so a concurrent `setClientTools` - // has a place to land while we're awaiting `_sdkService.startup`. - // Removed in `finally` after the live session takes its place. - this._provisionalSessions.set(sessionId, provisional); + overlay.model, + undefined, + new PendingRequestRegistry(), + permissionMode, + this._metadataStore, + this._instantiationService, + ); + const entry = new ClaudeSessionEntry(session); + entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + this._sessions.set(sessionId, entry); const canUseTool: NonNullable = (toolName, input, options) => handleCanUseTool( - { getSession: id => this._sessions.get(id)?.session, configurationService: this._configurationService }, + { getSession: id => this._findAnySession(id), configurationService: this._configurationService }, sessionId, toolName, input, options, ); - // Phase 10 β€” cross-window resume starts with no client-tool snapshot; - // the workbench re-issues `setClientTools` on active-client re-attach - // and the first sendMessage's diff check rebinds with the new set. - const ctx: IMaterializeContext = { - provisional, - proxyHandle, - canUseTool, - permissionModeFallback: permissionMode, - isResume: true, - }; - - let session: ClaudeAgentSession; try { - session = await this._materializer.materialize(ctx); + await session.materialize({ proxyHandle, canUseTool, isResume: true }); } catch (err) { - this._provisionalSessions.delete(sessionId); + this._sessions.deleteAndDispose(sessionId); throw err; } - const initialEffort = clampEffortForRuntime(resolveClaudeEffort(overlay.model)); - session.seedBijectiveState({ - model: overlay.model?.id, - effort: initialEffort, - permissionMode, - }); - - const entry = new ClaudeSessionEntry(session); - entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); - // See `_materializeProvisional` β€” re-sync any `setClientTools` that - // landed on the resume provisional while `startup` was in flight. - if (provisional.clientTools !== undefined) { - session.setClientTools(provisional.clientTools, provisional.clientId); - } - this._sessions.set(sessionId, entry); - this._provisionalSessions.delete(sessionId); - this._onDidMaterializeSession.fire({ session: sessionUri, workingDirectory, @@ -708,11 +521,9 @@ export class ClaudeAgent extends Disposable implements IAgent { // no DB write β€” symmetric with `createSession`. const sessionId = AgentSession.id(session); return this._disposeSequencer.queue(sessionId, async () => { - const provisional = this._provisionalSessions.get(sessionId); - if (provisional) { - provisional.abortController.abort(); - this._provisionalSessions.delete(sessionId); - return; + const sess = this._findAnySession(sessionId); + if (sess && !sess.isPipelineReady) { + sess.abortController.abort(); } this._sessions.deleteAndDispose(sessionId); }); @@ -727,7 +538,8 @@ export class ClaudeAgent extends Disposable implements IAgent { * existence; the protocol surface (`IAgent`) does not include it. */ getSessionForTesting(session: URI): ClaudeAgentSession | undefined { - return this._sessions.get(AgentSession.id(session))?.session; + const sess = this._sessions.get(AgentSession.id(session))?.session; + return sess?.isPipelineReady ? sess : undefined; } /** @@ -740,7 +552,8 @@ export class ClaudeAgent extends Disposable implements IAgent { */ async getSessionMessages(session: URI): Promise { const sessionId = AgentSession.id(session); - if (this._provisionalSessions.has(sessionId)) { + const sess = this._findAnySession(sessionId); + if (sess && !sess.isPipelineReady) { return []; } if (isSubagentSession(session)) { @@ -935,10 +748,11 @@ export class ClaudeAgent extends Disposable implements IAgent { // so that re-entrant calls return the cached promise *identity*, // not a fresh outer-async wrapper around it. return this._shutdownPromise ??= (async () => { - for (const provisional of this._provisionalSessions.values()) { - provisional.abortController.abort(); + for (const entry of this._sessions.values()) { + if (!entry.session.isPipelineReady) { + entry.session.abortController.abort(); + } } - this._provisionalSessions.clear(); const sessionIds = [...this._sessions.keys()]; await Promise.all(sessionIds.map(sessionId => @@ -963,21 +777,14 @@ export class ClaudeAgent extends Disposable implements IAgent { // invariant holds even if a hypothetical caller forgets it. const effectiveTurnId = turnId ?? generateUuid(); return this._sessionSequencer.queue(sessionId, async () => { - let session = this._sessions.get(sessionId)?.session; - if (!session) { - if (this._provisionalSessions.has(sessionId)) { - // Materialize seeds permissionMode via Options.permissionMode, - // so no setPermissionMode call needed on this turn. - session = await this._materializeProvisional(sessionId); - } else { - // Session exists on disk (created in another window or - // before agent restart) but has no in-memory state in - // this agent instance. Reconstruct a provisional record - // from the SDK transcript + metadata overlay and bring - // it up under `Options.resume` so the SDK reloads the - // existing history rather than minting a fresh one. - session = await this._resumeSession(sessionId, sessionUri); - } + const existing = this._findAnySession(sessionId); + let session: ClaudeAgentSession; + if (existing?.isPipelineReady) { + session = existing; + } else if (existing) { + session = await this._materializeProvisional(sessionId); + } else { + session = await this._resumeSession(sessionId, sessionUri); } const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); @@ -1032,13 +839,15 @@ export class ClaudeAgent extends Disposable implements IAgent { // which lets the queued sendMessage task complete and frees the // sequencer for the next caller. const sessionId = AgentSession.id(session); - const provisional = this._provisionalSessions.get(sessionId); - if (provisional) { - provisional.abortController.abort(); + const sess = this._findAnySession(sessionId); + if (!sess) { return; } - const entry = this._sessions.get(sessionId); - entry?.session.abort(); + if (!sess.isPipelineReady) { + sess.abortController.abort(); + return; + } + sess.abort(); } setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { @@ -1058,54 +867,29 @@ export class ClaudeAgent extends Disposable implements IAgent { } async changeModel(session: URI, model: ModelSelection): Promise { - // Phase 9 D6/D7: bundle-atomic. Provisional sessions mutate their - // pending `model` field directly (next sendMessage reads it when - // building Options). Materialized sessions queue a {@link - // ClaudeAgentSession.queueModelChange} bundle that the prompt - // iterable's yield-boundary applies via `Query.setModel` and - // `Query.applyFlagSettings`. `'max'` effort is clamped to `'xhigh'` - // on the runtime path β€” genuine `'max'` requires the - // restart-required path which is deferred (see TODO). + // Session owns its own provisional/runtime branching and metadata + // write (see {@link ClaudeAgentSession.setModel}). The agent only + // covers the "external-only session" case where there is no + // in-memory record to delegate to. const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { - const provisional = this._provisionalSessions.get(sessionId); - if (provisional) { - provisional.model = model; + const sess = this._findAnySession(sessionId); + if (sess) { + await sess.setModel(model); + } else { await this._metadataStore.write(session, { model }); - return; } - const entry = this._sessions.get(sessionId); - if (entry) { - const requestedEffort = resolveClaudeEffort(model); - const runtimeEffort = clampEffortForRuntime(requestedEffort); - if (requestedEffort === 'max') { - // Copilot CAPI does not currently expose a 'max' reasoning - // tier, so the runtime hot-swap path clamps to 'xhigh'. Lift - // when CAPI gains a 'max' model. - this._logService.warn(`[Claude:${sessionId}] changeModel: 'max' effort clamped to 'xhigh' (Copilot CAPI has no 'max' model yet)`); - } - await entry.session.queueModelChange(model.id, runtimeEffort); - } - await this._metadataStore.write(session, { model }); }); } setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { const sessionId = AgentSession.id(session); this._logService.info(`[Claude:${sessionId}] setClientTools clientId=${clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`); - const provisional = this._provisionalSessions.get(sessionId); - if (provisional) { - provisional.clientTools = tools; - provisional.clientId = clientId; + const sess = this._findAnySession(sessionId); + if (!sess) { return; } - const entry = this._sessions.get(sessionId); - if (entry) { - entry.session.setClientTools(tools, clientId); - return; - } - // Unknown id \u2014 silent drop (workbench may have raced a session - // dispose, or the session lives in another window). + sess.setClientTools(tools, clientId); } onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { @@ -1160,10 +944,11 @@ export class ClaudeAgent extends Disposable implements IAgent { // Step 3: only then release the proxy handle, preserving the // wrapper-before-proxy ordering invariant. This is locked by // test "dispose disposes the proxy handle and is idempotent". - for (const provisional of this._provisionalSessions.values()) { - provisional.abortController.abort(); + for (const entry of this._sessions.values()) { + if (!entry.session.isPipelineReady) { + entry.session.abortController.abort(); + } } - this._provisionalSessions.clear(); super.dispose(); this._proxyHandle?.dispose(); this._proxyHandle = undefined; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index d3a03ebed7a44e..33e2a8733074bc 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -3,26 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionMode, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { Options, PermissionMode, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; -import { ClaudeRuntimeEffortLevel } from '../../common/claudeModelConfig.js'; -import { AgentSignal } from '../../common/agentService.js'; +import { ClaudeRuntimeEffortLevel, clampEffortForRuntime, resolveClaudeEffort } from '../../common/claudeModelConfig.js'; +import { AgentSignal, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; -import { ISessionDatabase } from '../../common/sessionDataService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { PendingMessage, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, ToolCallPendingConfirmationState, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { PendingMessage, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, ToolCallPendingConfirmationState, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ToolCallResult } from '../../common/state/sessionState.js'; +import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; +import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; +import { ClaudeSessionMetadataStore } from './claudeSessionMetadataStore.js'; import { convertToolCallResult } from './clientTools/claudeClientToolResult.js'; import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; +import { IClaudeProxyHandle } from './claudeProxyService.js'; import { ClaudeSdkPipeline, IRematerializer } from './claudeSdkPipeline.js'; import { SubagentRegistry } from './claudeSubagentRegistry.js'; import { ClaudePermissionKind } from './claudeToolDisplay.js'; @@ -30,6 +35,26 @@ import { ClaudePermissionKind } from './claudeToolDisplay.js'; // Re-export for callers that import IRematerializer from the session. export type { IRematerializer } from './claudeSdkPipeline.js'; +/** + * Inputs to {@link ClaudeAgentSession.materialize}. Carries the + * agent-supplied dependencies that the session itself does not own + * (proxy auth, the `canUseTool` closure that bridges back to the + * agent's per-session lookup, and the resume-vs-fresh discriminator). + */ +export interface IMaterializeContext { + readonly proxyHandle: IClaudeProxyHandle; + readonly canUseTool: NonNullable; + readonly isResume: boolean; +} + +function resolveCurrentPermissionMode( + configurationService: IAgentConfigurationService, + sessionUri: URI, + permissionModeFallback: ClaudePermissionMode, +): ClaudePermissionMode { + return readClaudePermissionMode(configurationService, sessionUri) ?? permissionModeFallback; +} + /** * Per-session coordinator. Owns: * β€’ Per-session identity (sessionId / sessionUri / workingDirectory). @@ -42,7 +67,49 @@ export type { IRematerializer } from './claudeSdkPipeline.js'; */ export class ClaudeAgentSession extends Disposable { - private readonly _pipeline: ClaudeSdkPipeline; + private _pipeline: ClaudeSdkPipeline | undefined; + + /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ + private _provisionalModel: ModelSelection | undefined; + /** Pre-materialize `IAgentCreateSessionConfig.config` bag. Read at materialize time. */ + readonly provisionalConfig: Record | undefined; + /** Resolved project metadata captured at create time (if any). */ + readonly project: IAgentSessionProjectInfo | undefined; + /** Always-present abort controller; wired into `Options.abortController` at materialize time. */ + readonly abortController: AbortController; + + /** Exposed for the materializer's MCP-server build closure. */ + get pendingClientToolCalls(): PendingRequestRegistry { return this._pendingClientToolCalls; } + /** Snapshot of permission-mode fallback used when live read is undefined. */ + get permissionModeFallback(): ClaudePermissionMode { return this._permissionModeFallback; } + + static createProvisional( + sessionId: string, + sessionUri: URI, + workingDirectory: URI | undefined, + project: IAgentSessionProjectInfo | undefined, + model: ModelSelection | undefined, + config: Record | undefined, + pendingClientToolCalls: PendingRequestRegistry, + permissionModeFallback: ClaudePermissionMode, + metadataStore: ClaudeSessionMetadataStore, + instantiationService: IInstantiationService, + ): ClaudeAgentSession { + return instantiationService.createInstance( + ClaudeAgentSession, + sessionId, + sessionUri, + workingDirectory, + project, + model, + config, + new AbortController(), + pendingClientToolCalls, + new SessionClientToolsDiff(), + permissionModeFallback, + metadataStore, + ); + } /** * Phase 12 β€” per-session registry of Task tool calls that spawn @@ -85,24 +152,177 @@ export class ClaudeAgentSession extends Disposable { readonly sessionId: string, readonly sessionUri: URI, readonly workingDirectory: URI | undefined, - warm: WarmQuery, + project: IAgentSessionProjectInfo | undefined, + model: ModelSelection | undefined, + config: Record | undefined, abortController: AbortController, - dbRef: IReference, private readonly _pendingClientToolCalls: PendingRequestRegistry, toolDiff: SessionClientToolsDiff, private readonly _permissionModeFallback: ClaudePermissionMode, - @IInstantiationService instantiationService: IInstantiationService, + private readonly _metadataStore: ClaudeSessionMetadataStore, + @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, ) { super(); + this.project = project; + this._provisionalModel = model; + this.provisionalConfig = config; + this.abortController = abortController; this.toolDiff = this._register(toolDiff); - this._pipeline = this._register(instantiationService.createInstance( - ClaudeSdkPipeline, sessionId, sessionUri, warm, abortController, dbRef, this.subagents, toolDiff.model.state.get().clientId, - )); - this._register(this._pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(s))); } - get isResumed(): boolean { return this._pipeline.isResumed; } + /** + * Bring the session up: build SDK `Options`, start the SDK, open the + * session-scoped DB ref, construct the pipeline, and attach the + * rematerializer used for yield-restart (e.g. after a client-tool + * snapshot change). Idempotent on re-call: extra calls throw rather + * than silently re-materialize. + * + * If the supplied {@link IMaterializeContext.proxyHandle}'s underlying + * `abortController` fires while `sdk.startup()` is in flight, the SDK + * unwinds via the controller; if `startup` resolves anyway, the + * `WarmQuery` is asyncDisposed and a {@link CancellationError} is + * thrown (Q8 belt-and-suspenders). + */ + async materialize(ctx: IMaterializeContext): Promise { + if (this._pipeline) { + throw new Error('ClaudeAgentSession is already materialized'); + } + if (!this.workingDirectory) { + throw new Error(`Cannot materialize Claude session ${this.sessionId}: workingDirectory is required`); + } + + const permissionMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + const mcpServers = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService); + + const options = await buildOptions( + { + sessionId: this.sessionId, + workingDirectory: this.workingDirectory, + model: this._provisionalModel, + abortController: this.abortController, + permissionMode, + canUseTool: ctx.canUseTool, + isResume: ctx.isResume, + mcpServers, + }, + ctx.proxyHandle, + data => this._logService.error(`[Claude SDK stderr] ${data}`), + msg => this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${msg}`), + ); + + this._logService.info(`[Claude] session ${this.sessionId}: enableFileCheckpointing=${options.enableFileCheckpointing} isResume=${ctx.isResume}`); + + const warm = await this._sdkService.startup({ options }); + + if (this.abortController.signal.aborted) { + await warm[Symbol.asyncDispose](); + throw new CancellationError(); + } + + const dbRef = this._sessionDataService.openDatabase(this.sessionUri); + let pipeline: ClaudeSdkPipeline; + try { + pipeline = this._register(this._instantiationService.createInstance( + ClaudeSdkPipeline, + this.sessionId, + this.sessionUri, + warm, + this.abortController, + dbRef, + this.subagents, + this.toolDiff.model.state.get().clientId, + )); + } catch (err) { + dbRef.dispose(); + await warm[Symbol.asyncDispose](); + throw err; + } + this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(s))); + this._pipeline = pipeline; + + // Seed the pipeline's bijective config cache so a rebuild re-applies + // the user's last-chosen model / effort without losing the picker + // config. Read provisional state directly off the session. + pipeline.seedCurrentConfig( + this._provisionalModel?.id, + clampEffortForRuntime(resolveClaudeEffort(this._provisionalModel)), + permissionMode, + ); + + // Fresh sessions persist their customization-directory / model / + // permissionMode overlay so a later resume re-reads them. Resume + // sessions skip the write because they READ from the overlay + // upstream and would otherwise overwrite their source. + if (!ctx.isResume) { + try { + await this._metadataStore.write(this.sessionUri, { + customizationDirectory: this.workingDirectory, + model: this._provisionalModel, + permissionMode, + }); + } catch (err) { + this._logService.error(`[Claude] Failed to persist customization directory; aborting materialize`, err); + throw err; + } + } + + // Final pre-commit abort gate. The first gate above caught aborts + // that landed while `sdk.startup()` was in flight; this one catches + // aborts that landed during the metadata write (a separate async + // boundary). Without it, a racing `disposeSession` could complete + // before this method returns and leave the pipeline live. + if (this.abortController.signal.aborted) { + throw new CancellationError(); + } + + pipeline.attachRematerializer(async (_reason) => { + const liveMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + try { + const rebuildMcp = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService); + const rebuildAbort = new AbortController(); + const rebuildOptions = await buildOptions( + { + sessionId: this.sessionId, + workingDirectory: this.workingDirectory!, + model: this._provisionalModel, + abortController: rebuildAbort, + permissionMode: liveMode, + canUseTool: ctx.canUseTool, + isResume: true, + mcpServers: rebuildMcp, + }, + ctx.proxyHandle, + data => this._logService.error(`[Claude SDK stderr] ${data}`), + msg => this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${msg}`), + ); + this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild`); + const rebuildWarm = await this._sdkService.startup({ options: rebuildOptions }); + return { warm: rebuildWarm, abortController: rebuildAbort }; + } catch (err) { + this.toolDiff.markDirty(); + throw err; + } + }); + } + + /** True once {@link materialize} has installed the SDK pipeline. */ + get isPipelineReady(): boolean { return this._pipeline !== undefined; } + + /** Pre-materialize model selection accessor (read by materializer to build Options). */ + get provisionalModel(): ModelSelection | undefined { return this._provisionalModel; } + + private _requirePipeline(): ClaudeSdkPipeline { + if (!this._pipeline) { + throw new Error('ClaudeAgentSession is not materialized'); + } + return this._pipeline; + } + + get isResumed(): boolean { return this._requirePipeline().isResumed; } /** * Seed the pipeline's current + applied config cache from @@ -111,11 +331,11 @@ export class ClaudeAgentSession extends Disposable { * `applyFlagSettings` call. */ seedBijectiveState(state: { model?: string; effort?: ClaudeRuntimeEffortLevel; permissionMode?: PermissionMode }): void { - this._pipeline.seedCurrentConfig(state.model, state.effort, state.permissionMode); + this._requirePipeline().seedCurrentConfig(state.model, state.effort, state.permissionMode); } attachRematerializer(rematerializer: IRematerializer): void { - this._pipeline.attachRematerializer(rematerializer); + this._requirePipeline().attachRematerializer(rematerializer); } /** @@ -132,27 +352,17 @@ export class ClaudeAgentSession extends Disposable { * so this is free when nothing changed. * * Model / effort are not threaded through here β€” the pipeline's current - * model / effort (set eagerly via {@link queueModelChange}) is whatever + * model / effort (set eagerly via {@link setModel}) is whatever * the SDK has been told. */ async send(prompt: SDKUserMessage, turnId: string): Promise { + const pipeline = this._requirePipeline(); if (this.toolDiff.hasDifference) { await this.rebindForClientTools(); } else { - await this._pipeline.setPermissionMode(this._currentPermissionMode()); + await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this.sessionUri, this._permissionModeFallback)); } - return this._pipeline.send(prompt, turnId); - } - - /** - * Live `permissionMode` for this session: reads from - * {@link IAgentConfigurationService} and falls back to the snapshot - * captured at materialize time when the live read is `undefined` - * (e.g. cross-window resume before the workbench has registered the - * session's schema). - */ - private _currentPermissionMode(): ClaudePermissionMode { - return readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + return pipeline.send(prompt, turnId); } /** @@ -165,22 +375,38 @@ export class ClaudeAgentSession extends Disposable { abort(): void { this._pendingPermissions.denyAll(false); this._pendingUserInputs.denyAll({ response: SessionInputResponseKind.Cancel }); - this._pipeline.abort(); + this._requirePipeline().abort(); } /** - * Eagerly push a model and / or effort change to the SDK. Safe to - * call mid-turn: per the SDK contract, `setModel` / - * `applyFlagSettings` only take effect on the NEXT user request. - * Per-field last-write-wins. + * Eagerly apply a model change and persist the new selection. Safe to + * call before or after materialize: + * + * - Pre-materialize: stash the model on the session so the first SDK + * startup picks it up via `Options.model` / `Options.effort`. + * - Post-materialize: queue the change on the pipeline; the SDK + * applies it on the NEXT user request via + * `Query.setModel` / `Query.applyFlagSettings`. `'max'` effort is + * clamped to `'xhigh'` on the runtime path (CAPI lacks a `'max'` + * tier today). + * + * In both cases the new model is persisted to the per-session + * metadata overlay so a later resume sees the user's choice. */ - async queueModelChange(model: string | undefined, effort: ClaudeRuntimeEffortLevel | undefined): Promise { - if (model !== undefined) { - await this._pipeline.setModel(model); - } - if (effort !== undefined) { - await this._pipeline.setEffort(effort); + async setModel(model: ModelSelection): Promise { + this._provisionalModel = model; + if (this._pipeline) { + const requestedEffort = resolveClaudeEffort(model); + const runtimeEffort = clampEffortForRuntime(requestedEffort); + if (requestedEffort === 'max') { + this._logService.warn(`[Claude:${this.sessionId}] setModel: 'max' effort clamped to 'xhigh' (Copilot CAPI has no 'max' model yet)`); + } + await this._pipeline.setModel(model.id); + if (runtimeEffort !== undefined) { + await this._pipeline.setEffort(runtimeEffort); + } } + await this._metadataStore.write(this.sessionUri, { model }); } /** @@ -191,7 +417,8 @@ export class ClaudeAgentSession extends Disposable { * is aborted. */ injectSteering(steeringMessage: PendingMessage): void { - if (this._pipeline.isAborted) { + const pipeline = this._requirePipeline(); + if (pipeline.isAborted) { return; } const contentBlocks = resolvePromptToContentBlocks( @@ -210,12 +437,12 @@ export class ClaudeAgentSession extends Disposable { // boundary is the convention for both code paths. uuid: steeringMessage.id as `${string}-${string}-${string}-${string}-${string}`, }; - this._pipeline.injectSteering(sdkMessage, steeringMessage.id); + pipeline.injectSteering(sdkMessage, steeringMessage.id); } /** Live permission-mode change. Forwards to the pipeline; the pipeline remembers it for re-application after a rebind. */ setPermissionMode(mode: PermissionMode): Promise { - return this._pipeline.setPermissionMode(mode); + return this._requirePipeline().setPermissionMode(mode); } // #region Phase 7 / S3.2 β€” pending state @@ -235,7 +462,7 @@ export class ClaudeAgentSession extends Disposable { /** Phase 12 step 5 β€” when the confirmation belongs to a subagent context, route it to the subagent session. */ readonly parentToolCallId?: string; }): Promise { - if (this._pipeline.isAborted) { + if (!this._pipeline || this._pipeline.isAborted) { return Promise.resolve(false); } return this._pendingPermissions.registerAndFire(args.toolUseID, () => { @@ -260,7 +487,7 @@ export class ClaudeAgentSession extends Disposable { * Resolves with `{ response: Cancel }` if the pipeline is aborted. */ requestUserInput(request: SessionInputRequest, parentToolCallId?: string): Promise<{ response: SessionInputResponseKind; answers?: Record }> { - if (this._pipeline.isAborted) { + if (!this._pipeline || this._pipeline.isAborted) { return Promise.resolve({ response: SessionInputResponseKind.Cancel }); } return this._pendingUserInputs.registerAndFire(request.id, () => { @@ -291,7 +518,9 @@ export class ClaudeAgentSession extends Disposable { /** Replace the registered client tools snapshot. */ setClientTools(tools: readonly ToolDefinition[], clientId?: string): void { this.toolDiff.model.setTools(tools, clientId); - this._pipeline.setClientId(this.toolDiff.model.state.get().clientId); + if (this._pipeline) { + this._pipeline.setClientId(this.toolDiff.model.state.get().clientId); + } } /** @@ -316,7 +545,7 @@ export class ClaudeAgentSession extends Disposable { */ async rebindForClientTools(): Promise { this._pendingClientToolCalls.rejectAll(new CancellationError()); - await this._pipeline.rebindForRestart(); + await this._requirePipeline().rebindForRestart(); } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts b/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts deleted file mode 100644 index 1f3a24fa19e184..00000000000000 --- a/src/vs/platform/agentHost/node/claude/claudeMaterializer.ts +++ /dev/null @@ -1,357 +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 type { McpSdkServerConfigWithInstance, Options, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { CancellationError } from '../../../../base/common/errors.js'; -import { delimiter, dirname } from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import { rgDiskPath } from '../../../../base/node/ripgrep.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; -import { resolveClaudeEffort } from '../../common/claudeModelConfig.js'; -import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; -import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; -import { IAgentSessionProjectInfo } from '../../common/agentService.js'; -import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; -import { ClaudeAgentSession } from './claudeAgentSession.js'; -import { buildClientToolMcpServer } from './clientTools/claudeClientToolMcpServer.js'; -import { IClaudeProxyHandle } from './claudeProxyService.js'; -import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; -import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; - -/** - * In-memory record for a provisional Claude session passed into - * {@link ClaudeMaterializer.materialize}. Mirrors the agent's own - * `IClaudeProvisionalSession` shape β€” kept structurally typed so the - * agent doesn't need to export its private interface. - */ -export interface IClaudeMaterializeProvisional { - readonly sessionId: string; - readonly sessionUri: URI; - readonly workingDirectory: URI | undefined; - readonly abortController: AbortController; - readonly project: IAgentSessionProjectInfo | undefined; - readonly model: ModelSelection | undefined; - /** - * Phase 10 β€” the workbench-registered client-tool snapshot at - * materialize time (if any). The materializer seeds the session's - * {@link SessionClientToolsDiff.model} from this and uses it to build - * the SDK's initial `Options.mcpServers`. - */ - readonly clientTools?: readonly ToolDefinition[]; - /** - * Phase 10 β€” the workbench `clientId` paired with {@link clientTools}. - * Used by the stream mapper to stamp `SessionToolCallStart.toolClientId`. - */ - readonly clientId?: string; -} - -/** - * Per-session bundle the agent hands to {@link ClaudeMaterializer.materialize}. - * Everything the materializer needs to bring a session up AND to rebind - * it on yield-restart β€” the materializer attaches the rematerializer - * hook internally using this bundle, so the agent does not own any - * SDK / client-tool plumbing. - */ -export interface IMaterializeContext { - readonly provisional: IClaudeMaterializeProvisional; - readonly proxyHandle: IClaudeProxyHandle; - readonly canUseTool: NonNullable; - /** - * Fallback permission mode used when the live read from - * {@link IAgentConfigurationService} returns `undefined` (e.g. the - * session's schema has not been registered yet). The materializer - * reads the live value at materialize / rebind and falls back to - * this value so the SDK never silently downgrades to `'default'`. - */ - readonly permissionModeFallback: ClaudePermissionMode; - readonly isResume: boolean; -} - -/** - * Promotes an {@link IClaudeMaterializeProvisional} record into a live - * {@link ClaudeAgentSession}: assembles the SDK `Options` bag, awaits - * `IClaudeAgentSdkService.startup`, opens the per-session DB ref, and - * constructs the session wrapper. - * - * The caller (`ClaudeAgent`) retains: - * - sequencing and the `_provisionalSessions` / `_sessions` maps, - * - permission-mode resolution policy (passed in as a value), - * - the post-materialize metadata write and the second abort gate, - * - the `_onDidMaterializeSession` event fan-out. - * - * The materializer owns Gate 1 (post-`startup` abort): if the - * controller fires while we awaited the SDK, the WarmQuery is asyncDisposed - * and a {@link CancellationError} is thrown before any session resources - * are constructed. - * - * Contract: a successful call returns a fully-owned `ClaudeAgentSession`. - * If the call throws, no resources leak β€” the materializer cleans up - * internally. After return, the caller owns disposal of the session - * (including any post-materialize abort it observes). - */ -export class ClaudeMaterializer { - - constructor( - @ILogService private readonly _logService: ILogService, - @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, - @ISessionDataService private readonly _sessionDataService: ISessionDataService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - ) { } - - async materialize(ctx: IMaterializeContext): Promise { - const { provisional, proxyHandle, canUseTool, isResume, permissionModeFallback } = ctx; - if (!provisional.workingDirectory) { - throw new Error(`Cannot materialize Claude session ${provisional.sessionId}: workingDirectory is required`); - } - - // Phase 10 β€” per-session client-tool plumbing. The MCP server's - // `tool()` handler closures capture the SAME registry + diff that - // the eventually-owned session holds, so the closures park on the - // session's live registry (council finding C1). - const pendingClientToolCalls = new PendingRequestRegistry(); - const toolDiff = new SessionClientToolsDiff(); - toolDiff.model.setTools(provisional.clientTools, provisional.clientId); - - const permissionMode = readClaudePermissionMode(this._configurationService, provisional.sessionUri) ?? permissionModeFallback; - const initialMcpServers = await this._buildClientMcpServers(toolDiff, pendingClientToolCalls); - - const options = await this._buildOptions(provisional, proxyHandle, permissionMode, canUseTool, isResume, initialMcpServers); - - // Trace what the SDK gets so live debugging doesn't have to infer - // from the absence of a `fileEdit` block whether the edit-tracking - // plumbing was wired this session. - this._logService.info(`[Claude] session ${provisional.sessionId}: enableFileCheckpointing=${options.enableFileCheckpointing} isResume=${isResume}`); - - const warm = await this._sdkService.startup({ options }); - - // Q8 belt-and-suspenders: the SDK's comment guarantees abort cleanup - // (sdk.d.ts:982), but if `startup()` resolved despite a racing abort, - // dispose the WarmQuery and surface cancellation. The agent has been - // shutting down while we awaited; do NOT materialize. - if (provisional.abortController.signal.aborted) { - await warm[Symbol.asyncDispose](); - throw new CancellationError(); - } - - // Open a DB ref for the session lifetime so - // `ClaudeAgentSession._observeUserMessage` can persist edit content - // via `FileEditTracker.takeCompletedEdit`. Ownership transfers to - // the session, which disposes the ref ahead of its `WarmQuery` - // abort so any in-flight write completes. - const dbRef = this._sessionDataService.openDatabase(provisional.sessionUri); - let session: ClaudeAgentSession; - try { - session = this._instantiationService.createInstance( - ClaudeAgentSession, - provisional.sessionId, - provisional.sessionUri, - provisional.workingDirectory, - warm, - provisional.abortController, - dbRef, - pendingClientToolCalls, - toolDiff, - permissionModeFallback, - ); - } catch (err) { - // Construction failed β€” own the cleanup so no resource leaks. - dbRef.dispose(); - await warm[Symbol.asyncDispose](); - throw err; - } - - // Phase 9 β€” wire the rematerializer so the session can rebind the - // SDK on yield-restart (e.g. after a client-tool snapshot change). - // The closure captures the ctx so `getPermissionMode` is re-read on - // each rebind and any concurrent `SessionConfigChanged` wins. - session.attachRematerializer(async (_reason) => { - const liveMode = readClaudePermissionMode(this._configurationService, provisional.sessionUri) ?? permissionModeFallback; - try { - const mcpServers = await this._buildClientMcpServers(session.toolDiff, pendingClientToolCalls); - return await this._materializeResume(provisional, proxyHandle, liveMode, canUseTool, mcpServers); - } catch (err) { - // The client-tool diff was consumed by `_buildClientMcpServers`, - // but the rebind never reached a live SDK. Re-mark dirty so the - // next send retries with the same snapshot instead of silently - // running on the stale server set. - session.toolDiff.markDirty(); - throw err; - } - }); - - return session; - } - - /** - * Phase 9 β€” build a fresh {@link WarmQuery} + {@link AbortController} - * for an *existing* session (resume mode). The caller (typically - * {@link ClaudeAgentSession._rebindQuery}) owns the returned warm and - * controller; `materializeResume` does NOT construct a - * {@link ClaudeAgentSession} or open a DB ref β€” those resources are - * already live on the recovering session. - * - * The new controller is fresh so a previous abort doesn't propagate - * into the rebuilt subprocess. Caller is responsible for wiring the - * controller into the session's existing dispose chain (the session's - * `_register(toDisposable(() => this._abortController.abort()))` reads - * the field via `this`, so swapping `_abortController` post-rebind is - * sufficient). - */ - private async _materializeResume( - provisional: IClaudeMaterializeProvisional, - proxyHandle: IClaudeProxyHandle, - permissionMode: ClaudePermissionMode, - canUseTool: NonNullable, - mcpServers: Record | undefined, - ): Promise<{ readonly warm: WarmQuery; readonly abortController: AbortController }> { - if (!provisional.workingDirectory) { - throw new Error(`Cannot materialize Claude session ${provisional.sessionId}: workingDirectory is required`); - } - const abortController = new AbortController(); - const resumedProvisional: IClaudeMaterializeProvisional = { ...provisional, abortController }; - const options = await this._buildOptions(resumedProvisional, proxyHandle, permissionMode, canUseTool, true, mcpServers); - this._logService.info(`[Claude] session ${provisional.sessionId}: resume rebuild`); - const warm = await this._sdkService.startup({ options }); - return { warm, abortController }; - } - - private async _buildOptions( - provisional: IClaudeMaterializeProvisional, - proxyHandle: IClaudeProxyHandle, - permissionMode: ClaudePermissionMode, - canUseTool: NonNullable, - isResume: boolean, - mcpServers: Record | undefined, - ): Promise { - const subprocessEnv = buildSubprocessEnv(); - // Settings env: forwarded to the Claude subprocess via the SDK's - // `Options.settings.env` channel (separate from `Options.env` which - // is the spawn env). PATH composition uses `delimiter` (`:` or `;`) - // so Windows agent hosts don't corrupt PATH on subprocess fork. - const resolvedRgDiskPath = await rgDiskPath(); - const settingsEnv: Record = { - ANTHROPIC_BASE_URL: proxyHandle.baseUrl, - ANTHROPIC_AUTH_TOKEN: `${proxyHandle.nonce}.${provisional.sessionId}`, - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', - USE_BUILTIN_RIPGREP: '0', - PATH: `${dirname(resolvedRgDiskPath)}${delimiter}${process.env.PATH ?? ''}`, - }; - - return { - cwd: provisional.workingDirectory!.fsPath, - executable: process.execPath as 'node', - env: subprocessEnv, - abortController: provisional.abortController, - allowDangerouslySkipPermissions: true, - canUseTool, - // Plan S3.7: silence the SDK's auto-decline path for any - // incidental MCP elicitation request. Full MCP wiring is - // Phase 10; until then we explicitly cancel so the caller - // gets a deterministic `cancel` response and we record the - // event for diagnostics. - onElicitation: async req => { - this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${req.message ?? ''}`); - return { action: 'cancel' }; - }, - disallowedTools: ['WebSearch'], - includePartialMessages: true, - // Phase 12: forward subagent text + thinking blocks through the - // live message stream. Without this the SDK emits only - // `tool_use` / `tool_result` from subagent contexts and the - // child session shows up content-empty live, while replay via - // `getSubagentMessages` returns the full transcript β€” a silent - // UX asymmetry. Startup-only option, not user-bypassable. - forwardSubagentText: true, - // Phase 8: enable the SDK's per-session checkpoint store so - // `Query.rewindFiles` can revert tool-applied edits without - // re-running the agent. The session restore UX (smoke row R8) - // depends on this being on at session start. This is a - // startup option, not a hook β€” not user-bypassable via - // settings. - enableFileCheckpointing: true, - // Phase 8: file-edit tracking is observed off the SDK message - // stream itself (in `ClaudeAgentSession._processMessages`), - // NOT via `Options.hooks.PreToolUse` / `Options.hooks.PostToolUse`. - // Hooks can be disabled by the user in settings β€” relying on - // them for edit tracking would silently break the diff/ - // checkpoint UX on those machines. The message stream is the - // non-bypassable signal: the SDK has to yield the assistant - // `tool_use` block and the synthetic-user `tool_result` block - // regardless of `permissionMode` (`bypassPermissions` - // short-circuits `canUseTool`, but never the message stream). - model: provisional.model?.id, - effort: resolveClaudeEffort(provisional.model), - permissionMode, - // Phase 9 β€” fresh sessions use `sessionId` so the SDK mints a new - // transcript file; resume sessions use `resume` so the SDK reloads - // the existing transcript. Setting both is invalid per the SDK's - // `Options` discriminated union. - ...(isResume - ? { resume: provisional.sessionId } - : { sessionId: provisional.sessionId }), - ...(mcpServers ? { mcpServers } : {}), - settingSources: ['user', 'project', 'local'], - settings: { env: settingsEnv }, - systemPrompt: { type: 'preset', preset: 'claude_code' }, - stderr: data => this._logService.error(`[Claude SDK stderr] ${data}`), - }; - } - - /** - * Phase 10 β€” consume the diff (clears its dirty bit) and build the - * in-process MCP server config from the resulting tool snapshot. - * Resolves to `undefined` when the snapshot is empty so - * `Options.mcpServers` is omitted entirely and the SDK keeps its - * default. If the build throws the diff is re-marked dirty so the - * next sendMessage retries (C6). - */ - private async _buildClientMcpServers( - toolDiff: SessionClientToolsDiff, - registry: PendingRequestRegistry, - ): Promise | undefined> { - const { tools } = toolDiff.consume(); - if (!tools || tools.length === 0) { - return undefined; - } - const server = await buildClientToolMcpServer(tools, id => registry.register(id), this._sdkService); - return { client: server }; - } -} - -/** - * Build the {@link Options.env} payload for the Claude subprocess. - * - * The agent host runs in an Electron utility process; the spawn env - * inherits the parent's env which contains `NODE_OPTIONS`, - * `ELECTRON_*`, and `VSCODE_*` variables that break the Claude - * subprocess (it's a plain Node script driven by Electron's - * `process.execPath` + `ELECTRON_RUN_AS_NODE`). Strip them via - * {@link Options.env} `undefined` semantics (sdk.d.ts:1075-1078: - * "Set a key to `undefined` to remove an inherited variable"). - * - * Mirror of CopilotAgent's strip pattern at copilotAgent.ts:434-450. - * - * Exported for unit testing as a pure function over `process.env`. - */ -export function buildSubprocessEnv(): Record { - const env: Record = { - ELECTRON_RUN_AS_NODE: '1', - NODE_OPTIONS: undefined, - ANTHROPIC_API_KEY: undefined, - }; - for (const key of Object.keys(process.env)) { - if (key === 'ELECTRON_RUN_AS_NODE') { continue; } - if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { - env[key] = undefined; - } - } - return env; -} diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts new file mode 100644 index 00000000000000..0f8f93167ae96a --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { McpSdkServerConfigWithInstance, Options } from '@anthropic-ai/claude-agent-sdk'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { delimiter, dirname } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { rgDiskPath } from '../../../../base/node/ripgrep.js'; +import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; +import { resolveClaudeEffort } from '../../common/claudeModelConfig.js'; +import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; +import type { ModelSelection } from '../../common/state/protocol/state.js'; +import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; +import { buildClientToolMcpServer } from './clientTools/claudeClientToolMcpServer.js'; +import { IClaudeProxyHandle } from './claudeProxyService.js'; +import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; + +/** + * Inputs to {@link buildOptions} that vary per startup. Pure-data: no + * services, no live event subscribers. The function is a deterministic + * projection from this bag plus a {@link IClaudeProxyHandle} onto the + * SDK's {@link Options} discriminated union. + */ +export interface IBuildOptionsInput { + readonly sessionId: string; + readonly workingDirectory: URI; + readonly model: ModelSelection | undefined; + readonly abortController: AbortController; + readonly permissionMode: ClaudePermissionMode; + readonly canUseTool: NonNullable; + readonly isResume: boolean; + readonly mcpServers: Record | undefined; +} + +/** + * Build the SDK {@link Options} bag for a Claude session startup. + * Deterministic over its declared inputs plus three ambient reads: + * 1. `process.env.PATH` (composed into `Options.settings.env.PATH` + * so ripgrep wins over any system install), + * 2. `process.env` keys via {@link buildSubprocessEnv} (used to + * strip `VSCODE_*` / `ELECTRON_*` / `NODE_OPTIONS` / + * `ANTHROPIC_API_KEY` from the spawn env), + * 3. the memoized `rgDiskPath()` lookup. + * The returned options carry the caller-supplied `abortController` so a + * racing dispose unwinds `sdk.startup()` cleanly. + * + * Used by both the initial materialize and the yield-restart rematerialize + * β€” both call sites pass a freshly-built `mcpServers` snapshot consumed + * from the session's {@link SessionClientToolsDiff}. + */ +export async function buildOptions( + input: IBuildOptionsInput, + proxyHandle: IClaudeProxyHandle, + logStderr: (data: string) => void, + logElicitation: (msg: string) => void, +): Promise { + const subprocessEnv = buildSubprocessEnv(); + const resolvedRgDiskPath = await rgDiskPath(); + const settingsEnv: Record = { + ANTHROPIC_BASE_URL: proxyHandle.baseUrl, + ANTHROPIC_AUTH_TOKEN: `${proxyHandle.nonce}.${input.sessionId}`, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + USE_BUILTIN_RIPGREP: '0', + PATH: `${dirname(resolvedRgDiskPath)}${delimiter}${process.env.PATH ?? ''}`, + }; + + return { + cwd: input.workingDirectory.fsPath, + executable: process.execPath as 'node', + env: subprocessEnv, + abortController: input.abortController, + allowDangerouslySkipPermissions: true, + canUseTool: input.canUseTool, + onElicitation: async req => { + logElicitation(req.message ?? ''); + return { action: 'cancel' }; + }, + disallowedTools: ['WebSearch'], + includePartialMessages: true, + forwardSubagentText: true, + enableFileCheckpointing: true, + model: input.model?.id, + effort: resolveClaudeEffort(input.model), + permissionMode: input.permissionMode, + ...(input.isResume + ? { resume: input.sessionId } + : { sessionId: input.sessionId }), + ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), + settingSources: ['user', 'project', 'local'], + settings: { env: settingsEnv }, + systemPrompt: { type: 'preset', preset: 'claude_code' }, + stderr: logStderr, + }; +} + +/** + * Consume the diff (clears its dirty bit) and build the in-process MCP + * server config from the resulting tool snapshot. Resolves to + * `undefined` when the snapshot is empty so `Options.mcpServers` is + * omitted entirely and the SDK keeps its default. + * + * On builder throw the caller is responsible for re-marking the diff + * dirty (the diff has already been consumed). See + * {@link SessionClientToolsDiff.markDirty}. + */ +export async function buildClientMcpServers( + toolDiff: SessionClientToolsDiff, + registry: PendingRequestRegistry, + sdkService: IClaudeAgentSdkService, +): Promise | undefined> { + const { tools } = toolDiff.consume(); + if (!tools || tools.length === 0) { + return undefined; + } + const server = await buildClientToolMcpServer(tools, id => registry.register(id), sdkService); + return { client: server }; +} + +/** + * Build the {@link Options.env} payload for the Claude subprocess. + * + * The agent host runs in an Electron utility process; the spawn env + * inherits the parent's env which contains `NODE_OPTIONS`, + * `ELECTRON_*`, and `VSCODE_*` variables that break the Claude + * subprocess (it's a plain Node script driven by Electron's + * `process.execPath` + `ELECTRON_RUN_AS_NODE`). Strip them via + * {@link Options.env} `undefined` semantics (sdk.d.ts:1075-1078: + * "Set a key to `undefined` to remove an inherited variable"). + * + * Mirror of CopilotAgent's strip pattern at copilotAgent.ts:434-450. + * + * Exported for unit testing as a pure function over `process.env`. + */ +export function buildSubprocessEnv(): Record { + const env: Record = { + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: undefined, + ANTHROPIC_API_KEY: undefined, + }; + for (const key of Object.keys(process.env)) { + if (key === 'ELECTRON_RUN_AS_NODE') { continue; } + if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { + env[key] = undefined; + } + } + return env; +} diff --git a/src/vs/platform/agentHost/node/claude/phase10.5-plan.md b/src/vs/platform/agentHost/node/claude/phase10.5-plan.md new file mode 100644 index 00000000000000..c760ec432f7727 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase10.5-plan.md @@ -0,0 +1,213 @@ +# Phase 10.5 β€” Unified ClaudeAgentSession lifecycle + +> Generated by super-planner. Source: `roadmap.md` (phase 10.5). +> Last updated: 2026-05-21 after grill-with-docs session. + +**Status:** βœ… **DONE** + +Verification: +- Full agent-host unit suite 1807/1807 passing. +- Agent-host integration suite 86/86 passing. +- `npm run compile-check-ts-native` clean. +- Live workbench E2E executed against `--agents` Code OSS (run dir `/tmp/phase10.5-e2e/logs/20260521T114550`). Validated live: + - Provider registration + proxy startup + auth path. + - Three Claude sessions created via local agent host (`b0b896c5-...`, `e40afbbb-...`, `749dc491-...`). + - `setClientTools` registered 13 workbench tools pre-materialize. + - `installPipeline` materialized cleanly (`enableFileCheckpointing=true isResume=false`). + - Plain-text send + result round-trip. + - Full client-tool round-trip end-to-end: Claude requested `openBrowserPage` β†’ permission prompt fired through workbench UI β†’ user approval β†’ workbench opened the page β†’ `tool_result` (`Page Title: Example Domain`) fed back to SDK β†’ final `result for sdkUuid=...`. + - 0 occurrences of legacy failure patterns (`Cannot materialize unknown provisional session`, `rebind: no rematerializer attached`). + - Visual proof: screenshots at `/tmp/code-oss-screenshots/2026-05-21T13-05-33/` (01-launched, 02-session-type-picker, 03-after-select-claude, 04-prompt-typed, 05-permission-prompt, 06-tool-completed). +- Live but not driven via UI this run (covered by unit + integration tests): built-in tool turn, mid-session client-tool change/`rebindForClientTools`, subagent spawn, abort mid-turn, UI dispose. Playwright type-into-Monaco hit focus-sync edge cases on the new-session form; refactor-sensitive plumbing was already proven by the browser-tool scenario above. +- Pre-existing UX note: sessions list shows the same Claude session twice (`agent-host-claude:/` and `claude-code:/`) because both providers index the same on-disk JSONL store β€” NOT a Phase 10.5 regression. + +## Goal + +Replace Claude's dual-map session lifecycle (`_provisionalSessions` + `_sessions`) with one session object identity per `sessionId`, owned by `ClaudeAgentSession`, so Phase 10's race-compensation code can be removed structurally instead of patched around. + +## Scope + +**In scope** + +- Replace dual-map lifecycle with one `_sessions` map of `ClaudeSessionEntry` values. +- Move materialization orchestration to `ClaudeAgentSession.materialize()`. +- Keep lifecycle phase derived from fields (`_pipeline`, `_materializePromise`, disposed state), not a separate enum. +- Move send sequencing from agent `SequencerByKey` to per-session `Sequencer`. +- Preserve provider surface (`IAgent`) while moving internals. +- Remove Phase 10 compensation paths once the new structure is in place. +- Retire `ClaudeMaterializer` class and extract pure SDK option helpers to `claudeSdkOptions.ts`. + +**Out of scope** + +- `IAgent` API changes. +- Copilot agent refactor (`src/vs/platform/agentHost/node/copilot/copilotAgent.ts`) - owned by a separate phase. +- New protocol events/methods beyond existing session progress/materialize forwarding. +- Cross-phase feature work from phases 11+. + +## Prerequisites + +- Phase 10 merged and green (race regression tests already present). +- Agent host test suite runnable via: + `./scripts/test.sh --runGlob "**/agentHost/test/**/*.test.js"`. +- Local E2E automation skills available: `launch` and `code-oss-logs`. +- Workspace builds transpile cleanly for touched files before E2E validation. + +## Approach + +Use an incremental, bisectable refactor where each step stays testable and revertible. First introduce session-owned materialization and delegation while preserving current behavior, then collapse map topology, then delete compensation paths, then retire obsolete abstractions. Keep E2E coverage on every step to catch wiring regressions early. Preserve domain terms from `CONTEXT.md` (materialization, session overlay, proxy) and avoid scope drift into neighboring phases. + +## Steps + +1. **βœ“ Add provisional factory on `ClaudeAgentSession`** - allow session instances to exist pre-materialization. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: none + - Done when: `createProvisional(...)` exists, `_pipeline` is nullable pre-materialize, `_requirePipeline()` guard exists, and tests prove no SDK startup occurs during provisional construction. + +2. **βœ“ Add `session.materialize()` implementation (2a)** - add session-owned lifecycle orchestration and idempotent in-flight materialize promise. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts`, `src/vs/platform/agentHost/node/claude/claudeMaterializer.ts` + - Depends on: step 1 + - Done when: direct unit tests cover happy path, concurrency idempotency, and abort cleanup; behavior of agent call paths is unchanged. + +3. **βœ“ Delegate agent materialize/resume methods to session (2b)** - make production path exercise `session.materialize()`. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgent.ts` + - Depends on: step 2 + - Done when: `_materializeProvisional` and `_resumeSession` delegate to session materialization and existing agent tests remain green. + +4. **βœ“ Collapse to one map while keeping compensation (3a)** - remove `_provisionalSessions` and keep compensation temporarily for safe bisecting. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgent.ts`, `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: step 3 + - Done when: create/send/change/abort/dispose/getMessages all resolve through one map, per-session `Sequencer` is in use, and all Phase 10 regressions still pass. + +5. **βœ“ Delete race compensation (3b)** - removed during 3a since there is no longer a separate provisional record to re-sync. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgent.ts` + - Depends on: step 4 + - Done when: no compensation branches remain, race regressions remain green, and any rollback target is isolated to this commit. + +6. **βœ“ Polish per-phase dispatch in session (4)** - `setClientTools`, `setProvisionalModel`/`queueModelChange`, `requestPermission`, `requestUserInput`, and `abort` are pipeline-aware on session. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: step 5 + - Done when: pre/during/post mutation matrix passes and startup options receive pre-materialize values on first materialize. + +7. **βœ“ Fold disk-only resume into same object pattern (5)** - `_resumeSession` uses `createProvisional` + `materializer.materialize(ctx)` with the same session object identity. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgent.ts`, `src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts` + - Depends on: step 6 + - Done when: resume path creates one provisional session object, materializes via `isResume: true`, and resume-bootstrap regression remains green. + +8. **βœ“ Extract pure SDK options module and retire materializer class (6)** - `claudeSdkOptions.ts` now holds the pure `buildOptions` / `buildClientMcpServers` / `buildSubprocessEnv` helpers; `session.materialize(ctx)` owns SDK startup, abort gate, DB ref open, pipeline construct, rematerializer wire; `ClaudeMaterializer` class + file deleted; tests relocated to `claudeSdkOptions.test.ts`. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| `src/vs/platform/agentHost/node/claude/claudeAgent.ts` | modify | Collapse maps, delegate/cleanup materialize/resume, remove compensation, drop materializer dependency | +| `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` | modify | Provisional factory, nullable pipeline guard, materialize flow, per-session sequencer, mutation dispatch | +| `src/vs/platform/agentHost/node/claude/claudeMaterializer.ts` | modify then delete | Transitional helper extraction; final removal | +| `src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts` | create | Pure `buildOptions`/`buildClientMcpServers`/`buildSubprocessEnv` helpers | +| `src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts` | modify (minimal) | Keep workspace-wide overlay store; optional `project()` helper extraction | +| `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` | modify | Adjust assertions for one-map + compensation deletion + resume flow | +| `src/vs/platform/agentHost/test/node/claudeAgentSession*.test.ts` | modify/create | Materialize idempotency and provisional-session tests | +| `src/vs/platform/agentHost/test/node/claudeMaterializer.test.ts` | modify/rename | Move helper coverage to new module | +| `src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts` | modify if needed | Ensure rebind/recover expectations still hold | +| `src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts` | modify | Pre-materialize `setClientTools` integration scenario | + +## Decisions + +- **How to frame materializer removal?** - Remove the class, not the behavior. Orchestration collapses to session; pure helper logic moves to `claudeSdkOptions.ts` for two-call-site reuse. +- **Step ordering for structural safety?** - Collapse topology before dispatch polish where required (`3a` before `4`) to avoid impossible write-through assumptions. +- **Should compensation removal be bundled?** - No. Split into `3a` (structure) and `3b` (pure deletion) for bisectability. +- **Should `session.materialize()` stay dead code temporarily?** - Split into `2a`/`2b` so implementation lands first, then production wiring lands as its own commit. +- **Where should metadata store live?** - Keep `ClaudeSessionMetadataStore` workspace-wide (required for `listSessions` on unseen sessions). Session consumes it via DI for writes. +- **Sequencer primitive choice?** - Move send sequencing to per-session `Sequencer` (not `SequencerByKey`) once each session owns its own queue. +- **Lifecycle state modeling?** - No explicit enum. Use field-derived state (`_pipeline`, `_materializePromise`, disposed state) to avoid duplicated state machines. +- **E2E cadence?** - Run E2E scenario for every step, not final-only, to catch integration regressions at the introducing commit. +- **Launch-skill runtime selector update?** - Use new picker model: select the `Claude` entry under `Local Agent Host` (no `[LOCAL]` sidebar suffix anymore). + +## Risks + +- **Abort semantics regress during materialization** - Keep explicit tests around startup+metadata async boundaries; verify no leaked warm query/subprocess. +- **Client-tool diff dirty-bit recovery regresses** - Preserve/validate `markDirty()` retry behavior on failed rebind startup. +- **Provider event compatibility breaks** - Ensure `onDidMaterializeSession` forwarding remains intact for `AgentService` consumers. +- **Resume path drift** - Resume must still work for sessions only present on disk, with overlay reads and project metadata fallback behavior. +- **Test hook coupling (`startupAdvance`)** - Update tests that previously asserted map shape details; assert behavior not internals. + +## Verification + +### Unit / Integration + +- Unit suite per step: + - `./scripts/test.sh --runGlob "**/agentHost/test/**/*.test.js"` +- Focused regressions after map collapse/compensation deletion: + - `./scripts/test.sh --grep "materialize gap|resume bootstrap gap|rebind failure leaves"` +- Integration assertion for pre-materialize tool registration: + - update and run `src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts` + +### E2E + +- **Launch skill**: `launch` - drives Agents window UI and Claude session turns in local Code OSS. +- **Log skill**: `code-oss-logs` - reads `agenthost.log` and related logs to validate runtime behavior. +- **Scenario**: + 1. Use `launch` to start `./scripts/code.sh --agents` and open a new session with the `Claude` entry under `Local Agent Host`. + 2. Before first send, change model; send turn 1; send turn 2. + 3. Trigger client-tools change and send turn 3. + 4. Abort an in-flight turn, then dispose the session. + 5. Use `code-oss-logs` to confirm expected lifecycle lines and absence of legacy failures. + 6. Confirm no leaked Claude subprocesses. + +Expected checks after each run: + +```bash +RUN="$(ls -td /tmp/phase10.5-e2e/logs/* | head -1)" +rg -n "Session materialized|enableFileCheckpointing|resume rebuild|setClientTools|rebind|CancellationError|error" "$RUN/agenthost.log" +rg -n "Cannot materialize unknown provisional session" "$RUN/agenthost.log" && echo FAIL +rg -n "rebind: no rematerializer attached" "$RUN/agenthost.log" && echo FAIL +rg -c "Session materialized.*" "$RUN/agenthost.log" +ps aux | grep claude | grep -v grep +``` + +### Manual + +- Verify agent picker ambiguity handling manually if automation refs become stale: + choose lower `Claude` entry under `Local Agent Host` and confirm `POST /v1/messages` appears in `agenthost.log` after a probe send. + +## Open Questions + +None. + +## References + +- Roadmap: `./roadmap.md` (phase 10.5) +- Context: `./CONTEXT.md` +- Prior plan: `./phase10-plan.md` +- E2E skills used: `launch`, `code-oss-logs` +- Launch guide: `../../../../../.agents/skills/launch/references/agents-window-guide.md` +- Rationale source: Roguski, Code Rule #01 (avoid private-method clusters) + +## Implementation Notes + +- Files actually changed: + - `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` +- Tests written: + - `src/vs/platform/agentHost/test/node/claudeAgent.test.ts`: + - `createProvisional creates a session without SDK startup contact` + - `pipeline methods throw before materialize on provisional sessions` + - `session.materialize is idempotent across concurrent calls` + - `session.materialize retries after a failed attempt` +- Deviations from plan and why: + - Step 2a landed as scaffolding: `session.materialize(ctx)` owns idempotency and in-flight promise caching but defers SDK startup / DB ref / pipeline construction to the caller-supplied `ctx.materialize()`. The full orchestration migration folds into Step 3a (structural collapse), where the agent will construct provisional `ClaudeAgentSession`s and pass an orchestration closure that invokes the existing materializer. Intentional design realignment to avoid building then immediately dismantling a stateful intermediate. + - `/avoid-private-methods` follow-up: extracted private `_currentPermissionMode()` into file-level helper `resolveCurrentPermissionMode(...)` in `claudeAgentSession.ts`. + +## Deferred refactors + +- `/avoid-private-methods` audit after Step 3a: `_requirePipeline` collapsed to single-concern guard, `_runMaterialize` deleted entirely along with the legacy callback shape. No outstanding session-level findings. +- Step 6 implemented after research confirmed the plan's framing was correct: `ClaudeMaterializer` was an orphan class (one caller, no polymorphism, no real boundary) once the session owned identity; net change is -50 lines and one obvious lifecycle owner. + +## Step 3a sub-step ledger + +- 3a.i βœ“ Introduce `_findAnySession(sessionId)` discriminated-union helper in `claudeAgent.ts`. +- 3a.ii βœ“ Route `setClientTools` through helper. +- 3a.iii βœ“ Route `changeModel` through helper. +- 3a.iv βœ“ Route `disposeSession`, `abortSession`, `getSessionMessages` provisional check through helper. +- 3a.v βœ“ Route `sendMessage` materialize/resume dispatch through helper. +- 3a.vi+vii+ix βœ“ Storage collapse: dropped `_provisionalSessions` and `IClaudeProvisionalSession`; provisional state now lives on `ClaudeAgentSession` (`project`, `provisionalModel`, `provisionalConfig`, `abortController`, `isPipelineReady`, `installPipeline`); materializer rewritten to install onto an existing session; `_findAnySession` collapsed to single-map lookup; shutdown/dispose iterate `_sessions` only; compensation re-sync paths removed naturally. +- 3a.viii βœ“ `createSession` now uses `ClaudeAgentSession.createProvisional` and registers immediately into `_sessions`. diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 0c8c566a188928..7bcdc4e28b925b 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -97,7 +97,7 @@ Phase numbers are stable identifiers β€” code comments, plan files do **not** renumber. The actual landing order diverges from numeric order to unblock self-hosting sooner: -**1 β†’ 1.5 β†’ 2 β†’ 3 β†’ 4 β†’ 5 β†’ 6 β†’ 9 β†’ 13 β†’ 7 β†’ 8 β†’ 10 β†’ 11 β†’ 12 β†’ 6.5 β†’ 14 β†’ 15** +**1 β†’ 1.5 β†’ 2 β†’ 3 β†’ 4 β†’ 5 β†’ 6 β†’ 9 β†’ 13 β†’ 7 β†’ 8 β†’ 10 β†’ 10.5 β†’ 11 β†’ 12 β†’ 6.5 β†’ 14 β†’ 15** Phase 13 (session restoration) is pulled forward immediately after Phase 9 because it unlocks two high-leverage capabilities: @@ -1027,6 +1027,34 @@ Exit criteria: client tools callable from a Claude session. Check what `ideMcpServer.ts` does. - Idle timeout for the MCP gateway β€” sensible default? +### Phase 10.5 β€” Unified `ClaudeAgentSession` lifecycle βœ… **DONE** + +Structural follow-up to Phase 10. The dual-map session pattern +(`_provisionalSessions` + `_sessions`) is the direct source of every +race bug surfaced by Phase 10's council review. Each was fixed with +compensation code; this phase collapses the structure so the +compensation goes away. + +**Goal:** one `_sessions` map of `ClaudeAgentSession` objects that own +their own `materialize()` lifecycle. Delete `_provisionalSessions`, +`IClaudeProvisionalSession`, and the `ClaudeMaterializer` class (pure +helpers move to a new `claudeSdkOptions.ts` module). + +**Scope:** internal refactor β€” `IAgent` surface unchanged. 8 bite-size +steps, each landing behind the agentHost test suite. Phase 10's race +regressions remain green and become trivially true once the structural +split is gone. `CopilotAgent` uses the same pattern but stays as +reference only (different lifecycle semantics β€” no MCP, no +yield-restart). + +Exit criteria: zero `_provisionalSessions` / `IClaudeProvisionalSession` +/ `ClaudeMaterializer` references under `src/vs/platform/agentHost/`; +Phase 10 race regressions still passing; E2E scenario (create β†’ +set-model β†’ send β†’ set-client-tools β†’ send β†’ rebind β†’ abort β†’ +dispose) clean across the whole session lifecycle. + +Full step-by-step plan: [phase10.5-plan.md](./phase10.5-plan.md). + ### Phase 11 β€” Customizations / plugins (full surface) **Inbound (host β†’ SDK):** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e8546189e922b9..2b8a7842260768 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -45,7 +45,7 @@ import { parsedPluginsEqual, toCustomizationAgentRefs, toSdkCustomAgents, toSdkH import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools } from './copilotShellTools.js'; import { SessionCustomizationDiscovery } from './sessionCustomizationDiscovery.js'; -import { SessionPluginBundler } from './sessionPluginBundler.js'; +import { SessionPluginBundler } from '../shared/sessionPluginBundler.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; interface ICreatedWorktree { @@ -313,7 +313,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async getSessionCustomizations(session: URI): Promise { - return this._plugins.getSessionCustomizations(await this._getSessionCustomizationDirectory(session)); + return this._plugins.getSessionCustomizationsSettled(await this._getSessionCustomizationDirectory(session)); } private async _getSessionCustomizationDirectory(session: URI): Promise { @@ -1949,6 +1949,32 @@ class PluginController extends Disposable { return result; } + /** + * Settled variant of {@link getSessionCustomizations}: awaits the + * in-flight host sync, the in-flight client sync, and (when a directory + * is supplied) the session-discovered entry's initial scan + parse + * before snapshotting the customization list. + * + * Callers that publish customizations into session state at session + * creation time MUST use this β€” the synchronous variant can return an + * empty list for a brand-new working directory because + * {@link SessionDiscoveredEntry} kicks off its `_refresh()` in its + * constructor without anyone awaiting it. + */ + public async getSessionCustomizationsSettled(directory: URI | undefined): Promise { + const entry = directory ? this._getOrCreateSessionEntry(directory) : undefined; + await Promise.all([ + this._hostSync.catch(err => { + this._logService.warn('[Copilot:PluginController] Host customization update failed', err); + }), + this._clientSync.catch(err => { + this._logService.warn('[Copilot:PluginController] Customization sync failed', err); + }), + entry?.whenSettled(), + ]); + return this.getSessionCustomizations(directory); + } + /** * Returns the current parsed plugins, awaiting any pending sync. */ diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 504a863627d53e..39b11ed23bba15 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -37,6 +37,17 @@ import { import { ChangesetOperationScope, ChangesetOperationTargetKind, isAhpRootChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; +import { + buildOtlpLogsChannelUri, + extractLevelFromOtlpLogsUri, + levelToSeverityNumber, + OTLP_CHANNEL_SCHEME, + OTLP_LOGS_CHANNEL_TEMPLATE, + OtlpLogEmitter, + toResourceLogsPayload, + type IOtlpLogRecord, + type OtlpLogLevelName, +} from '../common/otlp/otlpLogEmitter.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -78,6 +89,42 @@ type RequestHandlerMap = { [M in RequestMethod]: (client: IConnectedClient, params: CommandMap[M]['params']) => Promise; }; +/** + * Discriminant for {@link ChannelSubscription}. Distinguishes a regular + * state-bearing channel (root, session, terminal, changeset) from the + * stateless OTLP signal channels so each subscribe/unsubscribe path can + * dispatch through a single typed lookup. + */ +const enum ChannelKind { + /** + * Subscribed via {@link IAgentService.subscribe} and tracked by the + * server-side refcount. Carries replayable state, participates in + * action broadcasts ({@link _broadcastAction}) and reconnect + * snapshot/replay. + */ + State = 'state', + /** + * Subscribed against the OTLP logs channel template advertised in + * {@link InitializeResult.telemetry}. Stateless β€” no snapshot, no + * agent-service refcount. The `level` field records the minimum + * severity the client asked to receive. + */ + OtlpLogs = 'otlp-logs', +} + +/** + * Per-channel server-side subscription record. Stored on every + * {@link IConnectedClient} so each subscribed channel can be routed by + * its `kind` without re-deriving it from the URI on every dispatch. + * + * `uri` is the canonical channel URI string used everywhere a subscription + * is referenced β€” the same string is broadcast on outbound notifications + * and persists across reconnects. + */ +type ChannelSubscription = + | { readonly kind: ChannelKind.State; readonly uri: string } + | { readonly kind: ChannelKind.OtlpLogs; readonly uri: string; readonly level: OtlpLogLevelName }; + /** * Represents a connected protocol client with its subscription state. */ @@ -85,10 +132,38 @@ interface IConnectedClient { readonly clientId: string; readonly protocolVersion: string; readonly transport: IProtocolTransport; - readonly subscriptions: Set; + /** + * Every channel the client is currently subscribed to, keyed by the + * canonical channel URI. OTLP channel URIs are canonicalised to + * `buildOtlpLogsChannelUri(level)` so URI variants that resolve to + * the same logical channel collapse to one entry. + */ + readonly subscriptions: Map; readonly disposables: DisposableStore; } +/** + * Classifies a raw channel URI string into its {@link ChannelKind} and + * returns the canonical URI to key subscriptions by. Returns `undefined` + * when the channel is OTLP-flavoured but the URI does not parse into a + * supported shape (unknown level, missing path) so the caller can + * silently drop the subscribe rather than installing a broken entry. + * + * For state channels the canonical URI is just the input verbatim β€” the + * agent service is the authoritative deduplication point and tolerates + * whatever URI form the client sent. + */ +function classifyChannel(channel: string): ChannelSubscription | undefined { + if (channel.toLowerCase().startsWith(`${OTLP_CHANNEL_SCHEME}:`)) { + const level = extractLevelFromOtlpLogsUri(channel); + if (!level) { + return undefined; + } + return { kind: ChannelKind.OtlpLogs, uri: buildOtlpLogsChannelUri(level), level }; + } + return { kind: ChannelKind.State, uri: channel }; +} + /** * Configuration for protocol-level concerns outside of IAgentService. */ @@ -101,6 +176,17 @@ export interface IProtocolServerConfig { * clients in the `initialize` response. */ readonly completionTriggerCharacters?: readonly string[]; + /** + * Optional emitter to use as the source for the OTLP logs channel + * advertised via `InitializeResult.telemetry.logs`. When present, this + * handler will route `subscribe`/`unsubscribe` requests on + * `ahp-otlp:` channels to its internal OTLP subscription registry and + * broadcast every record fed into the emitter as an + * `otlp/exportLogs` notification. When absent, the OTLP channel is + * not advertised and any inbound `ahp-otlp:` subscribe request is + * rejected. + */ + readonly otlpLogEmitter?: OtlpLogEmitter; } /** @@ -144,6 +230,10 @@ export class ProtocolServerHandler extends Disposable { this._register(this._stateManager.onDidEmitNotification(notification => { this._broadcastNotification(notification); })); + + if (this._config.otlpLogEmitter) { + this._register(this._config.otlpLogEmitter.onDidLog(record => this._broadcastOtlpLog(record))); + } } // ---- Connection handling ------------------------------------------------- @@ -211,10 +301,7 @@ export class ProtocolServerHandler extends Disposable { switch (msg.method) { case 'unsubscribe': if (client) { - const channel = msg.params.channel; - if (client.subscriptions.delete(channel)) { - this._agentService.unsubscribe(URI.parse(channel), client.clientId); - } + this._removeSubscription(client, msg.params.channel); } break; case 'dispatchAction': @@ -248,11 +335,15 @@ export class ProtocolServerHandler extends Disposable { disposables.add(transport.onClose(() => { if (client && this._clients.get(client.clientId) === client) { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${client.subscriptions.size}`); - // Treat disconnect as an implicit unsubscribe of every resource the + // Treat disconnect as an implicit unsubscribe of every channel the // client held, so the server-side refcount can drop to zero and any - // idle restored session state can be evicted. - for (const resource of client.subscriptions) { - this._agentService.unsubscribe(URI.parse(resource), client.clientId); + // idle restored session state can be evicted. OTLP subscriptions + // have no server-side state to release, so the per-client map is + // simply discarded. + for (const sub of client.subscriptions.values()) { + if (sub.kind === ChannelKind.State) { + this._agentService.unsubscribe(URI.parse(sub.uri), client.clientId); + } } client.subscriptions.clear(); this._clients.delete(client.clientId); @@ -300,7 +391,7 @@ export class ProtocolServerHandler extends Disposable { clientId: params.clientId, protocolVersion: negotiated, transport, - subscriptions: new Set(), + subscriptions: new Map(), disposables, }; this._clients.set(params.clientId, client); @@ -319,13 +410,9 @@ export class ProtocolServerHandler extends Disposable { const snapshots: IStateSnapshot[] = []; if (params.initialSubscriptions) { for (const uri of params.initialSubscriptions) { - const snapshot = this._stateManager.getSnapshot(uri); + const snapshot = this._addInitialSubscription(client, uri.toString()); if (snapshot) { snapshots.push(snapshot); - const key = uri.toString(); - client.subscriptions.add(key); - this._agentService.addSubscriber(URI.parse(key), client.clientId); - this._clearClientToolCallDisconnectTimeout(params.clientId, key); } } } @@ -338,10 +425,50 @@ export class ProtocolServerHandler extends Disposable { snapshots, defaultDirectory: this._config.defaultDirectory, completionTriggerCharacters: this._config.completionTriggerCharacters, + telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined, }, }; } + /** + * Helper for `initialize` and `reconnect` initial-subscription + * processing: classify `channel`, install the matching subscription + * on the client, and return the snapshot to include in the handshake + * response (or `undefined` for stateless channels and missing state). + * + * Side effects: + * - State channels: register with the agent service and clear any + * pending tool-call disconnect timeout. + * - OTLP channels: install the canonical entry on the client's + * {@link IConnectedClient.subscriptions} map. + * + * Channels with unsupported shapes (e.g. `ahp-otlp://logs/verbose` + * with no recognised level, or a state channel the state manager + * does not know about) are silently dropped. + */ + private _addInitialSubscription(client: IConnectedClient, channel: string): IStateSnapshot | undefined { + const sub = classifyChannel(channel); + if (!sub) { + return undefined; + } + if (sub.kind === ChannelKind.OtlpLogs) { + if (!this._config.otlpLogEmitter) { + this._logService.warn(`[ProtocolServer] Ignoring OTLP initialSubscription ${channel}: no OTLP emitter configured.`); + return undefined; + } + client.subscriptions.set(sub.uri, sub); + return undefined; + } + const snapshot = this._stateManager.getSnapshot(channel); + if (!snapshot) { + return undefined; + } + client.subscriptions.set(sub.uri, sub); + this._agentService.addSubscriber(URI.parse(sub.uri), client.clientId); + this._clearClientToolCallDisconnectTimeout(client.clientId, sub.uri); + return snapshot; + } + /** * Forwards a client's upgrade request to the hosting VS Code CLI's * HTTP management API (advertised via the {@link VSCODE_AGENT_HOST_MANAGEMENT_SOCKET_ENV}). @@ -385,7 +512,7 @@ export class ProtocolServerHandler extends Disposable { clientId: params.clientId, protocolVersion: PROTOCOL_VERSION, transport, - subscriptions: new Set(), + subscriptions: new Map(), disposables, }; this._clients.set(params.clientId, client); @@ -414,10 +541,23 @@ export class ProtocolServerHandler extends Disposable { const missing: string[] = []; const snapshots = await Promise.all(params.subscriptions.map(async sub => { const key = sub.toString(); + const classified = classifyChannel(key); + if (!classified) { + return undefined; + } + if (classified.kind === ChannelKind.OtlpLogs) { + if (!this._config.otlpLogEmitter) { + this._logService.warn(`[ProtocolServer] Reconnect: dropping OTLP subscription ${key}: no OTLP emitter configured.`); + return undefined; + } + // Stateless: re-install without going through the agent service. + client.subscriptions.set(classified.uri, classified); + return undefined; + } try { const snapshot = await this._agentService.subscribe(URI.parse(key), client.clientId); - client.subscriptions.add(key); - this._clearClientToolCallDisconnectTimeout(client.clientId, key); + client.subscriptions.set(classified.uri, classified); + this._clearClientToolCallDisconnectTimeout(client.clientId, classified.uri); return snapshot; } catch (err) { this._logService.info(`[ProtocolServer] Reconnect: failed to restore subscription ${key}: ${err instanceof Error ? err.message : String(err)}`); @@ -540,10 +680,25 @@ export class ProtocolServerHandler extends Disposable { */ private readonly _requestHandlers: RequestHandlerMap = { subscribe: async (client, params) => { + const classified = classifyChannel(params.channel); + if (!classified) { + // OTLP-flavoured URI we don't understand (e.g. unknown + // level). Acknowledge as stateless so the client doesn't + // hang, but install nothing. + return {}; + } + if (classified.kind === ChannelKind.OtlpLogs) { + if (!this._config.otlpLogEmitter) { + this._logService.warn(`[ProtocolServer] Ignoring OTLP subscribe for ${params.channel}: no OTLP emitter configured.`); + return {}; + } + client.subscriptions.set(classified.uri, classified); + return {}; + } try { const snapshot = await this._agentService.subscribe(URI.parse(params.channel), client.clientId); - client.subscriptions.add(params.channel); - this._clearClientToolCallDisconnectTimeout(client.clientId, params.channel); + client.subscriptions.set(classified.uri, classified); + this._clearClientToolCallDisconnectTimeout(client.clientId, classified.uri); return { snapshot }; } catch (err) { if (err instanceof ProtocolError) { @@ -841,6 +996,56 @@ export class ProtocolServerHandler extends Disposable { } } + /** + * Drop a subscription identified by `channel` from `client`. Handles + * canonicalisation for OTLP URIs (so an `unsubscribe` with a URI + * variant collapses to the same entry as the original `subscribe`) + * and tears down the agent-service refcount for state channels. + */ + private _removeSubscription(client: IConnectedClient, channel: string): void { + const classified = classifyChannel(channel); + if (!classified) { + // OTLP-flavoured URI with an unknown level β€” there can never + // have been a matching subscription. Silently ignore. + return; + } + const sub = client.subscriptions.get(classified.uri); + if (!sub) { + return; + } + client.subscriptions.delete(classified.uri); + if (sub.kind === ChannelKind.State) { + this._agentService.unsubscribe(URI.parse(sub.uri), client.clientId); + } + } + + /** + * Fan out an OTLP log record to every connected client that has + * subscribed to a logs channel whose `{level}` band includes the + * record's `severityNumber`. The notification's `channel` field is + * the canonical URI the client subscribed against β€” clients can + * route by URI without re-deriving the level. + */ + private _broadcastOtlpLog(record: IOtlpLogRecord): void { + const payload = toResourceLogsPayload(record); + for (const client of this._clients.values()) { + for (const sub of client.subscriptions.values()) { + if (sub.kind !== ChannelKind.OtlpLogs) { + continue; + } + if (record.severityNumber < levelToSeverityNumber(sub.level)) { + continue; + } + const msg: AhpServerNotification<'otlp/exportLogs'> = { + jsonrpc: '2.0', + method: 'otlp/exportLogs', + params: { channel: sub.uri, payload }, + }; + client.transport.send(msg); + } + } + } + private _isRelevantToClient(client: IConnectedClient, envelope: ActionEnvelope): boolean { // The root channel has two equivalent string forms (`ahp-root://` and // the URI-roundtripped `ahp-root:`). Treat them interchangeably so a @@ -848,14 +1053,15 @@ export class ProtocolServerHandler extends Disposable { // regardless of which form the envelope carries. See // {@link isAhpRootChannel}. if (isAhpRootChannel(envelope.channel)) { - for (const sub of client.subscriptions) { - if (isAhpRootChannel(sub)) { + for (const sub of client.subscriptions.values()) { + if (sub.kind === ChannelKind.State && isAhpRootChannel(sub.uri)) { return true; } } return false; } - return client.subscriptions.has(envelope.channel); + const sub = client.subscriptions.get(envelope.channel); + return sub?.kind === ChannelKind.State; } override dispose(): void { diff --git a/src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts similarity index 97% rename from src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts rename to src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts index 6e4849c2612d4f..c32a1497cd28f8 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionPluginBundler.ts +++ b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts @@ -12,7 +12,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IAgentPluginManager } from '../../common/agentPluginManager.js'; import type { CustomizationRef } from '../../common/state/sessionState.js'; import type { URI as ProtocolURI } from '../../common/state/protocol/state.js'; -import { DiscoveredType, type IDiscoveredFile } from './sessionCustomizationDiscovery.js'; +import { DiscoveredType, type IDiscoveredFile } from '../copilot/sessionCustomizationDiscovery.js'; const DISPLAY_NAME = 'VS Code Synced Data'; const HOST_DISCOVERY_DIR = 'host-discovery'; diff --git a/src/vs/platform/agentHost/test/common/otlpLogEmitter.test.ts b/src/vs/platform/agentHost/test/common/otlpLogEmitter.test.ts new file mode 100644 index 00000000000000..546890b255ff72 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/otlpLogEmitter.test.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { LogLevel } from '../../../log/common/log.js'; +import { + buildOtlpLogsChannelUri, + extractLevelFromOtlpLogsUri, + iterateOtlpLogRecords, + levelToSeverityNumber, + logLevelToOtlpLevelName, + logLevelToOtlpSeverity, + OtlpEmitterLogger, + OtlpLogEmitter, + parseOtlpLogLevel, + severityNumberToLogLevel, + toResourceLogsPayload, + type IOtlpLogRecord, +} from '../../common/otlp/otlpLogEmitter.js'; + +suite('OtlpLogEmitter', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('level <-> severity number mappings are inverse-ish', () => { + // Each VS Code level β†’ severity number, then back, should land on + // the same level (the boundary numbers are picked to make this hold). + const cases: [LogLevel, number][] = [ + [LogLevel.Trace, 1], + [LogLevel.Debug, 5], + [LogLevel.Info, 9], + [LogLevel.Warning, 13], + [LogLevel.Error, 17], + ]; + const observed = cases.map(([level]) => { + const { severityNumber, severityText } = logLevelToOtlpSeverity(level); + return { level, severityNumber, severityText, roundTrip: severityNumberToLogLevel(severityNumber) }; + }); + assert.deepStrictEqual(observed, [ + { level: LogLevel.Trace, severityNumber: 1, severityText: 'trace', roundTrip: LogLevel.Trace }, + { level: LogLevel.Debug, severityNumber: 5, severityText: 'debug', roundTrip: LogLevel.Debug }, + { level: LogLevel.Info, severityNumber: 9, severityText: 'info', roundTrip: LogLevel.Info }, + { level: LogLevel.Warning, severityNumber: 13, severityText: 'warn', roundTrip: LogLevel.Warning }, + { level: LogLevel.Error, severityNumber: 17, severityText: 'error', roundTrip: LogLevel.Error }, + ]); + }); + + test('parseOtlpLogLevel + level name helpers', () => { + assert.deepStrictEqual( + { + trace: parseOtlpLogLevel('trace'), + TRACE: parseOtlpLogLevel('TRACE'), + fatal: parseOtlpLogLevel('Fatal'), + bogus: parseOtlpLogLevel('verbose'), + off: logLevelToOtlpLevelName(LogLevel.Off), + info: logLevelToOtlpLevelName(LogLevel.Info), + traceBoundary: levelToSeverityNumber('trace'), + warnBoundary: levelToSeverityNumber('warn'), + }, + { + trace: 'trace', + TRACE: 'trace', + fatal: 'fatal', + bogus: undefined, + off: undefined, + info: 'info', + traceBoundary: 1, + warnBoundary: 13, + }, + ); + }); + + test('OtlpEmitterLogger fans logs onto the shared emitter', () => { + const emitter = disposables.add(new OtlpLogEmitter()); + const logger = disposables.add(new OtlpEmitterLogger(emitter, LogLevel.Trace)); + const received: IOtlpLogRecord[] = []; + disposables.add(emitter.onDidLog(record => received.push(record))); + + logger.trace('hello trace'); + logger.debug('hello debug'); + logger.info('hello info'); + logger.warn('hello warn'); + logger.error('hello error'); + + // Filter out timestamp for stable assertion (timeUnixNano is real-time). + const sanitised = received.map(r => ({ severityNumber: r.severityNumber, severityText: r.severityText, body: r.body })); + assert.deepStrictEqual(sanitised, [ + { severityNumber: 1, severityText: 'trace', body: 'hello trace' }, + { severityNumber: 5, severityText: 'debug', body: 'hello debug' }, + { severityNumber: 9, severityText: 'info', body: 'hello info' }, + { severityNumber: 13, severityText: 'warn', body: 'hello warn' }, + { severityNumber: 17, severityText: 'error', body: 'hello error' }, + ]); + }); + + test('logger level gates which records reach the OTLP emitter', () => { + const emitter = disposables.add(new OtlpLogEmitter()); + const otlpLogger = disposables.add(new OtlpEmitterLogger(emitter, LogLevel.Warning)); + const received: IOtlpLogRecord[] = []; + disposables.add(emitter.onDidLog(record => received.push(record))); + + otlpLogger.trace('should-drop'); + otlpLogger.debug('should-drop'); + otlpLogger.info('should-drop'); + otlpLogger.warn('should-pass'); + otlpLogger.error('should-pass'); + + assert.deepStrictEqual(received.map(r => r.body), ['should-pass', 'should-pass']); + }); + + test('toResourceLogsPayload + iterateOtlpLogRecords round-trip', () => { + const record: IOtlpLogRecord = { + timeUnixNano: '123000000', + severityNumber: 9, + severityText: 'info', + body: 'a body', + }; + const payload = toResourceLogsPayload(record); + const decoded = [...iterateOtlpLogRecords(payload)]; + assert.deepStrictEqual(decoded, [record]); + }); + + test('iterateOtlpLogRecords tolerates malformed shapes', () => { + const decoded = [ + ...iterateOtlpLogRecords({ resourceLogs: [{ scopeLogs: [{ logRecords: [null, { severityNumber: 'bad' }] }] }] }), + ...iterateOtlpLogRecords({ resourceLogs: 'nope' }), + ...iterateOtlpLogRecords(undefined), + ]; + // One malformed record passes through with sensible defaults; the + // rest are silently dropped without throwing. + assert.deepStrictEqual(decoded, [{ + timeUnixNano: '0', + severityNumber: 0, + severityText: 'trace', + body: '', + }]); + }); + + test('buildOtlpLogsChannelUri + extractLevelFromOtlpLogsUri round-trip', () => { + const cases = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; + assert.deepStrictEqual( + cases.map(level => ({ level, uri: buildOtlpLogsChannelUri(level), parsed: extractLevelFromOtlpLogsUri(buildOtlpLogsChannelUri(level)) })), + cases.map(level => ({ level, uri: `ahp-otlp://logs/${level}`, parsed: level })), + ); + }); + + test('extractLevelFromOtlpLogsUri rejects unknown shapes', () => { + assert.deepStrictEqual( + { + bareScheme: extractLevelFromOtlpLogsUri('ahp-otlp://logs'), + unknownLevel: extractLevelFromOtlpLogsUri('ahp-otlp://logs/verbose'), + wrongScheme: extractLevelFromOtlpLogsUri('ahp-state://logs/info'), + }, + { + bareScheme: undefined, + unknownLevel: undefined, + wrongScheme: undefined, + }, + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 32e9162bd84f9f..b6123cdc2c0c5c 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -42,6 +42,10 @@ class MockProtocolClient extends Disposable { readonly onDidAction = Event.None; readonly onDidNotification = Event.None; readonly onDidChangeConnectionState = Event.None; + readonly onDidReceiveOtlpLogs = Event.None; + readonly connectionState = 'connecting' as const; + readonly initializeResult = undefined; + readonly telemetryCapabilities = undefined; public connectDeferred = new DeferredPromise(); @@ -157,10 +161,10 @@ suite('RemoteAgentHostService', () => { } as Partial); // Mock the instantiation service to capture created protocol clients. - // `_connectTo` calls `createInstance` for both `WebSocketClientTransport` and - // `RemoteAgentHostProtocolClient`. We only care about tracking the protocol - // client; for the transport we return a no-op disposable so the test can - // continue to assert on `createdClients.length`. + // `_connectTo` calls `createInstance` for `WebSocketClientTransport` + // and `RemoteAgentHostProtocolClient`. We only care about tracking + // the protocol client; for the transport we return a no-op + // disposable so the test can keep asserting on `createdClients.length`. const mockInstantiationService: Partial = { createInstance: (ctor: unknown, ...args: unknown[]) => { const ctorName = (ctor as { name?: string }).name; diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index af6a1994d7e3fd..ab34f3c07821ed 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -47,9 +47,9 @@ import { AgentConfigurationService, IAgentConfigurationService } from '../../nod import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { ClaudeAgentSession } from '../../node/claude/claudeAgentSession.js'; +import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js'; import { ClaudeAgentSdkService, IClaudeAgentSdkService, IClaudeSdkBindings } from '../../node/claude/claudeAgentSdkService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; -import { SessionClientToolsDiff } from '../../node/claude/clientTools/claudeSessionClientToolsModel.js'; import { IClaudeProxyHandle, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { resolvePromptToContentBlocks } from '../../node/claude/claudePromptResolver.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; @@ -544,6 +544,7 @@ interface ITestContext { readonly sessionData: RecordingSessionDataService; readonly stateManager: AgentHostStateManager; readonly configService: AgentConfigurationService; + readonly instantiationService: IInstantiationService; } /** @@ -594,7 +595,7 @@ function createTestContext( ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); - return { agent, proxy, api, sdk, sessionData, stateManager, configService }; + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; } /** Drains the microtask queue so awaited refresh writes settle. */ @@ -1051,6 +1052,100 @@ suite('ClaudeAgent', () => { }); }); + test('createProvisional creates a session without SDK startup contact', async () => { + const { sdk, instantiationService } = createTestContext(disposables); + + const session = disposables.add(ClaudeAgentSession.createProvisional( + 'test-session', + AgentSession.uri('claude', 'test-session'), + URI.file('/workspace'), + undefined, + undefined, + undefined, + new PendingRequestRegistry(), + 'default', + instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), + instantiationService, + )); + + assert.deepStrictEqual({ + startupCallCount: sdk.startupCallCount, + sessionId: session.sessionId, + sessionUri: session.sessionUri.toString(), + }, { + startupCallCount: 0, + sessionId: 'test-session', + sessionUri: 'claude:/test-session', + }); + }); + + test('pipeline methods throw before materialize on provisional sessions', async () => { + const { instantiationService } = createTestContext(disposables); + const session = disposables.add(ClaudeAgentSession.createProvisional( + 'test-session', + AgentSession.uri('claude', 'test-session'), + URI.file('/workspace'), + undefined, + undefined, + undefined, + new PendingRequestRegistry(), + 'default', + instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), + instantiationService, + )); + + await assert.rejects( + session.send({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + session_id: 'test-session', + parent_tool_use_id: null, + }, 'turn-1'), + /session is not materialized/i, + ); + }); + + test('resume keeps the existing overlay model (materialize does not clobber on isResume)', async () => { + // On the resume path `session.materialize(ctx)` must NOT write the + // session overlay: the overlay is the SOURCE of model / + // permissionMode at resume time. If materialize wrote unconditionally, + // the user's prior model selection would be silently overwritten with + // whatever default `_resumeSession` had to fall back to. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + // Phase 1: fresh materialize so the overlay is seeded with the + // session's initial model. + const initialModel = { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }; + const created = await agent.createSession({ workingDirectory: URI.file('/work-resume'), model: initialModel }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.sendMessage(created.session, 'hi', undefined, 'turn-1'); + + // Phase 2: user changes the model post-materialize β€” this hits the + // runtime path inside session.setModel and rewrites the overlay. + const updatedModel = { id: 'claude-opus-4.6', config: { thinkingLevel: 'medium' } }; + await agent.changeModel(created.session, updatedModel); + + // Phase 3: simulate cross-window resume by tearing the in-memory + // entry down and forcing the resume branch on the next send. + await agent.disposeSession(created.session); + sdk.sessionList = [{ sessionId, cwd: '/work-resume', summary: '', lastModified: Date.now() }]; + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.sendMessage(created.session, 'turn 2', undefined, 'turn-2'); + + // Phase 4: confirm the overlay still carries the updated model from + // Phase 2. If materialize wrote unconditionally on resume, the + // overlay would carry whatever model the resume path passed in + // (typically the initial materialize-time model). + const metadataAfterResume = await agent.getSessionMetadata(created.session); + assert.deepStrictEqual( + metadataAfterResume?.model, + updatedModel, + 'resume must not clobber the overlay model', + ); + }); + test('createSession honors config.session when the workbench pre-mints the URI', async () => { // Workbench eagerly mints the session URI client-side (PR #313841 // folder-pick path) and round-trips it through createSession so @@ -3324,28 +3419,34 @@ suite('ClaudeAgentSession (Phase 7 Β§3.2)', () => { // deferred MUST resolve with `false` so the SDK's `for await` // loop unwinds and the subprocess shuts down cleanly. const sdk = new FakeClaudeAgentSdkService(); - const warm = new FakeWarmQuery(sdk); const fakeConfigService: IAgentConfigurationService = { getSessionConfigValues: () => undefined, } as unknown as IAgentConfigurationService; + const sessionData = new RecordingSessionDataService(createSessionDataService()); const services = new ServiceCollection( [ILogService, new NullLogService()], [IAgentConfigurationService, fakeConfigService], + [IClaudeAgentSdkService, sdk], + [ISessionDataService, sessionData], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - const dbRef = { object: new TestSessionDatabase(), dispose: () => { } }; - const session = disposables.add(instantiationService.createInstance( - ClaudeAgentSession, + const session = disposables.add(ClaudeAgentSession.createProvisional( 'session-id', URI.parse('claude:/session-id'), + URI.file('/workspace'), + undefined, + undefined, undefined, - warm, - new AbortController(), - dbRef, new PendingRequestRegistry(), - new SessionClientToolsDiff(), 'default', + instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), + instantiationService, )); + await session.materialize({ + proxyHandle: { baseUrl: 'http://127.0.0.1:0', nonce: 'n', dispose: () => { } }, + canUseTool: async () => ({ behavior: 'deny', message: 'unused' }), + isResume: false, + }); const permission = session.requestPermission({ toolUseID: 'tu_1', diff --git a/src/vs/platform/agentHost/test/node/claudeMaterializer.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts similarity index 94% rename from src/vs/platform/agentHost/test/node/claudeMaterializer.test.ts rename to src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts index 8e94a358ddb331..87d9a269de3fbc 100644 --- a/src/vs/platform/agentHost/test/node/claudeMaterializer.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts @@ -5,9 +5,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildSubprocessEnv } from '../../node/claude/claudeMaterializer.js'; +import { buildSubprocessEnv } from '../../node/claude/claudeSdkOptions.js'; -suite('ClaudeMaterializer / buildSubprocessEnv', () => { +suite('claudeSdkOptions / buildSubprocessEnv', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/protocol/otlpLogs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/otlpLogs.integrationTest.ts new file mode 100644 index 00000000000000..35d6987674e741 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/otlpLogs.integrationTest.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; +import { ROOT_STATE_URI } from '../../../common/state/sessionState.js'; +import type { InitializeResult } from '../../../common/state/sessionProtocol.js'; +import type { TelemetryCapabilities } from '../../../common/state/protocol/channels-otlp/state.js'; +import type { OtlpExportLogsParams } from '../../../common/state/protocol/channels-otlp/notifications.js'; +import { OTLP_LOGS_CHANNEL_TEMPLATE, iterateOtlpLogRecords } from '../../../common/otlp/otlpLogEmitter.js'; +import { IServerHandle, startServer, TestProtocolClient } from './testHelpers.js'; + +/** + * End-to-end checks that the agent host server actually advertises and + * honours the OTLP logs channel over the wire. Unit tests cover the + * per-subscriber filter and the OTLP/JSON envelope shape β€” this suite + * focuses on the protocol surface (`initialize.telemetry.logs`, + * `subscribe` on `ahp-otlp:` URIs, and `otlp/exportLogs` notifications). + */ +suite('Protocol WebSocket β€” OTLP logs channel', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + // `--quiet` skips the file logger but still constructs the + // `OtlpLogEmitter` and adds the `OtlpEmitterLogger` as the only + // underlying logger, so any `logService.info(...)` call from the + // server flows out as an `otlp/exportLogs` notification. That is + // exactly what we want to assert end-to-end. + server = await startServer({ quiet: true }); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + test('initialize advertises the logs channel template', async function () { + this.timeout(5_000); + + const result = await client.call('initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'test-otlp-handshake', + initialSubscriptions: [ROOT_STATE_URI], + }); + + assert.deepStrictEqual(result.telemetry, { logs: OTLP_LOGS_CHANNEL_TEMPLATE }); + }); + + test('subscribe on the logs channel returns a stateless empty result', async function () { + this.timeout(5_000); + + await client.call('initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'test-otlp-subscribe', + initialSubscriptions: [ROOT_STATE_URI], + }); + + const result = await client.call<{ snapshot?: unknown }>('subscribe', { + channel: 'ahp-otlp://logs/trace', + }); + assert.deepStrictEqual(result, {}); + }); + + test('subscribed clients receive otlp/exportLogs notifications for server log output', async function () { + this.timeout(10_000); + + await client.call('initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'test-otlp-receive', + initialSubscriptions: [ROOT_STATE_URI], + }); + await client.call('subscribe', { channel: 'ahp-otlp://logs/trace' }); + + // Triggering an invalid `createSession` causes the server to log + // the failure via `ILogService`, which fans out to the OTLP + // emitter. We don't care about the exact wording β€” only that a + // record arrives on the subscribed channel. + const notificationPromise = client.waitForNotification( + n => n.method === 'otlp/exportLogs' && (n.params as OtlpExportLogsParams).channel === 'ahp-otlp://logs/trace', + ); + await client.call('createSession', { channel: 'copilot:///test', provider: 'nonexistent' }).catch(() => undefined); + + const exportNotification = await notificationPromise; + const params = exportNotification.params as OtlpExportLogsParams; + const records = [...iterateOtlpLogRecords(params.payload)]; + assert.ok(records.length > 0, 'expected at least one decoded log record'); + assert.ok(records[0].body.length > 0, 'expected the record body to carry a formatted log line'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 6871cd10ca2f12..6a0b9326562930 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -21,6 +21,7 @@ import type { IProtocolServer, IProtocolTransport } from '../../common/state/ses import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js'; +import { iterateOtlpLogRecords, OtlpLogEmitter } from '../../common/otlp/otlpLogEmitter.js'; // ---- Mock helpers ----------------------------------------------------------- @@ -1205,4 +1206,173 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(agentService.createSessionConfigs.length, 0, 'agent service should not have been called'); }); }); + + suite('OTLP logs channel', () => { + // We need a separate handler instance that has an OtlpLogEmitter + // attached, so spin one up per-test using a private state manager. + // The outer-suite handler is left alone and continues to test the + // "no OTLP" code path implicitly. + let otlpEmitter: OtlpLogEmitter; + let otlpStateManager: AgentHostStateManager; + let otlpServer: MockProtocolServer; + let otlpAgentService: MockAgentService; + let localDisposables: DisposableStore; + + setup(() => { + localDisposables = new DisposableStore(); + otlpEmitter = localDisposables.add(new OtlpLogEmitter()); + otlpStateManager = localDisposables.add(new AgentHostStateManager(new NullLogService())); + otlpServer = localDisposables.add(new MockProtocolServer()); + otlpAgentService = new MockAgentService(); + otlpAgentService.setStateManager(otlpStateManager); + localDisposables.add(otlpAgentService); + localDisposables.add(new ProtocolServerHandler( + otlpAgentService, + otlpStateManager, + otlpServer, + { defaultDirectory: URI.file('/home/testuser').toString(), otlpLogEmitter: otlpEmitter }, + localDisposables.add(new AgentHostFileSystemProvider()), + new NullLogService(), + )); + }); + + teardown(() => { + localDisposables.dispose(); + }); + + function connectOtlpClient(clientId: string, initialSubscriptions?: readonly string[]): MockProtocolTransport { + const transport = new MockProtocolTransport(); + otlpServer.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId, + initialSubscriptions, + })); + return transport; + } + + function findOtlpLogs(sent: ProtocolMessage[]): { channel: string; payload: unknown }[] { + return sent + .filter(isJsonRpcNotification) + .filter((m): m is AhpNotification & { method: 'otlp/exportLogs'; params: { channel: string; payload: unknown } } => m.method === 'otlp/exportLogs') + .map(m => ({ channel: m.params.channel, payload: m.params.payload })); + } + + test('handshake advertises the logs channel template', () => { + const transport = connectOtlpClient('client-otlp-1'); + const resp = findResponse(transport.sent, 1) as { result: InitializeResult & { telemetry?: { logs?: string } } }; + assert.deepStrictEqual(resp.result.telemetry, { logs: 'ahp-otlp://logs/{level}' }); + }); + + test('subscribe to logs channel returns an empty stateless result and starts forwarding records at-or-above the requested level', async () => { + const transport = connectOtlpClient('client-otlp-2'); + transport.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/warn' })); + const resp = await waitForResponse(transport, 2); + assert.deepStrictEqual((resp as { result: unknown }).result, {}); + + otlpEmitter.emit({ timeUnixNano: '1000', severityNumber: 9, severityText: 'info', body: 'info-msg' }); + otlpEmitter.emit({ timeUnixNano: '1001', severityNumber: 13, severityText: 'warn', body: 'warn-msg' }); + otlpEmitter.emit({ timeUnixNano: '1002', severityNumber: 17, severityText: 'error', body: 'error-msg' }); + + const logs = findOtlpLogs(transport.sent); + const bodies = logs.flatMap(({ payload }) => [...iterateOtlpLogRecords(payload)].map(r => r.body)); + assert.deepStrictEqual(bodies, ['warn-msg', 'error-msg']); + for (const { channel } of logs) { + assert.strictEqual(channel, 'ahp-otlp://logs/warn'); + } + }); + + test('unsubscribe stops forwarding without affecting other subscribers', async () => { + const a = connectOtlpClient('client-otlp-a'); + const b = connectOtlpClient('client-otlp-b'); + + const aSubscribed = waitForResponse(a, 2); + const bSubscribed = waitForResponse(b, 2); + a.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/trace' })); + b.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/trace' })); + await aSubscribed; + await bSubscribed; + + otlpEmitter.emit({ timeUnixNano: '1', severityNumber: 9, severityText: 'info', body: 'first' }); + + a.simulateMessage(notification('unsubscribe', { channel: 'ahp-otlp://logs/trace' })); + otlpEmitter.emit({ timeUnixNano: '2', severityNumber: 9, severityText: 'info', body: 'second' }); + + const aBodies = findOtlpLogs(a.sent).flatMap(({ payload }) => [...iterateOtlpLogRecords(payload)].map(r => r.body)); + const bBodies = findOtlpLogs(b.sent).flatMap(({ payload }) => [...iterateOtlpLogRecords(payload)].map(r => r.body)); + assert.deepStrictEqual({ a: aBodies, b: bBodies }, { a: ['first'], b: ['first', 'second'] }); + }); + + test('multiple subscriptions to different levels each receive their own band', async () => { + const transport = connectOtlpClient('client-otlp-multi'); + const subscribed2 = waitForResponse(transport, 2); + const subscribed3 = waitForResponse(transport, 3); + transport.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/info' })); + transport.simulateMessage(request(3, 'subscribe', { channel: 'ahp-otlp://logs/error' })); + await subscribed2; + await subscribed3; + + otlpEmitter.emit({ timeUnixNano: '1', severityNumber: 9, severityText: 'info', body: 'info-only' }); + otlpEmitter.emit({ timeUnixNano: '2', severityNumber: 17, severityText: 'error', body: 'both' }); + + const byChannel = new Map(); + for (const { channel, payload } of findOtlpLogs(transport.sent)) { + const bodies = [...iterateOtlpLogRecords(payload)].map(r => r.body); + byChannel.set(channel, [...(byChannel.get(channel) ?? []), ...bodies]); + } + assert.deepStrictEqual(Object.fromEntries(byChannel), { + 'ahp-otlp://logs/info': ['info-only', 'both'], + 'ahp-otlp://logs/error': ['both'], + }); + }); + + test('client disconnect drops its OTLP subscriptions', async () => { + const transport = connectOtlpClient('client-otlp-disconnect'); + transport.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/trace' })); + await waitForResponse(transport, 2); + + transport.simulateClose(); + otlpEmitter.emit({ timeUnixNano: '1', severityNumber: 9, severityText: 'info', body: 'after-close' }); + + // After close, no further notifications should land on the + // disconnected transport. (Sanity: the only message we expect + // was the subscribe response we already consumed.) + const logs = findOtlpLogs(transport.sent); + assert.deepStrictEqual(logs, []); + }); + + test('unrecognised ahp-otlp URIs do not crash subscribe', async () => { + const transport = connectOtlpClient('client-otlp-bad'); + transport.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/verbose' })); + const resp = await waitForResponse(transport, 2); + assert.deepStrictEqual((resp as { result: unknown }).result, {}, 'unknown level should be acknowledged as stateless'); + + otlpEmitter.emit({ timeUnixNano: '1', severityNumber: 9, severityText: 'info', body: 'whatever' }); + assert.deepStrictEqual(findOtlpLogs(transport.sent), [], 'no records should leak to an invalid level'); + }); + + test('URI variants that parse to the same level collapse to one canonical subscription', async () => { + const transport = connectOtlpClient('client-otlp-canonical'); + const r2 = waitForResponse(transport, 2); + const r3 = waitForResponse(transport, 3); + const r4 = waitForResponse(transport, 4); + transport.simulateMessage(request(2, 'subscribe', { channel: 'ahp-otlp://logs/info' })); + transport.simulateMessage(request(3, 'subscribe', { channel: 'ahp-otlp://logs/info?dup=1' })); + transport.simulateMessage(request(4, 'subscribe', { channel: 'ahp-otlp://logs/info#frag' })); + await r2; await r3; await r4; + + otlpEmitter.emit({ timeUnixNano: '1', severityNumber: 9, severityText: 'info', body: 'once' }); + + const logs = findOtlpLogs(transport.sent); + assert.strictEqual(logs.length, 1, 'one record should produce exactly one notification'); + assert.strictEqual(logs[0].channel, 'ahp-otlp://logs/info', 'channel should be canonicalised'); + + // Unsubscribe should remove the canonical entry regardless of + // which URI variant the client uses to unsubscribe. + transport.simulateMessage(notification('unsubscribe', { channel: 'ahp-otlp://logs/info?dup=1' })); + otlpEmitter.emit({ timeUnixNano: '2', severityNumber: 9, severityText: 'info', body: 'after-unsub' }); + + assert.strictEqual(findOtlpLogs(transport.sent).length, 1, 'no further notifications after unsubscribe'); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index bc467971923cc6..bf4be87e326dfe 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -16,7 +16,7 @@ import { TestInstantiationService } from '../../../instantiation/test/common/ins import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IAgentPluginManager } from '../../common/agentPluginManager.js'; import { DiscoveredType, SessionCustomizationDiscovery } from '../../node/copilot/sessionCustomizationDiscovery.js'; -import { SessionPluginBundler } from '../../node/copilot/sessionPluginBundler.js'; +import { SessionPluginBundler } from '../../node/shared/sessionPluginBundler.js'; suite('SessionCustomizationDiscovery + SessionPluginBundler', () => { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index cfff846b44301c..ce51cf96222599 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -85,6 +85,11 @@ export interface IBrowserViewBounds { height: number; zoomFactor: number; cornerRadius: number; + emulation?: { + viewportWidth: number; + viewportHeight: number; + scale: number; + }; } export interface IBrowserViewCaptureScreenshotOptions { @@ -156,6 +161,7 @@ export interface IBrowserViewState { storageScope: BrowserViewStorageScope; browserZoomIndex: number; isElementSelectionActive: boolean; + device: IBrowserDeviceProfile | undefined; } export interface IBrowserViewNavigationEvent { @@ -255,6 +261,27 @@ export function browserZoomAccessibilityLabel(zoomFactor: number): string { return localize('browserZoomAccessibilityLabel', "Page Zoom: {0}%", Math.round(zoomFactor * 100)); } +/** + * The "device" half of browser emulation: characteristics the page sees as + * intrinsic to the device (touch / mobile media features, DPR, UA string). + */ +export interface IBrowserDeviceProfile { + readonly mobile?: boolean; + readonly userAgent?: string; + readonly deviceScaleFactor?: number; +} + +/** + * The "screen" half of browser emulation: the desired viewport size and zoom. + * + * `undefined` values mean the view should be sized to fit the container. + */ +export interface IBrowserScreenProfile { + readonly width?: number; + readonly height?: number; + readonly scale?: number; +} + /** * This should match the isolated world ID defined in `preload-browserView.ts`. */ @@ -281,6 +308,7 @@ export interface IBrowserViewService { onDynamicDidClose(id: string): Event; onDynamicDidSelectElement(id: string): Event; onDynamicDidChangeElementSelectionActive(id: string): Event; + onDynamicDidChangeDeviceEmulation(id: string): Event; /** * Get all known browser views with their ownership and state information. @@ -431,6 +459,9 @@ export interface IBrowserViewService { /** Set the browser zoom index (independent from VS Code zoom). */ setBrowserZoomIndex(id: string, zoomIndex: number): Promise; + /** Set or clear the active device profile for a browser view. */ + setDeviceEmulation(id: string, device: IBrowserDeviceProfile | undefined): Promise; + /** * Trust a certificate for a given host in the browser view's session. * The page will be automatically reloaded after trusting. diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index 27bd96adc253da..80f4d61766227a 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* eslint-disable no-restricted-globals */ +/* eslint-disable no-restricted-syntax */ // Only `import type` is allowed in preload scripts β€” Electron preloads cannot resolve module imports at runtime. import type { IBrowserViewTheme } from '../common/browserView.js'; @@ -46,7 +47,6 @@ function init() { // Listen for keydown events that the page did not handle and forward them for shortcut handling. window.addEventListener('keydown', (event) => { // Require that the event is trusted -- i.e. user-initiated. - // eslint-disable-next-line no-restricted-syntax if (!(event instanceof KeyboardEvent) || !event.isTrusted) { return; } @@ -155,10 +155,29 @@ function init() { } }, { capture: true }); + // Invoked over IPC to support frames (executeJavaScriptInIsolatedWorld doesn't exist on WebFrameMain). + ipcRenderer.on('vscode:browserView:setTheme', (_event: unknown, theme: IBrowserViewTheme) => { + elementPicker.setTheme(theme); + }); + ipcRenderer.on('vscode:browserView:startElementPicker', (_event: unknown) => { + elementPicker.start(); + }); + ipcRenderer.on('vscode:browserView:stopElementPicker', (_event: unknown) => { + elementPicker.stop(); + }); + ipcRenderer.on('vscode:browserView:highlightElement', (_event: unknown, { elementId }: { elementId: string }) => { + const element = getElement(elementId); + if (element) { + elementPicker.highlight(element); + } + }); + ipcRenderer.on('vscode:browserView:hideHighlight', (_event: unknown) => { + elementPicker.hideHighlight(); + }); + const getElement = (id: string): Element | null => { switch (id) { case 'active': - // eslint-disable-next-line no-restricted-syntax return document.activeElement; case 'context-menu-target': return contextMenuTargetRef?.deref() ?? null; @@ -180,26 +199,18 @@ function init() { } catch { return ''; } - }, - setTheme(theme: IBrowserViewTheme): void { - elementPicker.setTheme(theme); - }, - pickElement: elementPicker.api, - highlightElement(id: string): boolean { - const element = getElement(id); - if (!element) { - return false; - } - elementPicker.highlight(element); - return true; - }, - hideHighlight(): void { - elementPicker.hideHighlight(); } }; + // Generate a unique token for this frame instance. This token is used to + // correlate the Electron WebFrameMain (available via IPC senderFrame) with + // the CDP target session (discoverable via Runtime.evaluate in the main world). + const frameToken = `frame-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const mainWorldHelpers = { - getElement + getElement, + /** Opaque token exposed for CDP-side frame matching. */ + getFrameToken(): string { return frameToken; } }; try { @@ -214,7 +225,7 @@ function init() { console.error(error); } - ipcRenderer.send('vscode:browserView:preloadReady'); + ipcRenderer.send('vscode:browserView:preloadReady', frameToken); } /** @@ -233,9 +244,7 @@ function findCommonVisibleAncestor(candidates: readonly (Node | null | undefined // Find the nearest visible ancestor of a single element. const findVisible = (el: Element): Element => { for (let cur: Element | null = el; cur; cur = cur.parentElement) { - // eslint-disable-next-line no-restricted-syntax const width = cur instanceof HTMLElement ? cur.offsetWidth : cur.clientWidth; - // eslint-disable-next-line no-restricted-syntax const height = cur instanceof HTMLElement ? cur.offsetHeight : cur.clientHeight; if (width > 0 && height > 0) { return cur; @@ -307,12 +316,6 @@ class ElementPicker { private _highlightTarget: Element | undefined; private _cursorStylesheet: HTMLStyleElement | undefined; - readonly api = { - start: (): boolean => this.start(), - stop: (): void => this.stop(), - isActive: (): boolean => this._selectionActive, - }; - constructor( private readonly _onPicked: (element: Element) => void, private readonly _onStopped: () => void @@ -376,7 +379,6 @@ class ElementPicker { return true; } this._continuous = false; // for now - // eslint-disable-next-line no-restricted-syntax document.documentElement.appendChild(this._shadowHost); this._selectionActive = true; @@ -385,13 +387,12 @@ class ElementPicker { // Updated to crosshair in _onPointerDown, reset in _onPointerUp. const cursorStyle = document.createElement('style'); cursorStyle.textContent = ElementPicker._CURSOR_DEFAULT; - // eslint-disable-next-line no-restricted-syntax document.head.appendChild(cursorStyle); this._cursorStylesheet = cursorStyle; // Register high-frequency listeners only while selection is active. window.addEventListener('pointermove', this._onPointerMove, true); - window.addEventListener('pointerleave', this._onPointerLeave, true); + document.addEventListener('pointerleave', this._onPointerLeave, true); window.addEventListener('pointerdown', this._onPointerDown, true); window.addEventListener('pointerup', this._onPointerUp, true); window.addEventListener('click', this._onClick, true); @@ -413,7 +414,7 @@ class ElementPicker { // Remove high-frequency listeners. window.removeEventListener('pointermove', this._onPointerMove, true); - window.removeEventListener('pointerleave', this._onPointerLeave, true); + document.removeEventListener('pointerleave', this._onPointerLeave, true); window.removeEventListener('pointerdown', this._onPointerDown, true); window.removeEventListener('pointerup', this._onPointerUp, true); window.removeEventListener('click', this._onClick, true); @@ -444,7 +445,6 @@ class ElementPicker { */ highlight(element: Element): void { if (!this._shadowHost.parentNode) { - // eslint-disable-next-line no-restricted-syntax document.documentElement.appendChild(this._shadowHost); } this._updateHighlight(element); @@ -584,7 +584,6 @@ class ElementPicker { /** Return the page element under a viewport point, skipping our own overlay host. */ private _pickElementAt(x: number, y: number): Element | undefined { - // eslint-disable-next-line no-restricted-syntax const candidates = document.elementsFromPoint(x, y); for (const el of candidates) { if (el === this._shadowHost || this._shadowHost.contains(el)) { @@ -654,7 +653,6 @@ class ElementPicker { const labelTop = Math.max(0, Math.min(viewportHeight - labelHeight, idealTop)); // Use clientWidth (excludes scrollbar) rather than innerWidth so the // label doesn't extend behind the scrollbar on Windows/Linux. - // eslint-disable-next-line no-restricted-syntax const viewportWidth = document.documentElement.clientWidth; // Position label at the element's left edge, but push it left if it // would overflow the viewport. Clamp to 0 so it never goes off-screen. diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index e1ae26c8e26263..6ae74ed36edaca 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -8,7 +8,8 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; -import { BrowserViewElementInspector } from './browserViewElementInspector.js'; +import { BrowserViewEmulator } from './browserViewEmulator.js'; +import { BrowserViewInspector } from './browserViewInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; @@ -41,7 +42,8 @@ export class BrowserView extends Disposable { private _browserZoomIndex: number = browserZoomDefaultIndex; readonly debugger: BrowserViewDebugger; - readonly inspector: BrowserViewElementInspector; + readonly emulator: BrowserViewEmulator; + readonly inspector: BrowserViewInspector; private _ownerWindow: ICodeWindow; private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; @@ -191,7 +193,8 @@ export class BrowserView extends Disposable { }); this.debugger = new BrowserViewDebugger(this, this.logService); - this.inspector = this._register(new BrowserViewElementInspector(this)); + this.emulator = this._register(new BrowserViewEmulator(this, this.logService)); + this.inspector = this._register(new BrowserViewInspector(this)); this.setupEventListeners(); } @@ -472,7 +475,8 @@ export class BrowserView extends Disposable { certificateError: this.session.trust.getCertificateError(url), storageScope: this.session.storageScope, browserZoomIndex: this._browserZoomIndex, - isElementSelectionActive: this.inspector.isElementSelectionActive + isElementSelectionActive: this.inspector.isElementSelectionActive, + device: this.emulator.device }; } @@ -497,6 +501,11 @@ export class BrowserView extends Disposable { } this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor)); + + if (bounds.emulation) { + this.emulator.applyScreenEmulation(bounds.emulation.viewportWidth, bounds.emulation.viewportHeight, bounds.emulation.scale, bounds.zoomFactor); + } + this._view.setBounds({ x: Math.round(bounds.x * bounds.zoomFactor), y: Math.round(bounds.y * bounds.zoomFactor), diff --git a/src/vs/platform/browserView/electron-main/browserViewEmulator.ts b/src/vs/platform/browserView/electron-main/browserViewEmulator.ts new file mode 100644 index 00000000000000..aa699988bc9f3d --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewEmulator.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { IBrowserDeviceProfile } from '../common/browserView.js'; +import { ILogService } from '../../log/common/log.js'; +import type { BrowserView } from './browserView.js'; + +/** + * Manages device emulation for a browser view. The renderer is authoritative + * for layout (it computes the on-screen size and emulation scale); this class + * just forwards values to `webContents.enableDeviceEmulation` and manages the + * touch / media / user-agent overrides that have no native Electron equivalent. + */ +export class BrowserViewEmulator extends Disposable { + + private _device: IBrowserDeviceProfile | undefined; + private readonly _defaultUserAgent: string; + private _lastApplied: { viewportWidth: number; viewportHeight: number; scale: number; hostZoom: number } | undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + private readonly browser: BrowserView, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._defaultUserAgent = this.browser.webContents.getUserAgent(); + + // Chromium may reset emulation on cross-process navigation. + const onNavigate = () => { + if (this._device) { + void this._applyTouchAndMedia(); + this._lastApplied = undefined; + } + }; + this.browser.webContents.on('did-navigate', onNavigate); + this._register(toDisposable(() => this.browser.webContents.removeListener('did-navigate', onNavigate))); + } + + get device(): IBrowserDeviceProfile | undefined { + return this._device; + } + + async setDevice(device: IBrowserDeviceProfile | undefined): Promise { + const prev = this._device; + this._device = device; + + const nextUA = device?.userAgent; + if (prev?.userAgent !== nextUA) { + this.browser.webContents.setUserAgent(nextUA ?? this._defaultUserAgent); + } + + const mobileChanged = !!prev?.mobile !== !!device?.mobile; + const toggled = !!prev !== !!device; + if (mobileChanged || toggled) { + await this._applyTouchAndMedia(); + } + + this._lastApplied = undefined; + if (!device) { + this.browser.webContents.disableDeviceEmulation(); + } + + this._onDidChange.fire(device); + } + + /** + * Apply viewport + scale via Chromium's emulation API. `hostZoom` is the host + * window's CSS-to-screen zoom factor: bounds in main are multiplied by it, + * so the emulation scale must be too or the emulated viewport won't fill + * the WebContentsView when the workbench is zoomed. + */ + applyScreenEmulation(viewportWidth: number, viewportHeight: number, scale: number, hostZoom: number): void { + if (!this._device) { + return; + } + const w = Math.max(1, Math.round(viewportWidth)); + const h = Math.max(1, Math.round(viewportHeight)); + const z = Math.max(0.01, hostZoom); + const s = Math.max(0.01, scale); + const last = this._lastApplied; + if (last && last.viewportWidth === w && last.viewportHeight === h + && Math.abs(last.scale - s) < 0.0001 && Math.abs(last.hostZoom - z) < 0.0001) { + return; + } + this._lastApplied = { viewportWidth: w, viewportHeight: h, scale: s, hostZoom: z }; + this.browser.webContents.enableDeviceEmulation({ + screenPosition: this._device.mobile ? 'mobile' : 'desktop', + screenSize: { width: w, height: h }, + viewSize: { width: w, height: h }, + deviceScaleFactor: this._device.deviceScaleFactor ?? 0, + viewPosition: { x: 0, y: 0 }, + scale: s * z, + }); + } + + private async _applyTouchAndMedia(): Promise { + const mobile = !!this._device?.mobile; + try { + await this.browser.debugger.sendCommand('Emulation.setTouchEmulationEnabled', { enabled: mobile, maxTouchPoints: mobile ? 5 : 1 }); + await this.browser.debugger.sendCommand('Emulation.setEmulatedMedia', { features: this._device ? [{ name: 'pointer', value: mobile ? 'coarse' : 'fine' }] : [] }); + await this.browser.debugger.sendCommand('Emulation.setEmitTouchEventsForMouse', { enabled: mobile }); + } catch (err) { + this.logService.error('[BrowserViewEmulator] _applyTouchAndMedia failed', err); + } + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts similarity index 50% rename from src/vs/platform/browserView/electron-main/browserViewElementInspector.ts rename to src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts index 936547fb462cf7..5742d36c540c38 100644 --- a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts @@ -5,10 +5,15 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { browserViewIsolatedWorldId, IElementData, IElementAncestor, IBrowserViewTheme } from '../common/browserView.js'; +import { IElementData, IElementAncestor, IBrowserViewTheme } from '../common/browserView.js'; import { collapseToShorthands, formatMatchedStyles, keyComputedProperties, type IMatchedStyles } from '../common/cssHelpers.js'; import { ICDPConnection } from '../common/cdp/types.js'; -import type { BrowserView } from './browserView.js'; + +export interface IFrameElementHandle extends IDisposable { + addToChat(): Promise; + highlight(): Promise; + hideHighlight(): Promise; +} type Quad = [number, number, number, number, number, number, number, number]; @@ -37,27 +42,46 @@ interface ILayoutMetricsResult { }; } -interface IActiveSelection extends IDisposable { - readonly isCDP: boolean; -} - -export interface IElementHandle extends IDisposable { - addToChat(): Promise; - highlight(): Promise; - hideHighlight(): Promise; -} - -/** - * Well-known ids understood by `__vscode_helpers.getElement(id)` in - * `preload-browserView.ts`. Any other string is treated as the id of a - * dynamically tracked element. - */ -export const enum BrowserViewInspectElementId { - /** The page's `document.activeElement`. */ - Active = 'active', - /** The element targeted by the most recent `contextmenu` event. */ - ContextMenuTarget = 'context-menu-target', -} +/** Slightly customised CDP debugger inspect highlight colours. */ +export const inspectHighlightConfig = { + showInfo: true, + showRulers: false, + showStyles: true, + showAccessibilityInfo: true, + showExtensionLines: false, + contrastAlgorithm: 'aa', + contentColor: { r: 173, g: 216, b: 255, a: 0.8 }, + paddingColor: { r: 150, g: 200, b: 255, a: 0.5 }, + borderColor: { r: 120, g: 180, b: 255, a: 0.7 }, + marginColor: { r: 200, g: 220, b: 255, a: 0.4 }, + eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 }, + gridHighlightConfig: { + rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + rowLineColor: { r: 120, g: 180, b: 255 }, + columnLineColor: { r: 120, g: 180, b: 255 }, + rowLineDash: true, + columnLineDash: true + }, + flexContainerHighlightConfig: { + containerBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, + itemSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, + lineSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, + mainDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + crossDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + rowGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + columnGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + }, + flexItemHighlightConfig: { + baseSizeBox: { hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } }, + baseSizeBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, + flexibilityArrow: { color: { r: 130, g: 190, b: 255 } } + }, +}; function useScopedDisposal() { const store = new DisposableStore() as DisposableStore & { [Symbol.dispose](): void }; @@ -66,284 +90,218 @@ function useScopedDisposal() { } /** - * Manages element inspection on a browser view. + * Per-frame element inspector backed by a dedicated CDP session. + * + * Owns the full lifecycle of element inspection for a single frame: + * CDP domain initialization, element picking (overlay + CDP modes), + * node data extraction, and highlight management. * - * Attaches a persistent CDP session in the constructor; methods wait for - * it to be ready before issuing commands. + * Fires {@link onDidInspectElement} when an element is selected via + * CDP inspect mode (debugger paused). */ -export class BrowserViewElementInspector extends Disposable { +export class BrowserViewFrameInspector extends Disposable { - private readonly _connectionPromise: Promise; + private _isDisposed = false; + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose: Event = this._onWillDispose.event; - private readonly _onDidSelectElement = this._register(new Emitter()); - readonly onDidSelectElement: Event = this._onDidSelectElement.event; + private readonly _onDidInspectElement = this._register(new Emitter()); + readonly onDidInspectElement: Event = this._onDidInspectElement.event; - private readonly _onDidChangeElementSelectionActive = this._register(new Emitter()); - readonly onDidChangeElementSelectionActive: Event = this._onDidChangeElementSelectionActive.event; + private readonly _onDidStopPicking = this._register(new Emitter()); + readonly onDidStopPicking: Event = this._onDidStopPicking.event; - private _elementSelectionActive = false; - get isElementSelectionActive(): boolean { return this._elementSelectionActive; } + private _isPaused = false; + private readonly _activeInspection = this._register(new MutableDisposable()); - private readonly _activeSelection = this._register(new MutableDisposable()); - private _theme: IBrowserViewTheme = {}; + /** Whether this frame's JavaScript execution is currently paused by the debugger. */ + get isPaused(): boolean { return this._isPaused; } - constructor(private readonly browser: BrowserView) { - super(); - this._connectionPromise = this._createConnection(); - this._registerListeners().catch(() => { }); - } + /** Whether element inspection is currently active on this frame. */ + get isInspecting(): boolean { return !!this._activeInspection.value; } - private async _createConnection(): Promise { - const conn = await this.browser.debugger.attach(); + /** The CDP frame ID for this frame. */ + get frameId(): string { return this._frameId; } - try { - // Initialize CDP domains up-front rather than during selection: - // some (e.g. CSS.enable) hang if sent while the debugger is paused, - // but succeed when enabled before any pause. - await conn.sendCommand('DOM.enable'); - await conn.sendCommand('Overlay.enable'); - await conn.sendCommand('CSS.enable'); - await conn.sendCommand('Runtime.enable'); - } catch (error) { - conn.dispose(); - throw error; - } + /** + * @param connection The CDP session that owns this frame's target. + * @param frame The Electron WebFrameMain for this frame. + * @param _uniqueContextId The unique execution context ID for Runtime calls in this frame. + * @param _frameId The CDP frame ID for this frame. + */ + constructor( + readonly connection: ICDPConnection, + readonly frame: Electron.WebFrameMain, + private readonly _uniqueContextId: string, + private readonly _frameId: string, + ) { + super(); - if (this._store.isDisposed) { - conn.dispose(); - throw new Error('Inspector disposed before connection was ready'); - } - this._register(conn); + this._register(connection.onClose(() => { + this.dispose(); + })); - return conn; - } + this._register(connection.onEvent(async event => { + switch (event.method) { + case 'Overlay.inspectNodeRequested': { + const params = event.params as { backendNodeId: number }; + if (params?.backendNodeId) { + try { + // Verify the node belongs to this frame (important when + // sharing a session with same-origin siblings). + const { node } = await this.connection.sendCommand('DOM.describeNode', { + backendNodeId: params.backendNodeId, + }) as { node: { frameId?: string } }; + if (node.frameId && node.frameId !== this._frameId) { + break; + } + const nodeData = await this.extractNodeData({ backendNodeId: params.backendNodeId }); + this._onDidInspectElement.fire(nodeData); + } catch { + // Best effort. + } + } + break; + } + case 'Debugger.paused': + this._isPaused = true; + break; + case 'Debugger.resumed': + this._isPaused = false; + break; + } + })); - private async _registerListeners(): Promise { - const webContents = this.browser.webContents; - const onPicked = async (_event: unknown, pickId: string) => { - if (!pickId) { + // Listen for element-picked IPC from this frame's preload + const onPicked = async (event: Electron.IpcMainEvent, pickId: string) => { + if (!pickId || event.senderFrame !== this.frame) { return; } - - this._activeSelection.clear(); - try { - const handle = await this.getElementHandle(pickId); - await handle?.addToChat(); + const nodeData = await this.extractNodeDataById(pickId); + this._onDidInspectElement.fire(nodeData); } catch { // Best effort; user can re-pick. } }; - webContents.ipc.on('vscode:browserView:elementPicked', onPicked); - this._register({ - dispose: () => webContents.ipc.removeListener('vscode:browserView:elementPicked', onPicked) - }); - const onPickStopped = () => { - if (this._activeSelection.value) { - this._elementSelectionActive = false; - this._onDidChangeElementSelectionActive.fire(false); - this._activeSelection.clearAndLeak(); - } - }; - webContents.ipc.on('vscode:browserView:elementPickStopped', onPickStopped); - this._register({ - dispose: () => webContents.ipc.removeListener('vscode:browserView:elementPickStopped', onPickStopped) - }); - - // Navigation to a new document destroys the preload's page-side overlay - // and resets the CDP inspect mode. Clear the active selection so the - // workbench reflects the actual state. - const onNavigated = () => this._activeSelection.clear(); - webContents.on('did-navigate', onNavigated); - this._register({ - dispose: () => webContents.removeListener('did-navigate', onNavigated) - }); - - const connection = await this._connectionPromise; - this._register(connection.onEvent(async (event) => { - if (event.method !== 'Overlay.inspectNodeRequested') { - return; - } - - if (!this._activeSelection.value?.isCDP) { - return; - } + frame.ipc.on('vscode:browserView:elementPicked', onPicked); + this._register({ dispose: () => frame.ipc.removeListener('vscode:browserView:elementPicked', onPicked) }); - const params = event.params as { backendNodeId: number }; - if (!params?.backendNodeId) { + // Listen for pick-stopped IPC from this frame's preload + const onPickStopped = (event: Electron.IpcMainEvent) => { + if (event.senderFrame !== this.frame) { return; } + this._onDidStopPicking.fire(); + }; + frame.ipc.on('vscode:browserView:elementPickStopped', onPickStopped); + this._register({ dispose: () => frame.ipc.removeListener('vscode:browserView:elementPickStopped', onPickStopped) }); - this._activeSelection.clear(); + this._enableDomains().catch(() => { }); + } - try { - const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); - this._onDidSelectElement.fire({ - ...nodeData, - url: this.browser.getURL() - }); - } catch (err) { - // Best effort; ignore errors and let the user try again if they want. - } - })); + private async _enableDomains(): Promise { + await this.connection.sendCommand('DOM.enable'); + await this.connection.sendCommand('Overlay.enable'); + await this.connection.sendCommand('CSS.enable'); + await this.connection.sendCommand('Runtime.enable'); + await this.connection.sendCommand('Page.enable'); + } - webContents.on('ipc-message', async (event, channel) => { - if (channel === 'vscode:browserView:preloadReady' && event.senderFrame === webContents.mainFrame) { - this.setTheme(this._theme); - } - }); - this._register({ - dispose: () => webContents.removeAllListeners('ipc-message') - }); + override dispose() { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + this._onWillDispose.fire(); + super.dispose(); } + /** + * Send the theme to this frame's preload. + */ setTheme(theme: IBrowserViewTheme): void { - this._theme = theme; - const webContents = this.browser.webContents; - const themeJson = JSON.stringify(theme); - webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [ - { code: `window.browserViewAPI?.setTheme?.(${themeJson})` } - ]).catch(() => { /* best effort β€” page may not be loaded yet */ }); + this.frame.postMessage('vscode:browserView:setTheme', theme); } /** - * Toggle element selection mode on the browser view. - * - * When enabled, mounts a page-side overlay (see `preload-browserView.ts`) that - * lets the user click an element or drag a region (region β†’ deepest common - * ancestor). When the debugger is paused, falls back to Chromium's built-in - * `Overlay.setInspectMode`. - * - * Each pick fires {@link onDidSelectElement}; state changes are delivered via - * {@link onDidChangeElementSelectionActive}. - * - * @param enabled Whether to enable or disable. Omit to toggle. + * Start element inspection on this frame. + * Uses CDP inspect mode if paused, otherwise the preload picker. + * Stores a disposable so stop always tears down the correct mode. */ - async toggleElementSelection(enabled?: boolean): Promise { - const newEnabled = enabled ?? !this._elementSelectionActive; - if (newEnabled === this._elementSelectionActive) { - return; - } - - if (!newEnabled) { - this._activeSelection.clear(); - return; - } - - const useCDP = this.browser.debugger.isPaused; - const start = useCDP ? async () => { - const connection = await this._connectionPromise; - await connection.sendCommand('Overlay.setInspectMode', { + async startInspection(): Promise { + if (this._isPaused) { + await this.connection.sendCommand('Overlay.setInspectMode', { mode: 'searchForNode', highlightConfig: inspectHighlightConfig, }); - } : async () => { - const webContents = this.browser.webContents; - const started = await webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [ - { code: `window.browserViewAPI?.pickElement?.start?.() ?? false` } - ]); - if (!started) { - throw new Error('Preload element picker not available'); - } - }; - const stop = useCDP ? async () => { - const connection = await this._connectionPromise; - await connection.sendCommand('Overlay.setInspectMode', { - mode: 'none', - highlightConfig: { showInfo: false, showStyles: false } - }); - await connection.sendCommand('Overlay.hideHighlight'); - } : async () => { - const webContents = this.browser.webContents; - await webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [ - { code: 'window.browserViewAPI?.pickElement?.stop?.()' } - ]); - }; - - const selection: IActiveSelection = { - isCDP: useCDP, - dispose: () => { - if (this._activeSelection.value === selection) { - this._elementSelectionActive = false; - this._onDidChangeElementSelectionActive.fire(false); - this._activeSelection.clearAndLeak(); - void stop().catch(() => { /* best-effort cleanup */ }); + this._activeInspection.value = { + dispose: async () => { + if (this.frame.isDestroyed()) { + return; + } + try { + await this.connection.sendCommand('Overlay.setInspectMode', { + mode: 'none', + highlightConfig: { showInfo: false, showStyles: false } + }); + await this.connection.sendCommand('Overlay.hideHighlight'); + } catch { + // Best effort. + } } - } - }; - this._activeSelection.value = selection; - - try { - await start(); - if (this._activeSelection.value === selection) { - this._elementSelectionActive = true; - this._onDidChangeElementSelectionActive.fire(true); - } - } catch { - this._activeSelection.clear(); + }; + } else { + this.frame.postMessage('vscode:browserView:startElementPicker', {}); + this._activeInspection.value = { + dispose: () => { + if (this.frame.isDestroyed()) { + return; + } + this.frame.postMessage('vscode:browserView:stopElementPicker', {}); + } + }; } } /** - * Resolve a handle to the element identified by `id`. - * - * `id` is interpreted by `__vscode_helpers.getElement(id)` in the page - * preload (see {@link BrowserViewSelectedElementId} for well-known values). - * Returns `undefined` if no element is found. + * Stop element inspection on this frame. */ - async getElementHandle(id: string): Promise { - const connection = await this._connectionPromise; + async stopInspection(): Promise { + this._activeInspection.clear(); + } - const { result } = await connection.sendCommand('Runtime.evaluate', { - expression: `window.__vscode_helpers?.getElement(${JSON.stringify(id)})`, + /** + * Resolve an element by its preload-tracked id and extract full node data. + */ + async extractNodeDataById(elementId: string): Promise { + const { result } = await this.connection.sendCommand('Runtime.evaluate', { + expression: `window.__vscode_helpers?.getElement(${JSON.stringify(elementId)})`, returnByValue: false, + uniqueContextId: this._uniqueContextId, }) as { result: { objectId?: string } }; if (!result?.objectId) { - return undefined; + throw new Error(`Element not found: ${elementId}`); } - const objectId = result.objectId; - const elementId = id; - let disposed = false; + return this.extractNodeData({ objectId: result.objectId }); + } - return { - addToChat: async () => { - const nodeData = await extractNodeData(connection, { objectId }); - this._onDidSelectElement.fire({ - ...nodeData, - url: this.browser.getURL() - }); - }, - highlight: async () => { - const webContents = this.browser.webContents; - await webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [ - { code: `window.browserViewAPI?.highlightElement?.(${JSON.stringify(elementId)})` } - ]); - }, - hideHighlight: async () => { - const webContents = this.browser.webContents; - await webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [ - { code: 'window.browserViewAPI?.hideHighlight?.()' } - ]); - }, - dispose: () => { - if (disposed) { - return; - } - disposed = true; - const webContents = this.browser.webContents; - void webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [ - { code: 'window.browserViewAPI?.hideHighlight?.()' } - ]).catch(() => { /* best effort */ }); - } - }; + /** + * Extract full element data from a CDP node reference. + */ + async extractNodeData(id: { backendNodeId?: number; objectId?: string }): Promise { + const data = await extractNodeData(this.connection, id); + return { ...data, url: this.frame.url }; } + /** + * Get the visual viewport scale for this frame. + */ async getVisualViewportScale(): Promise { try { - const connection = await this._connectionPromise; - const result = await connection.sendCommand('Page.getLayoutMetrics') as ILayoutMetricsResult; + const result = await this.connection.sendCommand('Page.getLayoutMetrics') as ILayoutMetricsResult; if (typeof result.cssVisualViewport?.scale === 'number') { const scale = Number(result.cssVisualViewport.scale); if (Number.isFinite(scale) && scale > 0) { @@ -353,12 +311,37 @@ export class BrowserViewElementInspector extends Disposable { } catch { // Ignore execution errors while loading and use defaults. } - return 1; } + + /** + * Create a handle to an element tracked by the preload script. + */ + getElementHandle(elementId: string): IFrameElementHandle { + let disposed = false; + return { + addToChat: async () => { + const nodeData = await this.extractNodeDataById(elementId); + this._onDidInspectElement.fire(nodeData); + }, + highlight: async () => { + this.frame.postMessage('vscode:browserView:highlightElement', { elementId }); + }, + hideHighlight: async () => { + this.frame.postMessage('vscode:browserView:hideHighlight', {}); + }, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + this.frame.postMessage('vscode:browserView:hideHighlight', {}); + } + }; + } } -async function extractNodeData(connection: ICDPConnection, id: { backendNodeId?: number; objectId?: string }): Promise { +export async function extractNodeData(connection: ICDPConnection, id: { backendNodeId?: number; objectId?: string }): Promise { using store = useScopedDisposal(); const discoveredNodesByNodeId: Record = {}; @@ -504,44 +487,3 @@ function attributeArrayToRecord(attributes: string[]): Record { } return record; } - -/** Slightly customised CDP debugger inspect highlight colours. */ -const inspectHighlightConfig = { - showInfo: true, - showRulers: false, - showStyles: true, - showAccessibilityInfo: true, - showExtensionLines: false, - contrastAlgorithm: 'aa', - contentColor: { r: 173, g: 216, b: 255, a: 0.8 }, - paddingColor: { r: 150, g: 200, b: 255, a: 0.5 }, - borderColor: { r: 120, g: 180, b: 255, a: 0.7 }, - marginColor: { r: 200, g: 220, b: 255, a: 0.4 }, - eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 }, - shapeColor: { r: 130, g: 160, b: 255, a: 0.8 }, - shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 }, - gridHighlightConfig: { - rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, - rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, - columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, - columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, - rowLineColor: { r: 120, g: 180, b: 255 }, - columnLineColor: { r: 120, g: 180, b: 255 }, - rowLineDash: true, - columnLineDash: true - }, - flexContainerHighlightConfig: { - containerBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, - itemSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, - lineSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, - mainDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - crossDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - rowGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - columnGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - }, - flexItemHighlightConfig: { - baseSizeBox: { hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } }, - baseSizeBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, - flexibilityArrow: { color: { r: 130, g: 190, b: 255 } } - }, -}; diff --git a/src/vs/platform/browserView/electron-main/browserViewInspector.ts b/src/vs/platform/browserView/electron-main/browserViewInspector.ts new file mode 100644 index 00000000000000..21eeb3c7c75acb --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewInspector.ts @@ -0,0 +1,475 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { IElementData, IBrowserViewTheme } from '../common/browserView.js'; +import { ICDPConnection } from '../common/cdp/types.js'; +import type { BrowserView } from './browserView.js'; +import { BrowserViewFrameInspector } from './browserViewFrameInspector.js'; + +interface IActiveSelection extends IDisposable { +} + +export interface IElementHandle extends IDisposable { + addToChat(): Promise; + highlight(): Promise; + hideHighlight(): Promise; +} + +/** + * Well-known ids understood by `__vscode_helpers.getElement(id)` in + * `preload-browserView.ts`. Any other string is treated as the id of a + * dynamically tracked element. + */ +export const enum BrowserViewInspectElementId { + /** The page's `document.activeElement`. */ + Active = 'active', + /** The element targeted by the most recent `contextmenu` event. */ + ContextMenuTarget = 'context-menu-target', +} + +/** + * Manages element inspection across all frames in a browser view. + * + * Creates a {@link BrowserViewFrameInspector} for the main frame and + * automatically discovers iframe CDP targets via auto-attach, matching + * them to their corresponding `WebFrameMain` instances using an opaque + * token generated by the preload script. + * + * This class is a thin orchestrator β€” all per-frame CDP logic (domain + * initialization, element extraction, CDP inspect mode) lives in + * {@link BrowserViewFrameInspector}. + */ +export class BrowserViewInspector extends Disposable { + + private readonly _onDidSelectElement = this._register(new Emitter()); + readonly onDidSelectElement: Event = this._onDidSelectElement.event; + + private readonly _onDidChangeElementSelectionActive = this._register(new Emitter()); + readonly onDidChangeElementSelectionActive: Event = this._onDidChangeElementSelectionActive.event; + + private _elementSelectionActive = false; + get isElementSelectionActive(): boolean { return this._elementSelectionActive; } + + private readonly _activeSelection = this._register(new MutableDisposable()); + private _theme: IBrowserViewTheme = {}; + + private readonly _registry = this._register(new FrameInspectorRegistry()); + + constructor(private readonly browser: BrowserView) { + super(); + + const webContents = this.browser.webContents; + + // Wire up inspector adoption from the registry + this._register(this._registry.onDidAdopt(inspector => this._onInspectorAdopted(inspector))); + + // Navigation destroys preload overlays and CDP state + const onNavigated = () => { + this._activeSelection.clear(); + }; + webContents.on('did-navigate', onNavigated); + this._register({ dispose: () => webContents.removeListener('did-navigate', onNavigated) }); + + // Preload ready β€” the key correlation point between WebFrameMain and CDP target + const onIpcMessage = (_event: Electron.Event, channel: string, ...args: unknown[]) => { + if (channel !== 'vscode:browserView:preloadReady') { + return; + } + const senderFrame = (_event as { senderFrame?: Electron.WebFrameMain }).senderFrame; + if (!senderFrame) { + return; + } + const frameToken = args[0] as string; + if (!frameToken) { + return; + } + + // Apply theme immediately regardless of inspector state + senderFrame.postMessage('vscode:browserView:setTheme', this._theme); + + this._registry.notifyFrameReady(senderFrame, frameToken); + }; + webContents.on('ipc-message', onIpcMessage); + this._register({ dispose: () => webContents.removeListener('ipc-message', onIpcMessage) }); + + // Cross-origin (OOPIF) targets get their own session β€” watch it for contexts + this._register(this.browser.debugger.onTargetDiscovered(async ({ targetId, type }) => { + if (type === 'iframe') { + try { + const session = await this.browser.debugger.attachToTarget(targetId); + this._watchSession(session); + } catch { + return; + } + } + })); + + // Attach the main debugger session and watch it for contexts + this.browser.debugger.attach().then(conn => this._watchSession(conn)).catch(() => { }); + } + + /** + * Watch a CDP session for execution contexts. When a default context appears, + * probes for the preload token and correlates with the pending WebFrameMain. + * + * Called for every session: the main page session (sees same-origin frames) + * and each cross-origin target session (sees only its own frame). + */ + private _watchSession(session: ICDPConnection): void { + this._register(session.onEvent(async event => { + if (event.method === 'Runtime.executionContextCreated') { + const context = (event.params as { + context: { + uniqueId: string; + auxData?: { isDefault?: boolean; frameId?: string }; + }; + }).context; + + if (!context?.auxData?.isDefault || !context.auxData.frameId) { + return; + } + + const frameId = context.auxData.frameId; + const uniqueContextId = context.uniqueId; + + // Probe for the preload token in this context + try { + const { result } = await session.sendCommand('Runtime.evaluate', { + expression: 'window.__vscode_helpers?.getFrameToken?.()', + returnByValue: true, + uniqueContextId, + }) as { result: { value?: string } }; + + const token = result.value; + if (!token) { + return; + } + + this._registry.notifyContextDiscovered(session, uniqueContextId, frameId, token); + } catch { + // Context may have been destroyed by now β€” ignore. + } + } else if (event.method === 'Page.frameDetached') { + const frameId = (event.params as { frameId?: string })?.frameId; + if (frameId) { + this._registry.disposeByFrameId(frameId); + } + } else if (event.method === 'Runtime.executionContextsCleared') { + // Navigation cleared all contexts β€” dispose inspectors owned by this session + this._registry.disposeBySession(session); + } + })); + + Event.once(session.onClose)(() => { + this._registry.disposeBySession(session); + }); + + // Enable Runtime + Page to start receiving context and frame events + session.sendCommand('Runtime.enable').catch(() => { }); + session.sendCommand('Page.enable').catch(() => { }); + } + + /** + * Called by the registry when a frame inspector is fully adopted. + * Wires its events to this orchestrator. + */ + private _onInspectorAdopted(inspector: BrowserViewFrameInspector): void { + inspector.onDidInspectElement(async nodeData => { + this._activeSelection.clear(); + try { + const offset = await this._getFrameOffsetInPage(inspector.frame); + nodeData = this._offsetElementData(nodeData, offset); + } catch { + // Best effort. + } + this._onDidSelectElement.fire(nodeData); + }); + + // When a frame's preload stops picking, stop all other frames too + inspector.onDidStopPicking(() => { + this._activeSelection.clear(); + }); + + // If element selection is currently active, start it on the new frame + if (this._activeSelection.value) { + inspector.startInspection().catch(() => { }); + } + + inspector.setTheme(this._theme); + } + + setTheme(theme: IBrowserViewTheme): void { + this._theme = theme; + // Broadcast to all known inspectors + for (const inspector of this._registry.inspectors) { + inspector.setTheme(theme); + } + } + + /** + * Toggle element selection mode across all frames. + */ + async toggleElementSelection(enabled?: boolean): Promise { + const newEnabled = enabled ?? !this._elementSelectionActive; + if (newEnabled === this._elementSelectionActive) { + return; + } + + if (!newEnabled) { + this._activeSelection.clear(); + return; + } + + const start = () => Promise.all([...this._registry.inspectors].map(i => i.startInspection())); + const stop = () => Promise.all([...this._registry.inspectors].map(i => i.stopInspection())); + + const selection: IActiveSelection = { + dispose: () => { + if (this._activeSelection.value === selection) { + this._elementSelectionActive = false; + this._onDidChangeElementSelectionActive.fire(false); + this._activeSelection.clearAndLeak(); + void stop().catch(() => { }); + } + } + }; + this._activeSelection.value = selection; + + try { + await start(); + if (this._activeSelection.value === selection) { + this._elementSelectionActive = true; + this._onDidChangeElementSelectionActive.fire(true); + } + } catch { + this._activeSelection.clear(); + } + } + + /** + * Resolve a handle to an element. Routes to the correct frame inspector. + */ + getElementHandle(id: string, frame: Electron.WebFrameMain): IElementHandle | undefined { + return this._registry.getByFrame(frame)?.getElementHandle(id); + } + + async getVisualViewportScale(frame: Electron.WebFrameMain = this.browser.webContents.mainFrame): Promise { + return this._registry.getByFrame(frame)?.getVisualViewportScale() ?? 1; + } + + /** + * Compute the cumulative offset of a frame relative to the top-level page. + * Walks up the frame hierarchy using the parent's CDP session to query the + * iframe element's box model via `DOM.getFrameOwner` + `DOM.getBoxModel`. + * Works for both same-origin and cross-origin frames. + */ + private async _getFrameOffsetInPage(frame: Electron.WebFrameMain): Promise<{ x: number; y: number }> { + const mainFrame = this.browser.webContents.mainFrame; + let x = 0; + let y = 0; + let current = frame; + + while (current !== mainFrame) { + const parent = current.parent; + if (!parent) { + break; + } + + const childInspector = this._registry.getByFrame(current); + const parentInspector = this._registry.getByFrame(parent); + if (!childInspector || !parentInspector) { + break; + } + + try { + const childFrameId = childInspector.frameId; + + // Ask the parent session for the iframe element that owns this frame + const frameOwner = await parentInspector.connection.sendCommand('DOM.getFrameOwner', { + frameId: childFrameId, + }) as { backendNodeId: number }; + + // Get the iframe element's box model in the parent's coordinate space + const boxModel = await parentInspector.connection.sendCommand('DOM.getBoxModel', { + backendNodeId: frameOwner.backendNodeId, + }) as { model: { content: number[] } }; + + // content quad: [x1,y1, x2,y2, x3,y3, x4,y4] β€” top-left is first pair + const content = boxModel.model.content; + x += content[0]; + y += content[1]; + } catch { + break; + } + + current = parent; + } + + return { x, y }; + } + + /** + * Offset element data bounds by a frame offset. + */ + private _offsetElementData(data: IElementData, offset: { x: number; y: number }): IElementData { + if (offset.x === 0 && offset.y === 0) { + return data; + } + return { + ...data, + bounds: { + x: data.bounds.x + offset.x, + y: data.bounds.y + offset.y, + width: data.bounds.width, + height: data.bounds.height, + } + }; + } +} + + +interface IPendingContext { + readonly session: ICDPConnection; + readonly uniqueContextId: string; + readonly frameId: string; +} + +/** + * Tracks the two-sided correlation between preload tokens (from WebFrameMain IPC) + * and CDP execution contexts, and indexes adopted inspectors for O(1) lookup by + * frame, frameId, or owning session. + */ +class FrameInspectorRegistry extends Disposable { + + private readonly _onDidAdopt = this._register(new Emitter()); + readonly onDidAdopt: Event = this._onDidAdopt.event; + + /** Pending halves waiting for their counterpart. */ + private readonly _pendingFrames = new Map(); + private readonly _pendingSessions = new Map(); + + /** Adopted inspectors indexed multiple ways. */ + private readonly _all = new Set(); + private readonly _byFrame = new WeakMap(); + private readonly _byFrameId = new Map(); + private readonly _bySession = new Map>(); + + get inspectors(): Iterable { return this._all; } + + getByFrame(frame: Electron.WebFrameMain): BrowserViewFrameInspector | undefined { + return this._byFrame.get(frame); + } + + /** + * Called when a preload script signals readiness with a token. + * If a matching CDP context was already discovered, adopts immediately. + */ + notifyFrameReady(frame: Electron.WebFrameMain, token: string): void { + const pending = this._pendingSessions.get(token); + if (pending) { + this._pendingSessions.delete(token); + this._adopt(pending.session, pending.uniqueContextId, pending.frameId, frame); + } else { + this._pendingFrames.set(token, frame); + } + } + + /** + * Called when a CDP execution context is discovered and its preload token probed. + * If a matching WebFrameMain was already registered, adopts immediately. + */ + notifyContextDiscovered(session: ICDPConnection, uniqueContextId: string, frameId: string, token: string): void { + const frame = this._pendingFrames.get(token); + if (frame) { + this._pendingFrames.delete(token); + this._adopt(session, uniqueContextId, frameId, frame); + } else { + this._pendingSessions.set(token, { session, uniqueContextId, frameId }); + } + } + + /** Dispose the inspector owning the given CDP frameId, if any. Also cleans pending entries. */ + disposeByFrameId(frameId: string): void { + this._byFrameId.get(frameId)?.dispose(); + // Remove pending session entries whose frameId matches the detached frame + for (const [token, pending] of this._pendingSessions) { + if (pending.frameId === frameId) { + this._pendingSessions.delete(token); + } + } + // Remove any pending frame entries whose frame is now detached/destroyed + for (const [token, frame] of this._pendingFrames) { + if (frame.detached || frame.isDestroyed()) { + this._pendingFrames.delete(token); + } + } + } + + /** Dispose all inspectors whose connection is the given session and clear related pending state. */ + disposeBySession(session: ICDPConnection): void { + const set = this._bySession.get(session); + if (set) { + for (const inspector of [...set]) { + inspector.dispose(); + } + } + for (const [token, pending] of this._pendingSessions) { + if (pending.session === session) { + this._pendingSessions.delete(token); + } + } + } + + private _adopt( + session: ICDPConnection, + uniqueContextId: string, + frameId: string, + frame: Electron.WebFrameMain, + ): void { + // Guard: frame may have been destroyed between IPC and context match + if (frame.detached || frame.isDestroyed()) { + return; + } + + const inspector = new BrowserViewFrameInspector(session, frame, uniqueContextId, frameId); + + this._all.add(inspector); + this._byFrame.set(frame, inspector); + this._byFrameId.set(frameId, inspector); + + let sessionSet = this._bySession.get(session); + if (!sessionSet) { + sessionSet = new Set(); + this._bySession.set(session, sessionSet); + } + sessionSet.add(inspector); + + inspector.onWillDispose(() => { + this._all.delete(inspector); + this._byFrame.delete(frame); + this._byFrameId.delete(frameId); + const s = this._bySession.get(session); + if (s) { + s.delete(inspector); + if (s.size === 0) { + this._bySession.delete(session); + } + } + }); + + this._onDidAdopt.fire(inspector); + } + + override dispose(): void { + for (const inspector of [...this._all]) { + inspector.dispose(); + } + this._pendingFrames.clear(); + this._pendingSessions.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 62fdb0cb098e84..25d9a6e909b036 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewTheme, IBrowserViewConfiguration } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewTheme, IBrowserViewConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -20,7 +20,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { localize } from '../../../nls.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; -import { BrowserViewInspectElementId } from './browserViewElementInspector.js'; +import { BrowserViewInspectElementId } from './browserViewInspector.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -187,6 +187,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).inspector.onDidChangeElementSelectionActive; } + onDynamicDidChangeDeviceEmulation(id: string) { + return this._getBrowserView(id).emulator.onDidChange; + } + async getState(id: string): Promise { return this._getBrowserView(id).getState(); } @@ -263,6 +267,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).setBrowserZoomIndex(zoomIndex); } + async setDeviceEmulation(id: string, device: IBrowserDeviceProfile | undefined): Promise { + return this._getBrowserView(id).emulator.setDevice(device); + } + async trustCertificate(id: string, host: string, fingerprint: string): Promise { return this._getBrowserView(id).trustCertificate(host, fingerprint); } @@ -400,7 +408,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const inspectTarget = this._configuration.aiFeaturesDisabled ? undefined - : await view.inspector.getElementHandle(BrowserViewInspectElementId.ContextMenuTarget); + : params.frame && await view.inspector.getElementHandle(BrowserViewInspectElementId.ContextMenuTarget, params.frame); const menu = new Menu(); if (params.linkURL) { diff --git a/src/vs/platform/networkFilter/common/networkFilterService.ts b/src/vs/platform/networkFilter/common/networkFilterService.ts index 5dc2ebdeb33345..fa147b2b2cb255 100644 --- a/src/vs/platform/networkFilter/common/networkFilterService.ts +++ b/src/vs/platform/networkFilter/common/networkFilterService.ts @@ -10,22 +10,17 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { AgentSandboxSettingId } from '../../sandbox/common/settings.js'; -import { ITerminalSandboxService } from '../../sandbox/common/terminalSandboxService.js'; import { extractDomainFromUri, isDomainAllowed } from './domainMatcher.js'; import { AgentNetworkDomainSettingId } from './settings.js'; export const IAgentNetworkFilterService = createDecorator('agentNetworkFilterService'); -export const AgentNetworkFilterFetchWebToolName = 'fetchWebTool'; - /** * Service that filters network requests made by agent tools (fetch tool, * integrated browser) based on the configured allowed/denied domain lists. * * Filtering is active for all callers when the `chat.agent.networkFilter` setting - * is enabled. When only sandboxing is enabled, filtering is active for fetch web - * page tool requests. This has to be revisited for integrated browser requests. + * is enabled. * When both domain lists are empty, all domains are denied. * When a domain appears on the denied list it is always blocked, even if it * also matches an entry on the allowed list. @@ -37,10 +32,9 @@ export interface IAgentNetworkFilterService { * Extracts the domain from a URI and checks it against the configured * allowed/denied domain filter. * File URIs and URIs without an authority always pass. - * @param toolName Optional tool name for sandbox-only filtering. * @returns `true` if the URI's domain is allowed, `false` if blocked. */ - isUriAllowed(uri: URI, toolName?: string): boolean; + isUriAllowed(uri: URI): boolean; /** * Formats an error message for a blocked URI based on the current filter configuration. @@ -59,7 +53,6 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo readonly _serviceBrand: undefined; private networkFilterEnabled = false; - private terminalSandboxEnabled = false; private allowedPatterns: string[] = []; private deniedPatterns: string[] = []; private readonly domainCache = new LRUCache(100); @@ -69,11 +62,9 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo constructor( @IConfigurationService private readonly configurationService: IConfigurationService, - @ITerminalSandboxService private readonly terminalSandboxService: ITerminalSandboxService, ) { super(); this.readConfiguration(); - void this.updateTerminalSandboxEnabled(); this._register(this.configurationService.onDidChangeConfiguration(e => { if ( @@ -83,11 +74,6 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo ) { this.readConfiguration(); this.onDidChangeEmitter.fire(); - } else if ( - e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxEnabled) || - e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) - ) { - void this.updateTerminalSandboxEnabled(); } })); } @@ -101,23 +87,9 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo this.domainCache.clear(); } - private async updateTerminalSandboxEnabled(): Promise { - const [isSandboxEnabled, isSandboxAllowNetworkEnabled] = await Promise.all([ - this.terminalSandboxService.isEnabled(), - this.terminalSandboxService.isSandboxAllowNetworkEnabled(), - ]); - const enabled = isSandboxEnabled && !isSandboxAllowNetworkEnabled; - if (this.terminalSandboxEnabled === enabled) { - return; - } - this.terminalSandboxEnabled = enabled; - this.readConfiguration(); - this.onDidChangeEmitter.fire(); - } - - isUriAllowed(uri: URI, toolName?: string): boolean { + isUriAllowed(uri: URI): boolean { // When domain filtering is inactive, allow all requests. - if (!this.shouldFilter(toolName)) { + if (!this.shouldFilter()) { return true; } @@ -140,11 +112,9 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo return result; } // Determines whether network filtering should be applied for a given request - // based on the global network filter setting, the terminal sandbox state, and the tool making the request. - // For sandbox mode, network filtering is applied only when the global network filter is disabled - // and the request is coming from the fetch web tool. - private shouldFilter(toolName: string | undefined): boolean { - return this.networkFilterEnabled || (this.terminalSandboxEnabled && toolName === AgentNetworkFilterFetchWebToolName); + // based on the global network filter setting. + private shouldFilter(): boolean { + return this.networkFilterEnabled; } formatError(uri: URI): string { diff --git a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts index b491b008f49986..d2aad37425fcfd 100644 --- a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts +++ b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts @@ -9,28 +9,17 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ConfigurationTarget } from '../../../configuration/common/configuration.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; -import { AgentNetworkFilterFetchWebToolName, AgentNetworkFilterService } from '../../common/networkFilterService.js'; +import { AgentNetworkFilterService } from '../../common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../common/settings.js'; -import { AgentSandboxSettingId } from '../../../sandbox/common/settings.js'; -import { ITerminalSandboxService, NullTerminalSandboxService } from '../../../sandbox/common/terminalSandboxService.js'; suite('AgentNetworkFilterService', () => { let disposables: DisposableStore; let configService: TestConfigurationService; - let terminalSandboxEnabled: boolean; - let terminalSandboxAllowNetworkEnabled: boolean; - let terminalSandboxService: ITerminalSandboxService; setup(() => { disposables = new DisposableStore(); configService = new TestConfigurationService(); - terminalSandboxEnabled = false; - terminalSandboxAllowNetworkEnabled = false; - terminalSandboxService = Object.assign(new NullTerminalSandboxService(), { - isEnabled: async () => terminalSandboxEnabled, - isSandboxAllowNetworkEnabled: async () => terminalSandboxAllowNetworkEnabled, - }); configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, true); configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []); configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, []); @@ -43,9 +32,8 @@ suite('AgentNetworkFilterService', () => { ensureNoDisposablesAreLeakedInTestSuite(); async function createService(): Promise { - const service = new AgentNetworkFilterService(configService, terminalSandboxService); + const service = new AgentNetworkFilterService(configService); disposables.add(service); - await Promise.resolve(); return service; } @@ -58,36 +46,16 @@ suite('AgentNetworkFilterService', () => { }); } - test('allows all domains when filter is disabled', async () => { - configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); - const service = await createService(); - assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), true); - }); - - test('network filter disabled with sandbox enabled filters fetch web tool only', async () => { + test('allows all domains when filter is disabled, regardless of configured lists', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); - terminalSandboxEnabled = true; configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); + configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['blocked.com']); const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com'), AgentNetworkFilterFetchWebToolName), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com'), AgentNetworkFilterFetchWebToolName), false); - }); - - test('network filter disabled with sandbox network allowed does not activate filtering', async () => { - configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); - terminalSandboxEnabled = true; - terminalSandboxAllowNetworkEnabled = true; - configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); - - const service = await createService(); - - assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://blocked.com')), true); }); test('denies all domains when both lists are empty', async () => { @@ -162,31 +130,4 @@ suite('AgentNetworkFilterService', () => { assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false); }); - test('terminal sandbox network mode change fires onDidChange and updates fetch web tool filtering', async () => { - configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); - configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); - terminalSandboxEnabled = true; - terminalSandboxAllowNetworkEnabled = true; - const service = await createService(); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com'), AgentNetworkFilterFetchWebToolName), true); - - let fired = false; - const didChange = new Promise(resolve => { - disposables.add(service.onDidChange(() => { - fired = true; - resolve(); - })); - }); - - terminalSandboxAllowNetworkEnabled = false; - fireConfigChange(AgentSandboxSettingId.AgentSandboxEnabled); - await didChange; - - assert.strictEqual(fired, true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com'), AgentNetworkFilterFetchWebToolName), true); - assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com'), AgentNetworkFilterFetchWebToolName), false); - }); }); diff --git a/src/vs/platform/sandbox/browser/sandboxHelperService.ts b/src/vs/platform/sandbox/browser/sandboxHelperService.ts index 2506ae2e49fac6..54cfa7895fffc8 100644 --- a/src/vs/platform/sandbox/browser/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/browser/sandboxHelperService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; -import { ISandboxDependencyStatus, ISandboxHelperService } from '../common/sandboxHelperService.js'; +import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from '../common/sandboxHelperService.js'; class NullSandboxHelperService implements ISandboxHelperService { declare readonly _serviceBrand: undefined; @@ -18,6 +18,14 @@ class NullSandboxHelperService implements ISandboxHelperService { socatInstalled: true, }; } + + async getWindowsMxcFilesystemPolicy(): Promise { + return undefined; + } + + async getWindowsMxcEnvironment(): Promise { + return undefined; + } } registerSingleton(ISandboxHelperService, NullSandboxHelperService, InstantiationType.Delayed); diff --git a/src/vs/platform/sandbox/common/sandboxHelperIpc.ts b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts index 125288dcf6875e..e210254331fe6a 100644 --- a/src/vs/platform/sandbox/common/sandboxHelperIpc.ts +++ b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts @@ -6,7 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { ISandboxDependencyStatus, ISandboxHelperService } from './sandboxHelperService.js'; +import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from './sandboxHelperService.js'; export const SANDBOX_HELPER_CHANNEL_NAME = 'sandboxHelper'; @@ -22,6 +22,10 @@ export class SandboxHelperChannel implements IServerChannel { switch (command) { case 'checkSandboxDependencies': return this.service.checkSandboxDependencies() as Promise; + case 'getWindowsMxcFilesystemPolicy': + return this.service.getWindowsMxcFilesystemPolicy() as Promise; + case 'getWindowsMxcEnvironment': + return this.service.getWindowsMxcEnvironment() as Promise; } throw new Error('Invalid call'); @@ -36,4 +40,12 @@ export class SandboxHelperChannelClient implements ISandboxHelperService { checkSandboxDependencies(): Promise { return this.channel.call('checkSandboxDependencies'); } + + getWindowsMxcFilesystemPolicy(): Promise { + return this.channel.call('getWindowsMxcFilesystemPolicy'); + } + + getWindowsMxcEnvironment(): Promise { + return this.channel.call('getWindowsMxcEnvironment'); + } } diff --git a/src/vs/platform/sandbox/common/sandboxHelperService.ts b/src/vs/platform/sandbox/common/sandboxHelperService.ts index 5660dd21eb63eb..fc754dc666cdef 100644 --- a/src/vs/platform/sandbox/common/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/common/sandboxHelperService.ts @@ -12,7 +12,14 @@ export interface ISandboxDependencyStatus { readonly socatInstalled: boolean; } +export interface IWindowsMxcFilesystemPolicy { + readonly readonlyPaths: string[]; + readonly readwritePaths: string[]; +} + export interface ISandboxHelperService { readonly _serviceBrand: undefined; checkSandboxDependencies(): Promise; + getWindowsMxcFilesystemPolicy(): Promise; + getWindowsMxcEnvironment(): Promise; } diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index df6b7d92ea067e..b16b21460df15b 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -8,11 +8,13 @@ */ export const enum AgentSandboxSettingId { AgentSandboxEnabled = 'chat.agent.sandbox.enabled', + AgentSandboxWindowsEnabled = 'chat.agent.sandbox.enabled.windows', AgentSandboxAllowUnsandboxedCommands = 'chat.agent.sandbox.allowUnsandboxedCommands', AgentSandboxAutoApproveUnsandboxedCommands = 'chat.agent.sandbox.autoApproveUnsandboxedCommands', AgentSandboxAllowAutoApprove = 'chat.agent.sandbox.allowAutoApprove', AgentSandboxLinuxFileSystem = 'chat.agent.sandbox.fileSystem.linux', AgentSandboxMacFileSystem = 'chat.agent.sandbox.fileSystem.mac', + AgentSandboxWindowsFileSystem = 'chat.agent.sandbox.fileSystem.windows', AgentSandboxAdvancedRuntime = 'chat.agent.sandbox.advanced.runtime', DeprecatedAgentSandboxEnabled = 'chat.agent.sandbox', DeprecatedAgentSandboxLinuxFileSystem = 'chat.agent.sandboxFileSystem.linux', diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 1d588558e47cfd..954ff03a79510f 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -6,7 +6,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { dirname, posix, win32 } from '../../../base/common/path.js'; +import { posix, win32 } from '../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -15,8 +15,9 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { matchesDomainPattern, normalizeDomain } from '../../networkFilter/common/domainMatcher.js'; import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings.js'; -import { ISandboxDependencyStatus } from './sandboxHelperService.js'; +import { ISandboxDependencyStatus, IWindowsMxcFilesystemPolicy } from './sandboxHelperService.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from './settings.js'; +import { IWindowsMxcTerminalSandboxRuntime } from './terminalSandboxMxcRuntime.js'; import { getTerminalSandboxReadAllowListForCommands } from './terminalSandboxReadAllowList.js'; import { getTerminalSandboxRuntimeConfigurationForCommands } from './terminalSandboxRuntimeConfigurationPerOperation.js'; import { ITerminalSandboxCommand, ITerminalSandboxPrerequisiteCheckResult, ITerminalSandboxResolvedNetworkDomains, ITerminalSandboxWrapResult, TerminalSandboxPrerequisiteCheck } from './terminalSandboxService.js'; @@ -41,6 +42,8 @@ export interface ITerminalSandboxRuntimeInfo { * `execPath` already points at a real `node` binary (remote, agent host). */ runAsNode?: boolean; + /** CPU architecture of the environment that runs the sandbox runtime. */ + arch?: string; } /** @@ -71,6 +74,10 @@ export interface ITerminalSandboxEngineHost { readonly onDidChangeRoots: Event; /** Resolves the installed sandbox-dependency status (bubblewrap, socat). */ checkSandboxDependencies(): Promise; + /** Resolves host filesystem policy fragments needed by the Windows MXC process container. */ + getWindowsMxcFilesystemPolicy(): Promise; + /** Resolves host environment variables needed by the Windows MXC process container. */ + getWindowsMxcEnvironment(): Promise; } /** @@ -96,6 +103,9 @@ export class TerminalSandboxEngine extends Disposable { private _userHome: URI | undefined; private _srtPath: string | undefined; private _rgPath: string | undefined; + private _mxcPath: string | undefined; + private _windowsMxcFilesystemPolicy: IWindowsMxcFilesystemPolicy | undefined; + private _windowsMxcEnvironment: string[] | undefined; private _sandboxConfigPath: string | undefined; private _sandboxDependencyStatus: ISandboxDependencyStatus | undefined; private _needsForceUpdateConfigFile = true; @@ -103,6 +113,8 @@ export class TerminalSandboxEngine extends Disposable { private _commandAllowListKeywords: readonly string[] = []; private _commandAllowListCommandDetails: readonly ITerminalSandboxCommand[] = []; private _commandCwd: URI | undefined; + private _commandLine: string | undefined; + private _commandShell: string | undefined; private _os: OperatingSystem = OS; private readonly _defaultWritePaths: string[] = []; @@ -111,6 +123,7 @@ export class TerminalSandboxEngine extends Disposable { @IConfigurationService private readonly _configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, + @IWindowsMxcTerminalSandboxRuntime private readonly _windowsMxcRuntime: IWindowsMxcTerminalSandboxRuntime, ) { super(); this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, (e: IConfigurationChangeEvent | undefined) => { @@ -163,11 +176,14 @@ export class TerminalSandboxEngine extends Disposable { || !this._areStringArraysEqual(this._commandAllowListKeywords, normalizedCommandKeywords) || !this._areStringArraysEqual(currentReadAllowListPaths, nextReadAllowListPaths) || !this._areObjectsEqual(currentRuntimeConfiguration, nextRuntimeConfiguration) - || this._commandCwd?.toString() !== cwd?.toString(); + || this._commandCwd?.toString() !== cwd?.toString() + || (this._os === OperatingSystem.Windows && (this._commandLine !== command || this._commandShell !== shell)); if (shouldRefreshConfig) { this._commandAllowListKeywords = normalizedCommandKeywords; this._commandAllowListCommandDetails = normalizedCommandDetails; this._commandCwd = cwd; + this._commandLine = command; + this._commandShell = shell; await this.getSandboxConfigPath(true); } @@ -197,6 +213,16 @@ export class TerminalSandboxEngine extends Disposable { }; } + if (this._os === OperatingSystem.Windows) { + if (!this._mxcPath) { + throw new Error('MXC executable path not resolved'); + } + return { + command: this._windowsMxcRuntime.wrapCommand(this._mxcPath, this._sandboxConfigPath), + isSandboxWrapped: true, + }; + } + if (!this._execPath) { throw new Error('Executable path not set to run sandbox commands'); } @@ -210,7 +236,7 @@ export class TerminalSandboxEngine extends Disposable { // TMPDIR must be set as environment variable before the command // Quote shell arguments so the wrapped command cannot break out of the outer shell. const commandToRunInSandbox = this._getSandboxCommandWithPreservedCwd(command, cwd); - const sandboxRuntimeCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(commandToRunInSandbox)}`; + const sandboxRuntimeCommand = `PATH="$PATH:${this._pathDirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(commandToRunInSandbox)}`; const wrappedCommand = this._os === OperatingSystem.Linux && cwd?.path && cwd.path !== this._tempDir.path ? `cd ${this._quoteShellArgument(this._tempDir.path)}; ${sandboxRuntimeCommand}` : sandboxRuntimeCommand; @@ -319,6 +345,7 @@ export class TerminalSandboxEngine extends Disposable { return true; // initial run-and-subscribe } return e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxEnabled) + || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxWindowsEnabled) || e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) || e.affectsConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains) || e.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains) @@ -330,13 +357,14 @@ export class TerminalSandboxEngine extends Disposable { || e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxLinuxFileSystem) || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxMacFileSystem) || e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxMacFileSystem) + || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxWindowsFileSystem) || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxAdvancedRuntime); } private async _checkSandboxDependencies(forceRefresh = false): Promise { const os = await this.getOS(); if (os === OperatingSystem.Windows) { - return false; + return true; } if (!forceRefresh && this._sandboxDependencyStatus) { @@ -368,6 +396,9 @@ export class TerminalSandboxEngine extends Disposable { } private _wrapUnsandboxedCommand(command: string, shell?: string): string { + if (this._os === OperatingSystem.Windows) { + return this._windowsMxcRuntime.wrapUnsandboxedCommand(command); + } if (!this._tempDir?.path) { return command; } @@ -473,9 +504,9 @@ export class TerminalSandboxEngine extends Disposable { } private async _isSandboxConfiguredEnabled(): Promise { - const os = await this.getOS(); - if (os === OperatingSystem.Windows) { - return false; + await this.getOS(); + if (this._os === OperatingSystem.Windows) { + return this._getSandboxConfiguredWindowsEnabledValue() === AgentSandboxEnabledValue.AllowNetwork; } const value = this._getSandboxConfiguredEnabledValue(); return value === true || value === AgentSandboxEnabledValue.On || value === AgentSandboxEnabledValue.AllowNetwork; @@ -493,6 +524,7 @@ export class TerminalSandboxEngine extends Disposable { this._userHome = await this._host.getUserHome(); this._srtPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'sandbox-runtime', 'dist', 'cli.js'); this._rgPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'); + this._mxcPath = this._windowsMxcRuntime.getExecutablePath(this._appRoot, runtimeInfo.arch); } private async _createSandboxConfig(): Promise { @@ -510,6 +542,9 @@ export class TerminalSandboxEngine extends Disposable { const macFileSystemSetting = this._os === OperatingSystem.Macintosh ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxMacFileSystem, AgentSandboxSettingId.DeprecatedAgentSandboxMacFileSystem) ?? {} : {}; + const windowsFileSystemSetting = this._os === OperatingSystem.Windows + ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsFileSystem) ?? {} + : {}; const runtimeSetting = this._getSettingValue>(AgentSandboxSettingId.AgentSandboxAdvancedRuntime) ?? {}; const commandRuntimeSetting = getTerminalSandboxRuntimeConfigurationForCommands(this._os, this._commandAllowListCommandDetails); const commandRuntimeAllowReadPaths = this._getCommandRuntimeFileSystemPaths(commandRuntimeSetting, 'allowRead'); @@ -519,7 +554,17 @@ export class TerminalSandboxEngine extends Disposable { let allowReadPaths: string[] = []; let denyReadPaths: string[] = []; let denyWritePaths: string[] | undefined; - if (this._os === OperatingSystem.Macintosh) { + if (this._os === OperatingSystem.Windows) { + const filesystemPolicy = await this._getWindowsMxcFilesystemPolicy(); + const env = await this._getWindowsMxcEnvironment(); + allowWritePaths = await this._resolveFileSystemPaths([ + ...this._updateAllowWritePathsWithWorkspaceFolders(windowsFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths), + ...filesystemPolicy.readwritePaths + ]); + allowReadPaths = await this._resolveFileSystemPaths([...(await this._updateAllowReadPathsWithAllowWrite(windowsFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths)), ...filesystemPolicy.readonlyPaths]); + denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(windowsFileSystemSetting.denyRead)); + this._windowsMxcEnvironment = env; + } else if (this._os === OperatingSystem.Macintosh) { allowWritePaths = await this._resolveFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite, commandRuntimeAllowWritePaths)); allowReadPaths = await this._resolveFileSystemPaths(await this._updateAllowReadPathsWithAllowWrite(macFileSystemSetting.allowRead, allowWritePaths, commandRuntimeAllowReadPaths)); denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(macFileSystemSetting.denyRead)); @@ -530,7 +575,17 @@ export class TerminalSandboxEngine extends Disposable { denyReadPaths = await this._resolveFileSystemPaths(this._updateDenyReadPathsWithHome(linuxFileSystemSetting.denyRead)); denyWritePaths = await this._resolveFileSystemPaths(linuxFileSystemSetting.denyWrite); } - const sandboxSettings = { + const sandboxSettings = this._os === OperatingSystem.Windows ? this._windowsMxcRuntime.createConfig({ + command: this._commandLine ?? '', + cwd: this._commandCwd, + tempDir: this._tempDir, + allowNetwork, + networkDomains: this.getResolvedNetworkDomains(), + allowReadPaths, + allowWritePaths, + denyReadPaths, + env: this._windowsMxcEnvironment ?? [], + }) : { network: allowNetwork ? { allowedDomains: [], deniedDomains: [], enabled: false } : this.getResolvedNetworkDomains(), filesystem: { denyRead: denyReadPaths, @@ -539,9 +594,11 @@ export class TerminalSandboxEngine extends Disposable { denyWrite: denyWritePaths, }, }; - this._mergeAdditionalSandboxConfigProperties(sandboxSettings as Record, allowNetwork ? this._withoutNetworkRuntimeSetting(runtimeSetting) : runtimeSetting); - this._mergeAdditionalSandboxConfigProperties(sandboxSettings as Record, commandRuntimeSetting); - this._sandboxConfigPath = configFileUri.path; + if (this._os !== OperatingSystem.Windows) { + this._mergeAdditionalSandboxConfigProperties(sandboxSettings as Record, allowNetwork ? this._withoutNetworkRuntimeSetting(runtimeSetting) : runtimeSetting); + this._mergeAdditionalSandboxConfigProperties(sandboxSettings as Record, commandRuntimeSetting); + } + this._sandboxConfigPath = this._getUriPath(configFileUri); await this._fileService.createFile(configFileUri, VSBuffer.fromString(JSON.stringify(sandboxSettings, null, '\t')), { overwrite: true }); return this._sandboxConfigPath; } @@ -584,11 +641,33 @@ export class TerminalSandboxEngine extends Disposable { return typeof value === 'object' && value !== null && !Array.isArray(value); } + private async _getWindowsMxcFilesystemPolicy(): Promise { + if (!this._windowsMxcFilesystemPolicy) { + this._windowsMxcFilesystemPolicy = await this._host.getWindowsMxcFilesystemPolicy() ?? { readonlyPaths: [], readwritePaths: [] }; + } + return this._windowsMxcFilesystemPolicy; + } + + private async _getWindowsMxcEnvironment(): Promise { + if (!this._windowsMxcEnvironment) { + this._windowsMxcEnvironment = await this._host.getWindowsMxcEnvironment() ?? []; + } + return this._windowsMxcEnvironment; + } + private _pathJoin = (...segments: string[]) => { const path = this._os === OperatingSystem.Windows ? win32 : posix; return path.join(...segments); }; + private _pathDirname(path: string): string { + return (this._os === OperatingSystem.Windows ? win32 : posix).dirname(path); + } + + private _getUriPath(uri: URI): string { + return this._os === OperatingSystem.Windows ? this._windowsMxcRuntime.toWindowsPath(uri) : uri.path; + } + private async _initTempDir(): Promise { if (!(await this.isEnabled())) { return; @@ -597,19 +676,23 @@ export class TerminalSandboxEngine extends Disposable { this._tempDir = await this._host.getSandboxTempDir(); if (this._tempDir) { await this._fileService.createFolder(this._tempDir); - this._defaultWritePaths.push(this._tempDir.path); + this._defaultWritePaths.push(this._getUriPath(this._tempDir)); } else { this._logService.warn('TerminalSandboxEngine: Cannot create sandbox settings file because no tmpDir is available in this environment'); } } private _updateAllowWritePathsWithWorkspaceFolders(configuredAllowWrite: string[] | undefined, commandRuntimeAllowWrite: string[] = []): string[] { - const writeRootPaths = this._host.getWriteRoots().map(folder => folder.path); + const writeRootPaths = this._host.getWriteRoots().map(folder => this._getUriPath(folder)); return [...new Set([...writeRootPaths, ...this._defaultWritePaths, ...(configuredAllowWrite ?? []), ...commandRuntimeAllowWrite])]; } private _updateDenyReadPathsWithHome(configuredDenyRead: string[] | undefined): string[] { - const userHome = this._userHome?.path; + // TODO: On Windows, deny read on home directory. + if (this._os === OperatingSystem.Windows) { + return [...new Set(configuredDenyRead ?? [])]; + } + const userHome = this._userHome ? this._getUriPath(this._userHome) : undefined; return [...new Set([...(configuredDenyRead ?? []), ...(userHome ? [userHome] : [])])]; } @@ -624,6 +707,9 @@ export class TerminalSandboxEngine extends Disposable { private async _resolveFileSystemPath(path: string): Promise { const expandedPath = this._os === OperatingSystem.Linux ? this._expandHomePath(path) : path; + if (this._os === OperatingSystem.Windows) { + return expandedPath; + } if (!this._isAbsoluteFileSystemPath(expandedPath)) { return expandedPath; } @@ -662,9 +748,12 @@ export class TerminalSandboxEngine extends Disposable { if (!this._appRoot) { return []; } + if (this._os === OperatingSystem.Windows) { + return this._windowsMxcRuntime.getRuntimeReadPaths(this._appRoot, this._mxcPath); + } const paths: string[] = [this._appRoot]; if (this._execPath) { - for (const path of [this._execPath, dirname(this._execPath)]) { + for (const path of [this._execPath, this._pathDirname(this._execPath)]) { if (!this._isPathUnderAppRoot(path)) { paths.push(path); } @@ -682,14 +771,21 @@ export class TerminalSandboxEngine extends Disposable { private async _getWorkspaceStorageReadPaths(): Promise { const root = await this._host.getWorkspaceStorageReadRoot(); - return root ? [root.path] : []; + return root ? [this._getUriPath(root)] : []; } private _getSandboxConfiguredEnabledValue(): AgentSandboxEnabledValue | boolean { return this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) ?? AgentSandboxEnabledValue.Off; } + private _getSandboxConfiguredWindowsEnabledValue(): AgentSandboxEnabledValue { + return this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsEnabled) ?? AgentSandboxEnabledValue.Off; + } + private _isSandboxAllowNetworkConfigured(): boolean { + if (this._os === OperatingSystem.Windows) { + return this._getSandboxConfiguredWindowsEnabledValue() === AgentSandboxEnabledValue.AllowNetwork; + } return this._getSandboxConfiguredEnabledValue() === AgentSandboxEnabledValue.AllowNetwork; } diff --git a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts new file mode 100644 index 00000000000000..740b82034c09fd --- /dev/null +++ b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { win32 } from '../../../base/common/path.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { ITerminalSandboxResolvedNetworkDomains } from './terminalSandboxService.js'; + +export interface IWindowsMxcProcessConfig { + commandLine: string; + cwd?: string; + env: string[]; + timeout: number; +} + +export interface IWindowsMxcFilesystemConfig { + readwritePaths: string[]; + readonlyPaths: string[]; + deniedPaths: string[]; +} + +export interface IWindowsMxcNetworkConfig { + defaultPolicy: 'allow' | 'block'; + allowedHosts?: string[]; + blockedHosts?: string[]; +} + +export interface IWindowsMxcConfig { + version: string; + containerId: string; + containment: 'process'; + lifecycle: { + destroyOnExit: boolean; + preservePolicy: boolean; + }; + process: IWindowsMxcProcessConfig; + filesystem: IWindowsMxcFilesystemConfig; + network: IWindowsMxcNetworkConfig; + ui: { + disable: boolean; + clipboard: 'none'; + injection: boolean; + }; +} + +export interface IWindowsMxcConfigOptions { + command: string; + cwd: URI | undefined; + tempDir: URI; + allowNetwork: boolean; + networkDomains: ITerminalSandboxResolvedNetworkDomains; + allowReadPaths: string[]; + allowWritePaths: string[]; + denyReadPaths: string[]; + env: string[]; +} + +export const IWindowsMxcTerminalSandboxRuntime = createDecorator('windowsMxcTerminalSandboxRuntime'); + +export interface IWindowsMxcTerminalSandboxRuntime { + readonly _serviceBrand: undefined; + + getExecutablePath(appRoot: string, arch: string | undefined): string; + getRuntimeReadPaths(appRoot: string | undefined, executablePath: string | undefined): string[]; + createConfig(options: IWindowsMxcConfigOptions): IWindowsMxcConfig; + wrapCommand(executablePath: string, configPath: string): string; + wrapUnsandboxedCommand(command: string): string; + toWindowsPath(uri: URI): string; +} + +/** + * Windows-only MXC integration for terminal sandboxing. + * + * This class is intentionally isolated from the SRT-backed runtime so it can be + * removed once SRT supports Windows sandboxing. + */ +export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSandboxRuntime { + declare readonly _serviceBrand: undefined; + + private readonly _configVersion = '0.4.0-alpha'; + + getExecutablePath(appRoot: string, arch: string | undefined): string { + const binArch = arch === 'arm64' ? 'arm64' : 'x64'; + return win32.join(appRoot, 'node_modules', '@microsoft', 'mxc-sdk', 'bin', binArch, 'wxc-exec.exe'); + } + + getRuntimeReadPaths(appRoot: string | undefined, executablePath: string | undefined): string[] { + const paths: string[] = []; + if (appRoot) { + paths.push(appRoot); + } + if (executablePath) { + paths.push(executablePath, win32.dirname(executablePath)); + } + return [...new Set(paths)]; + } + + createConfig(options: IWindowsMxcConfigOptions): IWindowsMxcConfig { + const tempDirPath = this.toWindowsPath(options.tempDir); + return { + version: this._configVersion, + containerId: 'vscode-terminal-sandbox', + containment: 'process', + lifecycle: { + destroyOnExit: true, + preservePolicy: false, + }, + process: { + commandLine: options.command, + cwd: options.cwd ? this.toWindowsPath(options.cwd) : tempDirPath, + env: [ + ...options.env + ], + timeout: 0, + }, + filesystem: { + readwritePaths: [...new Set([...options.allowWritePaths])], + readonlyPaths: [...new Set([tempDirPath, ...options.allowReadPaths])], + deniedPaths: options.denyReadPaths, + }, + network: this._createNetworkConfig(options.allowNetwork, options.networkDomains), + ui: { + disable: false, + clipboard: 'none', + injection: false, + }, + }; + } + + wrapCommand(executablePath: string, configPath: string): string { + return `& ${this._quotePowerShellArgument(executablePath)} ${this._quotePowerShellArgument(configPath)}`; + } + + wrapUnsandboxedCommand(command: string): string { + return command; + } + + toWindowsPath(uri: URI): string { + let value: string; + if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') { + value = `\\\\${uri.authority}${uri.path}`; + } else if (/^\/[a-zA-Z]:/.test(uri.path)) { + value = uri.path.slice(1); + } else { + value = uri.fsPath; + } + return value.replace(/\//g, '\\'); + } + + private _createNetworkConfig(allowNetwork: boolean, networkDomains: ITerminalSandboxResolvedNetworkDomains): IWindowsMxcNetworkConfig { + if (allowNetwork) { + return { defaultPolicy: 'allow' }; + } + return { + defaultPolicy: 'block', + allowedHosts: networkDomains.allowedDomains, + blockedHosts: networkDomains.deniedDomains + }; + } + + private _quotePowerShellArgument(value: string): string { + return `'${value.replace(/'/g, `''`)}'`; + } +} diff --git a/src/vs/platform/sandbox/common/terminalSandboxReadAllowList.ts b/src/vs/platform/sandbox/common/terminalSandboxReadAllowList.ts index d1ff40e5afd435..bbbeb9d115a922 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxReadAllowList.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxReadAllowList.ts @@ -86,6 +86,10 @@ const terminalSandboxReadAllowListKeywordMap: ReadonlyMap Promise; @@ -31,4 +33,58 @@ export class SandboxHelperService implements ISandboxHelperService { checkSandboxDependencies(): Promise { return SandboxHelperService.checkSandboxDependenciesWith(findExecutable); } + + async getWindowsMxcFilesystemPolicy(): Promise { + if (!isWindows) { + return undefined; + } + + const { getAvailableToolsPolicy, getUserProfilePolicy } = await import('@microsoft/mxc-sdk'); + const availableToolsPolicy = getAvailableToolsPolicy(process.env, { containerType: 'processcontainer' }); + const userProfilePolicy = getUserProfilePolicy(); + const psHome = await this._getPSHome(); + return { + readonlyPaths: [...new Set([...availableToolsPolicy.readonlyPaths, ...userProfilePolicy.readonlyPaths, ...this._getTempReadPaths(), ...(psHome ? [psHome] : [])])], + readwritePaths: [...new Set([...availableToolsPolicy.readwritePaths, ...userProfilePolicy.readwritePaths])], + }; + } + + async getWindowsMxcEnvironment(): Promise { + if (!isWindows) { + return undefined; + } + + const env: string[] = []; + const path = getCaseInsensitive(process.env, 'PATH'); + if (typeof path === 'string' && path) { + env.push(`PATH=${path}`); + } + + const psHome = await this._getPSHome(); + if (psHome) { + env.push(`PSHOME=${psHome}`); + } + return env; + } + + private async _getPSHome(): Promise { + const psHome = getCaseInsensitive(process.env, 'PSHOME'); + if (typeof psHome === 'string' && psHome) { + return psHome; + } + + const powerShellPath = await findExecutable('pwsh') ?? await findExecutable('powershell'); + return powerShellPath ? win32.dirname(powerShellPath) : undefined; + } + + private _getTempReadPaths(): string[] { + const paths: string[] = []; + for (const variable of ['TMP', 'TEMP']) { + const path = getCaseInsensitive(process.env, variable); + if (typeof path === 'string' && path) { + paths.push(path); + } + } + return paths; + } } diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index a97970e7152266..b77cbc94c5a040 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ok, strictEqual } from 'assert'; +import { deepStrictEqual, ok, strictEqual } from 'assert'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; import { OperatingSystem } from '../../../../base/common/platform.js'; @@ -14,9 +14,10 @@ import { TestConfigurationService } from '../../../configuration/test/common/tes import { IFileService } from '../../../files/common/files.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import type { ISandboxDependencyStatus } from '../../common/sandboxHelperService.js'; +import type { ISandboxDependencyStatus, IWindowsMxcFilesystemPolicy } from '../../common/sandboxHelperService.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../common/settings.js'; import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../common/terminalSandboxEngine.js'; +import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../common/terminalSandboxMxcRuntime.js'; suite('TerminalSandboxEngine', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -42,7 +43,12 @@ suite('TerminalSandboxEngine', () => { async createFile(uri: URI, content: VSBuffer): Promise { createFileCount++; - createdFiles.set(uri.path, content.toString()); + const contentString = content.toString(); + createdFiles.set(uri.path, contentString); + createdFiles.set(uri.fsPath, contentString); + if (/^\/[a-zA-Z]:/.test(uri.path)) { + createdFiles.set(uri.path.slice(1).replace(/\//g, '\\'), contentString); + } return {}; } async createFolder(uri: URI): Promise { @@ -68,11 +74,35 @@ suite('TerminalSandboxEngine', () => { getWriteRoots: () => [URI.file('/workspace')], onDidChangeRoots: rootsEmitter.event, checkSandboxDependencies: (): Promise => Promise.resolve({ bubblewrapInstalled: true, socatInstalled: true }), + getWindowsMxcFilesystemPolicy: (): Promise => Promise.resolve(undefined), + getWindowsMxcEnvironment: (): Promise => Promise.resolve(undefined), ...overrides, }; return Object.assign(host, { rootsEmitter }); } + function createWindowsHost(overrides: Partial = {}): ITerminalSandboxEngineHost & { rootsEmitter: Emitter } { + return createHost({ + getOS: () => Promise.resolve(OperatingSystem.Windows), + getRuntimeInfo: () => Promise.resolve({ appRoot: 'C:\\app', arch: 'x64' }), + getUserHome: () => Promise.resolve(URI.from({ scheme: 'file', path: '/c:/Users/user' })), + getSandboxTempDir: () => Promise.resolve(URI.from({ scheme: 'file', path: '/c:/Users/user/.test-data/tmp' })), + getWorkspaceStorageReadRoot: () => Promise.resolve(URI.from({ scheme: 'file', path: '/c:/Users/user/workspaceStorage/workspace-id' })), + getWriteRoots: () => [URI.from({ scheme: 'file', path: '/c:/workspace' })], + getWindowsMxcFilesystemPolicy: () => Promise.resolve({ readonlyPaths: ['C:\\tools\\node', 'C:\\tools\\python', 'C:\\Users\\user\\AppData\\Local\\Programs\\Git', 'C:\\Users\\user\\AppData\\Local\\Temp'], readwritePaths: [] }), + getWindowsMxcEnvironment: () => Promise.resolve(['PATH=C:\\tools\\node;C:\\Windows\\System32', 'PSHOME=C:\\Program Files\\PowerShell\\7']), + ...overrides, + }); + } + + function normalizeWindowsPathForAssert(path: string): string { + return path.replace(/\\/g, '/').toLowerCase(); + } + + function enableWindowsSandbox(): void { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.AllowNetwork); + } + setup(() => { createdFiles = new Map(); createFileCount = 0; @@ -86,6 +116,7 @@ suite('TerminalSandboxEngine', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); }); test('runAsNode=true prefixes the wrapped command with ELECTRON_RUN_AS_NODE=1', async () => { @@ -201,12 +232,124 @@ suite('TerminalSandboxEngine', () => { await engine.cleanupTempDir(); // must not throw }); - test('isEnabled returns false on Windows regardless of configuration', async () => { - const host = createHost({ getOS: () => Promise.resolve(OperatingSystem.Windows) }); + test('isEnabled returns false on Windows when Windows sandbox setting is disabled by default', async () => { + const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); strictEqual(await engine.isEnabled(), false); strictEqual(await engine.isSandboxAllowNetworkEnabled(), false); + strictEqual(await engine.getSandboxConfigPath(), undefined); + }); + + test('isEnabled returns true on Windows when Windows sandbox setting allows network even if global sandboxing is off', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); + enableWindowsSandbox(); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + strictEqual(await engine.isEnabled(), true); + strictEqual(await engine.isSandboxAllowNetworkEnabled(), true); + }); + + test('wrapCommand uses MXC executable and writes MXC config on Windows', async () => { + enableWindowsSandbox(); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + const wrapped = await engine.wrapCommand('echo hello', false, 'pwsh', URI.from({ scheme: 'file', path: '/c:/workspace' })); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + strictEqual(wrapped.isSandboxWrapped, true); + ok(wrapped.command.startsWith(`& 'C:\\app\\node_modules\\@microsoft\\mxc-sdk\\bin\\x64\\wxc-exec.exe'`), `Expected MXC executable. Actual: ${wrapped.command}`); + ok(wrapped.command.includes(` '${configPath}'`), `Expected wrapped command to pass the MXC config path. Actual: ${wrapped.command}`); + strictEqual(config.version, '0.4.0-alpha'); + strictEqual(config.containment, 'process'); + strictEqual(config.process.commandLine, 'echo hello'); + strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/workspace'); + strictEqual(config.ui.disable, false); + ok(config.process.env.includes('PATH=C:\\tools\\node;C:\\Windows\\System32'), 'PATH should be injected into the MXC process env'); + ok(config.process.env.includes('PSHOME=C:\\Program Files\\PowerShell\\7'), 'PSHOME should be injected into the MXC process env'); + deepStrictEqual(config.network, { defaultPolicy: 'allow' }); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/workspace'), 'Workspace should be writable'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path).endsWith('/.test-data/tmp')), 'Sandbox temp dir should be writable'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path).endsWith('/.test-data/tmp')), 'Sandbox temp dir should be readable through readonly paths'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/app'), 'App root should be readable for MXC'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/tools/node'), 'MXC available tools policy should add tool paths to readonly paths'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/programs/git'), 'MXC user profile policy should add user profile paths to readonly paths'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'MXC actual temp policy should add host temp path to readonly paths'); + ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); + }); + + test('wrapCommand applies Windows filesystem setting to MXC config', async () => { + enableWindowsSandbox(); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { + allowWrite: ['C:\\configured\\write'], + allowRead: ['C:\\configured\\read'], + denyRead: ['C:\\configured\\secret'], + }); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('echo hello', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/write'), 'Configured Windows allowWrite path should be writable'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/read'), 'Configured Windows allowRead path should be readonly'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user/appdata/local/temp'), 'Host temp path from Windows policy should be readonly'); + ok(config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/secret'), 'Configured Windows denyRead path should be denied'); + ok(!config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/users/user'), 'User home should not be denied by default on Windows'); + }); + + test('wrapCommand uses arm64 MXC executable on Windows arm64', async () => { + enableWindowsSandbox(); + const host = createWindowsHost({ + getRuntimeInfo: () => Promise.resolve({ appRoot: 'C:\\app', arch: 'arm64' }), + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + const wrapped = await engine.wrapCommand('echo hello', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + strictEqual(wrapped.command, `& 'C:\\app\\node_modules\\@microsoft\\mxc-sdk\\bin\\arm64\\wxc-exec.exe' '${configPath}'`); + strictEqual(normalizeWindowsPathForAssert(config.process.cwd), 'c:/users/user/.test-data/tmp'); + }); + + test('wrapCommand rewrites MXC config when Windows command changes', async () => { + enableWindowsSandbox(); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('echo first', false, 'pwsh'); + let configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const firstCommandLine = JSON.parse(createdFiles.get(configPath)!).process.commandLine; + strictEqual(firstCommandLine, 'echo first'); + + await engine.wrapCommand('echo second', false, 'pwsh'); + configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const secondCommandLine = JSON.parse(createdFiles.get(configPath)!).process.commandLine; + strictEqual(secondCommandLine, 'echo second'); + }); + + test('allowNetwork maps to MXC allow network config on Windows', async () => { + enableWindowsSandbox(); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); + const host = createWindowsHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + await engine.wrapCommand('curl https://example.com', false, 'pwsh'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + deepStrictEqual(config.network, { defaultPolicy: 'allow' }); }); test('uses OS-specific filesystem absolute path detection', async () => { diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 7af7ff1e6fa972..1df687735333a1 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -81,14 +81,14 @@ A **session** groups one or more **chats** (conversations) that share the same w ``` ISession -β”œβ”€β”€ mainChat: IChat ← primary (first) chat -β”œβ”€β”€ chats: IObservable ← all chats in creation order +β”œβ”€β”€ mainChat: IObservable ← primary (first) chat (settable by provider when committing a new session) +β”œβ”€β”€ chats: IObservable ← all chats in creation order β”œβ”€β”€ capabilities.supportsMultipleChats -└── session-level observables ← derived from chats +└── session-level observables ← derived from chats ``` Session-level properties are derived from chats: -- Most properties (`title`, `changes`, `changesets`, `modelId`, etc.) come from `mainChat` +- Most properties (`title`, `changes`, `changesets`, `modelId`, etc.) come from the main chat - `updatedAt` and `lastTurnEnd` are the latest across all chats - `status` is aggregated (`NeedsInput` > `InProgress` > other) - `isRead` is `true` only when all chats are read @@ -135,8 +135,11 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups β€” is also offered by another provider 3. User types a message and sends - β†’ SessionsManagementService.sendAndCreateChat(session, {query, attachedContext}) - β†’ Delegates to provider.sendAndCreateChat(sessionId, options) + β†’ SessionsManagementService.sendNewChatRequest(session, {query, attachedContext}) + β†’ Calls provider.createNewChat(sessionId) + β†’ Provider creates the backend chat model and returns an IChat + β†’ Management service opens the chat widget with that chat's resource + β†’ Delegates to provider.sendRequest(sessionId, chatResource, options) β†’ Provider sends request, returns committed session β†’ isNewChatSession context β†’ false ``` @@ -180,3 +183,22 @@ Providers may fire `onDidReplaceSession` when a temporary (untitled) session is 5. Use `toSessionId(providerId, resource)` for session IDs 6. Fire `onDidChangeSessions` on every session change and `onDidReplaceSession` on untitledβ†’committed transitions 7. Set `supportsLocalWorkspaces: true` if the provider can resolve local file-system workspaces + +--- + +## Interface Design Guidelines + +### `ISessionsProvider` must have no optional methods + +Every method on `ISessionsProvider` is part of the mandatory contract. Do **not** declare any method as optional (i.e., using `?`). Every provider must implement the full interface. If a method is not meaningful for a particular provider, implement it as a no-op or return a safe default. + +**Rationale:** Optional methods weaken the contract and force call sites to add guard code (`if (provider.method)`). Mandatory methods keep the management service clean and ensure the interface documents the complete capability set of every provider. + +### Any addition to `ISession` or `ISessionsProvider` must be consumed in the agents window core workbench + +The **agents window core workbench** is defined as all sessions code *outside* `src/vs/sessions/contrib/providers/` β€” that is, code in `src/vs/sessions/services/`, `src/vs/sessions/browser/`, `src/vs/sessions/common/`, and non-provider `src/vs/sessions/contrib/*` folders (views, UI contributions, toolbars, etc.). + +When you add a property or method to `ISession` or `ISessionsProvider`, it **must** be referenced by at least one file in the core workbench, not only within provider implementations. + +**Rationale:** If an interface member is only used inside providers, it belongs on the provider's concrete class, not on the shared interface. Interfaces should capture what the orchestration layer (management service, UI) needs from providers β€” not internal implementation details that leak outward. + diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 61e87545fd09a8..ededde55ccab9f 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -75,7 +75,7 @@ export class ChatCompositeBar extends Disposable { const chats = activeSession.chats.read(reader); const activeChatUri = activeSession.activeChat.read(reader)?.resource.toString() ?? ''; - const mainChatUri = activeSession.mainChat.resource.toString(); + const mainChatUri = activeSession.mainChat.read(reader).resource.toString(); this._rebuildTabs(chats, activeChatUri, mainChatUri); })); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 1c80029f8071e6..eb80fddb88ba73 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -45,6 +45,12 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { readonly onDidChangeSessionConfig: Event; /** Returns the last resolved dynamic configuration for a session. */ getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined; + /** + * Observable: `true` while a `resolveSessionConfig` round-trip is in + * flight. Pickers gate on this rather than `session.loading` so they + * stay interactive in the required-values-missing state. + */ + isSessionConfigResolving(sessionId: string): IObservable; /** Sets one dynamic configuration property and re-resolves the schema. */ setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; /** diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 2ca8d048e6b2f3..7c30a74b1c4541 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -54,7 +54,8 @@ color: var(--vscode-agentsChatInput-placeholderForeground); } -.chat-input-picker-item .action-label.disabled { +.chat-input-picker-item .action-label.disabled, +.chat-input-picker-item.disabled .action-label { opacity: 0.5; cursor: default; pointer-events: none; diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 3d04e13269d0df..8684663894c9ee 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -345,7 +345,7 @@ class NewChatWidget extends Disposable { return; } try { - await this.sessionsManagementService.sendAndCreateChat(session, { query, attachedContext }); + await this.sessionsManagementService.sendNewChatRequest(session, { query, attachedContext }); } catch (e) { this.logService.error('Failed to send request:', e); } diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index a259595e0e7b53..f8cc894eb281be 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -302,12 +302,12 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr private async _generateNewTask(session: ISession): Promise { const query = '/generate-run-commands'; // Prefer sending to the already-open chat widget for the session; - // fall back to sendAndCreateChat for untitled sessions or when no widget is loaded. - const widget = this._chatWidgetService.getWidgetBySessionResource(session.mainChat.resource); + // fall back to sendRequest for untitled sessions or when no widget is loaded. + const widget = this._chatWidgetService.getWidgetBySessionResource(session.mainChat.get().resource); if (widget) { await widget.acceptInput(query); } else { - await this._sessionManagementService.sendAndCreateChat(session, { query }); + await this._sessionManagementService.sendNewChatRequest(session, { query }); } } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 7ce387622b47a5..c521006a273058 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -26,7 +26,7 @@ import { IOutputService } from '../../../../../workbench/services/output/common/ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { extUri } from '../../../../../base/common/resources.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { ISendRequestOptions, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; @@ -98,9 +98,8 @@ function createMockProvider(id: string, opts?: { unarchiveSession: async () => { }, deleteSession: async () => { }, deleteChat: async () => { }, - sendAndCreateChat: async () => { throw new Error('Not implemented'); }, - addChat: () => { throw new Error('Not implemented'); }, - sendRequest: async () => { throw new Error('Not implemented'); }, + createNewChat: async () => { throw new Error('Not implemented'); }, + sendRequest: async (_sessionId: string, _chatResource: URI, _options: ISendRequestOptions) => { throw new Error('Not implemented'); }, }; if (opts?.connectionStatus) { return { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts index 11a664f98cd46a..e2f333b14348a1 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts @@ -71,7 +71,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession lastTurnEnd: chat.lastTurnEnd, description: chat.description, chats: observableValue('chats', [chat]), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, } satisfies ISession; return session; diff --git a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts index 67e8022313ef5f..92a48923f70efc 100644 --- a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts @@ -55,7 +55,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), chats: observableValue('chats', [chat]), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, }; } diff --git a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts index cbe7903f476e58..5838a33281ddbd 100644 --- a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts @@ -69,7 +69,7 @@ function makeSession(opts: { id?: string; runsWorktreeCreatedTasks?: boolean; lo lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), chats: observableValue('chats', [chat]), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false, runsWorktreeCreatedTasks: opts.runsWorktreeCreatedTasks }, }; return { session, loading, status, workspace }; diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts index 57ec7641244133..e705500a025e60 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -8,7 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore, IDisposable, ImmortalReference, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -167,7 +167,7 @@ class TestSession implements ISession { readonly description: ReturnType>; readonly lastTurnEnd: ReturnType>; readonly chats: ReturnType>; - readonly mainChat: IChat; + readonly mainChat: IObservable; readonly capabilities: ISessionCapabilities = { supportsMultipleChats: false }; constructor(id: string, gitHubInfo: IGitHubInfo | undefined, archived: boolean) { @@ -204,7 +204,7 @@ class TestSession implements ISession { const checkpoints = observableValue(`test.checkpoints.${id}`, undefined); - this.mainChat = { + const mainChat: IChat = { resource: this.resource, createdAt: this.createdAt, title: this.title, @@ -219,7 +219,8 @@ class TestSession implements ISession { description: this.description, lastTurnEnd: this.lastTurnEnd, }; - this.chats = observableValue(`test.chats.${id}`, [this.mainChat]); + this.mainChat = constObservable(mainChat); + this.chats = observableValue(`test.chats.${id}`, [mainChat]); } } diff --git a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts index 1063996060608d..b6513bc898fc28 100644 --- a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts @@ -91,7 +91,7 @@ function makeSession(resource: URI, opts?: { description: chat.description, chats: observableValue('chats', [chat]), activeChat: observableValue('activeChat', chat), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index b83c4f2a88fd18..54d6aecade58b5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -124,6 +124,23 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { protected _getFooterActionItems(): readonly IActionListItem[] { return []; } protected _handleFooterActionItem(_item: IAgentHostSessionEnumPickerItem): boolean { return false; } + /** + * `true` while the active session's provider is resolving its config. + * Subclasses gate picker-open paths on this; the desktop chip is + * rendered visually disabled in {@link _updateTrigger}. + */ + protected _isCurrentlyResolvingConfig(): boolean { + const session = this._sessionsManagementService.activeSession.get(); + if (!session) { + return false; + } + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider)) { + return false; + } + return provider.isSessionConfigResolving(session.sessionId).get(); + } + private _getActiveContext(): { provider: IAgentHostSessionsProvider; sessionId: string; currentValue: string; items: readonly IAgentHostSessionEnumPickerItem[] } | undefined { const session = this._sessionsManagementService.activeSession.get(); if (!session) { @@ -177,6 +194,13 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { labelSpan.textContent = label; this._triggerElement.ariaLabel = this._getTriggerAriaLabel(label); + + // Reflect the resolving state. Schema is preserved across the + // round-trip so the chip keeps its label; toggling `.disabled` + // on the slot blocks pointer events (see chatWidget.css). + const isResolving = ctx.provider.isSessionConfigResolving(ctx.sessionId).get(); + this._slotElement.classList.toggle('disabled', isResolving); + this._triggerElement.setAttribute('aria-disabled', isResolving ? 'true' : 'false'); } protected _showPicker(): void { @@ -187,6 +211,10 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { if (!ctx) { return; } + // Defensive against stale keyboard activation on a disabled chip. + if (this._isCurrentlyResolvingConfig()) { + return; + } const triggerElement = this._triggerElement; const actionItems: IActionListItem[] = ctx.items.map(item => ({ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts index 823f5590f3961f..b3e496f9192c26 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerActionItem.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable } from '../../../../../base/common/observable.js'; import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -16,6 +16,9 @@ import { IStorageService } from '../../../../../platform/storage/common/storage. import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { PermissionPickerActionItem } from '../../../../../workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.js'; +import { isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDelegate.js'; /** @@ -29,6 +32,8 @@ import { AgentHostPermissionPickerDelegate } from './agentHostPermissionPickerDe export class AgentHostPermissionPickerActionItem extends PermissionPickerActionItem { private readonly _delegate: AgentHostPermissionPickerDelegate; + /** Active session's `isSessionConfigResolving`. */ + private readonly _isResolvingActiveSessionConfig: IObservable; constructor( action: MenuItemAction, @@ -42,6 +47,8 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI @IDialogService dialogService: IDialogService, @IOpenerService openerService: IOpenerService, @IStorageService storageService: IStorageService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, ) { const delegate = instantiationService.createInstance(AgentHostPermissionPickerDelegate); super( @@ -58,6 +65,19 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI storageService, ); this._delegate = this._register(delegate); + // Initialized here (not as a class field) so the `derived` body can + // safely close over the parameter-property service references. + this._isResolvingActiveSessionConfig = derived(this, reader => { + const session = this._sessionsManagementService.activeSession.read(reader); + if (!session) { + return false; + } + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider)) { + return false; + } + return provider.isSessionConfigResolving(session.sessionId).read(reader); + }); // The base widget's label is rendered on demand via `refresh()`. Keep it // in sync with the delegate's level observable. @@ -76,5 +96,23 @@ export class AgentHostPermissionPickerActionItem extends PermissionPickerActionI const visible = this._delegate.isApplicable.read(reader); container.style.display = visible ? '' : 'none'; })); + + // Reflect the resolving state. The underlying ActionWidgetDropdown + // still handles Enter/Space on its label and pointer-events: none + // doesn't block keyboard, so the delegate also bails at the + // provider boundary. + this._register(autorun(reader => { + const isResolving = this._isResolvingActiveSessionConfig.read(reader); + const element = this.element; + if (!element) { + return; + } + element.classList.toggle('disabled', isResolving); + if (isResolving) { + element.setAttribute('aria-disabled', 'true'); + } else { + element.removeAttribute('aria-disabled'); + } + })); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index aed59c5faf44d5..6daeb0521bedc0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -92,6 +92,11 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return; } + // Defensive: ActionWidgetDropdown picks up Enter/Space on its + // label even when `pointer-events: none` is set on the chip. + if (provider.isSessionConfigResolving(session.sessionId).get()) { + return; + } provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, level) .catch(() => { /* best-effort */ }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 14822186758e45..34959d1ce35765 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -252,10 +252,7 @@ export class AgentHostSessionConfigPicker extends Disposable { super(); this._register(autorun(reader => { - const session = this._sessionsManagementService.activeSession.read(reader); - if (session) { - session.loading.read(reader); - } + this._sessionsManagementService.activeSession.read(reader); this._renderConfigPickers(); })); @@ -305,6 +302,11 @@ export class AgentHostSessionConfigPicker extends Disposable { // non-mutable properties like `isolation` must remain visible and // interactive there. const isNewSession = provider.getCreateSessionConfig(session.sessionId) !== undefined; + // Disable interactions while a resolve is in flight. Schema is + // preserved so chips stay visible. Not `session.loading` β€” + // that also covers the required-values-missing state where + // chips must remain interactive. + const isLoading = provider.isSessionConfigResolving(session.sessionId).get(); const properties = this._orderProperties(Object.entries(resolvedConfig.schema.properties)); @@ -341,7 +343,17 @@ export class AgentHostSessionConfigPicker extends Disposable { const value = resolvedConfig.values[property] ?? schema.default; const isReadOnly = this._isReadOnlyChip(property, schema, isNewSession); const slot = dom.append(this._container, dom.$('.sessions-chat-picker-slot')); + // `renderPickerTrigger`'s `disabled` flag means "read-only" + // (renders a `` with `aria-readonly`). The resolving + // state is transient and uses `.disabled` on the slot (see + // CSS in `chatWidget.css`) + `aria-disabled` on the trigger, + // keeping it focusable and using correct ARIA semantics. The + // click handler bails when resolving in `_showPicker`. const trigger = renderPickerTrigger(slot, isReadOnly, this._renderDisposables, () => this._showPicker(provider, session.sessionId, property, schema, trigger)); + if (!isReadOnly && isLoading) { + slot.classList.add('disabled'); + trigger.setAttribute('aria-disabled', 'true'); + } this._renderTrigger(trigger, property, schema, value, isReadOnly); } } @@ -411,7 +423,11 @@ export class AgentHostSessionConfigPicker extends Disposable { if (schema.readOnly || this._actionWidgetService.isVisible) { return; } - + // Mobile bottom-sheet override dispatches through this entry + // point, so guard here for both invocation paths. + if (provider.isSessionConfigResolving(sessionId).get()) { + return; + } const rawItems = await this._getItems(provider, sessionId, property, schema); const { items, policyRestricted } = applyAutoApproveFiltering(rawItems, property, this._configurationService); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index bc13266a6801c6..04e0b98d9a3589 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -23,10 +23,11 @@ import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/co import { AgentSelection, CustomizationAgentRef, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.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 { ChatViewPaneTarget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../../../workbench/contrib/chat/common/constants.js'; @@ -118,7 +119,7 @@ export class AgentHostSessionAdapter implements ISession { readonly lastTurnEnd: ISettableObservable; readonly gitHubInfo: IObservable; - readonly mainChat: IChat; + readonly mainChat: IObservable; readonly chats: IObservable; readonly capabilities = { supportsMultipleChats: false }; @@ -251,7 +252,7 @@ export class AgentHostSessionAdapter implements ISession { const checkpoints = observableValue(this, undefined); - this.mainChat = { + const mainChat: IChat = { resource: this.resource, createdAt: this.createdAt, title: this.title, @@ -266,7 +267,8 @@ export class AgentHostSessionAdapter implements ISession { description: this.description, lastTurnEnd: this.lastTurnEnd, }; - this.chats = constObservable([this.mainChat]); + this.mainChat = observableValue(this, mainChat); + this.chats = this.mainChat.map(c => [c]); this.changesets = createChangesets(this.sessionType, this.workspace, this.chats, _options.instantiationService); } @@ -474,6 +476,16 @@ interface INewSessionConstructionContext { * list as the committed session that replaces it. */ readonly instantiationService: IInstantiationService; + /** + * Forwards `SessionState` snapshots from the eagerly-held wire + * subscription back to the provider. `state === undefined` is a + * cleanup sentinel emitted by {@link NewSession.dispose} on the + * close-without-graduation path so the provider can drop any cached + * entry it accumulated for this session. The graduation path skips + * this sentinel because the running-session subscription pipeline + * takes over ownership of the same `sessionId` key. + */ + readonly onSessionState?: (sessionId: string, state: SessionState | undefined) => void; } /** @@ -482,7 +494,7 @@ interface INewSessionConstructionContext { * * Encapsulates: * - the `ISession` skeleton + its observables (status, modelId, loading) - * - the user's selected model (read by `sendAndCreateChat`) + * - the user's selected model (read by `sendRequest`) * - the resolved session config + a stale-request guard * - the eagerly created backend session (URI + subscription) that lets the * chat handler skip its legacy `createSession`-on-first-message round-trip @@ -492,7 +504,7 @@ interface INewSessionConstructionContext { * subscription. Wire ordering matters β€” see the comment in the body. * - {@link graduate} releases the subscription without firing * `disposeSession`; called when the session successfully transitions into - * a real running session via `sendAndCreateChat`. + * a real running session via `sendRequest`. * - {@link Disposable.dispose}/`dispose` releases the subscription **and** * fires `connection.disposeSession`; called when the user abandons the * new session (workspace switch, send failure, etc.). @@ -508,6 +520,7 @@ class NewSession extends Disposable { private readonly _modelId: ISettableObservable; private readonly _mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; private readonly _loading: ISettableObservable; + private readonly _mainChat: ISettableObservable; private _selectedModelId: string | undefined; private _selectedAgent: ISessionAgentRef | undefined; @@ -526,12 +539,30 @@ class NewSession extends Disposable { */ private _configRequestSeq = 0; + /** + * `true` while a `resolveConfig` round-trip is in flight. Distinct from + * {@link ISession.loading} which also stays true when required config + * values are missing β€” pickers gate on this so they stay interactive + * in that state. Set sync in {@link beginResolveConfigSync} so the + * optimistic `onDidChangeSessionConfig` pulse already exposes it. + */ + private readonly _isResolvingConfig: ISettableObservable; + /** Backend session URI, set the moment {@link eagerCreate} starts. */ private _backendUri: URI | undefined; /** Connection used to create the backend session, captured for `disposeSession` on tear-down. */ private _connection: IAgentConnection | undefined; /** Held state subscription. Set after the wire `createSession` resolves. */ - private _subscription: IReference | undefined; + private _subscription: IReference> | undefined; + /** + * `onDidChange` listener for {@link _subscription}. Forwards every + * `SessionState` snapshot to the provider via {@link _onSessionState} + * so the new session's customizations (and any other state) reach + * `_lastSessionStates` while the session is still Untitled. Detached + * in {@link graduate} (handoff) and {@link dispose} (close-without-send). + */ + private readonly _stateListener = this._register(new MutableDisposable()); + private readonly _onSessionState: ((sessionId: string, state: SessionState | undefined) => void) | undefined; private readonly _logService: ILogService; private readonly _providerId: string; @@ -546,6 +577,7 @@ class NewSession extends Disposable { this.agentProvider = ctx.sessionType.id; this._providerId = ctx.providerId; this._logService = ctx.logService; + this._onSessionState = ctx.onSessionState; const resource = URI.from({ scheme: ctx.resourceScheme, path: `/${generateUuid()}` }); this._status = observableValue(this, SessionStatus.Untitled); @@ -562,6 +594,7 @@ class NewSession extends Disposable { const description = observableValue(this, undefined); const lastTurnEnd = observableValue(this, undefined); this._loading = observableValue(this, true); + this._isResolvingConfig = observableValue(this, false); const createdAt = new Date(); const mainChat: IChat = { @@ -572,9 +605,10 @@ class NewSession extends Disposable { modelId: this._modelId, mode, isArchived, isRead, description, lastTurnEnd, }; + this._mainChat = observableValue(this, mainChat); const authPending = ctx.authenticationPending; const loading = this._loading; - const chats = constObservable([mainChat]); + const chats = this._mainChat.map(c => [c]); const changesets = createChangesets(ctx.sessionType.id, workspaceObs, chats, ctx.instantiationService); this.session = { sessionId: `${ctx.providerId}:${resource.toString()}`, @@ -596,7 +630,7 @@ class NewSession extends Disposable { isRead, description, lastTurnEnd, - mainChat, + mainChat: this._mainChat, chats, capabilities: { supportsMultipleChats: false }, }; @@ -637,13 +671,37 @@ class NewSession extends Disposable { getConfigValues(): Record | undefined { return this._config?.values; } /** - * Optimistically merges a single property into the cached config. Used by - * the picker to update local state before the next {@link resolveConfig} - * round-trip completes. + * Optimistically merges a single property into the cached config. + * Preserves the existing schema so schema-driven pickers don't flash + * during the async re-resolve. {@link resolveConfig} replaces both + * schema and values when its response lands. */ setConfigValue(property: string, value: unknown): void { - const current = this._config?.values ?? {}; - this._config = { schema: { type: 'object', properties: {} }, values: { ...current, [property]: value } }; + const current = this._config; + this._config = { + schema: current?.schema ?? { type: 'object', properties: {} }, + values: { ...(current?.values ?? {}), [property]: value }, + }; + } + + /** + * `true` while a {@link resolveConfig} round-trip is in flight. See + * {@link _isResolvingConfig} for why this is distinct from {@link ISession.loading}. + */ + get isResolvingConfig(): IObservable { return this._isResolvingConfig; } + + /** Mark a resolve as starting before the optimistic event fires. */ + beginResolveConfigSync(): void { + this._isResolvingConfig.set(true, undefined); + } + + /** + * Clear the in-flight flag for early-return paths that skip + * {@link resolveConfig} (e.g. no connection), where the `finally` + * cleanup never runs. + */ + endResolveConfigSync(): void { + this._isResolvingConfig.set(false, undefined); } /** @@ -655,6 +713,7 @@ class NewSession extends Disposable { */ async resolveConfig(connection: IAgentConnection): Promise { const seq = ++this._configRequestSeq; + this._isResolvingConfig.set(true, undefined); try { const result = await connection.resolveSessionConfig({ provider: this.agentProvider, @@ -672,6 +731,11 @@ class NewSession extends Disposable { } this._config = undefined; return true; + } finally { + // Only the latest request owns the flag. + if (seq === this._configRequestSeq) { + this._isResolvingConfig.set(false, undefined); + } } } @@ -747,16 +811,38 @@ class NewSession extends Disposable { // handler refcounts the same subscription via `getSubscription` // when chat content opens, so when we release this ref on // graduation the wire-level refcount stays positive. - this._subscription = connection.getSubscription(StateComponents.Session, backendUri); + const ref = connection.getSubscription(StateComponents.Session, backendUri); + this._subscription = ref; + + // Forward `SessionState` updates back to the provider so + // `_lastSessionStates` (and therefore `getCustomAgents`) becomes + // populated for this still-Untitled session. Seed once from the + // cached value, then attach a listener for subsequent deltas. + const onSessionState = this._onSessionState; + if (onSessionState) { + const initial = ref.object.value; + if (initial && !(initial instanceof Error)) { + onSessionState(this.sessionId, initial); + } + this._stateListener.value = ref.object.onDidChange(state => { + onSessionState(this.sessionId, state); + }); + } })(); } /** * Release the backend subscription without firing `disposeSession`. - * Used on the success path in `sendAndCreateChat` when the session has + * Used on the success path in `sendRequest` when the session has * graduated into a real running session. */ graduate(): void { + // Detach the new-session listener BEFORE releasing the subscription. + // Both code paths (this one and the running-session pipeline) write + // `_lastSessionStates` under the same `sessionId` key, so detaching + // here hands ownership cleanly to `_ensureSessionStateSubscription` + // without a transient empty-read window or a duplicate writer. + this._stateListener.clear(); this._subscription?.dispose(); this._subscription = undefined; this._backendUri = undefined; @@ -768,6 +854,18 @@ class NewSession extends Disposable { // Bump the seq so any in-flight resolveConfig discards itself. this._configRequestSeq++; + // Detach the state listener BEFORE firing the cleanup sentinel so + // a racing `onDidChange` cannot re-populate `_lastSessionStates` + // after we have asked the provider to delete the entry. Then fire + // the sentinel so the provider drops the cached snapshot. Only + // fires when a listener was actually wired (i.e. `eagerCreate` + // reached the post-`createSession` branch). + const hadListener = !!this._stateListener.value; + this._stateListener.clear(); + if (hadListener) { + this._onSessionState?.(this.sessionId, undefined); + } + this._subscription?.dispose(); this._subscription = undefined; @@ -795,7 +893,7 @@ class NewSession extends Disposable { * the session cache, the new-session/running-session config picker state, * the lazy session-state subscriptions, the AHP notification/action * handlers, and every connection-routed method (set/get/archive/delete/ - * rename/setModel/sendAndCreateChat). + * rename/setModel/sendRequest). * * Subclasses supply the genuine variation points: the connection * accessor, the authentication-pending observable, an adapter factory, @@ -1125,6 +1223,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement logService: this._logService, initialConfigValues: this._initialNewSessionConfig(), instantiationService: this._instantiationService, + onSessionState: (id, state) => state === undefined + ? this._handleNewSessionStateGone(id) + : this._handleNewSessionStateUpdate(id, state), }); this._newSession = newSession; this._onDidChangeSessionConfig.fire(newSession.sessionId); @@ -1150,7 +1251,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private async _refreshNewSessionConfig(session: NewSession): Promise { const connection = this.connection; if (!connection) { + // {@link resolveConfig} (the only other clear path) is skipped + // on this branch, so clear the flag here to avoid stalling + // the picker forever. + session.endResolveConfigSync(); session.setLoading(false); + this._onDidChangeSessionConfig.fire(session.sessionId); return; } session.setLoading(true); @@ -1211,10 +1317,33 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._runningSessionConfigs.get(sessionId); } + /** + * Observable: `true` while a `resolveSessionConfig` round-trip is in + * flight. Distinct from `session.loading` (which also covers the + * required-values-missing state) β€” pickers gate on this so they stay + * interactive when the user has to fill in required values. + */ + isSessionConfigResolving(sessionId: string): IObservable { + return this._newSession?.sessionId === sessionId + ? this._newSession.isResolvingConfig + : constObservable(false); + } + async setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise { - // New session (pre-creation): re-resolve the full config schema + // New session: re-resolve the full config schema. Flip the + // resolving flag and `loading` *before* firing the change event + // so the first picker re-render already observes the in-flight + // state. const newSession = this._newSession?.sessionId === sessionId ? this._newSession : undefined; if (newSession) { + // Defense-in-depth: pickers render disabled during a resolve, + // but keyboard dropdown and mobile sheet paths bypass that. + // Drop the second pick so it can't race the schema replacement. + if (newSession.isResolvingConfig.get()) { + return; + } + newSession.beginResolveConfigSync(); + newSession.setLoading(true); newSession.setConfigValue(property, value); this._onDidChangeSessionConfig.fire(sessionId); await this._refreshNewSessionConfig(newSession); @@ -1407,7 +1536,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (this._newSession?.sessionId === sessionId) { this._newSession.setSelectedAgent(agent); // The selection is forwarded to the host at first-message time - // via `sendOptions.agentHostSessionAgent` (see `sendAndCreateChat`), + // via `sendOptions.agentHostSessionAgent` (see `sendRequest`), // mirroring how `userSelectedModelId` flows. return; } @@ -1490,15 +1619,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Agent host sessions don't support deleting individual chats } - addChat(_sessionId: string): IChat { - throw new Error('Multiple chats per session is not supported for agent host sessions'); - } - - async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { - throw new Error('Multiple chats per session is not supported for agent host sessions'); - } - - async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise { + async createNewChat(chatId: string): Promise { const connection = this.connection; if (!connection) { throw new Error(this._notConnectedSendErrorMessage()); @@ -1508,13 +1629,29 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!newSession || newSession.sessionId !== chatId) { throw new Error(`Session '${chatId}' not found or not a new session`); } - const sessionResource = newSession.session.resource; + + // Create the chat session model so the management service can open the widget + await this._chatSessionsService.getOrCreateChatSession(newSession.session.resource, CancellationToken.None); + return newSession.session.mainChat.get(); + } + + async sendRequest(chatId: string, chatResource: URI, options: ISendRequestOptions): Promise { + const newSession = this._newSession; + if (!newSession || newSession.sessionId !== chatId) { + throw new Error(`Session '${chatId}' not found or not a new session`); + } + + const connection = this.connection; + if (!connection) { + throw new Error(this._notConnectedSendErrorMessage()); + } + const selectedModelId = newSession.getSelectedModelId(); const selectedAgent = newSession.getSelectedAgent(); const { query, attachedContext } = options; - const sessionType = sessionResource.scheme; + const sessionType = chatResource.scheme; const contribution = this._chatSessionsService.getChatSessionContribution(sessionType); const sendOptions: IChatSendRequestOptions = { @@ -1545,16 +1682,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement agentHostSessionConfig: this.getCreateSessionConfig(chatId), }; - // Open chat widget β€” getOrCreateChatSession will wait for the session - // handler to become available via canResolveChatSession internally. - await this._chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); - const chatWidget = await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); - if (!chatWidget) { - throw new Error(`[${this.id}] Failed to open chat widget`); - } - - // Load session model and apply selected model - const modelRef = await this._chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + // Chat session model was already created by createNewChat and + // the widget was opened by the management service. Load session + // model and apply selected model. + const modelRef = await this._chatService.acquireOrLoadSession(chatResource, ChatAgentLocation.Chat, CancellationToken.None); if (modelRef) { if (selectedModelId) { const languageModel = this._languageModelsService.lookupLanguageModel(selectedModelId); @@ -1579,7 +1710,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._ensureSessionCache(); const existingKeys = new Set(this._sessionCache.keys()); - const result = await this._chatService.sendRequest(sessionResource, query, sendOptions); + const result = await this._chatService.sendRequest(chatResource, query, sendOptions); if (result.kind === 'rejected') { throw new Error(`[${this.id}] sendRequest rejected: ${result.reason}`); } @@ -1629,7 +1760,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return skeleton; } - /** Localized error message when sendAndCreateChat is invoked without a connection. Subclasses can override. */ + /** Localized error message when sendRequest is invoked without a connection. Subclasses can override. */ protected _notConnectedSendErrorMessage(): string { return localize('notConnectedSend', "Cannot send request: not connected to agent host."); } @@ -1757,6 +1888,34 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._applySessionMetaFromState(sessionId, state); } + /** + * NewSession variant of {@link _applySessionStateUpdate}: writes the + * customizations subset (the only one the agent picker reads) and + * fires `_onDidChangeCustomAgents` when it changes. Skips + * {@link _seedRunningConfigFromState} (NewSession owns its own config + * via `NewSession._config`) and {@link _applySessionMetaFromState} + * (which only applies to cached running sessions). + */ + private _handleNewSessionStateUpdate(sessionId: string, state: SessionState): void { + const previous = this._lastSessionStates.get(sessionId); + this._lastSessionStates.set(sessionId, state); + if (previous?.customizations !== state.customizations || previous?.activeClient?.customizations !== state.activeClient?.customizations) { + this._onDidChangeCustomAgents.fire(); + } + } + + /** + * Cleanup sentinel from {@link NewSession.dispose}: drops the cached + * `_lastSessionStates` entry the new session contributed. Fires + * `_onDidChangeCustomAgents` so any open picker re-reads and falls + * back to the empty list rather than rendering stale agents. + */ + private _handleNewSessionStateGone(sessionId: string): void { + if (this._lastSessionStates.delete(sessionId)) { + this._onDidChangeCustomAgents.fire(); + } + } + private _applySessionMetaFromState(sessionId: string, state: SessionState): void { const rawId = this._rawIdFromChatId(sessionId); if (!rawId) { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts index 7a893e99491dfb..f6da6a20c34faa 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts @@ -35,6 +35,11 @@ export class MobileAgentHostModePicker extends AgentHostModePicker { if (!this._triggerElement) { return; } + // Guard applies to both the phone sheet and the desktop popover β€” + // either path can dispatch through `setSessionConfigValue`. + if (this._isCurrentlyResolvingConfig()) { + return; + } if (this._phonePresenter.enabled.get()) { // The presenter's agent-host branch reads mode + model // directly from the active session's provider, so we don't diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 8e84d4f5dbb982..22da54c54fa63f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -278,6 +278,12 @@ class MobileChatInputConfigPicker extends Disposable { "Pick Mode and Model, {0}", ariaParts.join(', '), ); + + // Sheet's mode row writes through `setSessionConfigValue`, so + // disable the chip while a resolve is in flight. + const isResolving = ctx.provider.isSessionConfigResolving(ctx.session.sessionId).get(); + this._slotElement.classList.toggle('disabled', isResolving); + this._triggerElement.setAttribute('aria-disabled', isResolving ? 'true' : 'false'); } /** @@ -307,13 +313,20 @@ class MobileChatInputConfigPicker extends Disposable { if (!this._triggerElement) { return; } + // Sheet's mode row writes through `setSessionConfigValue`; the + // chip retains its tap target while visually disabled, so + // guard explicitly. + const ctx = this._getContext(); + if (ctx && ctx.provider.isSessionConfigResolving(ctx.session.sessionId).get()) { + return; + } // Delegate sheet construction to the shared phone presenter so // the new-session chip and the opened-chat chip render the exact // same Mode + Model rows. The presenter's agent-host branch // reads the active session's config + filtered models and // handles the writes (provider mode/model + shared storage key). const trigger = this._triggerElement; - const beforeCtx = this._getContext(); + const beforeCtx = ctx; const beforeMode = beforeCtx?.currentMode; const beforeModeItem = beforeCtx?.modeItems.find(i => i.value === beforeMode); const beforeModelId = beforeCtx?.currentModelId; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index f4f85b93ce652b..d16253e06748ac 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -39,7 +39,7 @@ function makeWellKnownConfig(value: string | undefined): ResolveSessionConfigRes } as ResolveSessionConfigResult; } -class FakeProvider implements Pick { +class FakeProvider implements Pick { readonly id: string = PROVIDER_ID; private readonly _onDidChange = new Emitter(); readonly onDidChangeSessionConfig: Event = this._onDidChange.event; @@ -50,6 +50,9 @@ class FakeProvider implements Pick { this.setCalls.push([sessionId, property, value]); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts index 26f2ec04645651..475d6a62ad6750 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostClaudePermissionModePicker.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { Event } from '../../../../../../base/common/event.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { constObservable, observableValue } 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'; @@ -44,7 +44,7 @@ function makeClaudePermissionModeConfig(): ResolveSessionConfigResult { } as ResolveSessionConfigResult; } -class FakeProvider implements Pick { +class FakeProvider implements Pick { readonly id = PROVIDER_ID; readonly onDidChangeSessionConfig: Event = Event.None; readonly setCalls: Array<[string, string, unknown]> = []; @@ -53,6 +53,10 @@ class FakeProvider implements Pick { this.setCalls.push([sessionId, property, value]); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index f7a351c06567f7..961dcf0b6599f0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -62,7 +62,7 @@ function makeActiveSession(providerId: string): IActiveSession { description: chat.description, chats: observableValue('chats', [chat]), activeChat: observableValue('ac', chat), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, } satisfies IActiveSession; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index e6366378606cfd..c42b90048f1fb1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -852,6 +852,104 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(fired, afterFirstCustomization, 'expected event NOT to fire when customizations are unchanged'); }); + test('NewSession forwards SessionState into _lastSessionStates so the picker sees customizations before first message', async () => { + const provider = createProvider(disposables, agentHost); + const sessionTypeId = provider.sessionTypes[0].id; + const session = provider.createNewSession(URI.parse('file:///home/user/proj'), sessionTypeId); + await timeout(0); // let eagerCreate complete and the subscription seed + + const rawId = session.resource.path.substring(1); + + let fired = 0; + disposables.add(provider.onDidChangeCustomAgents(() => { fired++; })); + + // Push a SessionState carrying customizations as if the host had + // resolved them and dispatched a SessionCustomizationsChanged. + const customizations = [{ + customization: { uri: 'plugin://new-session', displayName: 'p' }, + enabled: true, + status: CustomizationStatus.Loaded, + agents: [ + { uri: 'agent://reviewer', name: 'reviewer' }, + { uri: 'agent://triage', name: 'triage' }, + ], + }]; + const state: SessionState = { + summary: { + resource: AgentSession.uri(sessionTypeId, rawId).toString(), + provider: sessionTypeId, + title: '', + status: ProtocolSessionStatus.Idle, + createdAt: 0, + modifiedAt: 0, + }, + lifecycle: SessionLifecycle.Ready, + turns: [], + customizations, + }; + agentHost.setSessionState(rawId, sessionTypeId, state); + + assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ + { uri: 'agent://reviewer', name: 'reviewer' }, + { uri: 'agent://triage', name: 'triage' }, + ]); + assert.ok(fired > 0, 'expected onDidChangeCustomAgents to fire when SessionState arrives'); + + // A second update with a different customizations identity should + // re-fire and update the picker. + const after = fired; + agentHost.setSessionState(rawId, sessionTypeId, { + ...state, + customizations: [{ + ...customizations[0], + agents: [{ uri: 'agent://only', name: 'only' }], + }], + }); + assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ + { uri: 'agent://only', name: 'only' }, + ]); + assert.ok(fired > after, 'expected onDidChangeCustomAgents to fire again on a second update'); + }); + + test('NewSession dispose clears _lastSessionStates entry and fires onDidChangeCustomAgents', async () => { + const provider = createProvider(disposables, agentHost); + const sessionTypeId = provider.sessionTypes[0].id; + const first = provider.createNewSession(URI.parse('file:///home/user/a'), sessionTypeId); + await timeout(0); + + const rawId = first.resource.path.substring(1); + agentHost.setSessionState(rawId, sessionTypeId, { + summary: { + resource: AgentSession.uri(sessionTypeId, rawId).toString(), + provider: sessionTypeId, + title: '', + status: ProtocolSessionStatus.Idle, + createdAt: 0, + modifiedAt: 0, + }, + lifecycle: SessionLifecycle.Ready, + turns: [], + customizations: [{ + customization: { uri: 'plugin://x', displayName: 'p' }, + enabled: true, + status: CustomizationStatus.Loaded, + agents: [{ uri: 'agent://x', name: 'x' }], + }], + }); + assert.strictEqual(provider.getCustomAgents(first.sessionId).length, 1); + + let fired = 0; + disposables.add(provider.onDidChangeCustomAgents(() => { fired++; })); + + // Trigger disposal of the first NewSession by creating a second one + // (the MutableDisposable holding `_newSession` disposes the previous). + provider.createNewSession(URI.parse('file:///home/user/b'), sessionTypeId); + await timeout(0); + + assert.deepStrictEqual(provider.getCustomAgents(first.sessionId), []); + assert.ok(fired > 0, 'expected onDidChangeCustomAgents to fire on NewSession dispose'); + }); + // ---- Session lifecycle ------- test('createNewSession returns session with correct fields', () => { @@ -1320,17 +1418,17 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(session.loading.get(), false); }); - // ---- sendAndCreateChat ------- + // ---- sendRequest ------- - test('sendAndCreateChat throws for unknown session', async () => { + test('sendRequest throws for unknown session', async () => { const provider = createProvider(disposables, agentHost); await assert.rejects( - () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + () => provider.sendRequest('nonexistent', URI.parse('untitled:chat'), { query: 'test' }), /not found or not a new session/, ); }); - test('sendAndCreateChat forwards resolved session config to chat service', async () => { + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, agentHost, undefined, { openSession: true, @@ -1345,7 +1443,8 @@ suite('LocalAgentHostSessionsProvider', () => { const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree'); - await provider.sendAndCreateChat(session.sessionId, { query: 'hello' }); + const chat = await provider.createNewChat(session.sessionId); + await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index 980f1745888fed..1b8ea6391bf195 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -78,18 +78,16 @@ The provider maintains a `Map` cache keyed by resou ## Send Flow -1. Validate the session is a current new session (`CopilotCLISession`, `RemoteNewSession`, or `ClaudeCodeNewSession`) -2. For the first chat, call `_sendFirstChat()`: - a. Resolve mode, permission level, and send options from session configuration - b. Open the chat widget via `IChatWidgetService.openSession()` - c. Load the session model and apply selected model, mode, and options - d. Send the request via `IChatService.sendRequest()` - e. Add temp session to cache and fire `onDidChangeSessions` - f. Wait for session commit (untitled β†’ real URI) - g. Replace via `onDidReplaceSession` event with the committed session -3. For subsequent chats (if `capabilities.supportsMultipleChats` enabled on the session), call `_sendSubsequentChat()` -4. Wrap the new agent session as `AgentSessionAdapter` and return it -5. Clear the current new session reference +The provider exposes two entry points on `ISessionsProvider`: + +- **`createNewChat(sessionId, prompt?)`** β€” Creates the backend chat model and returns the resulting `IChat`. The management service uses the returned `chat.resource` to open the widget *before* sending. For new sessions the provider also swaps the session's `mainChat` observable with the committed chat so the cached `ISession` reflects the real backend resource. +- **`sendRequest(sessionId, chatResource, options)`** β€” Sends a request for a chat that was already created via `createNewChat`. Internally it dispatches between: + - `_sendFirstChat()` when the session is the current new session β€” resolves mode/permission/send options, calls `IChatService.sendRequest`, adds the temp session to the cache, fires `onDidChangeSessions`, waits for commit (untitled β†’ real URI for CLI sessions), and then fires `onDidReplaceSession` with the committed session. + - `_sendExistingChat()` when the session already has committed chats β€” sends to the existing chat resource. + +For multi-chat sessions (`capabilities.supportsMultipleChats === true`), `createNewChat()` on an existing session calls `_createNewSubsequentChat()`, which creates a fresh `CopilotCLISession` linked to the parent via the `parentSessionId` option, registers it in `_currentNewSession`, and returns its `IChat`. A subsequent `sendRequest(sessionId, chat.resource, options)` then routes through `_sendFirstChat`. + +The provider never opens the chat widget itself; widget opening is owned by the management service. ## New-Session Picker Contribution Model diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index e511f5d6a8f587..7ae7a46c8883db 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -10,7 +10,7 @@ import { CancellationError } from '../../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -29,7 +29,6 @@ import { basename, dirname, isEqual } from '../../../../../base/common/resources import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { IsolationMode } from './isolationPicker.js'; -import { ChatViewPaneTarget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { isBuiltinChatMode, IChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; @@ -50,6 +49,7 @@ import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/ import { structuralEquals } from '../../../../../base/common/equals.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { createChangesets } from './copilotChatSessionsChangesets.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; /** Local session type β€” in-process VS Code chat, no background agent or worktree. */ export const LocalSessionType: ISessionType = { @@ -77,7 +77,7 @@ const STORAGE_KEY_ISOLATION_MODE = 'sessions.isolationPicker.selectedMode'; export interface ICopilotChatSession { /** Globally unique session ID (`providerId:localId`). */ - readonly id: string; + readonly sessionId: string; /** Resource URI identifying this session. */ readonly resource: URI; /** ID of the provider that owns this session. */ @@ -135,6 +135,14 @@ export interface ICopilotChatSession { readonly gitRepository?: IGitRepository; readonly branches: IObservable; + + /** + * Settable observable holding the {@link IChat} representation of this chat. + * For committed chats, the value is stable. For new sessions, the provider + * replaces the initial value via {@link createNewChat} once the real backend + * resource is known (e.g., Claude assigns a new resource on commit). + */ + readonly mainChat: ISettableObservable; } const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; @@ -163,6 +171,30 @@ function isNewSession(session: ICopilotChatSession): session is NewSession { return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession || session instanceof LocalNewSession; } +/** + * Builds an {@link IChat} snapshot from an {@link ICopilotChatSession}. Used to + * seed the chat's own `mainChat` observable. An optional `resource` override is + * supported for cases where the chat resource differs from the session resource + * (e.g. Claude commits a new resource at send time). + */ +function buildChatFromSession(chat: Omit, resource?: URI): IChat { + return { + resource: resource ?? chat.resource, + createdAt: chat.createdAt, + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + checkpoints: chat.checkpoints, + modelId: chat.modelId, + mode: chat.mode, + isArchived: chat.isArchived, + isRead: chat.isRead, + description: chat.description, + lastTurnEnd: chat.lastTurnEnd, + }; +} + /** * Local new session for Background agent sessions. * Implements {@link ICopilotChatSession} (session facade) and provides @@ -174,7 +206,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { // -- ISessionData fields -- - readonly id: string; + readonly sessionId: string; readonly providerId: string; readonly sessionType: typeof SessionType.CopilotCLI; readonly icon: ThemeIcon; @@ -233,6 +265,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { private readonly _branches = observableValue(this, []); readonly branches: IObservable = this._branches; + readonly mainChat: ISettableObservable; + private _defaultBranch: string | undefined; // -- New session configuration fields -- @@ -273,7 +307,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { @IStorageService private readonly storageService: IStorageService, ) { super(); - this.id = toSessionId(providerId, resource); + this.sessionId = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = AgentSessionProviders.Background; this.icon = CopilotCLISessionType.icon; @@ -306,6 +340,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { this._checkpoints = observableValueOpts({ owner: this, equalsFn: structuralEquals }, undefined); this.checkpoints = this._checkpoints; + + this.mainChat = observableValue(this, buildChatFromSession(this)); } private async _resolveGitRepository(): Promise { @@ -499,7 +535,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession // -- ISessionData fields -- - readonly id: string; + readonly sessionId: string; readonly providerId: string; readonly sessionType: string; readonly icon: ThemeIcon; @@ -542,6 +578,8 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession readonly branches: IObservable = constObservable([]); readonly gitRepository?: IGitRepository | undefined; + readonly mainChat: ISettableObservable; + readonly _hasGitRepo = observableValue(this, false); readonly hasGitRepo: IObservable = this._hasGitRepo; @@ -578,7 +616,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); - this.id = toSessionId(providerId, resource); + this.sessionId = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = target; this.icon = CopilotCloudSessionType.icon; @@ -603,6 +641,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession this.setOption('repositories', { id, name: id }); } + this.mainChat = observableValue(this, buildChatFromSession(this)); } setPermissionLevel(level: ChatPermissionLevel): void { throw new Error('Method not implemented.'); @@ -745,7 +784,7 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { // -- ISessionData fields -- readonly resource: URI; - readonly id: string; + readonly sessionId: string; readonly providerId: string; readonly sessionType: typeof SessionType.Local; readonly icon: ThemeIcon; @@ -790,6 +829,8 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { readonly branches: IObservable = constObservable([]); readonly gitRepository?: IGitRepository | undefined; + readonly mainChat: ISettableObservable; + // -- New session configuration fields -- private _modelId: string | undefined; @@ -827,7 +868,7 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { } this.resource = modelRef.object.sessionResource; - this.id = toSessionId(providerId, this.resource); + this.sessionId = toSessionId(providerId, this.resource); this.providerId = providerId; this.sessionType = AgentSessionProviders.Local; this.icon = LocalSessionType.icon; @@ -835,6 +876,8 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { this._workspaceData.set(sessionWorkspace, undefined); + this.mainChat = observableValue(this, buildChatFromSession(this)); + // Resolve git state asynchronously so the Changes view has // branch names, uncommitted counts, etc. without needing // an agent session in agentSessionsService. @@ -1020,7 +1063,7 @@ class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { // -- ISessionData fields -- - readonly id: string; + readonly sessionId: string; readonly providerId: string; readonly sessionType: typeof SessionType.ClaudeCode; readonly icon: ThemeIcon; @@ -1063,6 +1106,8 @@ class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { readonly branches: IObservable = constObservable([]); readonly gitRepository?: IGitRepository | undefined; + readonly mainChat: ISettableObservable; + // -- New session configuration fields -- private _modelId: string | undefined; @@ -1083,13 +1128,15 @@ class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { providerId: string, ) { super(); - this.id = toSessionId(providerId, resource); + this.sessionId = toSessionId(providerId, resource); this.providerId = providerId; this.sessionType = AgentSessionProviders.Claude; this.icon = ClaudeCodeSessionType.icon; this.createdAt = new Date(); this._workspaceData.set(sessionWorkspace, undefined); + + this.mainChat = observableValue(this, buildChatFromSession(this)); } setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { @@ -1162,7 +1209,7 @@ function toSessionStatus(status: ChatSessionStatus): SessionStatus { */ class AgentSessionAdapter implements ICopilotChatSession { - readonly id: string; + readonly sessionId: string; readonly resource: URI; readonly providerId: string; readonly sessionType: string; @@ -1213,12 +1260,14 @@ class AgentSessionAdapter implements ICopilotChatSession { readonly gitRepository?: IGitRepository | undefined; readonly branches: IObservable = constObservable([]); + readonly mainChat: ISettableObservable; + constructor( session: IAgentSession, providerId: string, private readonly _gitHubService: IGitHubService, ) { - this.id = toSessionId(providerId, session.resource); + this.sessionId = toSessionId(providerId, session.resource); this.resource = session.resource; this.providerId = providerId; this.sessionType = session.providerType; @@ -1271,6 +1320,8 @@ class AgentSessionAdapter implements ICopilotChatSession { this.description = this._description; this._lastTurnEnd = observableValue(this, session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined); this.lastTurnEnd = this._lastTurnEnd; + + this.mainChat = observableValue(this, buildChatFromSession(this)); } setPermissionLevel(level: ChatPermissionLevel): void { @@ -1602,6 +1653,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly id = COPILOT_PROVIDER_ID; readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; + get sessionTypes(): readonly ISessionType[] { const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; if (this._localSessionEnabled) { @@ -1654,7 +1706,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatService private readonly chatService: IChatService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IDialogService private readonly dialogService: IDialogService, @ICommandService private readonly commandService: ICommandService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -1664,6 +1715,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, @ILabelService private readonly labelService: ILabelService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, ) { super(); @@ -1771,7 +1823,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } getSession(sessionId: string): ICopilotChatSession | undefined { - if (this._currentNewSession.value?.id === sessionId) { + if (this._currentNewSession.value?.sessionId === sessionId) { return this._currentNewSession.value; } return this._findChatSession(sessionId); @@ -1832,7 +1884,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } setModel(sessionId: string, modelId: string): void { - if (this._currentNewSession.value?.id === sessionId) { + if (this._currentNewSession.value?.sessionId === sessionId) { this._currentNewSession.value.setModelId(modelId); return; } @@ -1975,7 +2027,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const key = chat.resource.toString(); this._sessionCache.delete(key); this._invalidateGroupingCaches(); - if (this._currentNewSession.value?.id === chatId) { + if (this._currentNewSession.value?.sessionId === chatId) { this._currentNewSession.clear(); } } @@ -2004,51 +2056,110 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } - // -- Send -- - - async sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise { - // Determine if this is the first chat or a subsequent chat - const session = this._currentNewSession.value; - if (session && session.id === sessionId) { - // First chat β€” use the existing new-session flow - return this._sendFirstChat(session, options); + async createNewChat(sessionId: string, prompt?: string): Promise { + if (this._currentNewSession.value?.sessionId === sessionId) { + const session = this._currentNewSession.value; + let newChat: IChat; + // new session + if (session instanceof ClaudeCodeNewSession) { + const newItem = await this.chatSessionsService.createNewChatSessionItem( + session.target, + { prompt: prompt ?? '', initialSessionOptions: session.selectedOptions.size > 0 ? session.selectedOptions : undefined, untitledResource: session.resource }, + CancellationToken.None, + ); + if (!newItem) { + throw new Error('[CopilotChatSessionsProvider] Failed to create Claude session item'); + } + (await this._createChatSession(newItem.resource, session)).dispose(); + newChat = this._toChat(session, newItem.resource); + } else if (session instanceof LocalNewSession) { + newChat = this._toChat(session); + } else { + (await this._createChatSession(session.resource, session)).dispose(); + newChat = this._toChat(session); + } + session.mainChat.set(newChat, undefined); + return newChat; } if (!this._isMultiChatEnabled()) { - throw new Error(`Session '${sessionId}' not found or not a new session`); + throw new Error(`[CopilotChatSessionsProvider] Session '${sessionId}' does not support multiple chats`); } - // Subsequent chat β€” create a new chat within the existing session - return this._sendSubsequentChat(sessionId, options); + return this._createNewSubsequentChat(sessionId); } - addChat(sessionId: string): IChat { - const session = this._findSession(sessionId); - if (!session?.capabilities.supportsMultipleChats) { - throw new Error('Multiple chats per session is not supported'); + private async _createNewSubsequentChat(sessionId: string): Promise { + // Find the primary chat for this session + const chatIds = this._getChatIdsInGroup(sessionId); + const firstChatId = chatIds[0] ?? sessionId; + const chat = this._sessionCache.get(this._localIdFromchatId(firstChatId)); + if (!chat) { + throw new Error(`Session '${sessionId}' not found`); } - const newChatSession = this._createNewSessionFrom(sessionId); + if (chat.sessionType !== CopilotCLISessionType.id) { + throw new Error('Multiple chats per session is only supported for Copilot CLI sessions'); + } - newChatSession.setTitle(localize('new chat', "New Chat")); - const key = newChatSession.resource.toString(); - this._sessionCache.set(key, newChatSession); - this._invalidateGroupingCaches(); + const workspace = chat.workspace.get(); + if (!workspace) { + throw new Error('Chat session has no associated workspace'); + } - // Invalidate the session group cache so it rebuilds with the new chat + const folder = workspace.folders[0]; + if (!folder) { + throw new Error('Workspace has no folder'); + } + + const newWorkspace = this.resolveWorkspace(folder.workingDirectory); + if (!newWorkspace) { + throw new Error(`Cannot resolve workspace for working directory URI: ${folder.workingDirectory.toString()}`); + } + + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); + const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); + session.setModelId(chat.modelId.get()); + session.setIsolationMode('workspace'); + session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1)); + session.setPermissionLevel(this._defaultPermissionLevel()); + session.setTitle(localize('new chat', "New Chat")); + this._currentNewSession.value = session; + + (await this._createChatSession(session.resource, session)).dispose(); + + this._sessionCache.set(session.resource.toString(), session); + this._invalidateGroupingCaches(); this._sessionGroupCache.delete(sessionId); + this._onDidGroupMembershipChange.fire({ sessionId }); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] }); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(session)] }); - return this._toChat(newChatSession); + return this._toChat(session); } async sendRequest(sessionId: string, chatResource: URI, options: ISendRequestOptions): Promise { - if (!this._isMultiChatEnabled()) { + const newSession = this._currentNewSession.value; + if (newSession && newSession.sessionId === sessionId) { + 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'); + } + return this._sendFirstChat(newSession, chatResource, options); + } + + const session = this._findSession(sessionId); + if (!session) { + throw new Error(`Session '${sessionId}' not found`); + } + + if (!session.capabilities.supportsMultipleChats) { throw new Error('Multiple chats per session is not supported'); } - // The chat must already exist (created via addChat) + if (!session.chats.get().some(chat => this.uriIdentityService.extUri.isEqual(chat.resource, chatResource))) { + throw new Error(`Chat '${chatResource.toString()}' does not belong to session '${sessionId}'`); + } + const key = chatResource.toString(); const chatSession = this._sessionCache.get(key); if (!chatSession || !(chatSession instanceof CopilotCLISession)) { @@ -2058,14 +2169,19 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._sendExistingChat(sessionId, chatSession, options); } - /** - * Sends the first chat for a newly created session. - * Adds the temp session to the cache, waits for commit, then replaces it. - */ - private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession | LocalNewSession, options: ISendRequestOptions): Promise { + private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession | LocalNewSession, chatResource: URI, options: ISendRequestOptions): Promise { const { query, attachedContext } = options; + session.setTitle(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(); + + // Add the new session to the sessions model immediately so it appears in the sessions list + const newSession = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); + const contribution = this.chatSessionsService.getChatSessionContribution(session.target); // Resolve mode @@ -2099,272 +2215,83 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions agentHostSessionConfig: session instanceof CopilotCLISession ? session.getAgentHostSessionConfig() : undefined, }; - // Claude sessions use the ChatSessionItemController API which creates - // real session URIs upfront, bypassing the untitledβ†’commitβ†’swap flow. - if (session instanceof ClaudeCodeNewSession) { - return this._sendFirstChatViaController(session, query, sendOptions); - } - - // Local sessions run in-process and do not go through the - // untitledβ†’commitβ†’swap flow (chatServiceImpl explicitly skips - // commit for localChatSessionType). Send the request and keep - // the session on its original URI. - if (session instanceof LocalNewSession) { - return this._sendFirstChatLocal(session, query, sendOptions, permissionLevel); - } - - await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); - const disposable = await this._applySessionModelState(session.resource, session, permissionLevel); - const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); - disposable.dispose(); - if (!chatWidget) { - throw new Error('[DefaultCopilotProvider] Failed to open chat widget'); - } - - // Send request - this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, { + const ref = await this._updateChatSessionState(chatResource, session, sendOptions.modeInfo?.permissionLevel); + this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.sessionId} with options:`, { userSelectedModelId: sendOptions.userSelectedModelId, }); - const result = await this.chatService.sendRequest(session.resource, query, sendOptions); - if (result.kind === 'rejected') { - throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); - } - - // Extract promises to detect cancellation vs normal completion - const responseCompletePromise = result.kind === 'sent' - ? result.data.responseCompletePromise - : undefined; - const responseCreatedPromise = result.kind === 'sent' - ? result.data.responseCreatedPromise - : undefined; - - // Add the new session to the sessions model immediately so it appears in the sessions list - session.setTitle(localize('new session', "New Session")); - session.setStatus(SessionStatus.InProgress); - const key = session.resource.toString(); - this._sessionCache.set(key, session); - this._invalidateGroupingCaches(); - const newSession = this._chatToSession(session); - this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); - try { - - // Wait for the session to be committed (URI swapped from untitled to real) - const committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise); - - // Wait for _refreshSessionCache to populate the committed adapter - const committedChat = await this._waitForSessionInCache(committedResource); - - // Remove the temp from the cache (the adapter now owns the committed key) - this._sessionCache.delete(key); - // Guard: only clear if _currentNewSession still points at this - // session β€” a newer createNewSession may have replaced it while - // we were awaiting the commit. - this._clearCurrentNewSessionIfMatch(session); - - const committedSession = this._chatToSession(committedChat); - - // Notify listeners that the temp session was replaced by the committed one - this._sessionGroupCache.delete(session.id); - this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); - - return committedSession; - } catch (error) { - this._clearCurrentNewSessionIfMatch(session, /* leak */ true); - - if (error instanceof CancellationError) { - session.setStatus(SessionStatus.Completed); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); - return newSession; + const result = await this.chatService.sendRequest(chatResource, query, sendOptions); + if (result.kind === 'rejected') { + // Clean up the temp session that was added to the cache and + // dispatched as `added` above, so the UI doesn't keep showing + // a stuck InProgress session that will never make progress. + this._sessionCache.delete(session.resource.toString()); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(session.sessionId); + this._clearCurrentNewSessionIfMatch(session, /* leak */ true); + this._onDidChangeSessions.fire({ added: [], removed: [newSession], changed: [] }); + session.dispose(); + throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); } - - // Unexpected error β€” clean up the temp session entirely - this._sessionCache.delete(key); - this._invalidateGroupingCaches(); - this._sessionGroupCache.delete(session.id); - this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); - session.dispose(); - throw error; - } - } - - /** - * Sends the first chat for a local (in-process) session. - * - * Local sessions do not create worktrees and the chat service explicitly - * skips the untitledβ†’commit flow for {@link localChatSessionType}. - * Instead of waiting for a commit event that will never arrive, this - * method keeps the session on its original untitled URI. - */ - private async _sendFirstChatLocal( - session: LocalNewSession, - query: string, - sendOptions: IChatSendRequestOptions, - permissionLevel: ChatPermissionLevel, - ): Promise { - // The chat model was already created in createNewSession via - // startNewLocalSession, so we skip getOrCreateChatSession here - // (which would otherwise try to resolve a content provider). - const disposable = await this._applySessionModelState(session.resource, session, permissionLevel); - const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); - disposable.dispose(); - if (!chatWidget) { - throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget for local session'); - } - - // Obtain user-selected tools from the chat widget so the copilot - // extension sees the full tool set. Without this, the direct - // chatService.sendRequest bypasses chatWidget.acceptInput() which - // normally provides these. - const { userSelectedTools } = chatWidget.getModeRequestOptions(); - - this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for local session ${session.id}`); - const result = await this.chatService.sendRequest(session.resource, query, { - ...sendOptions, - userSelectedTools, - instructionContext: { - modeKind: sendOptions.modeInfo?.kind ?? ChatModeKind.Agent, - enabledTools: userSelectedTools?.get(), - }, - }); - if (result.kind === 'rejected') { - throw new Error(`[CopilotChatSessionsProvider] Local sendRequest rejected: ${result.reason}`); - } - - // Local sessions stay on their original URI β€” no commit swap needed. - session.setTitle(query.split('\n')[0].substring(0, 100) || localize('new session', "New Session")); - session.setStatus(SessionStatus.InProgress); - const key = session.resource.toString(); - this._sessionCache.set(key, session); - this._invalidateGroupingCaches(); - this._currentNewSession.clearAndLeak(); - const newSession = this._chatToSession(session); - this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); - - // Listen for the response to complete so we can flip the status - // from InProgress β†’ Completed and unblock the input box. - if (result.kind === 'sent') { - result.data.responseCompletePromise.then(() => { - session.setStatus(SessionStatus.Completed); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); + // Extract promises to detect cancellation vs normal completion + const cts = new CancellationTokenSource(); + const responseCompletePromise = result.kind === 'sent' ? result.data.responseCompletePromise : undefined; + const responseCreatedPromise = result.kind === 'sent' ? result.data.responseCreatedPromise : undefined; + responseCreatedPromise?.then(r => { + if (r?.isCanceled) { + cts.cancel(); + } }); - } - return newSession; - } + try { + let committedResource = chatResource; + if (session instanceof CopilotCLISession) { + committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise); + } - /** - * Sends the first chat for a Claude session using the controller API. - * - * Unlike the legacy untitledβ†’commitβ†’swap flow, this creates the real - * session URI upfront via {@link IChatSessionsService.createNewChatSessionItem}, - * then sends the request directly to that URI. This avoids the commit - * event race and ensures the session appears under the correct workspace - * immediately. - */ - private async _sendFirstChatViaController( - session: ClaudeCodeNewSession, - query: string, - sendOptions: IChatSendRequestOptions, - ): Promise { - // Create the real session item via the controller's newChatSessionItemHandler. - // This returns a session with a real (non-untitled) URI. - const newItem = await this.chatSessionsService.createNewChatSessionItem( - session.target, - { prompt: query, initialSessionOptions: session.selectedOptions.size > 0 ? session.selectedOptions : undefined }, - CancellationToken.None, - ); - if (!newItem) { - throw new Error('[CopilotChatSessionsProvider] Failed to create Claude session item'); - } + // Wait for _refreshSessionCache to populate the committed adapter + const committedChat = await this._waitForSessionInCache(committedResource, cts.token); + this._sessionCache.delete(session.resource.toString()); + this._clearCurrentNewSessionIfMatch(session); - const realResource = newItem.resource; + const committedSession = this._chatToSession(committedChat); + this._sessionGroupCache.delete(session.sessionId); + this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); - // Open chat session and widget with the real URI - await this.chatSessionsService.getOrCreateChatSession(realResource, CancellationToken.None); - const disposable = await this._applySessionModelState(realResource, session, sendOptions.modeInfo?.permissionLevel); - const chatWidget = await this.chatWidgetService.openSession(realResource, ChatViewPaneTarget); - disposable.dispose(); - if (!chatWidget) { - throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget'); - } + return committedSession; + } catch (error) { + this._clearCurrentNewSessionIfMatch(session, /* leak */ true); - // Send request to the real URI β€” sendRequest skips the - // createNewChatSessionItem block since the URI is not untitled. - this.logService.debug(`[CopilotChatSessionsProvider] Sending first Claude chat to ${realResource.toString()} with options:`, { - userSelectedModelId: sendOptions.userSelectedModelId, - }); - const result = await this.chatService.sendRequest(realResource, query, sendOptions); - if (result.kind === 'rejected') { - throw new Error(`[CopilotChatSessionsProvider] sendRequest rejected: ${result.reason}`); - } + if (error instanceof CancellationError) { + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); + return newSession; + } - // Add the temp session to the cache immediately so it appears in the sessions list - session.setTitle(newItem.label); - session.setStatus(SessionStatus.InProgress); - const tempKey = session.resource.toString(); - this._sessionCache.set(tempKey, session); - const tempSession = this._chatToSession(session); - this._onDidChangeSessions.fire({ added: [tempSession], removed: [], changed: [] }); - - // Extract response promises for cancellation detection - const responseCreatedPromise = result.kind === 'sent' - ? result.data.responseCreatedPromise - : undefined; - const cts = new CancellationTokenSource(); - // TODO: Understand why we are not awaiting this an only handling the cancellation - responseCreatedPromise?.then(r => { - if (r?.isCanceled) { - cts.cancel(); + // Unexpected error β€” clean up the temp session entirely + this._sessionCache.delete(session.resource.toString()); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(session.sessionId); + this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); + session.dispose(); + throw error; + } finally { + cts.dispose(); } - }); - - try { - // Wait for the agent sessions model to pick up the real session, - // racing against cancellation so we don't timeout when the user - // stops the request before the agent creates a worktree. - const committedChat = await this._waitForSessionInCache(realResource, cts.token); - - // Clean up temp session and replace with the real adapter - this._sessionCache.delete(tempKey); - this._clearCurrentNewSessionIfMatch(session); - - const committedSession = this._chatToSession(committedChat); - this._sessionGroupCache.delete(session.id); - this._onDidReplaceSession.fire({ from: tempSession, to: committedSession }); - - return committedSession; } catch (error) { - this._clearCurrentNewSessionIfMatch(session, /* leak */ true); - - if (error instanceof CancellationError) { - // Keep the temp session visible so the user can review - // whatever content the agent produced before the cancellation. - session.setStatus(SessionStatus.Completed); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [tempSession] }); - return tempSession; - } - - // Unexpected error β€” clean up the temp session entirely - this._sessionCache.delete(tempKey); - this._sessionGroupCache.delete(session.id); - this._onDidChangeSessions.fire({ added: [], removed: [tempSession], changed: [] }); - session.dispose(); + this.logService.error(`[CopilotChatSessionsProvider] Failed to send first chat for session ${session.sessionId}:`, error); throw error; } finally { - cts.dispose(); + ref?.dispose(); } } - /** - * Loads the session model for the given resource and applies the selected - * language model, chat mode, and session options from the new session object. - */ - private async _applySessionModelState( - resource: URI, - session: { selectedModelId?: string; chatMode?: IChatMode; selectedOptions: Map }, - permissionLevel?: ChatPermissionLevel, - ): Promise { + private async _createChatSession(resource: URI, session: NewSession, permissionLevel?: ChatPermissionLevel): Promise { + await this.chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); + return this._updateChatSessionState(resource, session, permissionLevel); + } + + private async _updateChatSessionState(resource: URI, session: NewSession, permissionLevel?: ChatPermissionLevel): Promise { const modelRef = await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); if (!modelRef) { return Disposable.None; @@ -2388,31 +2315,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return modelRef; } - /** - * Sends a subsequent chat for an existing session that already has chats. - * Creates a new {@link CopilotCLISession} from the existing workspace and - * fires a `changed` event on the grouped session rather than an `added` event. - */ - private async _sendSubsequentChat(sessionId: string, options: ISendRequestOptions): Promise { - // Reuse a chat that was pre-created by addChat(), otherwise create one - let newChatSession: CopilotCLISession; - const current = this._currentNewSession.value; - if (current && this._getGroupIdForChat(current) === sessionId) { - newChatSession = current as CopilotCLISession; - } else { - newChatSession = this._createNewSessionFrom(sessionId); - newChatSession.setTitle(localize('new chat', "New Chat")); - const key = newChatSession.resource.toString(); - this._sessionCache.set(key, newChatSession); - this._invalidateGroupingCaches(); - this._sessionGroupCache.delete(sessionId); - this._onDidGroupMembershipChange.fire({ sessionId }); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] }); - } - - return this._sendExistingChat(sessionId, newChatSession, options); - } - /** * Sends a request for an existing chat session that is already registered * in the cache. @@ -2446,124 +2348,72 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions agentHostSessionConfig: newChatSession.getAgentHostSessionConfig(), }; - // Open chat widget - await this.chatSessionsService.getOrCreateChatSession(newChatSession.resource, CancellationToken.None); - const chatWidget = await this.chatWidgetService.openSession(newChatSession.resource, ChatViewPaneTarget); - if (!chatWidget) { - this._sessionCache.delete(key); - this._invalidateGroupingCaches(); - throw new Error('[DefaultCopilotProvider] Failed to open chat widget for subsequent chat'); - } - - // Load session model with selected options - (await this._applySessionModelState(newChatSession.resource, newChatSession)).dispose(); - - // Send request - const result = await this.chatService.sendRequest(newChatSession.resource, query, sendOptions); - if (result.kind === 'rejected') { - this._sessionCache.delete(key); - this._invalidateGroupingCaches(); - throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); - } - - // Extract promises to detect cancellation vs normal completion - const responseCompletePromise = result.kind === 'sent' - ? result.data.responseCompletePromise - : undefined; - const responseCreatedPromise = result.kind === 'sent' - ? result.data.responseCreatedPromise - : undefined; - + const ref = await this._updateChatSessionState(newChatSession.resource, newChatSession); try { - // Wait for the session to be committed - const committedResource = await this._waitForCommittedSession(newChatSession.resource, responseCompletePromise, responseCreatedPromise); + // Send request + const result = await this.chatService.sendRequest(newChatSession.resource, query, sendOptions); + if (result.kind === 'rejected') { + this._sessionCache.delete(key); + this._invalidateGroupingCaches(); + throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); + } - const committedChat = await this._waitForSessionInCache(committedResource); + // Extract promises to detect cancellation vs normal completion + const responseCompletePromise = result.kind === 'sent' + ? result.data.responseCompletePromise + : undefined; + const responseCreatedPromise = result.kind === 'sent' + ? result.data.responseCreatedPromise + : undefined; - // Clean up temp - this._sessionCache.delete(key); - this._invalidateGroupingCaches(); - this._clearCurrentNewSessionIfMatch(newChatSession); + try { + // Wait for the session to be committed + const committedResource = await this._waitForCommittedSession(newChatSession.resource, responseCompletePromise, responseCreatedPromise); - // Invalidate the session group cache so it rebuilds with the committed chat - this._sessionGroupCache.delete(sessionId); - this._onDidGroupMembershipChange.fire({ sessionId }); - const updatedSession = this._chatToSession(committedChat); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); + const committedChat = await this._waitForSessionInCache(committedResource); - return updatedSession; - } catch (error) { - this._clearCurrentNewSessionIfMatch(newChatSession, /* leak */ true); + // Clean up temp + this._sessionCache.delete(key); + this._invalidateGroupingCaches(); + this._clearCurrentNewSessionIfMatch(newChatSession); - if (error instanceof CancellationError) { - // Cancelled before commit β€” keep the chat in the group so the - // user can review the content the agent produced. - newChatSession.setStatus(SessionStatus.Completed); + // Invalidate the session group cache so it rebuilds with the committed chat this._sessionGroupCache.delete(sessionId); - const updatedSession = this._chatToSession(newChatSession); + this._onDidGroupMembershipChange.fire({ sessionId }); + const updatedSession = this._chatToSession(committedChat); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); + return updatedSession; - } + } catch (error) { + this._clearCurrentNewSessionIfMatch(newChatSession, /* leak */ true); + + if (error instanceof CancellationError) { + // Cancelled before commit β€” keep the chat in the group so the + // user can review the content the agent produced. + newChatSession.setStatus(SessionStatus.Completed); + this._sessionGroupCache.delete(sessionId); + const updatedSession = this._chatToSession(newChatSession); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); + return updatedSession; + } - // Unexpected error β€” clean up on error, fire changed on the parent session group - this._sessionCache.delete(key); - this._invalidateGroupingCaches(); - this._sessionGroupCache.delete(sessionId); - newChatSession.dispose(); - // Find the parent session's primary chat to fire a valid changed event - const parentChatIds = this._getChatIdsInGroup(sessionId); - const parentChatId = parentChatIds[0]; - const parentChat = parentChatId ? this._sessionCache.get(this._localIdFromchatId(parentChatId)) : undefined; - if (parentChat) { - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(parentChat)] }); + // Unexpected error β€” clean up on error, fire changed on the parent session group + this._sessionCache.delete(key); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(sessionId); + newChatSession.dispose(); + // Find the parent session's primary chat to fire a valid changed event + const parentChatIds = this._getChatIdsInGroup(sessionId); + const parentChatId = parentChatIds[0]; + const parentChat = parentChatId ? this._sessionCache.get(this._localIdFromchatId(parentChatId)) : undefined; + if (parentChat) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(parentChat)] }); + } + throw error; } - throw error; - } - } - - /** - * Creates a new {@link CopilotCLISession} from an existing session's workspace. - * Used for subsequent chats that share the same workspace but are independent conversations. - */ - private _createNewSessionFrom(sessionId: string): CopilotCLISession { - // Find the primary chat for this session - const chatIds = this._getChatIdsInGroup(sessionId); - const firstChatId = chatIds[0] ?? sessionId; - const chat = this._sessionCache.get(this._localIdFromchatId(firstChatId)); - if (!chat) { - throw new Error(`Session '${sessionId}' not found`); - } - - if (chat.sessionType === AgentSessionProviders.Cloud) { - throw new Error('Multiple chats per session is not supported for cloud sessions'); - } - - if (chat.sessionType === AgentSessionProviders.Claude) { - throw new Error('Multiple chats per session is not supported for Claude sessions'); - } - - const workspace = chat.workspace.get(); - if (!workspace) { - throw new Error('Chat session has no associated workspace'); - } - - const folder = workspace.folders[0]; - if (!folder) { - throw new Error('Workspace has no folder'); - } - - const newWorkspace = this.resolveWorkspace(folder.workingDirectory); - if (!newWorkspace) { - throw new Error(`Cannot resolve workspace for working directory URI: ${folder.workingDirectory.toString()}`); + } finally { + ref.dispose(); } - const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); - session.setModelId(chat.modelId.get()); - session.setIsolationMode('workspace'); - session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1)); - session.setPermissionLevel(this._defaultPermissionLevel()); - this._currentNewSession.value = session; - return session; } /** @@ -2779,7 +2629,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const chatsByGroupId = new Map(); const resolveGroupId = (chat: ICopilotChatSession): string => { - const cachedGroupId = groupIdByChatId.get(chat.id); + const cachedGroupId = groupIdByChatId.get(chat.sessionId); if (cachedGroupId) { return cachedGroupId; } @@ -2789,37 +2639,37 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions let current: ICopilotChatSession = chat; for (let depth = 0; depth < 100; depth++) { - const currentCachedGroupId = groupIdByChatId.get(current.id); + const currentCachedGroupId = groupIdByChatId.get(current.sessionId); if (currentCachedGroupId) { for (const trailChat of trail) { - groupIdByChatId.set(trailChat.id, currentCachedGroupId); + groupIdByChatId.set(trailChat.sessionId, currentCachedGroupId); } return currentCachedGroupId; } - if (seen.has(current.id)) { + if (seen.has(current.sessionId)) { for (const trailChat of trail) { - groupIdByChatId.set(trailChat.id, current.id); + groupIdByChatId.set(trailChat.sessionId, current.sessionId); } - return current.id; + return current.sessionId; } trail.push(current); - seen.add(current.id); + seen.add(current.sessionId); const parentRawSessionId = this._getDirectParentRawSessionId(current); if (!parentRawSessionId) { for (const trailChat of trail) { - groupIdByChatId.set(trailChat.id, current.id); + groupIdByChatId.set(trailChat.sessionId, current.sessionId); } - return current.id; + return current.sessionId; } const parentChat = chatByRawSessionId.get(parentRawSessionId); if (!parentChat) { const syntheticGroupId = this._getSyntheticGroupId(parentRawSessionId); for (const trailChat of trail) { - groupIdByChatId.set(trailChat.id, syntheticGroupId); + groupIdByChatId.set(trailChat.sessionId, syntheticGroupId); } return syntheticGroupId; } @@ -2827,8 +2677,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions current = parentChat; } - groupIdByChatId.set(chat.id, chat.id); - return chat.id; + groupIdByChatId.set(chat.sessionId, chat.sessionId); + return chat.sessionId; }; for (const chat of chats) { @@ -2841,7 +2691,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const chatIdsByGroupId = new Map(); for (const [groupId, groupChats] of chatsByGroupId) { groupChats.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - chatIdsByGroupId.set(groupId, groupChats.map(chat => chat.id)); + chatIdsByGroupId.set(groupId, groupChats.map(chat => chat.sessionId)); } this._chatByRawSessionIdCache = chatByRawSessionId; @@ -2863,12 +2713,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const key = chatSession.resource.toString(); this._sessionCache.delete(key); this._invalidateGroupingCaches(); - this._sessionGroupCache.delete(chatSession.id); - if (this._currentNewSession.value?.id === chatSession.id) { + this._sessionGroupCache.delete(chatSession.sessionId); + if (this._currentNewSession.value?.sessionId === chatSession.sessionId) { this._currentNewSession.clearAndLeak(); } const removedSession = this._chatToSession(chatSession); - this._sessionGroupCache.delete(chatSession.id); + this._sessionGroupCache.delete(chatSession.sessionId); this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); if (isNewSession(chatSession)) { chatSession.dispose(); @@ -3051,7 +2901,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions */ private _getGroupIdForChat(chat: ICopilotChatSession): string { this._ensureGroupingCaches(); - return this._groupIdByChatIdCache?.get(chat.id) ?? chat.id; + return this._groupIdByChatIdCache?.get(chat.sessionId) ?? chat.sessionId; } /** @@ -3118,13 +2968,18 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) ?? chat : chat; - const chatsObs = observableFromEvent( + // The primary chat owns the settable `mainChat` observable. When `createNewChat` + // commits a new session, it updates `primaryChat.mainChat` so the wrapping ISession + // reflects the real backend resource without rebuilding the cached wrapper. + const mainChat = primaryChat.mainChat; + + const groupChatsObs = observableFromEvent( this, Event.filter(this._onDidGroupMembershipChange.event, e => e.sessionId === sessionId), () => { const chatIds = this._getChatIdsInGroup(sessionId); if (chatIds.length === 0) { - return [this._toChat(chat)]; + return undefined; } const resolved: ICopilotChatSession[] = []; for (const id of chatIds) { @@ -3134,13 +2989,19 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } if (resolved.length === 0) { - return [this._toChat(chat)]; + return undefined; } return resolved.map(c => this._toChat(c)); }, ); - const mainChat = this._toChat(primaryChat); + // When the group has no resolved chats (typical for a new session before + // commit), fall back to the settable `mainChat` so it stays in sync after + // `createNewChat` swaps it. + const chatsObs: IObservable = derived(reader => { + const groupChats = groupChatsObs.read(reader); + return groupChats ?? [mainChat.read(reader)]; + }); const session: ISession = { sessionId, resource: primaryChat.resource, @@ -3176,12 +3037,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private _chatToSingleChatSession(chat: ICopilotChatSession): ISession { - const mainChat = this._toChat(chat); - const chatsObs = constObservable([mainChat]); + const mainChat = chat.mainChat; + const chatsObs = mainChat.map(c => [c] as readonly IChat[]); const changesets = this._createChangesets(chat.sessionType, chat.workspace, chatsObs); return { - sessionId: chat.id, + sessionId: chat.sessionId, resource: chat.resource, providerId: chat.providerId, sessionType: chat.sessionType, @@ -3209,9 +3070,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions }; } - private _toChat(chat: ICopilotChatSession): IChat { + private _toChat(chat: ICopilotChatSession, resource?: URI): IChat { return { - resource: chat.resource, + resource: resource ?? chat.resource, createdAt: chat.createdAt, title: chat.title, updatedAt: chat.updatedAt, 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 ab54f21e790c18..a1906d4460f537 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 @@ -35,6 +35,8 @@ import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbe import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, ClaudeCodeSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { extUri } from '../../../../../../base/common/resources.js'; import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostSessionsProvider.js'; // ---- Helpers ---------------------------------------------------------------- @@ -185,6 +187,7 @@ function createProviderWithConfig( instantiationService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path, }); + instantiationService.stub(IUriIdentityService, { extUri }); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); return { provider, configService }; @@ -257,6 +260,7 @@ function createProviderForSendTests( instantiationService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path, }); + instantiationService.stub(IUriIdentityService, { extUri }); return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); } @@ -540,7 +544,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].chats.get().length, 1); - assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); + assert.strictEqual(sessions[0].mainChat.get().resource.toString(), resource.toString()); }); test('setModel applies to existing sessions and their new chats', async () => { @@ -553,7 +557,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(session.modelId.get(), 'copilot/gpt-4o'); - const chat = provider.addChat(session.sessionId); + const chat = await provider.createNewChat(session.sessionId); try { assert.strictEqual(chat.modelId.get(), 'copilot/gpt-4o'); } finally { @@ -561,10 +565,10 @@ suite('CopilotChatSessionsProvider', () => { } }); - test('sendAndCreateChat throws for unknown session', async () => { + test('sendRequest throws for unknown session', async () => { const provider = createProvider(disposables, model); await assert.rejects( - () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + () => provider.sendRequest('nonexistent', URI.parse('untitled:chat'), { query: 'test' }), /not found/, ); }); @@ -604,7 +608,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].chats.get().length, 3); - assert.strictEqual(sessions[0].mainChat.resource.toString(), rootResource.toString()); + assert.strictEqual(sessions[0].mainChat.get().resource.toString(), rootResource.toString()); }); test('orders chats within a grouped session by createdAt', () => { @@ -712,7 +716,7 @@ suite('CopilotChatSessionsProvider', () => { const sessions = provider.getSessions(); assert.ok(sessions[0].mainChat); - assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); + assert.strictEqual(sessions[0].mainChat.get().resource.toString(), resource.toString()); }); test('deleteSession removes session from model and list', async () => { @@ -993,7 +997,8 @@ suite('CopilotChatSessionsProvider', () => { // Send and commit so the session enters the cache and can be disposed const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + const chat = await provider.createNewChat(session.sessionId); + const sendPromise = provider.sendRequest(session.sessionId, chat.resource, { query: 'test' }); await added; commitSession(); await assert.doesNotReject(sendPromise); @@ -1005,7 +1010,8 @@ suite('CopilotChatSessionsProvider', () => { const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + const chat1 = await provider.createNewChat(session.sessionId); + const sendPromise = provider.sendRequest(session.sessionId, chat1.resource, { query: 'test' }); await added; await provider.archiveSession(session.sessionId); @@ -1024,7 +1030,8 @@ suite('CopilotChatSessionsProvider', () => { const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + const chat2 = await provider.createNewChat(session.sessionId); + const sendPromise = provider.sendRequest(session.sessionId, chat2.resource, { query: 'test' }); await added; await provider.archiveSession(session.sessionId); @@ -1042,7 +1049,7 @@ suite('CopilotChatSessionsProvider', () => { // ---- Claude controller-based send flow ------- - test('sendAndCreateChat replaces temp session with committed session on success', async () => { + test('sendRequest replaces temp session with committed session on success', async () => { const { provider, commitSession } = makeClaudeInFlightProvider(); const workspace = URI.file('/test/project'); const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); @@ -1051,7 +1058,8 @@ suite('CopilotChatSessionsProvider', () => { disposables.add(provider.onDidReplaceSession(e => replacements.push(e))); const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'hello world' }); + const chat3 = await provider.createNewChat(session.sessionId); + const sendPromise = provider.sendRequest(session.sessionId, chat3.resource, { query: 'hello world' }); await added; assert.strictEqual(provider.getSessions().length, 1, 'temp session should appear while in-flight'); @@ -1064,13 +1072,14 @@ suite('CopilotChatSessionsProvider', () => { assert.ok(replacements.length > 0, 'onDidReplaceSessions should have fired'); }); - test('sendAndCreateChat uses the query as the temp session title', async () => { + test('sendRequest uses the query as the temp session title', async () => { const { provider, cancelRequest } = makeClaudeInFlightProvider(); const workspace = URI.file('/test/project'); const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'fix the login bug' }); + const chat4 = await provider.createNewChat(session.sessionId); + const sendPromise = provider.sendRequest(session.sessionId, chat4.resource, { query: 'fix the login bug' }); await added; const sessions = provider.getSessions(); @@ -1081,13 +1090,14 @@ suite('CopilotChatSessionsProvider', () => { await provider.deleteSession(session.sessionId); }); - test('sendAndCreateChat keeps temp session on cancellation', async () => { + test('sendRequest keeps temp session on cancellation', async () => { const { provider, cancelRequest } = makeClaudeInFlightProvider(); const workspace = URI.file('/test/project'); const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + const chat5 = await provider.createNewChat(session.sessionId); + const sendPromise = provider.sendRequest(session.sessionId, chat5.resource, { query: 'test' }); await added; // Cancel before the agent session appears @@ -1183,7 +1193,8 @@ suite('CopilotChatSessionsProvider', () => { const sessionId = newSession.sessionId; const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + const chat = await provider.createNewChat(sessionId); + const sendPromise = provider.sendRequest(sessionId, chat.resource, { query: 'test' }); await added; assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); @@ -1203,7 +1214,8 @@ suite('CopilotChatSessionsProvider', () => { const sessionId = newSession.sessionId; const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + const chat = await provider.createNewChat(sessionId); + const sendPromise = provider.sendRequest(sessionId, chat.resource, { query: 'test' }); await added; assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); @@ -1227,7 +1239,8 @@ suite('CopilotChatSessionsProvider', () => { const sessionId = newSession.sessionId; const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + const chat = await provider.createNewChat(sessionId); + const sendPromise = provider.sendRequest(sessionId, chat.resource, { query: 'test' }); await added; // Stop before commit arrives β€” session should stay as completed @@ -1248,107 +1261,6 @@ suite('CopilotChatSessionsProvider', () => { // Clean up to avoid leaked disposable await provider.deleteSession(sessionId); }); - - /** - * Returns a provider where the commit event is controllable. The - * caller can fire the commit event at the right moment to simulate - * the session being committed mid-request, then cancel the request - * afterwards. The session should persist after cancellation. - */ - function makeCommittableProvider(): { - provider: CopilotChatSessionsProvider; - commitSession: (original: URI, committed: URI) => void; - cancelRequest: () => void; - } { - let resolveComplete!: () => void; - let resolveCreated!: (r: IChatResponseModel) => void; - const responseCompletePromise = new Promise(r => { resolveComplete = r; }); - const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); - - const commitEmitter = disposables.add(new Emitter<{ original: URI; committed: URI }>()); - - const provider = createProviderForSendTests(disposables, model, async () => ({ - kind: 'sent' as const, - data: { - responseCompletePromise, - responseCreatedPromise, - agent: new class extends mock() { }(), - } as IChatSendRequestData, - }), { onDidCommitSession: commitEmitter.event }); - - return { - provider, - commitSession: (original, committed) => commitEmitter.fire({ original, committed }), - cancelRequest: () => { - resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); - resolveComplete(); - }, - }; - } - - test('stopping a committed session keeps it in the list', async () => { - const { provider, commitSession, cancelRequest } = makeCommittableProvider(); - - const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id); - const sessionId = newSession.sessionId; - - const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); - await added; - - assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); - - // Get the temp session's resource so we can fire the commit event - const tempSession = provider.getSessions()[0]; - const tempResource = tempSession.resource; - - // Simulate commit: the agent created the worktree, so the URI - // swaps from untitled to a real committed resource. - const committedResource = URI.from({ scheme: AgentSessionProviders.Background, path: `/committed-${Date.now()}` }); - const committedAgentSession = createMockAgentSession(committedResource); - model.addSession(committedAgentSession); - commitSession(tempResource, committedResource); - - // _sendFirstChat should complete successfully now - await sendPromise; - - assert.strictEqual(provider.getSessions().length, 1, 'committed session should remain in list'); - - // Now cancel the request β€” session must stay - cancelRequest(); - - assert.strictEqual(provider.getSessions().length, 1, 'committed session should persist after stopping'); - }); - - test('cancelling the request before commit keeps the session with completed status', async () => { - const { provider, cancelRequest } = makeInFlightProvider(); - - const changes: ISessionChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions(e => changes.push(e))); - - const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id); - const sessionId = newSession.sessionId; - - const added = waitForSessionAdded(provider); - const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); - await added; - - assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); - assert.ok(changes.some(e => e.added.some(s => s.sessionId === sessionId)), 'added event should have fired'); - - // Simulate user stopping the request - cancelRequest(); - await sendPromise; - - assert.strictEqual(provider.getSessions().length, 1, 'session should stay in list after cancellation'); - assert.ok( - changes.some(e => e.changed.some(s => s.sessionId === sessionId)), - 'changed event should have fired', - ); - - // Clean up the kept session so it doesn't leak - await provider.deleteSession(sessionId); - }); }); // ---- New session default permission level seeding ----------------------- 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 22c669e4a3f3b6..81d31f2e3f40b2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -11,6 +11,7 @@ import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import * as nls from '../../../../../nls.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { type AgentProvider, type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, type IRemoteAgentHostSSHConnection, RemoteAgentHostAutoConnectSettingId, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TunnelAgentHostsSettingId } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; @@ -49,6 +50,7 @@ import { ISessionsProvidersService } from '../../../../services/sessions/browser import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js'; import { createRemoteAgentCustomizationItemProvider, createRemoteAgentHarnessDescriptor, RemoteAgentPluginController } from './remoteAgentHostCustomizationHarness.js'; +import { RemoteAgentHostLogForwarder } from './remoteAgentHostLogForwarder.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; @@ -566,12 +568,26 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } const { address, name } = connectionInfo; - const channelLabel = `Agent Host (${name || address})`; + const channelLabel = `Agent Host IPC (${name || address})`; const connState = this._instantiationService.createInstance(ConnectionState, name, connection, `agenthost.${connection.clientId}`, channelLabel); const loggedConnection = connState.loggedConnection; this._connections.set(address, connState); const store = connState.store; + // Bridge the host's OTLP logs channel into a dedicated workbench + // Output channel (`Agent Host (${name})`). Concrete clients + // returned by `IRemoteAgentHostService.getConnection` are always + // `RemoteAgentHostProtocolClient` instances β€” `IAgentConnection` + // erases the concrete type, so cast here at the integration + // point rather than polluting that interface with OTLP-specific + // surface. + store.add(this._instantiationService.createInstance( + RemoteAgentHostLogForwarder, + connection as RemoteAgentHostProtocolClient, + address, + name || address, + )); + // Track authority -> connection mapping for FS provider routing const authority = agentHostAuthority(address); store.add(this._agentHostFileSystemService.registerAuthority(authority, connection)); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts new file mode 100644 index 00000000000000..878ec76cb41362 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostLogForwarder.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { UriTemplate } from '../../../../../base/common/uriTemplate.js'; +import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { iterateOtlpLogRecords, logLevelToOtlpLevelName, severityNumberToLogLevel, type IOtlpLogRecord, type OtlpLogLevelName } from '../../../../../platform/agentHost/common/otlp/otlpLogEmitter.js'; +import { AgentHostClientState, type RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../workbench/services/output/common/output.js'; + +/** + * Forwarder that bridges a connected {@link RemoteAgentHostProtocolClient}'s + * OTLP logs channel into the workbench's Output panel. + * + * For each {@link AgentHostClientState.Connected} transition (initial + * handshake or post-reconnect) where the host advertised + * `telemetry.logs`, the forwarder: + * + * - Lazily registers an Output channel via + * {@link IOutputChannelRegistry.registerChannel} keyed by the host's + * stable address so subsequent connects reuse the same channel and the + * user keeps any logs from previous sessions visible in the picker. + * - Subscribes to the host's logs channel at the level matching the + * workbench's current {@link ILogService} level and re-subscribes when + * that level changes. + * - Decodes incoming `otlp/exportLogs` notifications and appends each + * record to the Output channel. + * + * Output channels are deliberately NOT removed when the host disconnects β€” + * the user expects to still be able to inspect prior log output, and the + * count of remote hosts is small so leaking them is fine. + * + * The local agent host has its own out-of-band IPC log forwarder + * (`AgentHostProcessManager` + `LoggerChannel`) and does NOT route through + * this class. + */ +export class RemoteAgentHostLogForwarder extends Disposable { + + private readonly _channelId: string; + private readonly _channelLabel: string; + private _outputChannel: IOutputChannel | undefined; + private _channelRegistered = false; + /** Tracks whatever needs to be torn down for a single subscribe cycle. */ + private readonly _subscriptionStore = this._register(new MutableDisposable()); + private _currentLevel: OtlpLogLevelName | undefined; + + constructor( + private readonly _client: RemoteAgentHostProtocolClient, + address: string, + displayName: string, + @IOutputService private readonly _outputService: IOutputService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._channelId = `agentHost.otlp.${address}`; + this._channelLabel = `Agent Host (${displayName})`; + + // Wire up subscribe/teardown around the client's connection state. + this._register(_client.onDidChangeConnectionState(state => { + switch (state) { + case AgentHostClientState.Connected: + this._attach(); + break; + case AgentHostClientState.Reconnecting: + case AgentHostClientState.Closed: + this._detach(); + break; + } + })); + + // The workbench's overall log level drives the wire-level + // subscription. Re-subscribe when it changes so we stop receiving + // records the user does not want to see. + this._register(_logService.onDidChangeLogLevel(() => this._attach())); + + this._register(_client.onDidReceiveOtlpLogs(params => { + this._handleBatch(params.payload); + })); + + // If the client is already connected when the forwarder is + // constructed (e.g. attached after handshake), attach immediately. + if (_client.connectionState === AgentHostClientState.Connected) { + this._attach(); + } + } + + /** + * (Re-)subscribe to the host's logs channel at the level matching the + * workbench's current log level. Replaces any prior subscription. + * Silent no-op when the host did not advertise a logs channel β€” + * there is nothing to subscribe to. + */ + private _attach(): void { + // Defer subscribe traffic when the transport is not actually + // usable. The `Connected` state-change listener reruns `_attach()` + // once the soft-reconnect completes, so deferred work is picked + // up automatically. Without this guard, repeated log-level + // changes during a single reconnect would queue multiple gated + // `subscribe` requests that all fire after the gate resolves, + // leaving stale subscriptions on the server. + if (this._client.connectionState !== AgentHostClientState.Connected) { + return; + } + + const template = this._client.initializeResult?.telemetry?.logs; + if (!template) { + return; + } + + const desiredLevel = this._levelFromLogService(); + if (!desiredLevel) { + // `Off` β€” drop the subscription if we had one. + this._detach(); + return; + } + + if (this._subscriptionStore.value && this._currentLevel === desiredLevel) { + // Already attached at the same level β€” nothing to do. + return; + } + + // Output channel is registered lazily so hosts without an OTLP + // logs channel never produce an empty entry in the picker. + this._ensureChannelRegistered(); + + const store = new DisposableStore(); + this._subscriptionStore.value = store; + this._currentLevel = desiredLevel; + + const channelUri = this._expandLogsChannel(template, desiredLevel); + + // Best-effort: the server may reject the subscribe (incompatible + // protocol version, host without OTLP, etc.). Log to our channel + // and bail β€” the channel itself stays registered. + this._client.subscribeStateless(URI.parse(channelUri)).catch(err => { + this._appendLine(`Failed to subscribe to OTLP logs channel ${channelUri}: ${formatError(err)}`); + }); + + store.add(toDisposable(() => { + // Server unsubscribe is best-effort: if the connection has + // already torn down we just drop our state. + try { + this._client.unsubscribe(URI.parse(channelUri)); + } catch { + // ignore + } + })); + } + + /** + * Register the per-host Output channel on first attach. Subsequent + * calls are no-ops β€” registering the same id twice replaces the + * existing channel. + * + * The channel is intentionally never deregistered: the host count is + * small, and the user typically wants to inspect logs after a host + * has disconnected (e.g. when diagnosing why it dropped). + */ + private _ensureChannelRegistered(): void { + if (this._channelRegistered) { + return; + } + this._channelRegistered = true; + const registry = Registry.as(Extensions.OutputChannels); + if (!registry.getChannel(this._channelId)) { + registry.registerChannel({ + id: this._channelId, + label: this._channelLabel, + log: false, + languageId: 'log', + }); + } + } + + /** + * Drop the current subscription (if any). Idempotent. The Output + * channel registration is preserved β€” only the in-flight subscribe + * is undone. + */ + private _detach(): void { + this._subscriptionStore.clear(); + this._currentLevel = undefined; + } + + /** + * Resolve the level we want to subscribe at from the workbench's + * global log level. `Off` yields `undefined` so the caller can drop + * any existing subscription. + */ + private _levelFromLogService(): OtlpLogLevelName | undefined { + const level = this._logService.getLevel(); + if (level === LogLevel.Off) { + return undefined; + } + return logLevelToOtlpLevelName(level) ?? 'info'; + } + + /** + * Expand the host's RFC 6570 URI template to a concrete subscribable + * channel URI. Hosts that hard-code a literal channel (no template + * variables) round-trip verbatim β€” `UriTemplate.resolve` substitutes + * any `{level}` variable and otherwise emits the literal sequence. + * + * Using `UriTemplate.parse` (rather than a hand-rolled `.replace`) + * keeps the implementation spec-conformant: the host can theoretically + * advertise variants like `{?level}` or pin additional unknown + * variables the protocol may later define. + */ + private _expandLogsChannel(template: string, level: OtlpLogLevelName): string { + return UriTemplate.parse(template).resolve({ level }); + } + + /** + * Decode an OTLP/JSON `ExportLogsServiceRequest` payload and append + * each contained record to the registered Output channel. Records + * whose severity is below the workbench's current log level are + * filtered defensively (the host *should* have honoured `{level}` + * but the spec says we MUST still filter). + */ + private _handleBatch(payload: unknown): void { + if (!this._channelRegistered) { + // We never got far enough to register a channel β€” drop any + // stray records rather than implicitly creating one here. + return; + } + const loggerLevel = this._logService.getLevel(); + if (loggerLevel === LogLevel.Off) { + return; + } + for (const record of iterateOtlpLogRecords(payload)) { + const level = severityNumberToLogLevel(record.severityNumber); + if (level < loggerLevel) { + continue; + } + this._appendLine(formatRecord(record)); + } + } + + private _appendLine(text: string): void { + if (!this._outputChannel) { + this._outputChannel = this._outputService.getChannel(this._channelId); + if (!this._outputChannel) { + return; + } + } + this._outputChannel.append(text.endsWith('\n') ? text : `${text}\n`); + } +} + +function formatRecord(record: IOtlpLogRecord): string { + // Match the `[timestamp] [level] message` shape the workbench's + // other log Output channels use, so the rendering looks consistent + // when the user switches between them. Timestamps are formatted + // from the OTLP nanosecond integer string. + const timestamp = formatTimestamp(record.timeUnixNano); + const severity = record.severityText.toUpperCase().padEnd(5); + return `[${timestamp}] [${severity}] ${record.body}`; +} + +function formatTimestamp(timeUnixNano: string): string { + // `timeUnixNano` is a base-10 integer string with the last 6 digits + // being sub-ms precision. Cap precision at ms so the conversion fits + // in a JS number. + const ms = timeUnixNano.length > 6 ? Number(timeUnixNano.slice(0, -6)) : 0; + if (!Number.isFinite(ms)) { + return new Date().toISOString(); + } + return new Date(ms).toISOString(); +} + +function formatError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index 390288ea9561d2..fbb374e1b74382 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -757,7 +757,6 @@ suite('RemoteAgentHostCustomizationHarness', () => { skillItems.map(i => ({ name: i.name, description: i.description, uri: i.uri.toString() })).sort((a, b) => a.name.localeCompare(b.name)), [ { name: 'Pretty Name', description: 'A friendly skill description', uri: 'vscode-agent-host://test/plugins/skills-bundle/skills/valid-skill/SKILL.md' }, - { name: 'legacy', description: undefined, uri: 'vscode-agent-host://test/plugins/skills-bundle/skills/legacy.skill.md' }, ].sort((a, b) => a.name.localeCompare(b.name)), ); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index d836398c17e080..6547363e08d03a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -877,15 +877,15 @@ suite('RemoteAgentHostSessionsProvider', () => { ); })); - test('sendAndCreateChat throws for unknown session', async () => { + test('sendRequest throws for unknown session', async () => { const provider = createProvider(disposables, connection); await assert.rejects( - () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + () => provider.sendRequest('nonexistent', URI.parse('untitled:chat'), { query: 'test' }), /not found or not a new session/, ); }); - test('sendAndCreateChat forwards resolved session config to chat service', async () => { + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, connection, { openSession: true, @@ -900,7 +900,8 @@ suite('RemoteAgentHostSessionsProvider', () => { const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id); await timeout(0); - await provider.sendAndCreateChat(session.sessionId, { query: 'hello' }); + const chat = await provider.createNewChat(session.sessionId); + await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]); }); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 8eb2e6a6b64577..01dc0880ad0f6f 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -672,7 +672,7 @@ registerAction2(class RenameSessionAction extends Action2 { if (newTitle) { const trimmedTitle = newTitle.trim(); if (trimmedTitle) { - await sessionsManagementService.renameChat(session, session.mainChat.resource, trimmedTitle); + await sessionsManagementService.renameChat(session, session.mainChat.get().resource, trimmedTitle); } } } @@ -896,6 +896,6 @@ registerAction2(class AddChatAction extends Action2 { return; } - sessionsManagementService.openNewChatInSession(activeSession); + await sessionsManagementService.openNewChatInSession(activeSession); } }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index ffe8f602ec16ea..0bff73ea12b535 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -47,7 +47,7 @@ function createSession(id: string, opts: { description: observableValue(`description-${id}`, undefined), lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue(`chats-${id}`, []), - mainChat: undefined!, + mainChat: observableValue(`mainChat-${id}`, undefined!), capabilities: { supportsMultipleChats: false }, }; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts index 94e9f231871309..07f54226bd8fd6 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts @@ -43,7 +43,7 @@ function createSession(id: string, status: SessionStatus = SessionStatus.Complet description: observableValue(`description-${id}`, undefined), lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue(`chats-${id}`, []), - mainChat: undefined!, + mainChat: constObservable(undefined!), capabilities: { supportsMultipleChats: false }, }; } @@ -361,7 +361,7 @@ suite('SessionsListModelService', () => { service.markRead(session); // Make session the active one - activeSession.set({ ...session, activeChat: constObservable(session.mainChat) }, undefined); + activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()) }, undefined); // Seed the last-known status as InProgress sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); diff --git a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts index f51030e83cd4ef..2c1c17a3811c4f 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts @@ -61,7 +61,7 @@ function makeSession(opts: { providerId: string; cwd?: URI }): ISession { lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), chats: observableValue('chats', [chat]), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, }; } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 16f8bd5dd21972..1094f2c88ef634 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -110,7 +110,7 @@ function makeAgentSession(opts: { description: chat.description, chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, } satisfies TestActiveSession; return session; @@ -167,7 +167,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT lastTurnEnd: chat.lastTurnEnd, description: chat.description, chats: observableValue('test.chats', [chat]), - mainChat: chat, + mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false }, } satisfies ISession; return session; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 5c9a52d2bc555a..b562ebd210397d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -386,7 +386,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } - async sendAndCreateChat(session: ISession, options: ISendRequestOptions): Promise { + async sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise { this._pendingNewSession = undefined; this.isNewChatSessionContext.set(false); this._isNewChatInSessionContext.set(false); @@ -413,9 +413,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (!provider) { throw new Error(`Sessions provider '${session.providerId}' not found`); } - const updatedSession = await provider.sendAndCreateChat(session.sessionId, options); + + // Ask the provider to create the new chat; open its widget before sending + const chat = await provider.createNewChat(session.sessionId, options.query); + await this.chatWidgetService.openSession(chat.resource, ChatViewPaneTarget); + + const updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); if (updatedSession.sessionId !== session.sessionId && this._activeSession.get()?.sessionId === session.sessionId) { - this.logService.info(`[SessionsManagement] sendAndCreateChat: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); + this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); this.setActiveSession(updatedSession); setActiveChatToLast(); } @@ -466,7 +471,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - openNewChatInSession(session: ISession): void { + async openNewChatInSession(session: ISession): Promise { this._startOpenSession(); const provider = this._getProvider(session); if (!provider) { @@ -476,7 +481,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // Reuse an existing untitled chat if one exists, otherwise create a new one const existingUntitled = session.chats.get().find(c => c.status.get() === SessionStatus.Untitled); - const chat = existingUntitled ?? provider.addChat(session.sessionId); + const chat = existingUntitled ?? await provider.createNewChat(session.sessionId); + + await this.chatWidgetService.openSession(chat.resource, ChatViewPaneTarget); this.setActiveSession(session); @@ -565,16 +572,18 @@ export class SessionsManagementService extends Disposable implements ISessionsMa })); // Track chat list changes β€” if the active chat is removed, fall back - this._activeSessionDisposables.add(autorun(reader => { - const chats = session.chats.read(reader); - const activeChat = activeChatObs.read(reader); - if (activeChat && !chats.some(c => this.uriIdentityService.extUri.isEqual(c.resource, activeChat.resource))) { - const fallback = chats[chats.length - 1] ?? session.mainChat; - if (fallback) { - this.openChat(session, fallback.resource); + if (session.status.get() !== SessionStatus.Untitled) { + this._activeSessionDisposables.add(autorun(reader => { + const chats = session.chats.read(reader); + const activeChat = activeChatObs.read(reader); + if (activeChat && !chats.some(c => this.uriIdentityService.extUri.isEqual(c.resource, activeChat.resource))) { + const fallback = chats[chats.length - 1] ?? session.mainChat.read(reader); + if (fallback) { + this.openChat(session, fallback.resource); + } } - } - })); + })); + } // Track active chat changes to persist per-session state this._activeSessionDisposables.add(autorun(reader => { diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 36914fee1260c7..3d2c906277c002 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -269,8 +269,8 @@ export interface ISession { readonly lastTurnEnd: IObservable; /** The chats belonging to this session group. */ readonly chats: IObservable; - /** The main (first) chat of this session. */ - readonly mainChat: IChat; + /** The main (first) chat of this session. Providers may replace it for a new session via {@link ISessionsProvider.createNewChat}. */ + readonly mainChat: IObservable; /** Capabilities of this session. */ readonly capabilities: ISessionCapabilities; } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index ba89c83614de34..5b28b878612fe0 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -162,7 +162,7 @@ export interface ISessionsManagementService { /** * Send a request, creating a new chat in the session. */ - sendAndCreateChat(session: ISession, options: ISendRequestOptions): Promise; + sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise; /** * Send a request for an existing chat within a session. @@ -174,7 +174,7 @@ export interface ISessionsManagementService { * Adds a new chat to the session via the provider, makes it the active chat, * and shows a rich input for composing a message. */ - openNewChatInSession(session: ISession): void; + openNewChatInSession(session: ISession): Promise; /** Navigate to the previous session in the navigation history. */ openPreviousSession(): Promise; diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 0f5c2f8f33f27c..99b5798aad7c66 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -152,23 +152,16 @@ export interface ISessionsProvider { deleteChat(sessionId: string, chatUri: URI): Promise; /** - * Send a request to a session and create a new chat with the response. - * @param sessionId The ID of the session to send the request to. - * @param options Options for the request, including the query and any attached context entries. - */ - sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise; - - /** - * Add a new empty chat to an existing session without sending a request. - * The new chat is registered in the group model and can be used to compose - * a message before sending. - * @param sessionId The ID of the session to add a chat to. - * @returns The newly created chat. + * Create a new chat in the given session and return it. + * + * @param sessionId The ID of the session to create the new chat in. + * @param prompt Optional prompt to initialize the new chat with. */ - addChat(sessionId: string): IChat; + createNewChat(sessionId: string, prompt?: string): Promise; /** - * Send a request for an existing chat within a session. + * Send a request for a chat within a session. + * * @param sessionId The ID of the session containing the chat. * @param chatResource The resource URI of the chat to send the request for. * @param options Options for the request, including the query and any attached context entries. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 2d73e597e5bc95..b7f00ce76cc5c9 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -74,7 +74,7 @@ function stubSession(id: string, status: SessionStatus = SessionStatus.Completed description: constObservable(undefined), lastTurnEnd: constObservable(undefined), chats: constObservable(sessionChats), - mainChat: sessionChats[0], + mainChat: constObservable(sessionChats[0]), capabilities: { supportsMultipleChats: chats !== undefined && chats.length > 1 }, }; } @@ -159,9 +159,9 @@ class MockSessionStore implements ISessionsManagementService { restoreLastActiveSession(): Promise { throw new Error('not implemented'); } createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } unsetNewSession(): void { throw new Error('not implemented'); } - sendAndCreateChat(_session: ISession, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } + sendNewChatRequest(_session: ISession, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } sendRequest(_session: ISession, _chat: IChat, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } - openNewChatInSession(_session: ISession): void { throw new Error('not implemented'); } + openNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } openPreviousSession(): Promise { throw new Error('not implemented'); } openNextSession(): Promise { throw new Error('not implemented'); } archiveSession(_session: ISession): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index db5cd13c5cabae..b1f297a1f37356 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -65,7 +65,7 @@ function stubSession(overrides: Partial & Pick() { override async unarchiveSession(): Promise { } override async deleteSession(): Promise { } override async deleteChat(): Promise { } - override async sendAndCreateChat(): Promise { return this._session; } - override addChat(): IChat { return this._session.mainChat; } override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { return this._session; } + override async createNewChat(): Promise { return this._session.mainChat.get(); } } function createSessionsManagementService(session: ISession, disposables: ReturnType): { service: ISessionsManagementService; chatWidgetService: TestChatWidgetService; agentSessionsService: TestAgentSessionsService } { diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 73461e226f0682..184335470d177e 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -225,6 +225,9 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { inputCost: m.inputCost, outputCost: m.outputCost, cacheCost: m.cacheCost, + longContextInputCost: m.longContextInputCost, + longContextOutputCost: m.longContextOutputCost, + longContextCacheCost: m.longContextCacheCost, priceCategory: m.priceCategory, maxInputTokens: m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, @@ -420,6 +423,9 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { inputCost: model.metadata.inputCost, outputCost: model.metadata.outputCost, cacheCost: model.metadata.cacheCost, + longContextInputCost: model.metadata.longContextInputCost, + longContextOutputCost: model.metadata.longContextOutputCost, + longContextCacheCost: model.metadata.longContextCacheCost, priceCategory: model.metadata.priceCategory, capabilities: { supportsImageToText: model.metadata.capabilities?.vision ?? false, diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 14fcb2ddc275e6..8b48b0c1297e9c 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -35,7 +35,8 @@ import { IBrowserViewOwner, browserZoomDefaultIndex, browserZoomFactors, - IBrowserViewState + IBrowserViewState, + IBrowserDeviceProfile } from '../../../../platform/browserView/common/browserView.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; @@ -213,6 +214,7 @@ export interface IBrowserViewModel extends IDisposable { readonly canZoomIn: boolean; readonly canZoomOut: boolean; readonly isElementSelectionActive: boolean; + readonly device: IBrowserDeviceProfile | undefined; readonly onDidChangeSharingState: Event; readonly onDidChangeZoom: Event; @@ -229,6 +231,7 @@ export interface IBrowserViewModel extends IDisposable { readonly onWillDispose: Event; readonly onDidSelectElement: Event; readonly onDidChangeElementSelectionActive: Event; + readonly onDidChangeDevice: Event; layout(bounds: IBrowserViewBounds): Promise; setVisible(visible: boolean): Promise; @@ -251,6 +254,7 @@ export interface IBrowserViewModel extends IDisposable { resetZoom(): Promise; getConsoleLogs(): Promise; toggleElementSelection(enabled?: boolean): Promise; + setDevice(device: IBrowserDeviceProfile | undefined): Promise; } export class BrowserViewModel extends Disposable implements IBrowserViewModel { @@ -272,6 +276,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _sharedWithAgent: boolean = false; private _browserZoomIndex: number = browserZoomDefaultIndex; private _isElementSelectionActive: boolean = false; + private _device: IBrowserDeviceProfile | undefined; private readonly _onDidChangeSharingState = this._register(new Emitter()); readonly onDidChangeSharingState: Event = this._onDidChangeSharingState.event; @@ -314,6 +319,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._storageScope = initialState.storageScope; this._browserZoomIndex = initialState.browserZoomIndex; this._isElementSelectionActive = initialState.isElementSelectionActive; + this._device = initialState.device; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; this._zoomHost = parseZoomHost(this._url); @@ -387,6 +393,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._visible = visible; })); + this._register(this.onDidChangeDevice(device => { + this._device = device; + })); + this._register(this.onDidChangeElementSelectionActive(active => { if (active) { this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); @@ -425,6 +435,8 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } get canZoomOut(): boolean { return this._browserZoomIndex > 0; } + get isElementSelectionActive(): boolean { return this._isElementSelectionActive; } + get device(): IBrowserDeviceProfile | undefined { return this._device; } get onDidNavigate(): Event { return this.browserViewService.onDynamicDidNavigate(this.id); @@ -462,6 +474,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidChangeVisibility(this.id); } + get onDidChangeDevice(): Event { + return this.browserViewService.onDynamicDidChangeDeviceEmulation(this.id); + } + get onDidClose(): Event { return this.browserViewService.onDynamicDidClose(this.id); } @@ -583,10 +599,6 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.getConsoleLogs(this.id); } - get isElementSelectionActive(): boolean { - return this._isElementSelectionActive; - } - async toggleElementSelection(enabled?: boolean): Promise { return this.browserViewService.toggleElementSelection(this.id, enabled); } @@ -599,6 +611,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidChangeElementSelectionActive(this.id); } + async setDevice(device: IBrowserDeviceProfile | undefined): Promise { + return this.browserViewService.setDeviceEmulation(this.id, device); + } + private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; async setSharedWithAgent(shared: boolean): Promise { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 7fbbfad8b4c0eb..e09eb970857b14 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -103,11 +103,50 @@ export abstract class BrowserEditorContribution extends Disposable { * Called when the editor is laid out with a new dimension. */ layout(_width: number): void { } + + /** + * Called once after the editor's browser container DOM has been created. + * Use to do setup that needs to attach to `editor.browserContainer`. + */ + onContainerReady(_container: HTMLElement): void { } + + /** + * Return an override to customize how the editor sizes the browser + * container. Returning `undefined` falls through to the next contribution + * (and finally to the default: container fills the wrapper's content area). + * The first contribution to return a non-undefined override wins. + */ + getContainerLayoutOverride(): IContainerLayoutOverride | undefined { return undefined; } } -/** - * A widget that can be contributed to the browser editor URL bar. - */ +/** Customization returned by {@link BrowserEditorContribution.getContainerLayoutOverride}. */ +export interface IContainerLayoutOverride { + /** + * Wrapper padding (CSS px) β€” typically used to reserve space for widgets + * that sit outside the container (e.g. resize sashes). Applied as inline + * style before the pane is measured for {@link compute}. + */ + readonly padding: { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; + /** Compute the container layout given the measured pane size. */ + compute(paneWidth: number, paneHeight: number): IContainerLayout; +} + +export interface IContainerLayout { + readonly width: number; + readonly height: number; + readonly emulation?: { + readonly viewportWidth: number; + readonly viewportHeight: number; + readonly scale: number; + }; +} + +/** A widget that can be contributed to the browser editor URL bar. */ export interface IBrowserEditorWidgetContribution { readonly element: HTMLElement; /** Ordering value β€” lower numbers appear first (left). */ @@ -366,6 +405,7 @@ export class BrowserEditor extends EditorPane { private overlayManager: BrowserOverlayManager | undefined; private _screenshotTimeout: ReturnType | undefined; private readonly _certActionButton = this._register(new MutableDisposable()); + private _currentPadding: { top: number; right: number; bottom: number; left: number } = { top: 0, right: 3, bottom: 3, left: 3 }; constructor( group: IEditorGroup, @@ -444,6 +484,12 @@ export class BrowserEditor extends EditorPane { this._browserContainer.tabIndex = 0; // make focusable this._browserContainerWrapper.appendChild(this._browserContainer); + // Notify contributions that the container DOM is ready (used e.g. by + // the device feature to attach resize sashes to the container). + for (const contribution of this._contributionInstances.values()) { + contribution.onContainerReady(this._browserContainer); + } + // Create additional wrapper around placeholder contents for applying border radius clipping. const placeholderContents = $('.browser-placeholder-contents'); this._browserContainer.appendChild(placeholderContents); @@ -610,6 +656,10 @@ export class BrowserEditor extends EditorPane { if (targetWindowId === this.window.vscodeWindowId) { // Update CSS variable for size calculations this._browserContainerWrapper.style.setProperty('--zoom-factor', String(getZoomFactor(this.window))); + // Re-push container bounds and emulation: zoom-factor affects + // both the screen-px conversion in main and the Chromium + // emulation scale (so the emulated viewport fills the WCV). + this.layoutBrowserContainer(); } })); @@ -1002,32 +1052,90 @@ export class BrowserEditor extends EditorPane { } /** - * Recompute the layout of the browser container and update the model with the new bounds. - * This should generally only be called via layout() to ensure that the container is ready and all necessary styles are loaded. + * Recompute the layout of the browser container and push the resulting + * bounds + emulation to the WebContentsView. Should generally only be + * called via {@link layout} so the container is fully styled first. */ layoutBrowserContainer(retries = 2): void { - if (this._model) { - this.checkOverlays(); - - const containerRect = this._browserContainer.getBoundingClientRect(); - const cornerRadius = this.window.getComputedStyle(this._browserContainer).borderTopLeftRadius ?? '0'; - - // This can happen under certain conditions. Keep trying for a couple of frames to allow things to stabilize. - if ((containerRect.width === 0 || containerRect.height === 0) && retries > 0) { - this.window.requestAnimationFrame(() => this.layoutBrowserContainer(retries - 1)); - return; + if (!this._model) { + return; + } + this.checkOverlays(); + + // Pick the first contribution that wants to override sizing. + let override: IContainerLayoutOverride | undefined; + for (const c of this._contributionInstances.values()) { + const o = c.getContainerLayoutOverride(); + if (o) { + override = o; + break; } + } - void this._model.layout({ - windowId: this.group.windowId, - x: containerRect.left, - y: containerRect.top, - width: containerRect.width, - height: containerRect.height, - zoomFactor: getZoomFactor(this.window), - cornerRadius: parseFloat(cornerRadius) - }); + // Apply the wrapper padding the editor will assume below. Inline style + // is the single source of truth β€” the wrapper's CSS has no padding. + // Right/bottom/left are clamped so the container always has breathing + // room (and resize sashes that sit on those edges remain reachable). + const raw = override?.padding; + const padding = { + top: raw?.top ?? 0, + right: Math.max(3, raw?.right ?? 0), + bottom: Math.max(3, raw?.bottom ?? 0), + left: Math.max(3, raw?.left ?? 0), + }; + this._currentPadding = padding; + this._browserContainerWrapper.style.padding = `${padding.top}px ${padding.right}px ${padding.bottom}px ${padding.left}px`; + + const wrapperRect = this._browserContainerWrapper.getBoundingClientRect(); + if ((wrapperRect.width === 0 || wrapperRect.height === 0) && retries > 0) { + // Wrapper not measured yet; retry on the next frame. + this.window.requestAnimationFrame(() => this.layoutBrowserContainer(retries - 1)); + return; } + + const paneWidth = Math.max(0, wrapperRect.width - padding.left - padding.right); + const paneHeight = Math.max(0, wrapperRect.height - padding.top - padding.bottom); + let layout: IContainerLayout; + if (override) { + layout = override.compute(paneWidth, paneHeight); + } else { + const z = getZoomFactor(this.window); + const snap = (v: number) => Math.floor(v * z) / z; + layout = { width: snap(paneWidth), height: snap(paneHeight) }; + } + + // Size the container, then derive its absolute screen rect analytically: + // the wrapper's flex rules center the container within the pane. + this._browserContainer.style.width = `${layout.width}px`; + this._browserContainer.style.height = `${layout.height}px`; + const containerLeft = wrapperRect.left + padding.left + (paneWidth - layout.width) / 2; + const containerTop = wrapperRect.top + padding.top + (paneHeight - layout.height) / 2; + const cornerRadius = parseFloat(this.window.getComputedStyle(this._browserContainer).borderTopLeftRadius ?? '0'); + void this._model.layout({ + windowId: this.group.windowId, + x: containerLeft, + y: containerTop, + width: layout.width, + height: layout.height, + zoomFactor: getZoomFactor(this.window), + cornerRadius, + emulation: layout.emulation, + }); + } + + /** + * Wrapper content-area size in CSS px β€” the maximum room the container + * can occupy after the active padding is applied. Derived from the last + * padding we wrote to the wrapper, so it stays in sync without re-reading + * the computed style. + */ + get paneSize(): { width: number; height: number } { + const r = this._browserContainerWrapper.getBoundingClientRect(); + const p = this._currentPadding; + return { + width: Math.max(0, r.width - p.left - p.right), + height: Math.max(0, r.height - p.top - p.bottom), + }; } override clearInput(): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index e8fc1087cc783f..f2eee43e297936 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -25,6 +25,7 @@ import './features/browserDataStorageFeatures.js'; import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; +import './features/browserEditorEmulationFeatures.js'; import './features/browserEditorFindFeature.js'; import './features/browserTabManagementFeatures.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 2bc3313b6ddcda..548235081240de 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -26,8 +26,9 @@ export const BrowserActionCategory = localize2('browserCategory', "Browser"); export enum BrowserActionGroup { Tabs = '1_tabs', Zoom = '2_zoom', - Page = '3_page', - Settings = '4_settings' + Developer = '3_developer', + Page = '4_page', + Settings = '5_settings' } class GoBackAction extends Action2 { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 8b0e3438caa077..25b3f3d009c4ae 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -12,7 +12,7 @@ import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/w import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { AUX_WINDOW_GROUP, IEditorService, PreferredGroup } from '../../../services/editor/common/editorService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; @@ -28,6 +28,11 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { focusBorder } from '../../../../platform/theme/common/colors/baseColors.js'; import { buttonForeground, buttonBackground } from '../../../../platform/theme/common/colors/inputColors.js'; import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; +import { findGroup } from '../../../services/editor/common/editorGroupFinder.js'; +import { ChatEditorInput } from '../../chat/browser/widgetHosts/editor/chatEditorInput.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { URI } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; /** * When enabled, integrated browser tools are exposed as client-provided tools @@ -89,6 +94,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV @ILogService private readonly logService: ILogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IThemeService private readonly themeService: IThemeService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); @@ -138,7 +144,9 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV const editor = this._known.get(e.info.id); if (editor && e.openOptions) { - this._openEditorForCreatedView(editor, e.openOptions); + void this._openEditorForCreatedView(editor, e.info.owner, e.openOptions).catch(error => { + this.logService.error('[BrowserViewWorkbenchService] Failed to open editor for created browser view.', error); + }); } })); } @@ -236,11 +244,11 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV /** * Open an editor tab for a newly created browser view. */ - private _openEditorForCreatedView(view: BrowserEditorInput, openOptions: IBrowserViewOpenOptions): void { + private async _openEditorForCreatedView(view: BrowserEditorInput, owner: IBrowserViewOwner, openOptions: IBrowserViewOpenOptions): Promise { const opts = openOptions; // Resolve target group: auxiliary window, parent's group, or default - let targetGroup: number | typeof AUX_WINDOW_GROUP | undefined; + let targetGroup: PreferredGroup | undefined; if (opts.auxiliaryWindow) { targetGroup = AUX_WINDOW_GROUP; } else if (opts.parentViewId) { @@ -250,14 +258,31 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } } - void this.editorService.openEditor(view, { + const editorOptions = { inactive: opts.background, preserveFocus: opts.preserveFocus, pinned: opts.pinned, auxiliary: opts.auxiliaryWindow ? { bounds: opts.auxiliaryWindow, compact: true } : undefined, - }, targetGroup); + }; + + // If the browser is opened by a chat session, + // only open in the foreground if the session's widget is currently visible + // and not the active editor in the target group. + const [group] = await this.instantiationService.invokeFunction(findGroup, { editor: view, options: editorOptions }, targetGroup); + if (owner.sessionId) { + const sessionResource = URI.parse(owner.sessionId); + const widget = this.chatWidgetService.getWidgetBySessionResource(sessionResource); + const isWidgetVisible = !!widget && widget.domNode.offsetParent !== null; + const activeIsSameSession = group.activeEditor instanceof ChatEditorInput + && isEqual(group.activeEditor.sessionResource, sessionResource); + if (!isWidgetVisible || activeIsSameSession) { + editorOptions.inactive = true; + } + } + + void this.editorService.openEditor(view, editorOptions, group); } /** diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts new file mode 100644 index 00000000000000..b71bd9ce3df562 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts @@ -0,0 +1,1004 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, EventType, getWindow } from '../../../../../base/browser/dom.js'; +import { getZoomFactor } from '../../../../../base/browser/browser.js'; +import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; +import { IHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegate.js'; +import { ISashEvent, Orientation, OrthogonalEdge, Sash, SashState } from '../../../../../base/browser/ui/sash/sash.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; +import { SelectBox } from '../../../../../base/browser/ui/selectBox/selectBox.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IBrowserDeviceProfile, IBrowserScreenProfile } from '../../../../../platform/browserView/common/browserView.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService, WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, IContainerLayout, IContainerLayoutOverride } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_EMULATION_TOOLBAR_VISIBLE = new RawContextKey( + 'browserEmulationToolbarVisible', + false, + localize('browser.emulationToolbarVisible', "Whether the browser emulation toolbar is visible") +); + +const CONTEXT_BROWSER_EMULATION_IS_MOBILE = new RawContextKey( + 'browserEmulationIsMobile', + false, + localize('browser.emulationIsMobile', "Whether the browser emulation is in mobile mode") +); + +const CONTEXT_BROWSER_EMULATION_HAS_USER_AGENT = new RawContextKey( + 'browserEmulationHasUserAgent', + false, + localize('browser.emulationHasUserAgent', "Whether the browser emulation has a custom user agent") +); + +/** + * A named device preset. Applying a preset stamps its `device` onto the + * active device profile and its `screen` (size only) onto the active screen + * profile, preserving the user's current zoom. + */ +export interface IBrowserDevicePreset { + readonly name: string; + readonly device?: IBrowserDeviceProfile; + readonly screen?: IBrowserScreenProfile; +} + +/** + * Keep track of the last used device and screen settings so we can restore them when the toolbar is opened. + * Note this isn't (currently) persisted in storage. + */ +const lastSettings = { + device: undefined as IBrowserDeviceProfile | undefined, + screen: undefined as IBrowserScreenProfile | undefined, +}; + +/** + * Toolbar shown above the browser viewport with device emulation controls + * (dimensions, DPR, zoom, and an action toolbar for presets / UA / mobile / close). + */ +class BrowserEmulationToolbar extends Disposable { + + readonly element: HTMLElement; + private readonly _groupWrapper: HTMLElement; + + private readonly _widthInput: InputBox; + private readonly _heightInput: InputBox; + private readonly _swapDimensionsAction: Action; + private readonly _dprInput: InputBox; + private readonly _zoom: SelectBox; + + private _suppressChange = false; + private _autoFitScale = 1; + + private static readonly ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2]; + private static readonly AUTO_INDEX = 0; + + constructor( + private readonly _feature: BrowserEditorEmulationSupport, + actionsContainer: HTMLElement, + hoverDelegate: IHoverDelegate, + @IContextViewService contextViewService: IContextViewService, + @IHoverService hoverService: IHoverService, + ) { + super(); + + this.element = $('.browser-emulation-toolbar'); + this.element.style.display = 'none'; + + this._groupWrapper = $('.browser-emulation-toolbar-groups'); + this.element.appendChild(this._groupWrapper); + + const dimensions = this._appendGroup('dimensions'); + const dimensionsLabel = $('span.browser-emulation-toolbar-label'); + dimensionsLabel.textContent = localize('browser.device.dimensionsLabel', "Dimensions:"); + dimensions.appendChild(dimensionsLabel); + this._widthInput = this._createNumberInput(dimensions, contextViewService, localize('browser.device.widthAriaLabel', "Viewport width"), 1, 9999); + + const swapDimensionsLabel = localize('browser.device.swapDimensionsTitle', "Swap Dimensions"); + this._swapDimensionsAction = this._register(new Action( + 'browser.device.swapDimensions', + swapDimensionsLabel, + ThemeIcon.asClassName(Codicon.arrowSwap), + false, + async () => this._feature.swapDimensions() + )); + const swapDimensionsBar = this._register(new ActionBar(dimensions, { hoverDelegate })); + swapDimensionsBar.push(this._swapDimensionsAction, { icon: true, label: false }); + + this._heightInput = this._createNumberInput(dimensions, contextViewService, localize('browser.device.heightAriaLabel', "Viewport height"), 1, 9999); + + // DPR override. Blank / 0 = system DPR. + const dprGroup = this._appendGroup('dpr'); + const dprLabel = $('span.browser-emulation-toolbar-label'); + dprLabel.textContent = localize('browser.device.dprLabel', "DPR:"); + this._register(hoverService.setupManagedHover(hoverDelegate, dprLabel, localize('browser.device.dprTitle', "Device pixel ratio (blank = system default)"))); + dprGroup.appendChild(dprLabel); + this._dprInput = this._createNumberInput(dprGroup, contextViewService, localize('browser.device.dprAriaLabel', "Device pixel ratio"), 0, 8, 'decimal'); + + const zoomGroup = this._appendGroup('zoom'); + const zoomLabel = $('span.browser-emulation-toolbar-label'); + zoomLabel.textContent = localize('browser.device.scaleLabel', "Scale:"); + zoomGroup.appendChild(zoomLabel); + this._zoom = this._register(new SelectBox( + this._buildZoomOptions(), + BrowserEmulationToolbar.AUTO_INDEX, + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('browser.device.zoomAriaLabel', "Zoom factor") } + )); + this._zoom.render(zoomGroup); + + this.element.appendChild($('.browser-emulation-toolbar-spacer')); + + this.element.appendChild(actionsContainer); + + this._registerEvents(); + } + + private _registerEvents(): void { + const commitDims = () => this._onDimensionInput(); + const onEnterDims = (e: KeyboardEvent) => { + if (e.keyCode === KeyCode.Enter) { + this._onDimensionInput(); + } + }; + this._register(addDisposableListener(this._widthInput.inputElement, EventType.CHANGE, commitDims)); + this._register(addDisposableListener(this._heightInput.inputElement, EventType.CHANGE, commitDims)); + this._register(addDisposableListener(this._widthInput.inputElement, EventType.KEY_DOWN, onEnterDims)); + this._register(addDisposableListener(this._heightInput.inputElement, EventType.KEY_DOWN, onEnterDims)); + + this._register(addDisposableListener(this._dprInput.inputElement, EventType.CHANGE, () => this._onDprInput())); + this._register(addDisposableListener(this._dprInput.inputElement, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.keyCode === KeyCode.Enter) { + this._onDprInput(); + } + })); + + this._register(this._zoom.onDidSelect(e => { + const model = this._feature.model; + if (this._suppressChange || !model?.device) { + return; + } + const screen = this._feature.screen ?? {}; + const scale = e.index === BrowserEmulationToolbar.AUTO_INDEX + ? undefined + : BrowserEmulationToolbar.ZOOM_PRESETS[e.index - 1]; + if (scale === screen.scale) { + return; + } + this._feature.setScreen({ ...screen, scale }); + })); + } + + get isVisible(): boolean { + return this.element.style.display !== 'none'; + } + + show(): void { + this.element.style.display = ''; + } + + hide(): void { + this.element.style.display = 'none'; + } + + setAutoFitScale(scale: number): void { + if (this._autoFitScale === scale) { + return; + } + const oldPercent = Math.round(this._autoFitScale * 100); + this._autoFitScale = scale; + const newPercent = Math.round(scale * 100); + if (oldPercent !== newPercent) { + // setOptions rebuilds