diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index 89659bbace250..b75a89658f7f8 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -12,17 +12,19 @@ use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, BufReader}; +use crate::auth::{Auth, AuthProvider}; use crate::constants; use crate::log; use crate::options::TelemetryLevel; use crate::state::LauncherPaths; use crate::tunnels::agent_host::{ classify_agent_host, AgentHostConfig, AgentHostManager, AgentHostReuseDecision, - AgentHostSidecar, LoopbackAuth, + AgentHostSidecar, GithubEnvironmentConfig, LoopbackAuth, }; use crate::tunnels::agent_host_registry::{self, AgentHostEndpointIdentity, AgentHostServerType}; use crate::tunnels::code_server::CodeServerArgs; use crate::tunnels::idle_timeout::{self, TokioIdleSleeper}; +use crate::tunnels::legal; use crate::tunnels::shutdown_signal::ShutdownRequest; use crate::tunnels::user_data_path::resolve_user_data_path; use crate::update_service::Platform; @@ -64,7 +66,11 @@ const SUPERVISOR_READY_TIMEOUT: Duration = Duration::from_secs(5 * 60); /// port, runs the proxy accept loop, and manages the underlying VS Code /// server as a regular child process so the supervisor can kill+respawn /// it on update. -pub async fn agent_host(ctx: CommandContext, args: AgentHostArgs) -> Result { +pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result { + if args.github_environment { + args.foreground = true; + return run_supervisor(ctx, args).await; + } if std::env::var_os(SUPERVISOR_ENV).is_some() { return run_supervisor(ctx, args).await; } @@ -252,6 +258,12 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res } let platform: Platform = PreReqChecker::new().verify().await?; + let github_environment = if args.github_environment { + legal::require_consent(&ctx.paths, args.accept_server_license_terms)?; + Some(github_environment_config(&ctx, &args).await?) + } else { + None + }; if !args.without_connection_token { if let Some(p) = args.connection_token_file.as_deref() { @@ -286,6 +298,8 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())), AgentHostConfig { server_data_dir: args.server_data_dir.clone(), + user_data_dir: args.user_data_dir.clone(), + github_environment, telemetry_level: if ctx.args.global_options.disable_telemetry { Some(TelemetryLevel::Off) } else { @@ -343,6 +357,12 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res ) .await?; let bound_port = sidecar.bound_addr().port(); + if args.github_environment { + if let Err(error) = manager.ensure_server().await { + sidecar.shutdown().await; + return Err(error.into()); + } + } let product = constants::QUALITYLESS_PRODUCT_NAME; let token_suffix = args @@ -360,7 +380,15 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res .as_deref() .and_then(|h| h.parse::().ok()) .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); - output::print_network_lines(bound_port, banner_listen_ip, &token_suffix); + if args.github_environment { + output::print_banner_line("Remote", "GitHub environment"); + output::print_banner_line( + "Local", + &format!("ws://localhost:{bound_port}{token_suffix}"), + ); + } else { + output::print_network_lines(bound_port, banner_listen_ip, &token_suffix); + } print_manage_banner_line(); output::print_banner_footer(); let _ = std::io::stdout().flush(); @@ -381,6 +409,17 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res let shutdown_rx = ShutdownRequest::create_rx([ShutdownRequest::CtrlC]); match (idle_timeout_duration, activity_rx) { + _ if args.github_environment => { + tokio::select! { + result = sidecar.serve(shutdown_rx) => { + result?; + } + result = manager.maintain_server() => { + sidecar.shutdown().await; + result?; + } + } + } (Some(duration), Some(rx)) => { tokio::select! { result = sidecar.serve(shutdown_rx) => { @@ -404,6 +443,82 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res Ok(0) } +async fn github_environment_config( + ctx: &CommandContext, + args: &AgentHostArgs, +) -> Result { + if args + .name + .as_deref() + .is_some_and(|name| name.trim().is_empty()) + { + return Err(wrap("", "GitHub environment name must not be empty").into()); + } + let roots = if args.github_environment_root.is_empty() { + vec![std::env::current_dir().map_err(|e| wrap(e, "could not resolve current directory"))?] + } else { + args.github_environment_root.clone() + }; + let roots = roots + .iter() + .map(|root| { + let path = fs::canonicalize(root) + .map_err(|e| wrap(e, format!("could not resolve project {}", root.display())))?; + if !path.is_dir() { + return Err(wrap( + "", + format!("project {} is not a directory", root.display()), + )); + } + Ok(path.to_string_lossy().into_owned()) + }) + .collect::, _>>()?; + let mut auth = Auth::with_namespace( + &ctx.paths, + ctx.log.clone(), + Some("github-environments".into()), + ); + auth.set_provider(AuthProvider::Github); + let credential = if auth.get_current_credential()?.is_some() { + auth.get_credential().await? + } else { + auth.login_with_scopes( + AuthProvider::Github, + Some("read:user+user:email+repo+workflow".into()), + ) + .await? + }; + let credential = credential.access_token().to_string(); + if credential.is_empty() { + return Err(wrap("", "GitHub environment access token must not be empty").into()); + } + #[derive(serde::Deserialize)] + struct GithubUser { + id: u64, + } + let response = ctx + .http + .get("https://api.github.com/user") + .bearer_auth(&credential) + .header("User-Agent", constants::get_default_user_agent()) + .send() + .await?; + if !response.status().is_success() { + return Err(crate::util::errors::StatusError::from_res(response) + .await? + .into()); + } + let user: GithubUser = response.json().await?; + Ok(GithubEnvironmentConfig { + base_url: "https://api.github.com".into(), + account_id: user.id.to_string(), + credential, + roots, + name: args.name.clone(), + live: true, + }) +} + /// Resolve the user's `--host`/`--port` choice into a single /// [`SocketAddr`]. Defaults to loopback when `--host` is unset. fn resolve_listen_addr(args: &AgentHostArgs) -> Result { diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index 2827ed001984c..a90123081d788 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -298,20 +298,35 @@ pub struct AgentHostArgs { /// Alias for `code tunnel`: expose remote editor and agent host access. #[clap( long, + group = "remote_host", conflicts_with_all = [ "host", "port", "connection_token", "connection_token_file", "without_connection_token", "replace", "new_instance", "foreground", "idle_timeout" ] )] pub tunnel: bool, - /// Sets the machine name for the tunnel. - #[clap(long, requires = "tunnel")] + /// Register this agent host as a GitHub environment. Runs in the foreground. + #[clap( + long, + group = "remote_host", + conflicts_with_all = [ + "host", "port", "connection_token", "connection_token_file", + "without_connection_token", "replace", "new_instance", "foreground", "idle_timeout", + "random_name", "tunnel_id", "host_token", "cluster", "tunnel_name" + ] + )] + pub github_environment: bool, + /// Grant access to a project directory. Repeat for multiple projects. Defaults to the current directory. + #[clap(long, requires = "github_environment", conflicts_with = "tunnel")] + pub github_environment_root: Vec, + /// Sets the machine name for the tunnel or GitHub environment. + #[clap(long, requires = "remote_host")] pub name: Option, /// Randomly name the machine for the tunnel. #[clap(long, requires = "tunnel")] pub random_name: bool, - /// Accept the server license terms when starting a tunnel. - #[clap(long, requires = "tunnel")] + /// Accept the server license terms when starting a tunnel or GitHub environment. + #[clap(long, requires = "remote_host")] pub accept_server_license_terms: bool, /// Automatically terminate this supervisor once no client has been @@ -360,7 +375,7 @@ pub struct AgentArgs { #[derive(Subcommand, Debug, Clone)] pub enum AgentSubcommand { - /// Start a local agent host server. + /// Start an agent host server, with optional remote access. Host(AgentHostArgs), /// List active sessions on a running agent host. @@ -1151,6 +1166,103 @@ mod tests { } } + #[test] + fn agent_github_environment_accepts_remote_options() { + for command in [vec!["code", "agent"], vec!["code", "agent", "host"]] { + let mut argv = command; + argv.extend([ + "--github-environment", + "--name", + "build-machine", + "--github-environment-root", + "project-a", + "--github-environment-root", + "project-b", + "--user-data-dir", + "profile", + "--server-data-dir", + "server-data", + "--accept-server-license-terms", + ]); + let args = parse_agent_host_args(&argv); + assert_eq!( + ( + args.github_environment, + args.name.as_deref(), + args.github_environment_root, + args.accept_server_license_terms + ), + ( + true, + Some("build-machine"), + vec!["project-a".into(), "project-b".into()], + true + ) + ); + } + } + + #[test] + fn agent_github_environment_rejects_incompatible_options() { + for command in [vec!["code", "agent"], vec!["code", "agent", "host"]] { + for options in [ + vec!["--tunnel"], + vec!["--host", "0.0.0.0"], + vec!["--port", "0"], + vec!["--connection-token", "token"], + vec!["--connection-token-file", "token"], + vec!["--without-connection-token"], + vec!["--replace"], + vec!["--new-instance"], + vec!["--foreground"], + vec!["--idle-timeout", "300"], + vec!["--random-name"], + vec!["--tunnel-id", "id"], + vec!["--host-token", "token"], + vec!["--cluster", "cluster"], + vec!["--tunnel-name", "name"], + ] { + let mut argv = command.clone(); + argv.push("--github-environment"); + argv.extend(options); + assert_eq!( + IntegratedCli::try_parse_from(&argv).unwrap_err().kind(), + clap::error::ErrorKind::ArgumentConflict, + "{argv:?}" + ); + } + } + } + + #[test] + fn github_environment_options_require_hosting() { + for command in [vec!["code", "agent"], vec!["code", "agent", "host"]] { + let mut argv = command.clone(); + argv.extend(["--github-environment-root", "project"]); + assert!(IntegratedCli::try_parse_from(&argv).is_err()); + let mut argv = command; + argv.extend(["--tunnel", "--github-environment-root", "project"]); + assert!(IntegratedCli::try_parse_from(&argv).is_err()); + } + } + + #[test] + fn github_environment_rejects_token_file_option() { + for command in [vec!["code", "agent"], vec!["code", "agent", "host"]] { + let mut argv = command; + argv.extend([ + "--github-environment", + "--github-environment-token-file", + "token", + ]); + assert_eq!( + IntegratedCli::try_parse_from(&argv).unwrap_err().kind(), + clap::error::ErrorKind::UnknownArgument, + "{argv:?}" + ); + } + } + #[test] fn agent_tunnel_alias_forwards_tunnel_options() { for command in [vec!["code", "agent"], vec!["code", "agent", "host"]] { diff --git a/cli/src/tunnels/agent_host.rs b/cli/src/tunnels/agent_host.rs index f9b9da40540d9..d8759d85e608a 100644 --- a/cli/src/tunnels/agent_host.rs +++ b/cli/src/tunnels/agent_host.rs @@ -36,7 +36,7 @@ use crate::state::LauncherPaths; use crate::update_service::{ unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, }; -use crate::util::command::{kill_tree, new_script_command}; +use crate::util::command::{kill_tree, new_script_command, DetachFromParent}; use crate::util::errors::{wrap, AnyError, CodeError}; use crate::util::http::{self, BoxedHttp}; use crate::util::http::{empty_body, full_body, HyperBody}; @@ -62,6 +62,29 @@ pub const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); /// startup; its presence is what tells the server that it has a managing /// CLI and may therefore advertise the management RPC method to clients. pub const MANAGEMENT_SOCKET_ENV: &str = "VSCODE_AGENT_HOST_MANAGEMENT_SOCKET"; +const GITHUB_ENVIRONMENT_OPTIONS_ENV: &str = "VSCODE_CLI_GITHUB_ENVIRONMENT_OPTIONS"; +const GITHUB_ENVIRONMENT_READY_PREFIX: &str = "__VSCODE_GITHUB_ENVIRONMENT_READY__:"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubEnvironmentConfig { + pub base_url: String, + pub account_id: String, + pub credential: String, + pub roots: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub live: bool, +} + +impl std::fmt::Debug for GithubEnvironmentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GithubEnvironmentConfig") + .field("name", &self.name) + .field("roots", &self.roots) + .finish_non_exhaustive() + } +} /// Environment variable holding a commit SHA used to override the agent /// host version the *first* time it is resolved. When set, the agent host @@ -98,6 +121,8 @@ const UPGRADE_KILL_DELAY: Duration = Duration::from_secs(3); #[derive(Clone, Debug)] pub struct AgentHostConfig { pub server_data_dir: Option, + pub user_data_dir: Option, + pub github_environment: Option, pub telemetry_level: Option, pub without_connection_token: bool, pub connection_token: Option, @@ -128,6 +153,7 @@ pub struct AgentHostManager { update_service: UpdateService, /// The latest known release, with the time it was checked. latest_release: Mutex>, + starting: Mutex<()>, /// The currently running server, if any. running: Mutex>, /// Barrier that opens when a server is ready (socket path available). @@ -170,6 +196,7 @@ impl AgentHostManager { platform, cache, latest_release: Mutex::new(None), + starting: Mutex::new(()), running: Mutex::new(None), ready: Mutex::new(None), management_socket_path: get_socket_name(), @@ -180,7 +207,8 @@ impl AgentHostManager { } /// Returns an endpoint to a running agent host, starting one if needed. - async fn ensure_server(self: &Arc) -> Result { + pub async fn ensure_server(self: &Arc) -> Result { + let _starting = self.starting.lock().await; // Fast path: if we already have a barrier, wait on it { let ready = self.ready.lock().await; @@ -188,7 +216,10 @@ impl AgentHostManager { if barrier.is_open() { // Check if the process is still running let running = self.running.lock().await; - if running.is_some() { + if running + .as_ref() + .is_some_and(|server| !server.exited.is_open()) + { return barrier .clone() .wait() @@ -213,6 +244,22 @@ impl AgentHostManager { self.start_server().await } + pub async fn maintain_server(self: &Arc) -> Result<(), CodeError> { + loop { + self.ensure_server().await?; + let exited = self + .running + .lock() + .await + .as_ref() + .map(|server| server.exited.clone()); + if let Some(mut exited) = exited { + let _ = exited.wait().await; + } + warning!(self.log, "GitHub environment server exited; restarting"); + } + } + /// Starts the server with the latest already-downloaded version. /// Only blocks on a network fetch if no version has been downloaded yet. async fn start_server(self: &Arc) -> Result { @@ -265,23 +312,37 @@ impl AgentHostManager { let agent_host_socket = get_socket_name(); let mut cmd = new_script_command(&executable); - cmd.stdin(std::process::Stdio::null()); + if self.config.github_environment.is_some() { + cmd.stdin(std::process::Stdio::piped()); + cmd.detach_from_parent(); + } else { + cmd.stdin(std::process::Stdio::null()); + } cmd.stderr(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); cmd.arg("--socket-path"); cmd.arg(get_socket_name()); cmd.arg("--agent-host-path"); cmd.arg(&agent_host_socket); - cmd.args([ - "--start-server", - "--accept-server-license-terms", - "--enable-remote-auto-shutdown", - ]); + cmd.args(["--start-server", "--accept-server-license-terms"]); + if let Some(options) = &self.config.github_environment { + cmd.env( + GITHUB_ENVIRONMENT_OPTIONS_ENV, + serde_json::to_string(options).unwrap(), + ); + } else { + cmd.arg("--enable-remote-auto-shutdown"); + cmd.env_remove(GITHUB_ENVIRONMENT_OPTIONS_ENV); + } if let Some(a) = &self.config.server_data_dir { cmd.arg("--server-data-dir"); cmd.arg(a); } + if let Some(a) = &self.config.user_data_dir { + cmd.arg("--user-data-dir"); + cmd.arg(a); + } if let Some(level) = self.config.telemetry_level { cmd.arg("--telemetry-level"); cmd.arg(level.to_string()); @@ -303,6 +364,8 @@ impl AgentHostManager { return; } }; + // Child::wait closes child.stdin, even when its future is cancelled by select!. + let mut stdin = child.stdin.take(); // Held until this server process ends; see `AgentHostManager::activity`. let activity_guard = self.activity.as_ref().map(|a| a.client_connected()); @@ -316,7 +379,12 @@ impl AgentHostManager { // Wait for readiness with a timeout let mut opener = Some(opener); let socket_path = agent_host_socket.clone(); - let startup_deadline = tokio::time::sleep(STARTUP_TIMEOUT); + let startup_timeout = if self.config.github_environment.is_some() { + Duration::from_secs(5 * 60) + } else { + STARTUP_TIMEOUT + }; + let startup_deadline = tokio::time::sleep(startup_timeout); tokio::pin!(startup_deadline); let mut ready = false; @@ -324,22 +392,37 @@ impl AgentHostManager { tokio::select! { Ok(Some(l)) = stdout.next_line() => { debug!(self.log, "[{} stdout]: {}", commit_prefix, l); - if !ready && l.contains("Agent host server listening on") { - ready = true; - if let Some(o) = opener.take() { - o.open(Ok(socket_path.clone())); + let is_ready = if self.config.github_environment.is_some() { + if let Some(id) = l.strip_prefix(GITHUB_ENVIRONMENT_READY_PREFIX) { + info!(self.log, "GitHub environment ready (ID {})", id); + true + } else { + false } + } else { + l.contains("Agent host server listening on") + }; + if !ready && is_ready { + ready = true; } } Ok(Some(l)) = stderr.next_line() => { debug!(self.log, "[{} stderr]: {}", commit_prefix, l); } _ = &mut startup_deadline, if !ready => { + if self.config.github_environment.is_some() { + if let Some(pid) = child.id() { + let _ = kill_tree(pid).await; + } + let _ = child.start_kill(); + let _ = child.wait().await; + if let Some(o) = opener.take() { + o.open(Err("GitHub environment did not become ready within 5 minutes; check the agent host supervisor log".into())); + } + return; + } warning!(self.log, "[{}]: Server did not become ready within {}s", commit_prefix, STARTUP_TIMEOUT.as_secs()); // Don't fail — the server may still start up, just slowly - if let Some(o) = opener.take() { - o.open(Ok(socket_path.clone())); - } ready = true; } e = child.wait() => { @@ -370,6 +453,9 @@ impl AgentHostManager { kill, }); } + if let Some(opener) = opener.take() { + opener.open(Ok(socket_path)); + } info!(self.log, "[{}]: Server ready", commit_prefix); @@ -398,21 +484,37 @@ impl AgentHostManager { let status = tokio::select! { status = child.wait() => status, Ok(reap_timeout) = &mut kill_rx => { - if let Some(pid) = child.id() { - let _ = kill_tree(pid).await; - } - // Bound the wait so a process that ignores SIGTERM can't - // wedge the supervisor's shutdown or upgrade path. - match tokio::time::timeout(reap_timeout, child.wait()).await { - Ok(status) => status, - Err(_) => { - warning!( - self.log, - "Server did not exit within {:?} after kill_tree; escalating to SIGKILL", - reap_timeout - ); - let _ = child.start_kill(); - child.wait().await + let graceful_status = if self.config.github_environment.is_some() { + drop(stdin.take()); + match tokio::time::timeout(reap_timeout, child.wait()).await { + Ok(status) => Some(status), + Err(_) => { + warning!(self.log, "GitHub environment did not shut down gracefully; terminating it"); + None + } + } + } else { + None + }; + if let Some(status) = graceful_status { + status + } else { + if let Some(pid) = child.id() { + let _ = kill_tree(pid).await; + } + // Bound the wait so a process that ignores SIGTERM can't + // wedge the supervisor's shutdown or upgrade path. + match tokio::time::timeout(reap_timeout, child.wait()).await { + Ok(status) => status, + Err(_) => { + warning!( + self.log, + "Server did not exit within {:?} after kill_tree; escalating to SIGKILL", + reap_timeout + ); + let _ = child.start_kill(); + child.wait().await + } } } } @@ -646,8 +748,12 @@ impl AgentHostManager { /// The kill is carried out by the task that owns the process, before it /// is reaped, so it can't target a PID the OS has already reused. pub async fn kill_running_server(&self) { - self.kill_running_server_within(Duration::from_secs(5)) - .await + let timeout = if self.config.github_environment.is_some() { + Duration::from_secs(25) + } else { + Duration::from_secs(5) + }; + self.kill_running_server_within(timeout).await } /// [`Self::kill_running_server`], escalating to a forced kill if the @@ -878,7 +984,7 @@ impl AgentHostManager { self_clone.kill_running_server().await; // Eagerly spin up the new server so the next dial sees a // ready endpoint instead of paying for startup again. - match self_clone.start_server().await { + match self_clone.ensure_server().await { Ok(_) => info!(self_clone.log, "Restarted agent host on {}", release_commit), Err(e) => warning!( self_clone.log, @@ -2231,6 +2337,8 @@ mod tests { Arc::new(ReqwestSimpleHttp::new()), AgentHostConfig { server_data_dir: None, + user_data_dir: None, + github_environment: None, telemetry_level: None, without_connection_token: true, connection_token: None, @@ -2279,6 +2387,264 @@ mod tests { server } + #[cfg(unix)] + #[tokio::test] + async fn github_environment_waits_for_remote_readiness_and_forwards_bootstrap() { + let dir = tempfile::tempdir().unwrap(); + let mut manager = make_test_manager_with_activity(dir.path(), None); + let options = GithubEnvironmentConfig { + base_url: "https://api.github.com".into(), + account_id: "123".into(), + credential: "test-credential".into(), + roots: vec![dir.path().to_string_lossy().into_owned()], + name: Some("build-machine".into()), + live: true, + }; + let config = &mut Arc::get_mut(&mut manager).unwrap().config; + config.github_environment = Some(options.clone()); + config.user_data_dir = Some(dir.path().join("profile").to_string_lossy().into_owned()); + let gate = dir.path().join("relay-ready"); + let bootstrap = dir.path().join("bootstrap.json"); + let argv = dir.path().join("argv"); + let manager_for_start = manager.clone(); + let path = dir.path().to_path_buf(); + let body = format!( + "printf '%s' \"$VSCODE_CLI_GITHUB_ENVIRONMENT_OPTIONS\" > '{}'\n\ + printf '%s\\n' \"$@\" > '{}'\n\ + i=0; while [ ! -e '{}' ] && [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done\n\ + echo '{}environment-123'", + bootstrap.display(), + argv.display(), + gate.display(), + GITHUB_ENVIRONMENT_READY_PREFIX, + ); + let mut start = + tokio::spawn(async move { start_fake_server(&manager_for_start, &path, &body).await }); + let early = tokio::time::timeout(Duration::from_millis(200), &mut start).await; + std::fs::write(&gate, b"").unwrap(); + let server = tokio::time::timeout(Duration::from_secs(5), start) + .await + .unwrap() + .unwrap(); + server.await.unwrap(); + assert!( + early.is_err(), + "local listener must not report GitHub environment readiness" + ); + assert_eq!( + serde_json::from_str::(&std::fs::read_to_string(bootstrap).unwrap()) + .unwrap(), + serde_json::to_value(&options).unwrap() + ); + let argv = std::fs::read_to_string(argv).unwrap(); + assert!(argv.contains("--user-data-dir\n")); + assert!(!argv.contains("--enable-remote-auto-shutdown")); + assert!(!argv.contains("test-credential")); + assert!(!format!("{options:?}").contains("test-credential")); + } + + #[cfg(unix)] + #[tokio::test] + async fn github_environment_shuts_down_by_closing_parent_stdin() { + let dir = tempfile::tempdir().unwrap(); + let mut manager = make_test_manager_with_activity(dir.path(), None); + Arc::get_mut(&mut manager) + .unwrap() + .config + .github_environment = Some(GithubEnvironmentConfig { + base_url: "https://api.github.com".into(), + account_id: "123".into(), + credential: "test-credential".into(), + roots: vec![dir.path().to_string_lossy().into_owned()], + name: None, + live: true, + }); + let stopped = dir.path().join("graceful-stop"); + let server = start_fake_server( + &manager, + dir.path(), + &format!( + "echo '{}environment-123'\nread ignored\nprintf 'withdrawn' > '{}'", + GITHUB_ENVIRONMENT_READY_PREFIX, + stopped.display(), + ), + ) + .await; + tokio::time::timeout(Duration::from_secs(3), manager.kill_running_server()) + .await + .unwrap(); + server.await.unwrap(); + assert_eq!(std::fs::read_to_string(stopped).unwrap(), "withdrawn"); + } + + #[cfg(unix)] + async fn make_cached_github_environment_manager( + dir: &Path, + body: &str, + ) -> Arc { + use std::os::unix::fs::PermissionsExt; + + let mut manager = make_test_manager(dir); + Arc::get_mut(&mut manager) + .unwrap() + .config + .github_environment = Some(GithubEnvironmentConfig { + base_url: "https://api.github.com".into(), + account_id: "123".into(), + credential: "test-credential".into(), + roots: vec![dir.to_string_lossy().into_owned()], + name: None, + live: true, + }); + let release = Release { + name: String::new(), + commit: "0123456789abcdef".into(), + platform: Platform::LinuxX64, + target: TargetKind::Server, + quality: Quality::Insiders, + }; + let bin = dir + .join(get_server_folder_name(release.quality, &release.commit)) + .join(SERVER_FOLDER_NAME) + .join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join(release.quality.server_entrypoint()); + std::fs::write(&script, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(script, std::fs::Permissions::from_mode(0o755)).unwrap(); + *manager.latest_release.lock().await = Some((Instant::now(), release)); + manager + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn github_environment_readiness_waits_for_running_server_state() { + let dir = tempfile::tempdir().unwrap(); + let emitted = dir.path().join("readiness-emitted"); + let manager = make_cached_github_environment_manager( + dir.path(), + &format!( + "echo '{}environment-123'\nprintf 'ready' > '{}'\nread ignored", + GITHUB_ENVIRONMENT_READY_PREFIX, + emitted.display(), + ), + ) + .await; + let running = manager.running.lock().await; + let manager_for_start = manager.clone(); + let start = tokio::spawn(async move { manager_for_start.ensure_server().await }); + tokio::time::timeout(Duration::from_secs(5), async { + while !emitted.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let mut ready = manager.ready.lock().await.as_ref().unwrap().clone(); + let early = tokio::time::timeout(Duration::from_millis(100), ready.wait()).await; + drop(running); + tokio::time::timeout(Duration::from_secs(5), start) + .await + .unwrap() + .unwrap() + .unwrap(); + let recorded = manager.running.lock().await.is_some(); + manager.kill_running_server().await; + assert!( + early.is_err(), + "readiness must wait until the process is tracked" + ); + assert!(recorded); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn github_environment_maintainer_restarts_then_stops_without_orphaning_server() { + let dir = tempfile::tempdir().unwrap(); + let starts = dir.path().join("starts"); + let crash = dir.path().join("crash"); + let stopped = dir.path().join("stopped"); + let manager = make_cached_github_environment_manager( + dir.path(), + &format!( + "echo '{}environment-123'\n\ + if [ ! -e '{}' ]; then\n\ + printf 'first\\n' > '{}'\n\ + while [ ! -e '{}' ]; do sleep 0.01; done\n\ + exit 1\n\ + fi\n\ + printf 'second\\n' >> '{}'\n\ + read ignored\n\ + printf 'withdrawn' > '{}'", + GITHUB_ENVIRONMENT_READY_PREFIX, + starts.display(), + starts.display(), + crash.display(), + starts.display(), + stopped.display(), + ), + ) + .await; + let manager_for_maintenance = manager.clone(); + let (shutdown, mut shutdown_rx) = oneshot::channel::<()>(); + let maintenance = tokio::spawn(async move { + tokio::select! { + result = manager_for_maintenance.maintain_server() => result.unwrap(), + _ = &mut shutdown_rx => {}, + } + }); + manager.ensure_server().await.unwrap(); + std::fs::write(crash, b"").unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while !starts.exists() || std::fs::read_to_string(&starts).unwrap() != "first\nsecond\n" + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + shutdown.send(()).unwrap(); + maintenance.await.unwrap(); + tokio::time::timeout(Duration::from_secs(3), manager.kill_running_server()) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(starts).unwrap(), "first\nsecond\n"); + assert_eq!(std::fs::read_to_string(stopped).unwrap(), "withdrawn"); + assert!(manager.running.lock().await.is_none()); + } + + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn github_environment_serializes_upgrade_restart_with_maintenance() { + let dir = tempfile::tempdir().unwrap(); + let starts = dir.path().join("starts"); + let manager = make_cached_github_environment_manager( + dir.path(), + &format!( + "printf 'started\\n' >> '{}'\necho '{}environment-123'\nread ignored", + starts.display(), + GITHUB_ENVIRONMENT_READY_PREFIX, + ), + ) + .await; + let manager_for_maintenance = manager.clone(); + let maintenance = + tokio::spawn(async move { manager_for_maintenance.maintain_server().await }); + manager.ensure_server().await.unwrap(); + manager.kill_running_server().await; + tokio::time::timeout(Duration::from_secs(5), manager.ensure_server()) + .await + .unwrap() + .unwrap(); + maintenance.abort(); + let _ = maintenance.await; + manager.kill_running_server().await; + assert_eq!( + std::fs::read_to_string(starts).unwrap(), + "started\nstarted\n" + ); + assert!(manager.running.lock().await.is_none()); + } + /// Regression test for managed agent hosts losing in-flight sessions /// 300s after the last client disconnects: `--idle-timeout` only /// counted connected clients, so it killed the server even while a diff --git a/extensions/markdown-language-features/README.md b/extensions/markdown-language-features/README.md index f0431fccdd455..2052521da84b8 100644 --- a/extensions/markdown-language-features/README.md +++ b/extensions/markdown-language-features/README.md @@ -5,31 +5,3 @@ ## Features See [Markdown in Visual Studio Code](https://code.visualstudio.com/docs/languages/markdown) to learn about the features of this extension. - -### Rich editor RPC - -The rich editor uses a private point-to-point `@vscode/hubrpc` connection between -the extension host and webview. Shared runtime-validated interfaces in -`src/preview/markdownEditorProtocol.ts` define document edits, commands, comments, -diff markers, highlighting, rich links, and embedded-editor routing. No global -Hub or capability service is required. - -The interfaces use `zod/mini` to retain runtime validation and schema hashing -without bundling Zod's full API and locale tables. HubRPC and Zod are development -dependencies: the desktop extension, browser extension, and rich-editor builds -bundle the runtime code they use. - -Embedded web-editor support is loaded on demand when a fenced block is rendered -and code-block providers are registered. Until loading completes, blocks use -their normal rendering. Provider updates and completed loading refresh the -blocks; the adapter still owns provider selection. Load failures are reported -through the existing code-block diagnostics and can be retried by reopening the -editor. - -The `TextDocument` remains authoritative: local edits use the existing epoch -queue and `WorkspaceEdit`, external changes replace the renderer mirror, and -history commands drain accepted edits before invoking host undo/redo. Each -webview generation has its own secret-authenticated transport and connection; -reload/disposal closes pending requests and detaches listeners. Nested code -block editors do not receive this secret and keep their separately sandboxed -protocols; only their opaque host-transport payloads cross named RPC methods. diff --git a/extensions/markdown-language-features/markdown-editor-src/completionController.ts b/extensions/markdown-language-features/markdown-editor-src/completionController.ts new file mode 100644 index 0000000000000..b4c47f32ae205 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/completionController.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorModel, EditorView, Selection, SuggestModel, SuggestWidget } from '@vscode/markdown-editor'; +import { Disposable, autorun } from '@vscode/observables'; +import type { MarkdownCompletion, MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; +import '@vscode/markdown-editor/suggest.css'; +import './languageFeatures.css'; + +export class CompletionController extends Disposable { + readonly #editor: EditorModel; + readonly #view: EditorView; + readonly #host: MarkdownEditorHost; + readonly #epoch: () => number; + readonly #suggest: SuggestModel; + #requestId = 0; + #active: number | undefined; + #timer: ReturnType | undefined; + #applying = false; + #disposed = false; + + constructor(editor: EditorModel, view: EditorView, host: MarkdownEditorHost, epoch: () => number) { + super(); + this.#editor = editor; + this.#view = view; + this.#host = host; + this.#epoch = epoch; + this.#suggest = new SuggestModel({ onAccept: item => { void this.#accept(item); }, onCancel: () => this.cancel() }); + const widget = this._register(new SuggestWidget({ + model: this.#suggest, focusTarget: view.element, id: `markdown-suggest-${crypto.randomUUID()}`, + })); + widget.element.classList.add('md-completion-overlay'); + this._register(view.mountOverlay(widget.element, 'above-decorations')); + this._register({ dispose: () => this.#suggest.close() }); + const layout = (): void => { + const caret = view.caretRect.get(); + if (!caret || this.#suggest.state.get().kind === 'closed') { return; } + const container = view.overlayContainer.getBoundingClientRect(); + const editorBounds = view.element.getBoundingClientRect(); + const top = Math.max(0, -container.top); + const bottom = Math.min(window.innerHeight, editorBounds.bottom) - container.top; + const below = Math.max(0, bottom - caret.y - caret.height); + const above = Math.max(0, caret.y - top); + const flipped = below < widget.element.offsetHeight && above > below; + widget.element.style.setProperty('--md-suggest-max-height', `${Math.max(19, (flipped ? above : below) - 4)}px`); + widget.element.style.left = `${Math.max(0, Math.min(caret.x, view.overlayContainer.clientWidth - widget.element.offsetWidth))}px`; + widget.element.style.top = `${flipped ? Math.max(top, caret.y - widget.element.offsetHeight) : caret.y + caret.height}px`; + }; + this._register(autorun(reader => { + this.#suggest.state.read(reader); + view.caretRect.read(reader); + layout(); + })); + const resize = new ResizeObserver(layout); + resize.observe(widget.element); + resize.observe(view.element); + document.addEventListener('scroll', layout, true); + window.addEventListener('resize', layout); + this._register({ + dispose: () => { + resize.disconnect(); + document.removeEventListener('scroll', layout, true); + window.removeEventListener('resize', layout); + } + }); + let previousSource = editor.sourceText.get().value; + this._register(autorun(reader => { + const source = editor.sourceText.read(reader).value; + editor.selection.read(reader); + const readonly = editor.readonlyMode.read(reader); + const changed = previousSource !== source; + previousSource = source; + if (this.#applying) { return; } + this.cancel(); + if (changed && !readonly && document.activeElement === view.element && document.hasFocus()) { + this.#timer = setTimeout(() => { this.#timer = undefined; void this.start(true); }, 150); + } + })); + const keydown = (event: KeyboardEvent): void => { + if (event.code === 'Space' && event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + event.stopPropagation(); + void this.start(); + } + }; + const blur = (): void => this.cancel(); + view.element.addEventListener('keydown', keydown, true); + view.element.addEventListener('blur', blur); + window.addEventListener('blur', blur); + this._register({ + dispose: () => { + view.element.removeEventListener('keydown', keydown, true); + view.element.removeEventListener('blur', blur); + window.removeEventListener('blur', blur); + } + }); + } + + async start(automatic = false): Promise { + this.cancel(); + const selection = this.#editor.selection.get(); + if (!selection || this.#editor.readonlyMode.get()) { + if (!automatic) { this.#suggest.showError('Switch to editing mode and place the caret to request suggestions.'); } + return; + } + const requestId = ++this.#requestId; + this.#active = requestId; + if (!automatic) { this.#suggest.showLoading(); } + try { + const result = await this.#host.completions({ requestId, offset: selection.active, editEpoch: this.#epoch(), automatic }); + if (this.#disposed || this.#active !== requestId) { return; } + if (automatic && !result.items.length) { this.cancel(); return; } + this.#suggest.show(result.items.map(item => item.unsupported ? { ...item, detail: item.unsupported } : item)); + } catch (error) { + if (!this.#disposed && this.#active === requestId) { this.#suggest.showError(error instanceof Error ? error.message : String(error)); } + } + } + + async #accept(item: MarkdownCompletion): Promise { + if (item.unsupported) { this.#suggest.showError(item.unsupported); return; } + const requestId = this.#active; + if (requestId === undefined) { return; } + this.#applying = true; + try { + const result = await this.#host.acceptCompletion({ requestId, id: item.id }); + if (!this.#disposed && this.#active === requestId && result.editEpoch === this.#epoch()) { + if (result.offset !== undefined) { + this.#editor.selection.set(Selection.collapsed(Math.min(result.offset, this.#editor.sourceText.get().value.length)), undefined); + this.#view.focus(); + } + this.#applying = false; + if (result.warning) { this.#suggest.showError(result.warning); } + else if (result.retrigger) { await this.start(); } + } + } catch (error) { + if (!this.#disposed && this.#active === requestId) { this.#suggest.showError(error instanceof Error ? error.message : String(error)); } + } finally { + this.#applying = false; + } + } + + cancel(): void { + clearTimeout(this.#timer); + this.#timer = undefined; + const requestId = this.#active; + this.#active = undefined; + this.#suggest.close(); + if (requestId !== undefined) { + void this.#host.cancelCompletions({ requestId }).catch(error => { + if (!this.#disposed) { console.error('Markdown completion cancellation failed', error); } + }); + } + } + + override dispose(): void { + this.#disposed = true; + this.cancel(); + super.dispose(); + } +} diff --git a/extensions/markdown-language-features/markdown-editor-src/diagnosticHover.ts b/extensions/markdown-language-features/markdown-editor-src/diagnosticHover.ts new file mode 100644 index 0000000000000..e9a60a0d9086d --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/diagnosticHover.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, autorun, observableValue } from '@vscode/observables'; +import type { MarkdownDiagnostic } from '../src/preview/markdownEditorProtocol'; + +export class DiagnosticHover extends Disposable { + readonly element = document.createElement('div'); + readonly #state = observableValue<{ readonly items: readonly MarkdownDiagnostic[]; readonly anchor: DOMRect } | undefined>('diagnosticHover', undefined); + #timer: ReturnType | undefined; + #pending: readonly MarkdownDiagnostic[] = []; + + constructor(openLink: (href: string) => Promise) { + super(); + this.element.className = 'md-diagnostic-hover'; + this.element.setAttribute('role', 'tooltip'); + this.element.id = `markdown-diagnostic-hover-${crypto.randomUUID()}`; + this.element.tabIndex = -1; + this._register(autorun(reader => { + const state = this.#state.read(reader); + this.element.hidden = !state; + this.element.replaceChildren(); + if (!state) { return; } + for (const item of state.items) { + const row = document.createElement('div'); + row.className = 'md-diagnostic-hover-row'; + const message = document.createElement('span'); + message.textContent = item.message; + row.append(message); + const details = document.createElement('span'); + details.className = 'md-diagnostic-hover-details'; + details.append(document.createTextNode(item.source ?? '')); + if (item.code !== undefined) { + if (item.codeTarget) { + const link = document.createElement('a'); + link.href = item.codeTarget; + link.textContent = `(${item.code})`; + link.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + void openLink(item.codeTarget!).catch(error => { + const failure = document.createElement('div'); + failure.setAttribute('role', 'alert'); + failure.textContent = `Could not open diagnostic link: ${error instanceof Error ? error.message : String(error)}`; + row.append(failure); + }); + }); + details.append(link); + } else { + details.append(document.createTextNode(`(${item.code})`)); + } + } + row.append(details); + this.element.append(row); + } + this.element.style.left = '0px'; + this.element.style.top = '0px'; + const bounds = this.element.getBoundingClientRect(); + this.element.style.left = `${Math.max(4, Math.min(state.anchor.left, window.innerWidth - bounds.width - 4))}px`; + const above = state.anchor.top - bounds.height; + this.element.style.top = `${Math.max(4, Math.min(above >= 4 ? above : state.anchor.bottom, window.innerHeight - bounds.height - 4))}px`; + })); + const enter = (): void => { clearTimeout(this.#timer); }; + const leave = (): void => this.scheduleHide(); + const escape = (event: KeyboardEvent): void => { + if (event.key === 'Escape' && this.#state.get()) { + event.preventDefault(); + event.stopPropagation(); + this.hide(); + } + }; + const hide = (): void => this.hide(); + const scroll = (event: Event): void => { + if (!(event.target instanceof Node) || !this.element.contains(event.target)) { this.hide(); } + }; + const stopPropagation = (event: Event): void => event.stopPropagation(); + this.element.addEventListener('mouseenter', enter); + this.element.addEventListener('mouseleave', leave); + this.element.addEventListener('pointerdown', stopPropagation); + this.element.addEventListener('keydown', stopPropagation); + document.addEventListener('keydown', escape, true); + document.addEventListener('scroll', scroll, true); + window.addEventListener('blur', hide); + window.addEventListener('resize', hide); + this._register({ + dispose: () => { + this.hide(); + document.removeEventListener('keydown', escape, true); + document.removeEventListener('scroll', scroll, true); + window.removeEventListener('blur', hide); + window.removeEventListener('resize', hide); + this.element.remove(); + } + }); + } + + show(items: readonly MarkdownDiagnostic[], anchor: DOMRect): void { + if (items.length === this.#pending.length && items.every((item, index) => item === this.#pending[index])) { + if (this.#state.get()) { clearTimeout(this.#timer); } + return; + } + clearTimeout(this.#timer); + this.#pending = items; + this.#timer = setTimeout(() => this.#state.set({ items, anchor }, undefined), this.#state.get() ? 0 : 300); + } + + scheduleHide(): void { + clearTimeout(this.#timer); + this.#pending = []; + this.#timer = setTimeout(() => this.hide(), 150); + } + + hide(): void { + clearTimeout(this.#timer); + this.#pending = []; + this.#state.set(undefined, undefined); + } +} diff --git a/extensions/markdown-language-features/markdown-editor-src/diagnosticsController.ts b/extensions/markdown-language-features/markdown-editor-src/diagnosticsController.ts new file mode 100644 index 0000000000000..b67eba7d18b6e --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/diagnosticsController.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorModel, EditorView, OffsetRange } from '@vscode/markdown-editor'; +import { Disposable, autorun, observableValue } from '@vscode/observables'; +import type { MarkdownDiagnostic, MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; +import { DiagnosticHover } from './diagnosticHover'; +import './languageFeatures.css'; + +export class DiagnosticsController extends Disposable { + readonly #items = observableValue('markdownDiagnostics', []); + #request = 0; + #disposed = false; + readonly #refresh: () => Promise; + + constructor(editor: EditorModel, view: EditorView, host: MarkdownEditorHost, epoch: () => number) { + super(); + const layer = document.createElement('div'); + layer.className = 'md-diagnostics-layer'; + layer.setAttribute('aria-hidden', 'true'); + this._register(view.mountOverlay(layer, 'above-decorations')); + const status = document.createElement('div'); + status.className = 'md-language-status'; + status.setAttribute('role', 'status'); + status.hidden = true; + this._register(view.mountOverlay(status, 'above-decorations')); + const hover = this._register(new DiagnosticHover(href => host.openLink({ href }))); + this._register(view.mountOverlay(hover.element, 'top-chrome')); + this.#refresh = async () => { + const request = ++this.#request; + const source = editor.sourceText.get().value; + try { + const result = await host.getDiagnostics({}); + if (this.#disposed || request !== this.#request || source !== editor.sourceText.get().value || epoch() !== result.editEpoch) { return; } + this.#items.set(result.items, undefined); + status.hidden = true; + } catch (error) { + if (!this.#disposed && request === this.#request) { + status.textContent = `Could not load diagnostics: ${error instanceof Error ? error.message : String(error)}`; + status.hidden = false; + } + } + }; + this._register(autorun(reader => { + editor.sourceText.read(reader); + this.#items.set([], undefined); + void this.refresh(); + })); + const markedItems = new Map(); + this._register(autorun(reader => { + hover.hide(); + markedItems.clear(); + const marks: HTMLElement[] = []; + const map = view.measuredLayout.visualLineMap.read(reader); + for (const item of this.#items.read(reader)) { + const point = item.start === item.endExclusive && !map.isEmpty ? map.lineRect(map.lineIndexOfOffset(item.start)) : undefined; + const rects = point + ? [{ x: map.xAtOffset(item.start), y: point.y, width: point.height * 0.4, height: point.height }] + : view.rangeRects(OffsetRange.fromTo(item.start, item.endExclusive)).read(reader); + for (const rect of item.severity === 'hint' ? rects.slice(0, 1) : rects) { + const mark = document.createElement('div'); + mark.className = `md-diagnostic md-diagnostic-${item.severity}`; + mark.dataset.message = `${item.source ? `${item.source}: ` : ''}${item.message}${item.code ? ` (${item.code})` : ''}`; + markedItems.set(mark, item); + mark.dataset.lineHeight = String(rect.height); + mark.style.left = `${rect.x}px`; + mark.style.top = `${rect.y + rect.height - 3}px`; + mark.style.width = `${item.severity === 'hint' ? 12 : Math.max(4, rect.width)}px`; + marks.push(mark); + } + } + layer.replaceChildren(...marks); + })); + const mousemove = (event: MouseEvent): void => { + if (event.target instanceof Node && hover.element.contains(event.target)) { return; } + const hits = [...markedItems].filter(([child]) => { + const rect = child.getBoundingClientRect(); + return event.clientX >= rect.left && event.clientX <= rect.right + && event.clientY >= rect.bottom - Number(child.dataset.lineHeight) && event.clientY <= rect.bottom + 3; + }); + if (hits.length) { + const rect = hits[0][0].getBoundingClientRect(); + const height = Number(hits[0][0].dataset.lineHeight); + hover.show([...new Set(hits.map(([, item]) => item))], new DOMRect(rect.x, rect.bottom - height, rect.width, height)); + } else { hover.scheduleHide(); } + }; + const mouseleave = (): void => hover.scheduleHide(); + const dismiss = (): void => hover.hide(); + view.element.addEventListener('mousemove', mousemove); + view.element.addEventListener('mouseleave', mouseleave); + view.element.addEventListener('input', dismiss); + this._register({ + dispose: () => { + view.element.removeEventListener('mousemove', mousemove); + view.element.removeEventListener('mouseleave', mouseleave); + view.element.removeEventListener('input', dismiss); + } + }); + } + + refresh(): Promise { return this.#refresh(); } + + override dispose(): void { + this.#disposed = true; + super.dispose(); + } +} diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index c577f710a2cf3..15e783e1a765e 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AsyncClipboardStrategy, CommentModeController, CommentsModel, CommentsView, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, commands, findNodeOffsetById, vscodeHostKeyboardProfile, vscodeLocalKeyboardProfile, type CodeBlockAstNode, type LinkPresentationKind } from '@vscode/markdown-editor'; +import { CommentModeController, CommentsModel, CommentsView, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, commands, findNodeOffsetById, vscodeHostKeyboardProfile, vscodeLocalKeyboardProfile, type CodeBlockAstNode, type LinkPresentationKind } from '@vscode/markdown-editor'; import type { IframeEmbeddedEditorHostTransport, IframeEmbeddedEditorProvider, ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; import { Disposable, autorun, observableValue, transaction } from '@vscode/observables'; import { HubRpcConnection } from '@vscode/hubrpc'; @@ -16,8 +16,12 @@ import './markdownEditor.css'; import { WebviewSyntaxHighlighter } from './syntaxHighlighter'; import { WebviewLinkPresentationProvider } from './linkPresentationProvider'; import { markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; -import { MarkdownEditorRpcTransport } from '../src/preview/markdownEditorRpc'; +import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from '../src/preview/markdownEditorRpc'; import { LazyCodeBlockEditorFactory } from '../src/preview/lazyCodeBlockEditorFactory'; +import { RenameController } from './renameController'; +import { CompletionController } from './completionController'; +import { DiagnosticsController } from './diagnosticsController'; +import { ImagePasteController } from './imagePasteController'; interface VsCodeApi { postMessage(message: unknown): void; @@ -118,6 +122,9 @@ class Editor extends Disposable { #nextCodeBlockEditorRuntimeId = 1; readonly #codeBlockEditorHostTransports = new Map(); #controller: EditorController | undefined; + #rename: RenameController | undefined; + #completion: CompletionController | undefined; + #diagnostics: DiagnosticsController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: LazyCodeBlockEditorFactory | undefined; /** Identifies the authoritative text baseline against which local edits are computed. */ @@ -151,7 +158,11 @@ class Editor extends Disposable { return { dispose: () => window.removeEventListener('message', onMessage) }; }, ); - this.#connection = HubRpcConnection.fromTransport(this.#transport); + this.#connection = createMarkdownEditorRpcConnection(this.#transport, (operation, error) => { + if (!this.#disposed) { + console.error(`Markdown editor ${operation} failed`, error); + } + }); this.#host = this.#connection.get(markdownEditorHost); this.#syntaxHighlighter = new WebviewSyntaxHighlighter(this.#host); this.#editEpoch = initialState.editEpoch; @@ -166,6 +177,7 @@ class Editor extends Disposable { this.model.readonlyMode.set(initialState.readonly, undefined); this._register(this.#connection.register(markdownEditorRenderer, { + diagnosticsChanged: () => this.#diagnostics?.refresh(), update: ({ content, editEpoch }) => { // Applying authoritative text maps selection and clears stale pending-paragraph state. this.#editEpoch = editEpoch; @@ -219,7 +231,15 @@ class Editor extends Disposable { this.#view?.revealRangeAtTop(OffsetRange.fromTo(start, endExclusive)); } }, - command: ({ command: commandId }) => { + command: async ({ command: commandId }) => { + if (commandId === 'markdown.editor.triggerSuggest') { + await this.#completion?.start(); + return; + } + if (commandId === 'markdown.editor.rename') { + await this.#rename?.start(); + return; + } const command = commands.find(command => command.id === commandId); if (command) { this.#controller?.executeCommand(command); @@ -242,8 +262,8 @@ class Editor extends Disposable { readonly #onPageHide = (): void => this.dispose(); - #send(operation: string, request: Promise): void { - void request.catch(error => { + #send(operation: string, request: Promise | void): void { + void request?.catch(error => { if (!this.#disposed) { console.error(`Markdown editor ${operation} failed`, error); } @@ -296,6 +316,7 @@ class Editor extends Disposable { const view = this._register(new EditorView(model, { classNames: ['md-theme-vscode-default'], + presentation: model.readonlyMode.get() ? 'reading' : 'editing', syntaxHighlighter: this.#syntaxHighlighter, linkPresentationProvider: this.#linkPresentationProvider, embeddedCodeEditorFactory, @@ -343,13 +364,16 @@ class Editor extends Disposable { }, })); this.#view = view; + this.#rename = this._register(new RenameController(model, view, this.#host, () => this.#editEpoch)); + this.#completion = this._register(new CompletionController(model, view, this.#host, () => this.#editEpoch)); + this.#diagnostics = this._register(new DiagnosticsController(model, view, this.#host, () => this.#editEpoch)); // Wire history chords (undo/redo) to the extension so they run against the // backing TextDocument's own undo stack. `record` is deliberately omitted: // the TextDocument owns the history, and a second local stack would drift // from the Edit menu, dirty state and hot exit. this.#controller = this._register(new EditorController(model, view, { - clipboardStrategy: new AsyncClipboardStrategy(), + clipboardStrategy: this._register(new ImagePasteController(model, view, this.#host, () => this.#editEpoch)), keyboardProfile: vscodeLocalKeyboardProfile, forwardedKeyboardProfile: vscodeHostKeyboardProfile, historyStrategy: { @@ -467,6 +491,7 @@ class Editor extends Disposable { let firstReadonly = true; this._register(autorun((reader) => { const isReadonly = reader.readObservable(this.model.readonlyMode); + this.model.presentation.set(isReadonly ? 'reading' : 'editing', undefined); if (!firstReadonly) { this.#send('setReadonly', this.#host.setReadonly({ readonly: isReadonly })); } diff --git a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts new file mode 100644 index 0000000000000..f2f245fa6a8f9 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AsyncClipboardStrategy, EditorModel, EditorView, Selection, readNativeClipboard, type IClipboardContext, type IClipboardStrategy } from '@vscode/markdown-editor'; +import { Disposable } from '@vscode/observables'; +import type { MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; + +export class ImagePasteController extends Disposable implements IClipboardStrategy { + readonly #strategy: AsyncClipboardStrategy; + readonly #status = document.createElement('div'); + #disposed = false; + #request = 0; + #interaction = 0; + + constructor( + readonly editor: EditorModel, + readonly view: EditorView, + readonly host: Pick, + readonly epoch: () => number, + readonly clipboard: Clipboard = navigator.clipboard, + ) { + super(); + this.#strategy = new AsyncClipboardStrategy(clipboard, (context, plainText) => this.#paste(context, async () => { + const native = readNativeClipboard(context.element); + if (native) { + return { text: native.text, images: plainText ? [] : native.files.filter(file => file.type.startsWith('image/')) }; + } + if (plainText || !clipboard.read) { return { text: await clipboard.readText(), images: [] }; } + const items = await clipboard.read(); + const images: File[] = []; + let text = ''; + for (const item of items) { + const type = item.types.find(type => type === 'image/png') ?? item.types.find(type => type.startsWith('image/')); + if (type) { + const blob = await item.getType(type); + const extension = type.slice('image/'.length).split('+')[0]; + images.push(new File([blob], `image.${extension}`, { type })); + } else if (item.types.includes('text/plain')) { + text += await (await item.getType('text/plain')).text(); + } + } + if (items.length && !images.length && !text && items.every(item => !item.types.includes('text/plain'))) { + throw new Error('The clipboard formats are not available to this editor. Try copying the image itself instead of its file.'); + } + return { text, images }; + })); + this.#status.className = 'md-language-status'; + this.#status.setAttribute('role', 'status'); + this.#status.hidden = true; + this._register(view.mountOverlay(this.#status, 'top-chrome')); + } + + writeText(text: string): Promise { return this.#strategy.writeText(text); } + + connect(context: IClipboardContext): { dispose(): void } { + const interact = (): void => { this.#interaction++; }; + context.element.addEventListener('pointerdown', interact); + context.element.addEventListener('keydown', interact); + const connection = this.#strategy.connect(context); + const paste = (event: ClipboardEvent): void => { + if (event.target !== context.element || !event.clipboardData) { return; } + const images = [...event.clipboardData.files].filter(file => file.type.startsWith('image/')); + const text = event.clipboardData.getData('text/plain'); + event.preventDefault(); + event.stopPropagation(); + void this.#paste(context, async () => ({ images, text })); + }; + context.element.addEventListener('paste', paste); + return { + dispose: () => { + connection.dispose(); + context.element.removeEventListener('pointerdown', interact); + context.element.removeEventListener('keydown', interact); + context.element.removeEventListener('paste', paste); + } + }; + } + + async #paste(context: IClipboardContext, read: () => Promise<{ readonly images: readonly File[]; readonly text: string }>): Promise { + const request = ++this.#request; + const source = this.editor.sourceText.get(); + const selection = this.editor.selection.get(); + const epoch = this.epoch(); + const interaction = this.#interaction; + if (this.editor.readonlyMode.get() || !selection) { + this.#showError('Switch to editing mode and place the caret before pasting.'); + return; + } + try { + const { images, text } = await read(); + const payload = await Promise.all(images.map(async image => ({ + name: image.name, + mime: image.type, + base64: await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).split(',')[1]); + reader.onerror = () => reject(reader.error ?? new Error('Could not read clipboard image.')); + reader.onabort = () => reject(new Error('Clipboard image reading was cancelled.')); + reader.readAsDataURL(image); + }), + }))); + if (this.#disposed || request !== this.#request) { return; } + if (source !== this.editor.sourceText.get() || selection !== this.editor.selection.get() || epoch !== this.epoch() + || this.editor.readonlyMode.get() || document.activeElement !== context.element) { + throw new Error('The editor changed while reading the clipboard. Paste again.'); + } + this.#status.hidden = true; + if (!payload.length) { + if (text) { context.insertText(text); } + return; + } + this.#status.textContent = 'Pasting image...'; + this.#status.hidden = false; + const result = await this.host.pasteImages({ + start: selection.range.start, endExclusive: selection.range.endExclusive, editEpoch: epoch, images: payload, + }); + if (this.#disposed || request !== this.#request) { return; } + this.#status.hidden = true; + if (result.offset !== undefined && result.editEpoch === this.epoch() && interaction === this.#interaction + && !this.editor.readonlyMode.get() && document.activeElement === context.element) { + this.editor.selection.set(Selection.collapsed(result.offset), undefined); + } + } catch (error) { + if (!this.#disposed && request === this.#request) { + this.#showError(`Could not paste: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + #showError(message: string): void { + this.#status.textContent = message; + this.#status.hidden = false; + } + + override dispose(): void { + this.#disposed = true; + super.dispose(); + } +} diff --git a/extensions/markdown-language-features/markdown-editor-src/languageFeatures.css b/extensions/markdown-language-features/markdown-editor-src/languageFeatures.css new file mode 100644 index 0000000000000..239d3962e0108 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/languageFeatures.css @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.md-diagnostics-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.md-diagnostic { + position: absolute; + height: 3px; + background: currentColor; + /* Use the code editor's 6x3 squiggle glyph, tinted with the webview theme. */ + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 3' height='3' width='6'%3E%3Cg fill='white'%3E%3Cpolygon points='5.5,0 2.5,3 1.1,3 4.1,0'/%3E%3Cpolygon points='4,0 6,2 6,0.6 5.4,0'/%3E%3Cpolygon points='0,2 1,3 2.4,3 0,0.6'/%3E%3C/g%3E%3C/svg%3E") repeat-x bottom left; +} + +.md-diagnostic-error { color: var(--vscode-editorError-foreground); } +.md-diagnostic-warning { color: var(--vscode-editorWarning-foreground); } +.md-diagnostic-info { color: var(--vscode-editorInfo-foreground); } +.md-diagnostic-hint { + color: var(--vscode-editorHint-foreground); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='3' width='12'%3E%3Cg fill='white'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3Ccircle cx='5' cy='1' r='1'/%3E%3Ccircle cx='9' cy='1' r='1'/%3E%3C/g%3E%3C/svg%3E") no-repeat bottom left; +} + +.md-language-status { + position: absolute; + bottom: 0; + padding: 4px 8px; + color: var(--vscode-errorForeground); + background: var(--vscode-editorWidget-background); +} + +.md-completion-overlay { + position: absolute; + z-index: 100; + max-width: 100%; +} + +.md-diagnostic-hover { + position: fixed; + z-index: 110; + box-sizing: border-box; + max-width: min(750px, calc(100vw - 8px)); + max-height: calc(100vh - 8px); + overflow: auto; + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: var(--vscode-cornerRadius-large, 8px); + color: var(--vscode-editorHoverWidget-foreground); + background: var(--vscode-editorHoverWidget-background); + box-shadow: var(--vscode-shadow-lg, 0 2px 8px var(--vscode-widget-shadow)); + font: var(--vscode-editor-font-size, 14px)/19px var(--vscode-editor-font-family, monospace); + user-select: text; + cursor: text; +} + +.md-diagnostic-hover[hidden] { + display: none; +} + +.md-diagnostic-hover-row { + padding: 4px 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.md-diagnostic-hover-row + .md-diagnostic-hover-row { + border-top: 1px solid var(--vscode-editorHoverWidget-border); +} + +.md-diagnostic-hover-details { + opacity: 0.6; + padding-left: 6px; +} + +.md-diagnostic-hover a { + color: var(--vscode-textLink-foreground); + cursor: pointer; +} + +.md-diagnostic-hover a:hover { + color: var(--vscode-textLink-activeForeground); +} diff --git a/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts b/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts index 3dab1a6bc6732..a8e06d7e2d3c7 100644 --- a/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts +++ b/extensions/markdown-language-features/markdown-editor-src/linkPresentationProvider.ts @@ -22,7 +22,7 @@ type WebviewLinkPresentation = LinkPresentation & { readonly isLoading?: boolean export class WebviewLinkPresentationProvider extends Disposable implements ILinkPresentationProvider { readonly #entries = new Map(); readonly #rules: readonly { id: string; uriPattern: RegExp; kind: LinkPresentationKind }[]; - readonly #syncTargets: (hrefs: string[]) => Promise; + readonly #syncTargets: (hrefs: string[]) => void; #syncScheduled = false; #disposed = false; @@ -106,11 +106,7 @@ export class WebviewLinkPresentationProvider extends Disposable implements ILink if (this.#disposed) { return; } - void this.#syncTargets([...this.#entries.keys()]).catch(error => { - if (!this.#disposed) { - console.error('Markdown editor rich link target synchronization failed', error); - } - }); + this.#syncTargets([...this.#entries.keys()]); }); } } diff --git a/extensions/markdown-language-features/markdown-editor-src/rename.css b/extensions/markdown-language-features/markdown-editor-src/rename.css new file mode 100644 index 0000000000000..30d50e320f2b3 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/rename.css @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.md-rename-status { + position: absolute; + z-index: 100; + max-width: 360px; + padding: 6px 10px; + color: var(--vscode-editorWidget-foreground); + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + font: 13px var(--vscode-font-family); +} + +.md-rename-status[hidden] { + display: none; +} + +.md-rename-target-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.md-rename-target { + position: absolute; + border-radius: 2px; + background: color-mix(in srgb, var(--vscode-editor-symbolHighlightBackground) 35%, transparent); + box-shadow: inset 0 0 0 1px var(--vscode-contrastActiveBorder, color-mix(in srgb, var(--vscode-editor-symbolHighlightBorder, var(--vscode-focusBorder)) 40%, transparent)); +} + +.md-editor.md-rename-active .md-cursor { + display: none; +} diff --git a/extensions/markdown-language-features/markdown-editor-src/renameController.ts b/extensions/markdown-language-features/markdown-editor-src/renameController.ts new file mode 100644 index 0000000000000..a86bdea6afd84 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/renameController.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorModel, EditorView, OffsetRange, RenameModel, RenameWidget } from '@vscode/markdown-editor'; +import { Disposable, autorun, observableValue } from '@vscode/observables'; +import type { MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; +import '@vscode/markdown-editor/rename.css'; +import './rename.css'; + +export class RenameController extends Disposable { + readonly #editor: EditorModel; + readonly #host: MarkdownEditorHost; + readonly #epoch: () => number; + readonly #rename: RenameModel; + readonly #widget: RenameWidget; + readonly #status = observableValue('renameStatus', undefined); + #nextRequestId = 0; + #active: { requestId: number; source: string; offset: number } | undefined; + #disposed = false; + + constructor(editor: EditorModel, view: EditorView, host: MarkdownEditorHost, epoch: () => number) { + super(); + this.#editor = editor; + this.#host = host; + this.#epoch = epoch; + this.#rename = this._register(new RenameModel({ + validate: name => /[\r\n]/.test(name) ? 'Enter a single-line name.' : undefined, + onSubmit: async ({ name, context, signal }) => { + signal.throwIfAborted(); + const cancel = () => this.#cancelRemote(context.requestId); + signal.addEventListener('abort', cancel, { once: true }); + try { + await host.rename({ requestId: context.requestId, newName: name }); + } finally { + signal.removeEventListener('abort', cancel); + } + }, + onCancel: () => this.cancel(), + })); + this.#widget = this._register(new RenameWidget(view.overlayContainer, this.#rename, { + autoFocus: false, + onDidHide: () => { + if (!this.#disposed) { + view.focus(); + } + }, + })); + this._register(view.suspendEditContextWhileFocused(this.#widget.element)); + const status = document.createElement('div'); + status.className = 'md-rename-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + view.overlayContainer.appendChild(status); + this._register({ dispose: () => status.remove() }); + const highlights = document.createElement('div'); + highlights.className = 'md-rename-target-layer'; + highlights.setAttribute('aria-hidden', 'true'); + this._register(view.mountOverlay(highlights, 'below-selection')); + this._register(autorun(reader => { + const message = this.#status.read(reader); + status.textContent = message ?? ''; + status.hidden = !message; + const session = this.#rename.session.read(reader); + const rects = session ? view.rangeRects(session.context.range).read(reader) : []; + highlights.replaceChildren(...rects.map(rect => { + const highlight = document.createElement('div'); + highlight.className = 'md-rename-target'; + highlight.style.left = `${rect.x}px`; + highlight.style.top = `${rect.y}px`; + highlight.style.width = `${Math.max(1, rect.width)}px`; + highlight.style.height = `${rect.height}px`; + return highlight; + })); + const rect = rects[0]; + const caret = view.caretRect.read(reader); + const anchor = rect ? { left: rect.x, top: rect.y, height: rect.height } + : caret ? { left: caret.x, top: caret.y, height: caret.height } : { left: 0, top: 0, height: 20 }; + this.#widget.layout(anchor); + status.style.left = `${anchor.left}px`; + status.style.top = `${anchor.top + anchor.height}px`; + })); + this._register(autorun(reader => { + const source = editor.sourceText.read(reader).value; + const selection = editor.selection.read(reader); + const readonly = editor.readonlyMode.read(reader); + if (this.#active && (readonly || source !== this.#active.source || selection?.active !== this.#active.offset)) { + this.cancel(); + } + })); + const keydown = (event: KeyboardEvent): void => { + if (event.isComposing) { return; } + if (event.key === 'F2' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + void this.start(); + } else if (event.key === 'Escape' && (this.#active || this.#status.get())) { + event.preventDefault(); + event.stopPropagation(); + this.cancel(); + } + }; + const focusout = (): void => { + queueMicrotask(() => { + if (!this.#disposed && this.#active && !this.#widget.element.contains(document.activeElement)) { + this.cancel(); + } + }); + }; + const documentFocusout = (): void => { + queueMicrotask(() => { + if (!this.#disposed && this.#active && !view.element.contains(document.activeElement)) { + this.cancel(); + } + }); + }; + const blur = (): void => this.cancel(); + const inputFocus = (): void => { view.element.classList.add('md-rename-active'); }; + const inputBlur = (): void => { view.element.classList.remove('md-rename-active'); }; + this.#widget.inputElement.addEventListener('focus', inputFocus); + this.#widget.inputElement.addEventListener('blur', inputBlur); + view.element.addEventListener('keydown', keydown, true); + this.#widget.element.addEventListener('focusout', focusout); + document.addEventListener('focusout', documentFocusout); + window.addEventListener('blur', blur); + this._register({ + dispose: () => { + view.element.classList.remove('md-rename-active'); + this.#widget.inputElement.removeEventListener('focus', inputFocus); + this.#widget.inputElement.removeEventListener('blur', inputBlur); + view.element.removeEventListener('keydown', keydown, true); + this.#widget.element.removeEventListener('focusout', focusout); + document.removeEventListener('focusout', documentFocusout); + window.removeEventListener('blur', blur); + } + }); + } + + async start(): Promise { + this.cancel(); + const selection = this.#editor.selection.get(); + if (!selection || this.#editor.readonlyMode.get()) { + this.#status.set('Switch to editing mode and place the caret on a symbol to rename.', undefined); + return; + } + const active = { requestId: ++this.#nextRequestId, source: this.#editor.sourceText.get().value, offset: selection.active }; + this.#active = active; + this.#status.set('Preparing rename...', undefined); + try { + const prepared = await this.#host.prepareRename({ requestId: active.requestId, offset: active.offset, editEpoch: this.#epoch() }); + if (this.#disposed || this.#active !== active) { return; } + this.#status.set(undefined, undefined); + this.#rename.open({ + initialName: prepared.placeholder, + context: { requestId: active.requestId, range: OffsetRange.fromTo(prepared.start, prepared.endExclusive) }, + }); + this.#widget.focusAndSelect(); + } catch (error) { + if (!this.#disposed && this.#active === active) { + this.#status.set(error instanceof Error ? error.message : String(error), undefined); + } + } + + } + + cancel(): void { + const active = this.#active; + this.#active = undefined; + this.#status.set(undefined, undefined); + this.#rename.cancel(); + if (active) { + this.#cancelRemote(active.requestId); + } + } + + #cancelRemote(requestId: number): void { + void this.#host.cancelRename({ requestId }).catch(error => { + if (!this.#disposed) { console.error('Markdown editor cancel rename failed', error); } + }); + } + + override dispose(): void { + this.#disposed = true; + this.cancel(); + super.dispose(); + } +} + +interface RenameContext { + readonly requestId: number; + readonly range: OffsetRange; +} diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index cc7baf31f6232..0db43083d777d 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-113", + "@vscode/markdown-editor": "0.0.2-114", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", @@ -672,9 +672,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-113", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-113.tgz", - "integrity": "sha512-9W8rdsSpCTPh60YHXEUwZ4zQRDoWnQjYEXNVJnTy0TMZGNZyP2kiXGJ9abZR83i2qVUUM4h82APNl++zFd+tNg==", + "version": "0.0.2-114", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-114.tgz", + "integrity": "sha512-gnktzReJIybeYYOA8mYYdXEN0CHfWylCNBeqkgV/k06hwSPibe8eaB2UJ/w35NpNMVXgWKZzknZgVQoVIWiVkg==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", @@ -685,6 +685,7 @@ "micromark": "^4.0.1", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", + "micromark-extension-gfm-autolink-literal": "^2.1.0", "micromark-extension-gfm-strikethrough": "^2.1.0", "micromark-extension-gfm-table": "^2.1.1", "micromark-extension-gfm-task-list-item": "^2.1.0", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 03bd2ff8569d6..cc0ab051fb794 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -142,6 +142,18 @@ } ], "commands": [ + { + "command": "markdown.editor.triggerSuggest", + "title": "%markdown.editor.triggerSuggest%", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'" + }, + { + "command": "markdown.editor.rename", + "title": "%markdown.editor.rename%", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'" + }, { "command": "_markdown.copyImage", "title": "%markdown.copyImage.title%", @@ -651,6 +663,14 @@ } ], "commandPalette": [ + { + "command": "markdown.editor.triggerSuggest", + "when": "activeCustomEditorId == 'vscode.markdown.editor'" + }, + { + "command": "markdown.editor.rename", + "when": "activeCustomEditorId == 'vscode.markdown.editor'" + }, { "command": "_markdown.openImage", "when": "false" @@ -950,6 +970,16 @@ ] }, "keybindings": [ + { + "command": "markdown.editor.triggerSuggest", + "key": "ctrl+space", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus" + }, + { + "command": "markdown.editor.rename", + "key": "f2", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus" + }, { "command": "markdown.showPreviewToSide", "key": "ctrl+k v", @@ -1440,6 +1470,12 @@ "onExP" ] }, + "markdown.editor.quickSuggestions": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.markdown.editor.quickSuggestions%", + "scope": "resource" + }, "markdown.links.openLocation": { "type": "string", "default": "currentGroup", @@ -1948,7 +1984,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-113", + "@vscode/markdown-editor": "0.0.2-114", "@vscode/observables": "^0.1.1-0", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index cb47e6688e32e..242e45996504e 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -1,9 +1,12 @@ { "displayName": "Markdown Language Features", "description": "Provides rich language support for Markdown.", + "markdown.editor.rename": "Rename Symbol", + "markdown.editor.triggerSuggest": "Trigger Suggest", "configuration.preview": "Preview", "configuration.editor": "Language Features", "configuration.markdown.experimental.richLinks.enabled": "Controls whether supported links in the Markdown editor are rendered as rich links with live metadata. Enabling this may make authenticated requests to services such as GitHub.", + "configuration.markdown.editor.quickSuggestions": "Controls whether typing automatically requests suggestions in the rich Markdown editor. Also requires `#editor.quickSuggestions#` to be enabled for Markdown. Does not affect manually invoking Trigger Suggest or suggestions in the Markdown code editor.", "configuration.validation": "Validation", "configuration.advanced": "Advanced", "markdown.copyImage.title": "Copy Image", @@ -367,7 +370,7 @@ "$generated": true }, "markdown.editor.toggleLocked.title": { - "message": "Toggle Editing/Locked Mode", + "message": "Toggle Markdown Read-only", "comment": [ "Generated from @vscode/markdown-editor/commands. Do not edit manually." ], diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index 85564580e5456..2ba461fa92a1b 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -100,7 +100,9 @@ function registerMarkdownEditorCommands( } return vscode.commands.registerCommand(commandId, () => provider.executeCommand(commandId)); }); - return vscode.Disposable.from(...registrations); + return vscode.Disposable.from(...registrations, + vscode.commands.registerCommand('markdown.editor.rename', () => provider.executeCommand('markdown.editor.rename')), + vscode.commands.registerCommand('markdown.editor.triggerSuggest', () => provider.executeCommand('markdown.editor.triggerSuggest'))); } function registerMarkdownLanguageFeatures( diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts index e59a207487d8f..9352d0e540d18 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts @@ -14,7 +14,7 @@ import { NewFilePathGenerator } from './newFilePathGenerator'; import { audioEditKind, baseLinkEditKind, createInsertUriListEdit, createUriListSnippet, DropOrPasteEdit, getSnippetLabelAndKind, imageEditKind, linkEditKind, videoEditKind } from './shared'; import { InsertMarkdownLink, shouldInsertMarkdownLinkByDefault } from './smartDropOrPaste'; -enum CopyFilesSettings { +export enum CopyFilesSettings { Never = 'never', MediaFiles = 'mediaFiles', } @@ -28,7 +28,7 @@ enum CopyFilesSettings { * - File object in the data transfer. * - Media data in the data transfer, such as `image/png`. */ -class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, vscode.DocumentDropEditProvider { +export class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, vscode.DocumentDropEditProvider { public static readonly mimeTypes = [ Mime.textUriList, @@ -121,7 +121,7 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v return; } - let edit = await this.#createEditForMediaFiles(document, dataTransfer, settings.copyIntoWorkspace, token); + let edit = await ResourcePasteOrDropProvider.createEditForMediaFiles(document, dataTransfer, settings.copyIntoWorkspace, token); if (token.isCancellationRequested) { return; } @@ -198,11 +198,12 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v * * This tries copying files outside of the workspace into the workspace. */ - async #createEditForMediaFiles( + static async createEditForMediaFiles( document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, copyIntoWorkspace: CopyFilesSettings, token: vscode.CancellationToken, + materializeContents = false, ): Promise { if (copyIntoWorkspace !== CopyFilesSettings.MediaFiles || getParentDocumentUri(document.uri).scheme === Schemes.untitled) { return; @@ -253,7 +254,7 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v for (const entry of fileEntries) { if (entry.newFile) { additionalEdits.createFile(entry.uri, { - contents: entry.newFile.contents, + contents: materializeContents ? await entry.newFile.contents.data() : entry.newFile.contents, overwrite: entry.newFile.overwrite, }); } diff --git a/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts b/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts new file mode 100644 index 0000000000000..59a07d101588f --- /dev/null +++ b/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { TextDocument } from 'vscode-languageserver-textdocument'; +import { CopyFilesSettings, ResourcePasteOrDropProvider } from '../languageFeatures/copyFiles/dropOrPasteResource'; +import type { MarkdownEditorHost } from './markdownEditorProtocol'; + +export class MarkdownEditorImagePaste implements vscode.Disposable { + #pending: vscode.CancellationTokenSource | undefined; + readonly #drain: () => Promise; + readonly #snapshot: () => { readonly text: string; readonly editEpoch: number }; + readonly #isEditable: () => boolean; + readonly #apply: typeof vscode.workspace.applyEdit; + + constructor( + readonly document: vscode.TextDocument, + drain: () => Promise, + snapshot: () => { readonly text: string; readonly editEpoch: number }, + isEditable: () => boolean, + apply: typeof vscode.workspace.applyEdit = edit => vscode.workspace.applyEdit(edit), + ) { + this.#drain = drain; + this.#snapshot = snapshot; + this.#isEditable = isEditable; + this.#apply = apply; + } + + async paste(request: Parameters[0]): ReturnType { + this.dispose(); + const pending = this.#pending = new vscode.CancellationTokenSource(); + try { + await this.#drain(); + const snapshot = this.#snapshot(); + const version = this.document.version; + const checkCurrent = (): void => { + if (pending.token.isCancellationRequested) { throw new vscode.CancellationError(); } + if (!this.#isEditable() || this.document.isClosed || version !== this.document.version || request.editEpoch !== this.#snapshot().editEpoch) { + throw new Error(vscode.l10n.t('The editor changed. Paste the image again.')); + } + }; + checkCurrent(); + if (!Number.isSafeInteger(request.start) || !Number.isSafeInteger(request.endExclusive) || request.start < 0 + || request.start > request.endExclusive || request.endExclusive > snapshot.text.length || !request.images.length) { + throw new Error(vscode.l10n.t('Invalid image paste range or empty clipboard image list.')); + } + if (this.document.isUntitled) { + throw new Error(vscode.l10n.t('Save the Markdown document before pasting an image.')); + } + const config = vscode.workspace.getConfiguration('markdown', this.document); + if (vscode.workspace.getConfiguration('editor', this.document).get('pasteAs.enabled') === false) { + throw new Error(vscode.l10n.t('Image paste is disabled by editor.pasteAs.enabled.')); + } + const enabled = config.get('editor.paste.enabled', true); + if (enabled === false || enabled === 'never') { + throw new Error(vscode.l10n.t('Markdown image paste is disabled by markdown.editor.paste.enabled.')); + } + const transfer = new vscode.DataTransfer(); + request.images.forEach((image, index) => { + if (!/^image\/[\w.+-]+$/.test(image.mime) || !image.name || /[\\/:]/.test(image.name) || image.name === '.' || image.name === '..' + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.base64) || !image.base64) { + throw new Error(vscode.l10n.t('Invalid clipboard image.')); + } + const contents = Uint8Array.from(atob(image.base64), char => char.charCodeAt(0)); + const item = new vscode.DataTransferItem(''); + item.asFile = () => ({ name: image.name, uri: undefined, data: async () => contents }); + transfer.set(`${image.mime};index=${index}`, item); + }); + const edit = await ResourcePasteOrDropProvider.createEditForMediaFiles(this.document, transfer, + config.get('editor.paste.copyIntoWorkspace', CopyFilesSettings.MediaFiles), pending.token, true); + await this.#drain(); + checkCurrent(); + if (!edit?.additionalEdits) { + throw new Error(vscode.l10n.t('Image paste requires markdown.editor.paste.copyIntoWorkspace to be mediaFiles.')); + } + const mirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, snapshot.text); + const start = mirror.positionAt(request.start); + const end = mirror.positionAt(request.endExclusive); + const range = new vscode.Range(start.line, start.character, end.line, end.character); + const oldLength = this.document.getText().length; + const oldEnd = this.document.offsetAt(range.end); + // Workspace edits resolve snippet defaults without starting a snippet session in a custom editor. + edit.additionalEdits.set(this.document.uri, [vscode.SnippetTextEdit.replace(range, edit.snippet)]); + if (!await this.#apply(edit.additionalEdits)) { + throw new Error(vscode.l10n.t('The workspace rejected the image paste.')); + } + const current = this.#snapshot(); + const newMirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, current.text); + const offset = this.document.version === version + 1 && this.#isEditable() && !pending.token.isCancellationRequested + ? newMirror.offsetAt(this.document.positionAt(oldEnd + this.document.getText().length - oldLength)) : undefined; + return { offset, editEpoch: current.editEpoch }; + } finally { + if (this.#pending === pending) { this.#pending = undefined; } + pending.dispose(); + } + } + + dispose(): void { + this.#pending?.cancel(); + this.#pending?.dispose(); + this.#pending = undefined; + } +} diff --git a/extensions/markdown-language-features/src/preview/markdownEditorLanguageFeatures.ts b/extensions/markdown-language-features/src/preview/markdownEditorLanguageFeatures.ts new file mode 100644 index 0000000000000..c081d6f0c5d53 --- /dev/null +++ b/extensions/markdown-language-features/src/preview/markdownEditorLanguageFeatures.ts @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { TextDocument } from 'vscode-languageserver-textdocument'; +import type { MarkdownCompletion, MarkdownDiagnostic, MarkdownEditorHost } from './markdownEditorProtocol'; + +export class MarkdownEditorLanguageFeatures { + #session: CompletionSession | undefined; + readonly #document: vscode.TextDocument; + readonly #drain: () => Promise; + readonly #snapshot: () => { text: string; editEpoch: number }; + readonly #isActive: () => boolean; + readonly #api: MarkdownCompletionApi; + + constructor(document: vscode.TextDocument, drain: () => Promise, snapshot: () => { text: string; editEpoch: number }, + isActive: () => boolean, api: MarkdownCompletionApi = completionApi) { + this.#document = document; + this.#drain = drain; + this.#snapshot = snapshot; + this.#isActive = isActive; + this.#api = api; + } + + async diagnostics(): ReturnType { + await this.#drain(); + const snapshot = this.#snapshot(); + return { editEpoch: snapshot.editEpoch, items: mapMarkdownDiagnostics(this.#document, snapshot.text, vscode.languages.getDiagnostics(this.#document.uri)) }; + } + + async completions(request: Parameters[0]): ReturnType { + const session: CompletionSession = { requestId: request.requestId }; + this.#session = session; + await this.#drain(); + this.#checkCurrent(session); + const snapshot = this.#snapshot(); + if (snapshot.editEpoch !== request.editEpoch || request.offset > snapshot.text.length) { + throw new Error(vscode.l10n.t('The document changed. Request suggestions again.')); + } + if (request.automatic) { + if (!vscode.workspace.getConfiguration('markdown.editor', this.#document).get('quickSuggestions', false)) { + return { items: [], incomplete: false }; + } + const quick = vscode.workspace.getConfiguration('editor', this.#document).get('quickSuggestions'); + if (quick === false || typeof quick === 'object' && (quick.other === false || quick.other === 'off' || quick.other === 'inline')) { + return { items: [], incomplete: false }; + } + } + const mirror = TextDocument.create(this.#document.uri.toString(), 'markdown', 0, snapshot.text); + const raw = mirror.positionAt(request.offset); + const position = new vscode.Position(raw.line, raw.character); + const version = this.#document.version; + const list = await this.#api.provide(this.#document.uri, position, 0); + this.#checkCurrent(session, version, snapshot.editEpoch); + session.prepared = { version, editEpoch: snapshot.editEpoch, position, items: list.items }; + const items: MarkdownCompletion[] = []; + for (const [index, item] of list.items.entries()) { + const label = typeof item.label === 'string' ? item.label : item.label.label; + const range = completionRange(this.#document, position, item); + const prefix = this.#document.getText(new vscode.Range(range.start, position)); + const normalizedPrefix = prefix.toLowerCase(); + if (!(item.filterText ?? label).toLowerCase().startsWith(normalizedPrefix)) { + continue; + } + items.push({ + id: String(index), label, + highlightLength: label.toLowerCase().startsWith(normalizedPrefix) ? prefix.length : 0, + detail: item.detail ?? (typeof item.label === 'string' ? undefined : item.label.description), + type: item.kind === undefined ? undefined : vscode.CompletionItemKind[item.kind], + unsupported: item.insertText instanceof vscode.SnippetString ? vscode.l10n.t('Snippet editing is not supported in the rich editor yet.') : undefined, + }); + } + return { items, incomplete: !!list.isIncomplete }; + } + + async accept(request: Parameters[0]): ReturnType { + const session = this.#session; + if (session?.requestId !== request.requestId || !session.prepared || session.applying) { + throw new Error(vscode.l10n.t('Request suggestions again before accepting a completion.')); + } + const index = Number(request.id); + const original = session.prepared.items[index]; + if (!Number.isInteger(index) || index < 0 || !original) { + throw new Error(vscode.l10n.t('Unknown completion item.')); + } + session.applying = true; + try { + await this.#drain(); + const { version, editEpoch, position } = session.prepared; + this.#checkCurrent(session, version, editEpoch); + // The public API resolves only a prefix of a freshly requested list, not a retained item. + const resolved = await this.#api.provide(this.#document.uri, position, index + 1); + await this.#drain(); + this.#checkCurrent(session, version, editEpoch); + const item = resolved.items[index]; + if (!item || completionIdentity(this.#document, position, item) !== completionIdentity(this.#document, position, original)) { + throw new Error(vscode.l10n.t('The completion list changed. Request suggestions again.')); + } + if (item.insertText instanceof vscode.SnippetString) { + throw new Error(vscode.l10n.t('Snippet editing is not supported in the rich editor yet.')); + } + const label = typeof item.label === 'string' ? item.label : item.label.label; + const text = item.textEdit?.newText ?? item.insertText ?? label; + const range = completionRange(this.#document, position, item); + const edits = [vscode.TextEdit.replace(range, text), ...(item.additionalTextEdits ?? [])]; + validateCompletionEdits(this.#document, edits); + const start = this.#document.offsetAt(range.start); + const eol = this.#document.eol === vscode.EndOfLine.CRLF ? '\r\n' : '\n'; + const normalizedLength = (value: string) => value.replace(/\r\n|\r|\n/g, eol).length; + let offset = start + normalizedLength(text); + for (const edit of item.additionalTextEdits ?? []) { + if (edit.range.end.isBeforeOrEqual(range.start)) { + offset += normalizedLength(edit.newText) - (this.#document.offsetAt(edit.range.end) - this.#document.offsetAt(edit.range.start)); + } + } + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(this.#document.uri, edits); + if (!await this.#api.apply(workspaceEdit)) { + throw new Error(vscode.l10n.t('The workspace rejected the completion.')); + } + const appliedVersion = this.#document.version; + let retrigger = false; + let warning: string | undefined; + if (item.command) { + if (item.command.command === 'editor.action.triggerSuggest') { + retrigger = true; + } else { + try { + await this.#api.execute(item.command); + } catch (error) { + warning = vscode.l10n.t('The completion was inserted, but its follow-up command failed: {0}', error instanceof Error ? error.message : String(error)); + } + } + } + const snapshot = this.#snapshot(); + const mirror = TextDocument.create(this.#document.uri.toString(), 'markdown', 0, snapshot.text); + const caret = this.#document.version === appliedVersion && this.#isActive() + ? mirror.offsetAt(this.#document.positionAt(offset)) : undefined; + return { offset: caret, editEpoch: snapshot.editEpoch, retrigger, ...(warning ? { warning } : {}) }; + } finally { + if (this.#session === session) { this.#session = undefined; } + } + } + + cancel(requestId?: number): void { + if (requestId === undefined || this.#session?.requestId === requestId) { this.#session = undefined; } + } + + #checkCurrent(session: CompletionSession, version?: number, editEpoch?: number): void { + if (this.#session !== session || !this.#isActive() || this.#document.isClosed) { throw new vscode.CancellationError(); } + if (version !== undefined && (this.#document.version !== version || this.#snapshot().editEpoch !== editEpoch)) { + throw new Error(vscode.l10n.t('The document changed. Request suggestions again.')); + } + } +} + +interface CompletionSession { + readonly requestId: number; + applying?: boolean; + prepared?: { readonly version: number; readonly editEpoch: number; readonly position: vscode.Position; readonly items: readonly vscode.CompletionItem[] }; +} + +export interface MarkdownCompletionApi { + provide(uri: vscode.Uri, position: vscode.Position, resolveCount: number): Promise; + apply(edit: vscode.WorkspaceEdit): Thenable; + execute(command: vscode.Command): Thenable; +} + +const completionApi: MarkdownCompletionApi = { + provide: async (uri, position, resolveCount) => vscode.commands.executeCommand('vscode.executeCompletionItemProvider', uri, position, undefined, resolveCount), + apply: edit => vscode.workspace.applyEdit(edit), + execute: command => vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])), +}; + +function completionRange(document: vscode.TextDocument, position: vscode.Position, item: vscode.CompletionItem): vscode.Range { + if (item.textEdit) { return item.textEdit.range; } + if (item.range) { return item.range instanceof vscode.Range ? item.range : item.range.replacing; } + return document.getWordRangeAtPosition(position) ?? new vscode.Range(position, position); +} + +function completionIdentity(document: vscode.TextDocument, position: vscode.Position, item: vscode.CompletionItem): string { + const label = typeof item.label === 'string' ? item.label : item.label.label; + return JSON.stringify([label, item.kind, item.sortText ?? label, item.filterText ?? label, + completionRange(document, position, item), item.textEdit?.newText ?? item.insertText ?? label]); +} + +function validateCompletionEdits(document: vscode.TextDocument, edits: readonly vscode.TextEdit[]): void { + for (const edit of edits) { + if (!document.validateRange(edit.range).isEqual(edit.range)) { + throw new Error(vscode.l10n.t('The completion provider returned an invalid edit range.')); + } + } + const sorted = [...edits].sort((a, b) => a.range.start.compareTo(b.range.start)); + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].range.start.isBefore(sorted[i - 1].range.end) || sorted[i].range.start.isEqual(sorted[i - 1].range.start)) { + throw new Error(vscode.l10n.t('The completion provider returned overlapping edits.')); + } + } +} + +export function mapMarkdownDiagnostics(document: vscode.TextDocument, text: string, diagnostics: readonly vscode.Diagnostic[]): MarkdownDiagnostic[] { + const mirror = TextDocument.create(document.uri.toString(), 'markdown', 0, text); + const severities = ['error', 'warning', 'info', 'hint'] as const; + return diagnostics.map(diagnostic => { + const range = document.validateRange(diagnostic.range); + return { + start: mirror.offsetAt(range.start), endExclusive: mirror.offsetAt(range.end), + message: diagnostic.message, severity: severities[diagnostic.severity], + source: diagnostic.source, + code: diagnostic.code === undefined ? undefined : String(typeof diagnostic.code === 'object' ? diagnostic.code.value : diagnostic.code), + codeTarget: typeof diagnostic.code === 'object' ? diagnostic.code.target.toString() : undefined, + }; + }); +} diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts index 664323ea353fe..85b1547403061 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -3,11 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { defineInterface, requestType, type InterfaceClient } from '@vscode/hubrpc'; +import { defineInterface, notificationType, requestType, type InterfaceClient } from '@vscode/hubrpc'; import * as z from 'zod/mini'; const offset = z.int().check(z.nonnegative()); const range = z.object({ start: offset, endExclusive: offset }); +const diagnostic = z.extend(range, { + message: z.string(), + severity: z.enum(['error', 'warning', 'info', 'hint']), + source: z.optional(z.string()), + code: z.optional(z.string()), + codeTarget: z.optional(z.string()), +}); +const completion = z.object({ + id: z.string(), + label: z.string(), + detail: z.optional(z.string()), + type: z.optional(z.string()), + unsupported: z.optional(z.string()), + highlightLength: z.optional(offset), +}); const sandbox = z.object({ forms: z.optional(z.boolean()), downloads: z.optional(z.boolean()), @@ -68,33 +83,45 @@ export const markdownEditorHost = defineInterface({ id: 'markdown.editor.host' } openLink: requestType(z.object({ href: z.string() }), z.void()), setReadonly: requestType(z.object({ readonly: z.boolean() }), z.void()), editorFocusChanged: requestType(z.object({ focused: z.boolean() }), z.void()), - richLinkTargets: requestType(z.object({ hrefs: z.readonly(z.array(z.string())) }), z.void()), + richLinkTargets: notificationType(z.object({ hrefs: z.readonly(z.array(z.string())) })), resolveCodeBlockEditor: requestType(z.object({ providerId: z.string(), language: z.string() }), z.object({ descriptor: z.optional(resolvedCodeBlockEditor) })), createCodeBlockEditorHostTransport: requestType(z.extend(runtime, { providerId: z.string(), runtimeKey: z.string() }), z.void()), codeBlockEditorHostTransportMessage: requestType(runtimeMessage, z.void()), disposeCodeBlockEditorHostTransport: requestType(runtime, z.void()), - codeBlockEditorDiagnostic: requestType(z.object({ message: z.string() }), z.void()), + codeBlockEditorDiagnostic: notificationType(z.object({ message: z.string() })), addComment: requestType(z.extend(range, { text: z.string() }), z.void()), deleteComment: requestType(z.object({ id: z.string() }), z.void()), highlight: requestType(z.object({ source: z.string(), languageId: z.string() }), highlightResult), + prepareRename: requestType(z.object({ requestId: offset, offset, editEpoch: offset }), z.extend(range, { placeholder: z.string() })), + rename: requestType(z.object({ requestId: offset, newName: z.string() }), z.void()), + cancelRename: requestType(z.object({ requestId: offset }), z.void()), + getDiagnostics: requestType(z.object({}), z.object({ editEpoch: offset, items: z.array(diagnostic) })), + completions: requestType(z.object({ requestId: offset, offset, editEpoch: offset, automatic: z.boolean() }), z.object({ items: z.array(completion), incomplete: z.boolean() })), + acceptCompletion: requestType(z.object({ requestId: offset, id: z.string() }), z.object({ offset: z.optional(offset), editEpoch: offset, retrigger: z.boolean(), warning: z.optional(z.string()) })), + cancelCompletions: requestType(z.object({ requestId: offset }), z.void()), + pasteImages: requestType(z.extend(range, { + editEpoch: offset, + images: z.array(z.object({ name: z.string(), mime: z.string(), base64: z.string() })), + }), z.object({ offset: z.optional(offset), editEpoch: offset })), }); export const markdownEditorRenderer = defineInterface({ id: 'markdown.editor.renderer' }, { + diagnosticsChanged: requestType(z.object({}), z.void()), update: requestType(z.object({ content: z.string(), editEpoch: offset }), z.void()), - codeBlockEditorProviders: requestType(z.object({ codeBlockEditorProviders: z.readonly(z.array(codeBlockEditorProvider)) }), z.void()), + codeBlockEditorProviders: notificationType(z.object({ codeBlockEditorProviders: z.readonly(z.array(codeBlockEditorProvider)) })), codeBlockEditorHostTransportMessage: requestType(runtimeMessage, z.void()), - gutterMarkers: requestType(z.object({ + gutterMarkers: notificationType(z.object({ markers: z.readonly(z.array(z.extend(range, { type: z.enum(['added', 'modified', 'deleted']) }))), - }), z.void()), - comments: requestType(z.object({ + })), + comments: notificationType(z.object({ comments: z.readonly(z.array(z.extend(range, { id: z.string(), body: z.string(), author: z.optional(z.string()) }))), acceptsComments: z.boolean(), - }), z.void()), - revealComment: requestType(z.object({ id: z.string() }), z.void()), + })), + revealComment: notificationType(z.object({ id: z.string() })), revealLinkTarget: requestType(z.extend(range, { selectionStart: offset }), z.void()), command: requestType(z.object({ command: z.string() }), z.void()), - highlightThemeChanged: requestType(z.object({}), z.void()), - richLinkPresentations: requestType(z.object({ presentations: z.readonly(z.array(richLinkPresentationUpdate)) }), z.void()), + highlightThemeChanged: notificationType(z.object({})), + richLinkPresentations: notificationType(z.object({ presentations: z.readonly(z.array(richLinkPresentationUpdate)) })), }); export type MarkdownEditorHost = InterfaceClient; @@ -103,3 +130,5 @@ export type CodeBlockEditorProviderDefinition = z.infer; export type HighlightResult = z.infer; export type RichLinkPresentationUpdate = z.infer; +export type MarkdownDiagnostic = z.infer; +export type MarkdownCompletion = z.infer; diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 83ed89d84756e..ca4381a3c59d3 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -22,7 +22,10 @@ import type { import { generateUuid } from '../util/uuid'; import { MarkdownEditorRichLinkController } from './markdownEditorRichLinks'; import { markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; -import { MarkdownEditorRpcTransport } from './markdownEditorRpc'; +import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from './markdownEditorRpc'; +import { MarkdownEditorRename } from './markdownEditorRename'; +import { MarkdownEditorLanguageFeatures } from './markdownEditorLanguageFeatures'; +import { MarkdownEditorImagePaste } from './markdownEditorImagePaste'; export interface MarkdownCodeBlockEditorApiV1 { getProvider(providerId: string): MarkdownCodeBlockEditorProviderApi | undefined; @@ -91,11 +94,15 @@ class AuthenticatedWebview implements vscode.Disposable { } #connect(): HubRpcConnection { - return HubRpcConnection.fromTransport(new MarkdownEditorRpcTransport( + return createMarkdownEditorRpcConnection(new MarkdownEditorRpcTransport( this.#messageSecret, message => this.webview.postMessage(message), listener => this.webview.onDidReceiveMessage(listener), - )); + ), (operation, error) => { + if (!this.#disposed) { + this.logger.trace('Markdown editor RPC', operation, error); + } + }); } get renderer() { @@ -133,8 +140,8 @@ class AuthenticatedWebview implements vscode.Disposable { return this.#messageSecret; } - report(operation: string, request: Promise): void { - void request.catch(error => { + report(operation: string, request: Promise | void): void { + void request?.catch(error => { if (!this.#disposed) { this.logger.trace('Markdown editor RPC', operation, error); } @@ -328,6 +335,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT let expectedWebviewContent: { readonly content: string; readonly epoch: number } | undefined; let webviewText = document.getText(); let webviewReady = false; + let readonly = this.#globalState.get(MarkdownEditorProvider.#readonlyStateKey, true); let codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] | undefined; let contributionUpdate = 0; const resolveCancellation = new vscode.CancellationTokenSource(); @@ -397,18 +405,18 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT this.#logger, params => editorWebview.renderer.richLinkPresentations(params), ); - const postCodeBlockEditorProviders = async (): Promise => { + const postCodeBlockEditorProviders = (): void => { if (webviewReady && codeBlockEditorProviders) { - await editorWebview.renderer.codeBlockEditorProviders({ codeBlockEditorProviders }); + editorWebview.renderer.codeBlockEditorProviders({ codeBlockEditorProviders }); } }; const initialContributionUpdate = contributionUpdate; - void initialCodeBlockEditorProviders.then(async providers => { + void initialCodeBlockEditorProviders.then(providers => { if (initialContributionUpdate !== contributionUpdate || resolveCancellation.token.isCancellationRequested) { return; } codeBlockEditorProviders = providers; - await postCodeBlockEditorProviders(); + postCodeBlockEditorProviders(); }).catch(error => { if (!resolveCancellation.token.isCancellationRequested) { this.#logger.trace('Markdown code block editor', 'Failed to initialize contributed editors', error); @@ -416,7 +424,33 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT }); const comments = this.#wireComments(document, editorWebview); + const rename = new MarkdownEditorRename(document, () => editQueue.drain(), + () => ({ text: webviewText, editEpoch: editQueue.epoch }), + () => webviewPanel.active && !resolveCancellation.token.isCancellationRequested); + const languageFeatures = new MarkdownEditorLanguageFeatures(document, () => editQueue.drain(), + () => ({ text: webviewText, editEpoch: editQueue.epoch }), + () => webviewPanel.active && !resolveCancellation.token.isCancellationRequested); + const imagePaste = new MarkdownEditorImagePaste(document, () => editQueue.drain(), + () => ({ text: webviewText, editEpoch: editQueue.epoch }), + () => !readonly && webviewPanel.active && !resolveCancellation.token.isCancellationRequested); + const diagnosticsChanged = (): void => { + if (webviewReady && !resolveCancellation.token.isCancellationRequested) { + void editorWebview.renderer.diagnosticsChanged({}).catch(error => + this.#logger.trace('Markdown editor', 'Failed to refresh diagnostics', error)); + } + }; + const diagnosticListener = vscode.languages.onDidChangeDiagnostics(event => { + if (event.uris.some(uri => uri.toString() === document.uri.toString())) { diagnosticsChanged(); } + }); editorWebview.setHandlers({ + getDiagnostics: () => languageFeatures.diagnostics(), + completions: message => languageFeatures.completions(message), + acceptCompletion: message => languageFeatures.accept(message), + cancelCompletions: message => languageFeatures.cancel(message.requestId), + pasteImages: message => imagePaste.paste(message), + prepareRename: message => rename.prepare(message), + rename: message => rename.rename(message), + cancelRename: message => rename.cancel(message.requestId), ready: async (message, _context, { signal }) => { webviewReady = true; editorWebview.acceptReady(); @@ -425,7 +459,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } signal.throwIfAborted(); editorWebview.publishReady(); - await postCodeBlockEditorProviders(); + postCodeBlockEditorProviders(); + diagnosticsChanged(); }, resolveCodeBlockEditor: async (message, _context, { signal }) => { @@ -468,6 +503,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT setReadonly: async message => { + readonly = message.readonly; await this.#globalState.update(MarkdownEditorProvider.#readonlyStateKey, message.readonly); }, history: async (message, _context, { signal }) => { @@ -548,6 +584,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT if (e.document.uri.toString() !== document.uri.toString()) { return; } + diagnosticsChanged(); if ( e.document.getText() === expectedWebviewContent?.content && expectedWebviewContent.epoch === editQueue.epoch @@ -565,6 +602,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT ? this.#wireDocumentDiff(originalDocument, document, editorWebview) : this.#wireQuickDiff(document, editorWebview); const reloadWebview = (): void => { + imagePaste.dispose(); + rename.cancel(); + languageFeatures.cancel(); webviewReady = false; void editQueue.enqueueBarrier(async epoch => { if (resolveCancellation.token.isCancellationRequested) { @@ -597,7 +637,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT return; } codeBlockEditorProviders = updatedCodeBlockEditorProviders; - await postCodeBlockEditorProviders(); + postCodeBlockEditorProviders(); }; const onContributionsChanged = this.#contributions.onContributionsChanged(() => { void refreshCodeBlockEditorProviders(true, false); @@ -631,6 +671,10 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT this.#configureWebview(document, editorWebview, editQueue.epoch); webviewPanel.onDidDispose(() => { + imagePaste.dispose(); + rename.cancel(); + languageFeatures.cancel(); + diagnosticListener.dispose(); contributionUpdate++; editQueue.invalidate(); resolveCancellation.cancel(); @@ -935,7 +979,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT if (originalVersion !== originalDocument.version || modifiedVersion !== modifiedDocument.version) { return; } - await editorWebview.renderer.gutterMarkers({ markers: lineRangesToGutterMarkers(modifiedDocument, changes) }); + editorWebview.renderer.gutterMarkers({ markers: lineRangesToGutterMarkers(modifiedDocument, changes) }); }; const publishMarkers = () => editorWebview.report('Publish document diff', postMarkers()); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorRename.ts b/extensions/markdown-language-features/src/preview/markdownEditorRename.ts new file mode 100644 index 0000000000000..999c3074f34db --- /dev/null +++ b/extensions/markdown-language-features/src/preview/markdownEditorRename.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { TextDocument } from 'vscode-languageserver-textdocument'; +import type { MarkdownEditorHost } from './markdownEditorProtocol'; + +export class MarkdownEditorRename { + readonly #drain: () => Promise; + readonly #snapshot: () => RenameSnapshot; + readonly #isActive: () => boolean; + readonly #api: MarkdownRenameApi; + #session: RenameSession | undefined; + + constructor( + readonly document: vscode.TextDocument, + drain: () => Promise, + snapshot: () => RenameSnapshot, + isActive: () => boolean, + api: MarkdownRenameApi = renameApi, + ) { + this.#drain = drain; + this.#snapshot = snapshot; + this.#isActive = isActive; + this.#api = api; + } + + async prepare(request: Parameters[0]): ReturnType { + const session: RenameSession = { requestId: request.requestId }; + this.#session = session; + await this.#drain(); + this.#checkCurrent(session); + const snapshot = this.#snapshot(); + if (request.editEpoch !== snapshot.editEpoch || request.offset > snapshot.text.length) { + throw new Error(vscode.l10n.t('The document changed. Start rename again.')); + } + const mirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, snapshot.text); + const position = mirror.positionAt(request.offset); + const version = this.document.version; + const prepared = await this.#api.prepare(this.document.uri, new vscode.Position(position.line, position.character)); + this.#checkCurrent(session); + if (version !== this.document.version || snapshot.editEpoch !== this.#snapshot().editEpoch) { + throw new Error(vscode.l10n.t('The document changed. Start rename again.')); + } + if (!prepared) { + throw new Error(vscode.l10n.t('This symbol cannot be renamed.')); + } + session.prepared = { position: new vscode.Position(position.line, position.character), version, editEpoch: snapshot.editEpoch }; + return { + start: mirror.offsetAt(prepared.range.start), + endExclusive: mirror.offsetAt(prepared.range.end), + placeholder: prepared.placeholder, + }; + } + + async rename(request: Parameters[0]): Promise { + const session = this.#session; + if (session?.requestId !== request.requestId || !session.prepared) { + throw new Error(vscode.l10n.t('Start rename again before applying a new name.')); + } + if (!request.newName.trim() || /[\r\n]/.test(request.newName)) { + throw new Error(vscode.l10n.t('Enter a non-empty, single-line name.')); + } + if (session.submitting) { + throw new Error(vscode.l10n.t('A rename is already in progress.')); + } + session.submitting = true; + try { + await this.#apply(session, session.prepared, request.newName); + } finally { + session.submitting = false; + } + } + + async #apply(session: RenameSession, prepared: NonNullable, newName: string): Promise { + await this.#drain(); + this.#checkCurrent(session); + const { version, editEpoch, position } = prepared; + const checkVersion = (): void => { + this.#checkCurrent(session); + if (version !== this.document.version || editEpoch !== this.#snapshot().editEpoch) { + throw new Error(vscode.l10n.t('The document changed. Start rename again.')); + } + }; + checkVersion(); + const versions = new Map(vscode.workspace.textDocuments.map(document => [document.uri.toString(), document.version])); + const edit = await this.#api.rename(this.document.uri, position, newName); + await this.#drain(); + checkVersion(); + if (!edit) { + throw new Error(vscode.l10n.t('The rename provider returned no changes.')); + } + for (const [uri] of edit.entries()) { + const key = uri.toString(); + const current = vscode.workspace.textDocuments.find(document => document.uri.toString() === key); + if (current && versions.has(key) && current.version !== versions.get(key)) { + throw new Error(vscode.l10n.t('A document changed while computing the rename. Start rename again.')); + } + } + if (!await this.#api.apply(edit)) { + throw new Error(vscode.l10n.t('The workspace rejected the rename.')); + } + if (this.#session === session) { + this.#session = undefined; + } + } + + cancel(requestId?: number): void { + if (requestId === undefined || this.#session?.requestId === requestId) { + this.#session = undefined; + } + } + + #checkCurrent(session: RenameSession): void { + if (this.#session !== session || !this.#isActive() || this.document.isClosed) { + throw new vscode.CancellationError(); + } + } +} + +interface RenameSession { + readonly requestId: number; + prepared?: { readonly position: vscode.Position; readonly version: number; readonly editEpoch: number }; + submitting?: boolean; +} + +interface RenameSnapshot { + readonly text: string; + readonly editEpoch: number; +} + +export interface MarkdownRenameApi { + prepare(uri: vscode.Uri, position: vscode.Position): Promise<{ range: vscode.Range; placeholder: string } | undefined>; + rename(uri: vscode.Uri, position: vscode.Position, newName: string): Promise; + apply(edit: vscode.WorkspaceEdit): Thenable; +} + +const renameApi: MarkdownRenameApi = { + prepare: async (uri, position) => vscode.commands.executeCommand('vscode.prepareRename', uri, position), + rename: async (uri, position, newName) => vscode.commands.executeCommand('vscode.executeDocumentRenameProvider', uri, position, newName), + apply: edit => vscode.workspace.applyEdit(edit), +}; diff --git a/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts b/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts index 06e00fdd2da79..8de9e4e95da80 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts @@ -59,14 +59,10 @@ export class MarkdownEditorRichLinkController extends Disposable { super.dispose(); } - async #publishPresentation(href: string, presentation: LinkPresentation | undefined): Promise { - try { - await this.#publish({ - presentations: [{ href, presentation }], - }); - } catch (error) { - this.#logger.trace('Markdown rich link', `Failed to publish ${href}`, error); - } + #publishPresentation(href: string, presentation: LinkPresentation | undefined): void { + this.#publish({ + presentations: [{ href, presentation }], + }); } } diff --git a/extensions/markdown-language-features/src/preview/markdownEditorRpc.ts b/extensions/markdown-language-features/src/preview/markdownEditorRpc.ts index adbd8da27b8f8..1ac9eef9df5b0 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorRpc.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorRpc.ts @@ -3,9 +3,39 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isResponse, type IMessageTransport, type JsonRpcMessage } from '@vscode/hubrpc'; +import { Channel, HubRpcConnection, JsonRpcChannel, isResponse, type IMessageTransport, type JsonRpcMessage } from '@vscode/hubrpc'; import * as z from 'zod/mini'; +export function createMarkdownEditorRpcConnection( + transport: IMessageTransport, + report: (operation: string, error: unknown) => void, +): HubRpcConnection { + const channel = JsonRpcChannel.create(transport); + // HubRPC 0.0.2-20 drops notification send promises and catches synchronous handler errors. + // Notifications have no response, so report failures at the endpoint that observes them. + return new HubRpcConnection(new Channel({ + sendRequest: (method, params, options) => channel.sender.sendRequest(method, params, options), + sendRequestWithStream: (method, params, options) => channel.sender.sendRequestWithStream(method, params, options), + sendNotification: async (method, params, options) => { + try { + await channel.sender.sendNotification(method, params, options); + } catch (error) { + report(`Send notification ${method}`, error); + } + }, + close: () => channel.sender.close(), + }, handler => channel.setRequestHandler(handler && { + handleRequest: call => handler.handleRequest(call), + handleNotification: call => { + try { + handler.handleNotification(call); + } catch (error) { + report(`Handle notification ${call.method}`, error); + } + }, + }), (observer, isInspectionMethod) => channel.setWireMessageObserver(observer, isInspectionMethod))); +} + interface IDisposable { dispose(): void; } diff --git a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts index 13486008c0d44..f886c49ce24e6 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts @@ -48,6 +48,7 @@ suite('Markdown editor bridge', () => { const updates: { content: string; editEpoch: number }[] = []; let updated: (() => void) | undefined; renderer.register(markdownEditorRenderer, { + diagnosticsChanged: () => { }, update: update => { updates.push(update); updated?.(); }, codeBlockEditorProviders: () => { }, codeBlockEditorHostTransportMessage: () => { }, diff --git a/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts b/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts new file mode 100644 index 0000000000000..e193042d6d51b --- /dev/null +++ b/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as os from 'os'; +import 'mocha'; +import * as vscode from 'vscode'; +import { MarkdownEditorImagePaste } from '../preview/markdownEditorImagePaste'; +import { CopyFilesSettings, ResourcePasteOrDropProvider } from '../languageFeatures/copyFiles/dropOrPasteResource'; + +suite('Markdown editor image paste', () => { + const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII='; + const image = { name: 'image.png', mime: 'image/png', base64 }; + let root: vscode.Uri; + let document: vscode.TextDocument; + let state: { text: string; editEpoch: number; editable: boolean }; + let paste: MarkdownEditorImagePaste; + const request = () => ({ start: 9, endExclusive: 13, editEpoch: state.editEpoch, images: [image] }); + const apply = async (edit: vscode.WorkspaceEdit): Promise => { + const result = await vscode.workspace.applyEdit(edit); + state.text = document.getText().replace(/\r\n/g, '\n'); + state.editEpoch++; + return result; + }; + + setup(async () => { + root = vscode.Uri.joinPath(vscode.Uri.file(os.tmpdir()), `markdown-image-paste-${crypto.randomUUID()}`); + await vscode.workspace.fs.createDirectory(root); + const uri = vscode.Uri.joinPath(root, 'document.md'); + await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode('# Title\r\n\r\nHERE')); + document = await vscode.workspace.openTextDocument(uri); + state = { text: '# Title\n\nHERE', editEpoch: 0, editable: true }; + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => state.editable, apply); + }); + + teardown(async () => { + paste.dispose(); + await vscode.workspace.fs.delete(root, { recursive: true }); + }); + + test('creates exact image bytes and replaces the selection with resolved Markdown, not snippet syntax', async () => { + const result = await paste.paste(request()); + assert.strictEqual(document.getText(), '# Title\r\n\r\n![alt text](image.png)'); + assert.strictEqual(result.offset, state.text.length); + assert.strictEqual(result.editEpoch, 1); + assert.deepStrictEqual(Uint8Array.from(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, 'image.png'))), Uint8Array.from(atob(base64), c => c.charCodeAt(0))); + }); + + test('uses the same naming and Markdown snippet as code-editor image paste', async () => { + const data = new vscode.DataTransfer(); + const item = new vscode.DataTransferItem(''); + item.asFile = () => ({ name: 'image.png', uri: undefined, data: async () => Uint8Array.from(atob(base64), c => c.charCodeAt(0)) }); + data.set('image/png', item); + const token = new vscode.CancellationTokenSource(); + try { + const normal = await ResourcePasteOrDropProvider.createEditForMediaFiles(document, data, CopyFilesSettings.MediaFiles, token.token); + assert.strictEqual(normal?.snippet.value, '![$' + '{1:alt text}](image.png)'); + await paste.paste(request()); + assert.ok(document.getText().endsWith('![alt text](image.png)')); + } finally { token.dispose(); } + }); + + test('increments collisions without overwriting and supports multiple images of one MIME type', async () => { + await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(root, 'image.png'), new Uint8Array([1, 2, 3])); + await paste.paste({ ...request(), images: [image, { ...image, base64: 'BAUG' }] }); + const links = Array.from(document.getText().matchAll(/!\[alt text\]\(([^)]+)\)/g), match => match[1]); + assert.deepStrictEqual([...links].sort(), ['image-1.png', 'image-2.png']); + assert.strictEqual(document.getText(), `# Title\r\n\r\n![alt text](${links[0]}) ![alt text](${links[1]})`); + assert.deepStrictEqual(await Promise.all(links.map(async name => + Uint8Array.from(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, name))))), [ + Uint8Array.from(atob(base64), c => c.charCodeAt(0)), + new Uint8Array([4, 5, 6]), + ]); + assert.deepStrictEqual(Uint8Array.from(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, 'image.png'))), new Uint8Array([1, 2, 3])); + }); + + test('honors copy destination and overwrite settings', async () => { + const config = vscode.workspace.getConfiguration('markdown', document); + const destination = config.inspect('copyFiles.destination')?.globalValue; + const overwrite = config.inspect('copyFiles.overwriteBehavior')?.globalValue; + try { + await config.update('copyFiles.destination', { '**/document.md': 'assets/${fileName}' }, vscode.ConfigurationTarget.Global); + await config.update('copyFiles.overwriteBehavior', 'overwrite', vscode.ConfigurationTarget.Global); + await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(root, 'assets')); + await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(root, 'assets', 'image.png'), new Uint8Array([0])); + await paste.paste(request()); + assert.ok(document.getText().endsWith('![alt text](assets/image.png)')); + assert.strictEqual((await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, 'assets', 'image.png'))).length, atob(base64).length); + } finally { + await config.update('copyFiles.destination', destination, vscode.ConfigurationTarget.Global); + await config.update('copyFiles.overwriteBehavior', overwrite, vscode.ConfigurationTarget.Global); + } + }); + + test('rejects invalid input, stale epochs, and locked editors before creating files', async () => { + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, name: '../escape.png' }] }), /Invalid clipboard image/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, base64: 'invalid!' }] }), /Invalid clipboard image/); + await assert.rejects(paste.paste({ ...request(), endExclusive: 999 }), /Invalid image paste range/); + await assert.rejects(paste.paste({ ...request(), start: -1 }), /Invalid image paste range/); + await assert.rejects(paste.paste({ ...request(), editEpoch: 9 }), /editor changed/); + state.editable = false; + await assert.rejects(paste.paste(request()), /editor changed/); + assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); + }); + + test('rejects a changed document or disposal while preparing image edits', async () => { + let drains = 0; + paste.dispose(); + paste = new MarkdownEditorImagePaste(document, async () => { + if (++drains === 2) { state.editEpoch++; } + }, () => state, () => true, apply); + await assert.rejects(paste.paste(request()), /editor changed/); + assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + paste = new MarkdownEditorImagePaste(document, async () => { entered.resolve(); await finish.promise; }, () => state, () => true, apply); + const result = paste.paste(request()); + await entered.promise; + paste.dispose(); + finish.resolve(); + await assert.rejects(result, vscode.CancellationError); + }); + + test('reports disabled settings, unsaved documents, and workspace rejection', async () => { + const config = vscode.workspace.getConfiguration('editor', document); + const previous = config.inspect('pasteAs.enabled')?.globalValue; + try { + await config.update('pasteAs.enabled', false, vscode.ConfigurationTarget.Global); + await assert.rejects(paste.paste(request()), /disabled/); + } finally { await config.update('pasteAs.enabled', previous, vscode.ConfigurationTarget.Global); } + const untitled = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'HERE' }); + const unsaved = new MarkdownEditorImagePaste(untitled, async () => { }, () => ({ text: 'HERE', editEpoch: 0 }), () => true); + try { + await assert.rejects(unsaved.paste({ ...request(), start: 0, endExclusive: 4, editEpoch: 0 }), /Save the Markdown/); + } finally { unsaved.dispose(); } + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async () => false); + await assert.rejects(paste.paste(request()), /workspace rejected/); + assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); + }); + + test('does not return a stale caret when another edit follows application', async () => { + paste.dispose(); + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async edit => { + await apply(edit); + const followup = new vscode.WorkspaceEdit(); + followup.insert(document.uri, new vscode.Position(0, 0), 'Other edit\n'); + return apply(followup); + }); + const result = await paste.paste(request()); + assert.deepStrictEqual(result, { offset: undefined, editEpoch: 2 }); + assert.ok(document.getText().includes('![alt text](image.png)')); + }); +}); diff --git a/extensions/markdown-language-features/src/test/markdownEditorLanguageFeatures.test.ts b/extensions/markdown-language-features/src/test/markdownEditorLanguageFeatures.test.ts new file mode 100644 index 0000000000000..12d77d9ee15ae --- /dev/null +++ b/extensions/markdown-language-features/src/test/markdownEditorLanguageFeatures.test.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; +import { MarkdownEditorLanguageFeatures, mapMarkdownDiagnostics, type MarkdownCompletionApi } from '../preview/markdownEditorLanguageFeatures'; + +suite('Markdown editor diagnostics and completion', () => { + const request = { requestId: 1, offset: 11, editEpoch: 0, automatic: false }; + + async function setup(overrides: Partial = {}) { + const document = await vscode.workspace.openTextDocument({ language: 'markdown', content: '# Title\r\n\r\nTa' }); + const state = { text: '# Title\n\nTa', editEpoch: 0, active: true }; + const item = new vscode.CompletionItem('Target', vscode.CompletionItemKind.Reference); + item.range = new vscode.Range(2, 0, 2, 2); + const counts: number[] = []; + const commands: vscode.Command[] = []; + let applied = 0; + const api: MarkdownCompletionApi = { + provide: async (_uri, position, resolveCount) => { + assert.deepStrictEqual(position, new vscode.Position(2, 2)); + counts.push(resolveCount); + return new vscode.CompletionList([item]); + }, + apply: async edit => { + applied++; + const result = await vscode.workspace.applyEdit(edit); + state.text = document.getText(); + state.editEpoch++; + return result; + }, + execute: async command => { commands.push(command); }, + ...overrides, + }; + const features = new MarkdownEditorLanguageFeatures(document, async () => { }, () => state, () => state.active, api); + return { document, state, item, counts, commands, features, get applied() { return applied; } }; + } + + test('maps all diagnostic severities, source and code from CRLF documents to LF renderer offsets', async () => { + const test = await setup(); + const diagnostics = [0, 1, 2, 3].map(severity => { + const item = new vscode.Diagnostic(new vscode.Range(2, 0, 2, 2), `Message ${severity}`, severity); + item.source = 'third-party'; + item.code = { value: 'rule', target: vscode.Uri.parse('https://example.com/rule') }; + return item; + }); + assert.deepStrictEqual(mapMarkdownDiagnostics(test.document, test.state.text, diagnostics), + ['error', 'warning', 'info', 'hint'].map((severity, index) => ({ + start: 9, endExclusive: 11, message: `Message ${index}`, severity, source: 'third-party', code: 'rule', codeTarget: 'https://example.com/rule', + }))); + }); + + test('reads diagnostics from arbitrary collections and propagates clearing', async () => { + const test = await setup(); + const collection = vscode.languages.createDiagnosticCollection('rich-editor-test'); + try { + collection.set(test.document.uri, [new vscode.Diagnostic(new vscode.Range(2, 0, 2, 2), 'Third-party warning', vscode.DiagnosticSeverity.Warning)]); + const result = await test.features.diagnostics(); + assert.ok(result.items.some(item => item.message === 'Third-party warning' && item.severity === 'warning')); + collection.clear(); + assert.ok(!(await test.features.diagnostics()).items.some(item => item.message === 'Third-party warning')); + } finally { + collection.dispose(); + } + }); + + test('defaults automatic completion to off and honors live rich-editor and language settings without disabling manual completion', async () => { + const test = await setup(); + const config = vscode.workspace.getConfiguration('editor', test.document); + const previous = config.inspect('quickSuggestions')?.globalLanguageValue; + const richConfig = vscode.workspace.getConfiguration('markdown.editor', test.document); + const previousRich = richConfig.inspect('quickSuggestions')?.globalValue; + try { + assert.strictEqual(richConfig.inspect('quickSuggestions')?.defaultValue, false); + await richConfig.update('quickSuggestions', undefined, vscode.ConfigurationTarget.Global); + await config.update('quickSuggestions', { other: 'on' }, vscode.ConfigurationTarget.Global, true); + assert.deepStrictEqual(await test.features.completions({ ...request, automatic: true }), { items: [], incomplete: false }); + assert.deepStrictEqual(test.counts, []); + assert.strictEqual((await test.features.completions({ ...request, requestId: 2 })).items[0].label, 'Target'); + await richConfig.update('quickSuggestions', true, vscode.ConfigurationTarget.Global); + const result = await test.features.completions({ ...request, requestId: 3, automatic: true }); + assert.strictEqual(result.items[0].label, 'Target'); + assert.strictEqual(result.items[0].highlightLength, 2); + await config.update('quickSuggestions', { other: 'off' }, vscode.ConfigurationTarget.Global, true); + assert.deepStrictEqual(await test.features.completions({ ...request, requestId: 4, automatic: true }), { items: [], incomplete: false }); + assert.strictEqual((await test.features.completions({ ...request, requestId: 5 })).items[0].label, 'Target'); + await config.update('quickSuggestions', { other: 'on' }, vscode.ConfigurationTarget.Global, true); + await richConfig.update('quickSuggestions', false, vscode.ConfigurationTarget.Global); + assert.deepStrictEqual(await test.features.completions({ ...request, requestId: 6, automatic: true }), { items: [], incomplete: false }); + assert.deepStrictEqual(test.counts, [0, 0, 0]); + } finally { + try { + await richConfig.update('quickSuggestions', previousRich, vscode.ConfigurationTarget.Global); + } finally { + await config.update('quickSuggestions', previous, vscode.ConfigurationTarget.Global, true); + } + } + }); + + test('matches and highlights ASCII I independently of locale, including filter text', async () => { + const document = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'i' }); + const items = ['Image', 'Other', '\u0131tem'].map(label => new vscode.CompletionItem(label)); + items[1].filterText = 'IMAGE'; + for (const item of items) { + item.range = new vscode.Range(0, 0, 0, 1); + } + const features = new MarkdownEditorLanguageFeatures(document, async () => { }, + () => ({ text: 'i', editEpoch: 0 }), () => true, { + provide: async () => new vscode.CompletionList(items), + apply: async () => { throw new Error('Unexpected edit'); }, + execute: async () => { throw new Error('Unexpected command'); }, + }); + assert.deepStrictEqual(await features.completions({ ...request, offset: 1 }), { + items: [ + { id: '0', label: 'Image', highlightLength: 1, detail: undefined, type: undefined, unsupported: undefined }, + { id: '1', label: 'Other', highlightLength: 0, detail: undefined, type: undefined, unsupported: undefined }, + ], + incomplete: false, + }); + }); + + test('requests, resolves and applies a provider edit with CRLF-safe caret placement', async () => { + const test = await setup(); + assert.deepStrictEqual((await test.features.completions(request)).items.map(item => item.label), ['Target']); + const result = await test.features.accept({ requestId: 1, id: '0' }); + assert.deepStrictEqual(test.counts, [0, 1]); + assert.strictEqual(test.document.getText(), '# Title\r\n\r\nTarget'); + assert.deepStrictEqual(result, { offset: 17, editEpoch: 1, retrigger: false }); + assert.strictEqual(test.applied, 1); + }); + + test('applies resolved additional edits and executes the provider command', async () => { + const test = await setup({ + provide: async (_uri, _position, resolveCount) => { + const item = new vscode.CompletionItem('Target'); + item.range = new vscode.Range(2, 0, 2, 2); + if (resolveCount) { + item.additionalTextEdits = [vscode.TextEdit.insert(new vscode.Position(0, 0), 'Top\n')]; + item.command = { command: 'test.followUp', title: '', arguments: [42] }; + } + return new vscode.CompletionList([item]); + }, + }); + await test.features.completions(request); + assert.strictEqual((await test.features.accept({ requestId: 1, id: '0' })).offset, 22); + assert.strictEqual(test.document.getText(), 'Top\r\n# Title\r\n\r\nTarget'); + assert.deepStrictEqual(test.commands, [{ command: 'test.followUp', title: '', arguments: [42] }]); + }); + + test('clearly marks snippet completions and never inserts snippet syntax literally', async () => { + const test = await setup(); + test.item.insertText = new vscode.SnippetString('Target${1:value}$0'); + const result = await test.features.completions(request); + assert.match(result.items[0].unsupported!, /Snippet editing is not supported/); + await assert.rejects(test.features.accept({ requestId: 1, id: '0' }), /Snippet editing is not supported/); + assert.strictEqual(test.applied, 0); + }); + + test('rejects stale, cancelled, and unknown sessions', async () => { + const test = await setup(); + await test.features.completions(request); + test.state.editEpoch++; + await assert.rejects(test.features.accept({ requestId: 1, id: '0' }), /document changed/); + test.state.editEpoch = 0; + await test.features.completions({ ...request, requestId: 2 }); + test.features.cancel(1); + await assert.rejects(test.features.accept({ requestId: 2, id: '999' }), /Unknown completion/); + test.features.cancel(2); + await assert.rejects(test.features.accept({ requestId: 2, id: '0' }), /Request suggestions again/); + assert.strictEqual(test.applied, 0); + }); + + test('discards cancelled in-flight provider results', async () => { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const test = await setup({ provide: async () => { entered.resolve(); await finish.promise; return new vscode.CompletionList([]); } }); + const result = test.features.completions(request); + await entered.promise; + test.features.cancel(1); + finish.resolve(); + await assert.rejects(result, vscode.CancellationError); + }); + + test('refuses a changed item identity when resolving instead of inserting the wrong completion', async () => { + const test = await setup({ provide: async (_uri, _position, count) => new vscode.CompletionList([new vscode.CompletionItem(count ? 'Tampered' : 'Target')]) }); + await test.features.completions(request); + await assert.rejects(test.features.accept({ requestId: 1, id: '0' }), /completion list changed/); + assert.strictEqual(test.applied, 0); + }); + + test('compares effective edits when provider resolution drops redundant defaults', async () => { + const test = await setup({ + provide: async (_uri, _position, count) => { + const item = new vscode.CompletionItem('Target'); + if (!count) { + item.sortText = 'Target'; + item.filterText = 'Target'; + item.insertText = 'Target'; + item.range = new vscode.Range(2, 0, 2, 2); + } + return new vscode.CompletionList([item]); + } + }); + await test.features.completions(request); + await test.features.accept({ requestId: 1, id: '0' }); + assert.strictEqual(test.document.getText(), '# Title\r\n\r\nTarget'); + }); + + test('rejects overlapping edits and workspace rejection', async () => { + const test = await setup(); + test.item.additionalTextEdits = [vscode.TextEdit.replace(new vscode.Range(2, 1, 2, 2), 'X')]; + await test.features.completions(request); + await assert.rejects(test.features.accept({ requestId: 1, id: '0' }), /overlapping edits/); + assert.strictEqual(test.applied, 0); + const rejected = await setup({ apply: async () => false }); + await rejected.features.completions(request); + await assert.rejects(rejected.features.accept({ requestId: 1, id: '0' }), /workspace rejected/); + }); + + test('routes the trigger-suggest command back to the rich editor', async () => { + const test = await setup(); + test.item.command = { command: 'editor.action.triggerSuggest', title: '' }; + await test.features.completions(request); + assert.strictEqual((await test.features.accept({ requestId: 1, id: '0' })).retrigger, true); + assert.deepStrictEqual(test.commands, []); + }); + + test('maps the accepted caret back to a normalized renderer snapshot', async () => { + const test = await setup({ + apply: async edit => { + const applied = await vscode.workspace.applyEdit(edit); + test.state.text = test.document.getText().replace(/\r\n/g, '\n'); + test.state.editEpoch++; + return applied; + } + }); + await test.features.completions(request); + assert.strictEqual((await test.features.accept({ requestId: 1, id: '0' })).offset, 15); + assert.strictEqual(test.state.text, '# Title\n\nTarget'); + }); + + test('does not restore a stale caret after a follow-up command edits the document', async () => { + const test = await setup({ + execute: async () => { + const edit = new vscode.WorkspaceEdit(); + edit.insert(test.document.uri, new vscode.Position(0, 0), 'Command\n'); + await vscode.workspace.applyEdit(edit); + test.state.text = test.document.getText(); + test.state.editEpoch++; + } + }); + test.item.command = { command: 'test.edit', title: '' }; + await test.features.completions(request); + assert.deepStrictEqual(await test.features.accept({ requestId: 1, id: '0' }), { offset: undefined, editEpoch: 2, retrigger: false }); + assert.strictEqual(test.document.getText(), 'Command\r\n# Title\r\n\r\nTarget'); + }); + + test('reports a failed follow-up command without hiding the successful insertion', async () => { + const test = await setup({ execute: async () => { throw new Error('Command failed'); } }); + test.item.command = { command: 'test.fail', title: '' }; + await test.features.completions(request); + const result = await test.features.accept({ requestId: 1, id: '0' }); + assert.match(result.warning!, /completion was inserted.*Command failed/); + assert.strictEqual(result.offset, 17); + assert.strictEqual(test.document.getText(), '# Title\r\n\r\nTarget'); + }); + + test('rejects duplicate acceptance and cancellation during resolution before applying', async () => { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const test = await setup({ + provide: async (_uri, _position, count) => { + if (count) { entered.resolve(); await finish.promise; } + return new vscode.CompletionList([new vscode.CompletionItem('Target')]); + } + }); + await test.features.completions(request); + const accepting = test.features.accept({ requestId: 1, id: '0' }); + await entered.promise; + await assert.rejects(test.features.accept({ requestId: 1, id: '0' }), /Request suggestions again/); + test.features.cancel(1); + finish.resolve(); + await assert.rejects(accepting, vscode.CancellationError); + assert.strictEqual(test.applied, 0); + }); + + test('rejects document changes during resolution and invalid provider ranges', async () => { + const test = await setup({ + provide: async (_uri, _position, count) => { + if (count) { + const edit = new vscode.WorkspaceEdit(); + edit.insert(test.document.uri, new vscode.Position(0, 0), 'Changed\n'); + await vscode.workspace.applyEdit(edit); + } + return new vscode.CompletionList([new vscode.CompletionItem('Target')]); + } + }); + await test.features.completions(request); + await assert.rejects(test.features.accept({ requestId: 1, id: '0' }), /document changed/); + assert.strictEqual(test.applied, 0); + + const invalid = await setup(); + invalid.item.additionalTextEdits = [vscode.TextEdit.insert(new vscode.Position(99, 0), 'Out of bounds')]; + await invalid.features.completions(request); + await assert.rejects(invalid.features.accept({ requestId: 1, id: '0' }), /invalid edit range/); + assert.strictEqual(invalid.applied, 0); + }); +}); diff --git a/extensions/markdown-language-features/src/test/markdownEditorRename.test.ts b/extensions/markdown-language-features/src/test/markdownEditorRename.test.ts new file mode 100644 index 0000000000000..fb7fd4bcfeb5e --- /dev/null +++ b/extensions/markdown-language-features/src/test/markdownEditorRename.test.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as os from 'os'; +import 'mocha'; +import * as vscode from 'vscode'; +import { MarkdownEditorRename, type MarkdownRenameApi } from '../preview/markdownEditorRename'; + +suite('Markdown editor rename', () => { + const prepared = { range: new vscode.Range(2, 0, 2, 6), placeholder: 'Target' }; + const request = { requestId: 1, offset: 10, editEpoch: 0 }; + + async function setup(overrides: Partial = {}, drain: () => Promise = async () => { }) { + const document = await vscode.workspace.openTextDocument({ language: 'markdown', content: '# Title\r\n\r\nTarget' }); + const state = { text: '# Title\n\nTarget', editEpoch: 0, active: true }; + const calls: { uri: vscode.Uri; position: vscode.Position; name?: string }[] = []; + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, prepared.range, 'Renamed'); + let applications = 0; + const api: MarkdownRenameApi = { + prepare: async (uri, position) => { calls.push({ uri, position }); return prepared; }, + rename: async (uri, position, name) => { calls.push({ uri, position, name }); return edit; }, + apply: async changes => { applications++; return vscode.workspace.applyEdit(changes); }, + ...overrides, + }; + const rename = new MarkdownEditorRename(document, drain, () => state, () => state.active, api); + return { document, state, calls, edit, rename, get applications() { return applications; } }; + } + + test('maps LF renderer offsets to CRLF document positions and back', async () => { + const test = await setup(); + assert.deepStrictEqual(await test.rename.prepare(request), { start: 9, endExclusive: 15, placeholder: 'Target' }); + assert.deepStrictEqual(test.calls[0].position, new vscode.Position(2, 1)); + await test.rename.rename({ requestId: 1, newName: 'Renamed' }); + assert.deepStrictEqual(test.calls[1].position, new vscode.Position(2, 1)); + assert.strictEqual(test.document.getText(), '# Title\r\n\r\nRenamed'); + assert.strictEqual(test.applications, 1); + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Again' }), /Start rename again/); + }); + + test('drains accepted edits before preparing and rejects stale epochs or offsets', async () => { + const drained = Promise.withResolvers(); + const test = await setup({}, () => drained.promise); + const result = test.rename.prepare(request); + assert.strictEqual(test.calls.length, 0); + drained.resolve(); + await result; + await assert.rejects(test.rename.prepare({ ...request, editEpoch: 1 }), /document changed/); + await assert.rejects(test.rename.prepare({ ...request, offset: 100 }), /document changed/); + assert.strictEqual(test.calls.length, 1); + }); + + test('cancels an in-flight prepare and ignores cancellation for an older session', async () => { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const test = await setup({ prepare: async () => { entered.resolve(); await finish.promise; return prepared; } }); + const result = test.rename.prepare(request); + await entered.promise; + test.rename.cancel(1); + finish.resolve(); + await assert.rejects(result, vscode.CancellationError); + await test.rename.prepare({ ...request, requestId: 2 }); + test.rename.cancel(1); + await test.rename.rename({ requestId: 2, newName: 'Renamed' }); + assert.strictEqual(test.applications, 1); + }); + + test('rejects unsupported symbols and prepare provider errors', async () => { + const unsupported = await setup({ prepare: async () => undefined }); + await assert.rejects(unsupported.rename.prepare(request), /cannot be renamed/); + const failed = await setup({ prepare: async () => { throw new Error('Provider rejected preparation'); } }); + await assert.rejects(failed.rename.prepare(request), /Provider rejected preparation/); + assert.strictEqual(failed.applications, 0); + }); + + test('rejects a document changed during prepare', async () => { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const test = await setup({ prepare: async () => { entered.resolve(); await finish.promise; return prepared; } }); + const result = test.rename.prepare(request); + await entered.promise; + await vscode.workspace.applyEdit(test.edit); + finish.resolve(); + await assert.rejects(result, /document changed/); + assert.strictEqual(test.applications, 0); + }); + + test('rejects renamed sessions after document changes, reload, or deactivation', async () => { + for (const invalidate of [ + async (test: Awaited>) => { await vscode.workspace.applyEdit(test.edit); }, + async (test: Awaited>) => { test.state.editEpoch++; }, + async (test: Awaited>) => { test.state.active = false; }, + async (test: Awaited>) => { test.rename.cancel(); }, + ]) { + const test = await setup(); + await test.rename.prepare(request); + await invalidate(test); + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Renamed' })); + assert.strictEqual(test.applications, 0); + } + }); + + test('cancels during provider execution and rejects concurrent submissions', async () => { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const test = await setup({ rename: async () => { entered.resolve(); await finish.promise; return test.edit; } }); + await test.rename.prepare(request); + const result = test.rename.rename({ requestId: 1, newName: 'Renamed' }); + await entered.promise; + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Twice' }), /already in progress/); + test.rename.cancel(1); + finish.resolve(); + await assert.rejects(result, vscode.CancellationError); + assert.strictEqual(test.applications, 0); + assert.strictEqual(test.document.getText(), '# Title\r\n\r\nTarget'); + }); + + test('applies all files from the provider workspace edit', async () => { + const test = await setup(); + const reference = await vscode.workspace.openTextDocument({ language: 'markdown', content: '[Target](#target)' }); + test.edit.replace(reference.uri, new vscode.Range(0, 10, 0, 16), 'renamed'); + await test.rename.prepare(request); + await test.rename.rename({ requestId: 1, newName: 'Renamed' }); + assert.strictEqual(test.document.lineAt(2).text, 'Renamed'); + assert.strictEqual(reference.getText(), '[Target](#renamed)'); + }); + + test('drains edits accepted during provider execution before applying', async () => { + let drains = 0; + const test = await setup({}, async () => { + if (++drains === 3) { + await vscode.workspace.applyEdit(test.edit); + } + }); + await test.rename.prepare(request); + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Renamed' }), /document changed/); + assert.strictEqual(drains, 3); + assert.strictEqual(test.applications, 0); + }); + + test('does not overwrite a referenced document changed during provider execution', async () => { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + const reference = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'Target' }); + const test = await setup({ rename: async () => { entered.resolve(); await finish.promise; return test.edit; } }); + test.edit.replace(reference.uri, new vscode.Range(0, 0, 0, 6), 'Renamed'); + await test.rename.prepare(request); + const result = test.rename.rename({ requestId: 1, newName: 'Renamed' }); + await entered.promise; + const external = new vscode.WorkspaceEdit(); + external.insert(reference.uri, new vscode.Position(0, 0), 'External '); + await vscode.workspace.applyEdit(external); + finish.resolve(); + await assert.rejects(result, /document changed/); + assert.strictEqual(reference.getText(), 'External Target'); + assert.strictEqual(test.document.lineAt(2).text, 'Target'); + assert.strictEqual(test.applications, 0); + }); + + test('surfaces provider errors and workspace rejection, allowing retry', async () => { + let attempt = 0; + const test = await setup({ + rename: async () => { + if (++attempt === 1) { throw new Error('Provider rejected name'); } + return test.edit; + }, + apply: async () => false, + }); + await test.rename.prepare(request); + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Invalid' }), /Provider rejected name/); + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Rejected' }), /workspace rejected/); + assert.strictEqual(attempt, 2); + assert.strictEqual(test.document.lineAt(2).text, 'Target'); + }); + + test('applies a resource-only rename even though WorkspaceEdit.size is zero', async () => { + const root = vscode.Uri.joinPath(vscode.Uri.file(os.tmpdir()), `markdown-rename-${crypto.randomUUID()}`); + const before = vscode.Uri.joinPath(root, 'before.md'); + const after = vscode.Uri.joinPath(root, 'after.md'); + const contents = new TextEncoder().encode('# Target'); + await vscode.workspace.fs.createDirectory(root); + try { + await vscode.workspace.fs.writeFile(before, contents); + const edit = new vscode.WorkspaceEdit(); + edit.renameFile(before, after); + assert.strictEqual(edit.size, 0); + const test = await setup({ rename: async () => edit }); + await test.rename.prepare(request); + await test.rename.rename({ requestId: 1, newName: 'after' }); + assert.deepStrictEqual(await vscode.workspace.fs.readDirectory(root), [['after.md', vscode.FileType.File]]); + assert.deepStrictEqual(Uint8Array.from(await vscode.workspace.fs.readFile(after)), contents); + assert.strictEqual(test.applications, 1); + } finally { + await vscode.workspace.fs.delete(root, { recursive: true }); + } + }); + + test('rejects missing edits, missing preparation, and invalid names', async () => { + const test = await setup({ rename: async () => undefined }); + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Name' }), /Start rename again/); + await test.rename.prepare(request); + for (const newName of ['', ' ', 'two\nlines', 'two\rlines']) { + await assert.rejects(test.rename.rename({ requestId: 1, newName }), /non-empty, single-line/); + } + await assert.rejects(test.rename.rename({ requestId: 1, newName: 'Name' }), /no changes/); + assert.strictEqual(test.applications, 0); + }); +}); diff --git a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts index 676536119ed9e..eca0c6fa2464f 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts @@ -5,9 +5,9 @@ import * as assert from 'assert'; import 'mocha'; -import { ErrorCode, HubRpcConnection, type InterfaceHandlers, type JsonRpcMessage } from '@vscode/hubrpc'; +import { ErrorCode, type InterfaceHandlers, type JsonRpcMessage } from '@vscode/hubrpc'; import { markdownEditorHost, markdownEditorRenderer } from '../preview/markdownEditorProtocol'; -import { MarkdownEditorRpcTransport } from '../preview/markdownEditorRpc'; +import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from '../preview/markdownEditorRpc'; import { RecoveringTaskQueue } from '../preview/recoveringTaskQueue'; suite('Markdown editor RPC', () => { @@ -23,8 +23,10 @@ suite('Markdown editor RPC', () => { const rendererInbox = new Inbox(); const hostTransport = new MarkdownEditorRpcTransport('secret', value => rendererInbox.deliver(value), listener => hostInbox.subscribe(listener)); const rendererTransport = new MarkdownEditorRpcTransport('secret', value => hostInbox.deliver(value), listener => rendererInbox.subscribe(listener)); - const host = HubRpcConnection.fromTransport(hostTransport); - const renderer = HubRpcConnection.fromTransport(rendererTransport); + const errors: { operation: string; error: unknown }[] = []; + const report = (operation: string, error: unknown) => { errors.push({ operation, error }); }; + const host = createMarkdownEditorRpcConnection(hostTransport, report); + const renderer = createMarkdownEditorRpcConnection(rendererTransport, report); host.register(markdownEditorHost, { ready: () => { }, edit: () => { }, @@ -41,10 +43,19 @@ suite('Markdown editor RPC', () => { addComment: () => { }, deleteComment: () => { }, highlight: () => ({ tokens: [], colorMap: [] }), + prepareRename: () => ({ start: 0, endExclusive: 1, placeholder: 'name' }), + rename: () => { }, + cancelRename: () => { }, + getDiagnostics: () => ({ editEpoch: 0, items: [] }), + completions: () => ({ items: [], incomplete: false }), + acceptCompletion: () => ({ offset: 0, editEpoch: 0, retrigger: false }), + cancelCompletions: () => { }, + pasteImages: () => ({ offset: 0, editEpoch: 0 }), ...hostOverrides, }); host.get(markdownEditorRenderer); renderer.register(markdownEditorRenderer, { + diagnosticsChanged: () => { }, update: () => { }, codeBlockEditorProviders: () => { }, codeBlockEditorHostTransportMessage: () => { }, @@ -58,9 +69,69 @@ suite('Markdown editor RPC', () => { ...rendererOverrides, }); disposables.push({ dispose: () => { host.close(); renderer.close(); } }); - return { host, renderer, hostInbox, rendererInbox }; + return { host, renderer, hostInbox, rendererInbox, errors }; } + test('sends presentation notifications without request IDs or acknowledgement messages', async () => { + const seen: unknown[] = []; + const { host, renderer, hostInbox, rendererInbox, errors } = pair({ + richLinkTargets: params => { seen.push(params); }, + codeBlockEditorDiagnostic: params => { seen.push(params); }, + }, { + comments: params => { seen.push(params); }, + highlightThemeChanged: params => { seen.push(params); }, + }); + renderer.get(markdownEditorHost).richLinkTargets({ hrefs: ['target'] }); + renderer.get(markdownEditorHost).codeBlockEditorDiagnostic({ message: 'test' }); + host.get(markdownEditorRenderer).comments({ comments: [], acceptsComments: true }); + host.get(markdownEditorRenderer).highlightThemeChanged({}); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(seen, [{ hrefs: ['target'] }, { message: 'test' }, { comments: [], acceptsComments: true }, {}]); + assert.deepStrictEqual([...hostInbox.messages, ...rendererInbox.messages], [ + { channel: 'markdownEditor', messageSecret: 'secret', message: { jsonrpc: '2.0', method: 'markdown.editor.host::richLinkTargets', params: { hrefs: ['target'] } } }, + { channel: 'markdownEditor', messageSecret: 'secret', message: { jsonrpc: '2.0', method: 'markdown.editor.host::codeBlockEditorDiagnostic', params: { message: 'test' } } }, + { channel: 'markdownEditor', messageSecret: 'secret', message: { jsonrpc: '2.0', method: 'markdown.editor.renderer::comments', params: { comments: [], acceptsComments: true } } }, + { channel: 'markdownEditor', messageSecret: 'secret', message: { jsonrpc: '2.0', method: 'markdown.editor.renderer::highlightThemeChanged', params: {} } }, + ], 'notifications must not create request IDs or acknowledgement messages'); + assert.deepStrictEqual(errors, []); + }); + + test('validates and authenticates notifications and reports handler failures locally', async () => { + let targets = 0; + const failure = new Error('Notification handler failed'); + const { renderer, hostInbox, rendererInbox, errors } = pair({ + richLinkTargets: () => { targets++; }, + codeBlockEditorDiagnostic: () => { throw failure; }, + }); + const message = { jsonrpc: '2.0', method: 'markdown.editor.host::richLinkTargets', params: { hrefs: ['target'] } }; + hostInbox.deliver({ channel: 'markdownEditor', messageSecret: 'old-secret', message }); + await renderer.channel.sendNotification(message.method, { hrefs: [42] }); + assert.strictEqual(targets, 0); + renderer.get(markdownEditorHost).richLinkTargets({ hrefs: ['valid'] }); + renderer.get(markdownEditorHost).codeBlockEditorDiagnostic({ message: 'test' }); + assert.strictEqual(targets, 1); + assert.deepStrictEqual(errors, [{ operation: 'Handle notification markdown.editor.host::codeBlockEditorDiagnostic', error: failure }]); + assert.deepStrictEqual(rendererInbox.messages, [], 'even failed notifications have no response'); + }); + + test('reports notification delivery failures and sends after close without unhandled rejections', async () => { + const inbox = new Inbox(); + const errors: unknown[] = []; + const connection = createMarkdownEditorRpcConnection(new MarkdownEditorRpcTransport( + 'secret', () => Promise.resolve(false), listener => inbox.subscribe(listener), + ), (_operation, error) => errors.push(error)); + disposables.push({ dispose: () => connection.close() }); + const client = connection.get(markdownEditorRenderer); + client.highlightThemeChanged({}); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(errors.length, 1); + assert.match(String(errors[0]), /rejected RPC message/); + connection.close(); + client.highlightThemeChanged({}); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(errors.length, 2); + }); + test('dispatches validated requests in both directions and correlates out-of-order results', async () => { let finishFirst!: (value: { tokens: []; colorMap: string[] }) => void; const seen: unknown[] = []; @@ -99,6 +170,27 @@ suite('Markdown editor RPC', () => { }); }); + test('routes rename sessions and rejects invalid offsets across RPC', async () => { + const seen: unknown[] = []; + const { renderer } = pair({ + prepareRename: params => { seen.push(params); return { start: 2, endExclusive: 8, placeholder: 'Target' }; }, + rename: params => { seen.push(params); }, + cancelRename: params => { seen.push(params); }, + }); + const client = renderer.get(markdownEditorHost); + assert.deepStrictEqual(await client.prepareRename({ requestId: 4, offset: 3, editEpoch: 2 }), + { start: 2, endExclusive: 8, placeholder: 'Target' }); + await client.rename({ requestId: 4, newName: 'New target' }); + await client.cancelRename({ requestId: 4 }); + await assert.rejects(renderer.channel.sendRequest('markdown.editor.host::prepareRename', + { requestId: 4, offset: -1, editEpoch: 2 }), { code: ErrorCode.invalidParams }); + assert.deepStrictEqual(seen, [ + { requestId: 4, offset: 3, editEpoch: 2 }, + { requestId: 4, newName: 'New target' }, + { requestId: 4 }, + ]); + }); + test('rejects malformed parameters, unknown members, and failed handlers', async () => { let edits = 0; const { renderer } = pair({ @@ -200,6 +292,7 @@ suite('Markdown editor RPC', () => { class Inbox { private readonly _listeners = new Set<(value: unknown) => void>(); + readonly messages: unknown[] = []; get size(): number { return this._listeners.size; @@ -212,6 +305,7 @@ class Inbox { deliver(value: unknown): void { const serialized: unknown = JSON.parse(JSON.stringify(value)); + this.messages.push(serialized); for (const listener of this._listeners) { listener(serialized); } diff --git a/package-lock.json b/package-lock.json index 134ee8a8efc05..e7df46786ca04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.9", + "@github/copilot-sdk": "1.0.18-preview.3", "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -1172,9 +1172,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.9.tgz", - "integrity": "sha512-FWEcVrihQnteAWvjy9xysqKA7LYHOMp3FnGbSgFI6QdU1VEJVI8VQaBkBH/5srKX4Qsj41QopAjSLYen8yfS8A==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.18-preview.3.tgz", + "integrity": "sha512-8VfzBRIuhYmxvwEEsFMkpLqisxUjW1I7lxn2EsSJXj1Wlr+yrQe1wjXxEBDygQJeedMK/Dtc5tQEKndx/KiUBQ==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -1185,20 +1185,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.9", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.9", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.9", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.9" + "@github/copilot-sdk-darwin-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-darwin-x64": "1.0.18-preview.3", + "@github/copilot-sdk-linux-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-linux-x64": "1.0.18-preview.3", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-linuxmusl-x64": "1.0.18-preview.3", + "@github/copilot-sdk-win32-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-win32-x64": "1.0.18-preview.3" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-B+iH6xgVR8vNz7Wx0sfhRIKsSc9Kp7v6HZMAveyES2TUQuvWji2l4J7xRpSWo/9gtKhtI352V4+9PGhd39/AAg==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-hxSXGzGnOrtQH6j7ull6OqJdN7JBofLejZJbf/hNnRB7dkBuaSbYIyDes4NyA2pynhTo3cVipibjfAdJ2MyGGg==", "cpu": [ "arm64" ], @@ -1209,9 +1209,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-h+Kzw1LShdDSb49sWuDVlR4OJ3SBW+pdhVwtxiIveBzXStfVbXIHmVWZaI6nJn8k03Zwr4FiA8mAwT4pfkcnbA==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-U5LCul63JA0P1a55fda78ReG2tkBG0/UrgCArVDvojPEl1wfpqix39LbmIMwsbaWjdLXCVb2jsp/bkzir9/yIw==", "cpu": [ "x64" ], @@ -1222,9 +1222,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-lR6vSYVqAAgnjYD4jEOx+aR6xvGRm2e9O38tVW3g0RPujpWSJuk5+ctgEYtiTaVoP3KCspI0uRucono7dhR8pQ==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-XGr0yzDnp4qPM19dp95rWTy0Yp/B5QQ612arweY9vurOQRwKjo1e0p3k/yJRK25Vs9YJ6GSvTprlOALPMChQ4g==", "cpu": [ "arm64" ], @@ -1238,9 +1238,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-p0DoN1HlTVKtMfzLaUfL7kp4xP7X4fNE2Hqw0Gu1JEPL32DdYPaBYbphjF1mHU/rxNotwu8Kff7F0Wp5aHBEeQ==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-OB/mXR4dDz65IJ1KLwenef85JVnDbK/ldqmEKF/ux/eZk1Tlvv3rV/qy0D7TBLu+zqgBWebA3PXRFf/L9TN1Sg==", "cpu": [ "x64" ], @@ -1254,9 +1254,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-T1pbhKGNqUDAZLCemM+OYMN+m5rq8wY788Sg+NyXbk4F6nE/OAGi0Jtidx+GlOCMl68ja2VRnR1RmqrOI7UZVg==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-0Y79CSq3UGMBcgyB63rdsvwlgLb0/ENGH9nkNnC5KCcldeiBUceGG4EqKoEjuxRk3JteyXLBwoh954mVG5Pt/Q==", "cpu": [ "arm64" ], @@ -1270,9 +1270,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-gm5epYvLw1IWQ+YBnQBQ3n+6+0LHyoW7JWWPPsiO6S6UDxgpcH4G9LqpNHfPDmGJWVQk9xtbUukgXYN6gAjv4g==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-t+OxKZOyjMPTkTNevFgy4GN29kNiZyDEuRa8ohtOxMivY0QAWyHjbCXa85Mb24jjBBk3dG4EORfCvfP6+QPf6g==", "cpu": [ "x64" ], @@ -1286,9 +1286,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-ZLKzHD4qPD56KMP9QETrTrAI48pdxAv8uqlnmV/ZDp77CF0EvK2i9SOv2vK4XNUDLpqVj97iQmyGRAlOJYl+Vg==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-qLVHPN1BloFXv8g/za8eO2BKvR0/ioEa4iadzMt6G0BrZclc8PG6ZYY7zvjrdHx3NykV/SAl2I0G6/OXb6Ry6A==", "cpu": [ "arm64" ], @@ -1299,9 +1299,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-/Fde8RzixrkIUEegdzNmEhmHiCxVP3U7Bdv49Pw/FApAJ4FGz+yonuu6DG8GRJFW7wH+spaybWgP2TDRu4Eg/w==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-Kzn0pZCGAboKbLedpfZFMPtZpdGZKH8etxR4aGrkiSv1IC6/FLWtPl7um9wOcilvl2yjfR8GAsowOb5hKAvSlg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 6b3f8e6357738..0883fdd103eab 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.9", + "@github/copilot-sdk": "1.0.18-preview.3", "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -328,5 +328,5 @@ "@vscode/windows-process-tree@0.8.0": true, "node-pty@1.1.0": true }, - "copilotRuntimeVersion": "1.0.93-3" + "copilotRuntimeVersion": "1.0.94-3" } diff --git a/remote/package-lock.json b/remote/package-lock.json index 57cc15aeede18..9f2a1d4e0270e 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.9", + "@github/copilot-sdk": "1.0.18-preview.3", "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -75,9 +75,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.9.tgz", - "integrity": "sha512-FWEcVrihQnteAWvjy9xysqKA7LYHOMp3FnGbSgFI6QdU1VEJVI8VQaBkBH/5srKX4Qsj41QopAjSLYen8yfS8A==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.18-preview.3.tgz", + "integrity": "sha512-8VfzBRIuhYmxvwEEsFMkpLqisxUjW1I7lxn2EsSJXj1Wlr+yrQe1wjXxEBDygQJeedMK/Dtc5tQEKndx/KiUBQ==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -88,20 +88,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.9", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.9", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.9", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.9", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.9" + "@github/copilot-sdk-darwin-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-darwin-x64": "1.0.18-preview.3", + "@github/copilot-sdk-linux-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-linux-x64": "1.0.18-preview.3", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-linuxmusl-x64": "1.0.18-preview.3", + "@github/copilot-sdk-win32-arm64": "1.0.18-preview.3", + "@github/copilot-sdk-win32-x64": "1.0.18-preview.3" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-B+iH6xgVR8vNz7Wx0sfhRIKsSc9Kp7v6HZMAveyES2TUQuvWji2l4J7xRpSWo/9gtKhtI352V4+9PGhd39/AAg==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-hxSXGzGnOrtQH6j7ull6OqJdN7JBofLejZJbf/hNnRB7dkBuaSbYIyDes4NyA2pynhTo3cVipibjfAdJ2MyGGg==", "cpu": [ "arm64" ], @@ -112,9 +112,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-h+Kzw1LShdDSb49sWuDVlR4OJ3SBW+pdhVwtxiIveBzXStfVbXIHmVWZaI6nJn8k03Zwr4FiA8mAwT4pfkcnbA==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-U5LCul63JA0P1a55fda78ReG2tkBG0/UrgCArVDvojPEl1wfpqix39LbmIMwsbaWjdLXCVb2jsp/bkzir9/yIw==", "cpu": [ "x64" ], @@ -125,9 +125,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-lR6vSYVqAAgnjYD4jEOx+aR6xvGRm2e9O38tVW3g0RPujpWSJuk5+ctgEYtiTaVoP3KCspI0uRucono7dhR8pQ==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-XGr0yzDnp4qPM19dp95rWTy0Yp/B5QQ612arweY9vurOQRwKjo1e0p3k/yJRK25Vs9YJ6GSvTprlOALPMChQ4g==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-p0DoN1HlTVKtMfzLaUfL7kp4xP7X4fNE2Hqw0Gu1JEPL32DdYPaBYbphjF1mHU/rxNotwu8Kff7F0Wp5aHBEeQ==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-OB/mXR4dDz65IJ1KLwenef85JVnDbK/ldqmEKF/ux/eZk1Tlvv3rV/qy0D7TBLu+zqgBWebA3PXRFf/L9TN1Sg==", "cpu": [ "x64" ], @@ -157,9 +157,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-T1pbhKGNqUDAZLCemM+OYMN+m5rq8wY788Sg+NyXbk4F6nE/OAGi0Jtidx+GlOCMl68ja2VRnR1RmqrOI7UZVg==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-0Y79CSq3UGMBcgyB63rdsvwlgLb0/ENGH9nkNnC5KCcldeiBUceGG4EqKoEjuxRk3JteyXLBwoh954mVG5Pt/Q==", "cpu": [ "arm64" ], @@ -173,9 +173,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-gm5epYvLw1IWQ+YBnQBQ3n+6+0LHyoW7JWWPPsiO6S6UDxgpcH4G9LqpNHfPDmGJWVQk9xtbUukgXYN6gAjv4g==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-t+OxKZOyjMPTkTNevFgy4GN29kNiZyDEuRa8ohtOxMivY0QAWyHjbCXa85Mb24jjBBk3dG4EORfCvfP6+QPf6g==", "cpu": [ "x64" ], @@ -189,9 +189,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.9.tgz", - "integrity": "sha512-ZLKzHD4qPD56KMP9QETrTrAI48pdxAv8uqlnmV/ZDp77CF0EvK2i9SOv2vK4XNUDLpqVj97iQmyGRAlOJYl+Vg==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.18-preview.3.tgz", + "integrity": "sha512-qLVHPN1BloFXv8g/za8eO2BKvR0/ioEa4iadzMt6G0BrZclc8PG6ZYY7zvjrdHx3NykV/SAl2I0G6/OXb6Ry6A==", "cpu": [ "arm64" ], @@ -202,9 +202,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.9.tgz", - "integrity": "sha512-/Fde8RzixrkIUEegdzNmEhmHiCxVP3U7Bdv49Pw/FApAJ4FGz+yonuu6DG8GRJFW7wH+spaybWgP2TDRu4Eg/w==", + "version": "1.0.18-preview.3", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.18-preview.3.tgz", + "integrity": "sha512-Kzn0pZCGAboKbLedpfZFMPtZpdGZKH8etxR4aGrkiSv1IC6/FLWtPl7um9wOcilvl2yjfR8GAsowOb5hKAvSlg==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index c4e9ed1457a02..7ca79af7d62a5 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.9", + "@github/copilot-sdk": "1.0.18-preview.3", "@hpke/core": "^1.9.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -81,5 +81,5 @@ "@vscode/windows-process-tree@0.8.0": true, "node-pty@1.1.0": true }, - "copilotRuntimeVersion": "1.0.93-3" + "copilotRuntimeVersion": "1.0.94-3" } diff --git a/scripts/chat-simulation/common/mock-llm-server.ts b/scripts/chat-simulation/common/mock-llm-server.ts index 0783a9c358aa8..51cdc1e7bb080 100644 --- a/scripts/chat-simulation/common/mock-llm-server.ts +++ b/scripts/chat-simulation/common/mock-llm-server.ts @@ -19,6 +19,7 @@ */ const http: typeof import('http') = require('http'); +const https: typeof import('https') = require('https'); const path: typeof import('path') = require('path'); const { EventEmitter }: typeof import('events') = require('events'); @@ -646,7 +647,7 @@ function makeThinkingIdChunk(cotId: string) { // -- Request handler --------------------------------------------------------- -function handleRequest(req: import('http').IncomingMessage, res: import('http').ServerResponse): void { +function handleRequest(req: import('http').IncomingMessage, res: import('http').ServerResponse, protocol: 'http' | 'https'): void { const contentLength = req.headers['content-length'] || '0'; const ts = new Date().toISOString().slice(11, -1); // HH:MM:SS.mmm _log(`[mock-llm] ${ts} ${req.method} ${req.url} (${contentLength} bytes)`); @@ -657,7 +658,7 @@ function handleRequest(req: import('http').IncomingMessage, res: import('http'). res.setHeader('Access-Control-Allow-Headers', '*'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - const reqUrl = new URL(req.url || '/', `http://${req.headers.host}`); + const reqUrl = new URL(req.url || '/', `${protocol}://${req.headers.host}`); const path = reqUrl.pathname; const json = (status: number, data: any) => { res.writeHead(status, { 'Content-Type': 'application/json' }); @@ -684,8 +685,8 @@ function handleRequest(req: import('http').IncomingMessage, res: import('http'). individual: true, copilot_plan: 'free', endpoints: { - api: `http://${req.headers.host}`, - proxy: `http://${req.headers.host}`, + api: reqUrl.origin, + proxy: reqUrl.origin, }, }); } else { @@ -1971,6 +1972,7 @@ interface StartServerOptions { verbose?: boolean; /** Address to listen on. Defaults to loopback. */ host?: string; + tls?: { cert: string; key: string }; /** Reject requests that do not carry this exact header. */ requiredRequestHeader?: { name: string; @@ -2008,7 +2010,6 @@ function _startServer(port = 0, options?: StartServerOptions): Promise !fn()); }; - serverEvents.on('scenarioCompletion', onCompletion); // Accumulate the parsed bodies of chat requests so tests can assert what // the client forwarded (see MockLlmServerHandle.getRequests). Off by default @@ -2025,11 +2026,7 @@ function _startServer(port = 0, options?: StartServerOptions): Promise { + const listener: import('http').RequestListener = (req, res) => { const requiredRequestHeader = options?.requiredRequestHeader; const requestHost = req.headers.host?.replace(/:\d+$/, '').toLowerCase(); const isTrustedProxy = options?.trustedRequestHost && requestHost === options.trustedRequestHost; @@ -2040,12 +2037,17 @@ function _startServer(port = 0, options?: StartServerOptions): Promise !fn()); - handleRequest(req, res); - }); + handleRequest(req, res, options?.tls ? 'https' : 'http'); + }; + const server = options?.tls ? https.createServer(options.tls, listener) : http.createServer(listener); + serverEvents.on('scenarioCompletion', onCompletion); + if (captureRequests) { + serverEvents.on('capturedRequest', onCapturedRequest); + } server.listen(port, options?.host ?? '127.0.0.1', () => { const addr = server.address(); const actualPort = typeof addr === 'object' && addr ? addr.port : port; - const url = `http://127.0.0.1:${actualPort}`; + const url = `${options?.tls ? 'https' : 'http'}://127.0.0.1:${actualPort}`; resolve({ port: actualPort, url, diff --git a/src/vs/editor/common/textModelEditSource.ts b/src/vs/editor/common/textModelEditSource.ts index 1dc17a64094a4..8c7ee38c576c5 100644 --- a/src/vs/editor/common/textModelEditSource.ts +++ b/src/vs/editor/common/textModelEditSource.ts @@ -132,6 +132,7 @@ export const EditSources = { agentHostChatApplyEdits(data: { modelId: string | undefined; + autoTier?: string; sessionId: string; chatSessionId?: string; requestId: string; @@ -139,6 +140,7 @@ export const EditSources = { }) { return EditSources.chatApplyEdits({ modelId: data.modelId, + autoTier: data.autoTier, sessionId: data.sessionId, chatSessionId: data.chatSessionId, requestId: data.requestId, diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 0161ee0bfeb4f..11bcb92697f0c 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -212,6 +212,8 @@ export interface IActionListItem { * Optional badge text to display after the label (e.g., "New"). */ readonly badge?: string; + /** Displays the badge before the label instead of after it. */ + readonly badgeBeforeLabel?: boolean; /** Badges displayed alongside {@link badge}, each with an optional single CSS class and hover. */ readonly additionalBadges?: readonly { readonly label: string; @@ -455,6 +457,7 @@ class ActionItemRenderer implements IListRenderer, IAction data.previousClassNames = classNames; data.text.textContent = stripNewlines(element.label); + data.container.insertBefore(data.badge, element.badgeBeforeLabel ? data.text : data.text.nextSibling); // Render optional badge if (element.badge) { @@ -1078,7 +1081,9 @@ export class ActionListWidget extends Disposable { if (element.kind === ActionListItemKind.Action) { let label = element.label ? stripNewlines(element?.label) : ''; if (element.badge) { - label = label + ', ' + stripNewlines(element.badge); + label = element.badgeBeforeLabel + ? stripNewlines(element.badge) + ', ' + label + : label + ', ' + stripNewlines(element.badge); } for (const badge of element.additionalBadges ?? []) { label = label + ', ' + stripNewlines(badge.label); diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 5c65e6668329b..ae6e6872df2b2 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -277,6 +277,29 @@ suite('ActionListWidget', () => { }); }); + test('keeps leading badges in visual and accessible order when rows resolve or are reused', () => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('Pull Request'), badge: '#123', badgeBeforeLabel: true }], + listOptions: { showFilter: false }, + }); + const read = () => { + const row = widget.domNode.querySelector('.monaco-list-row.action')!; + return { + ariaLabel: row.ariaLabel, + order: [...row.children].filter(element => element.classList.contains('title') || element.classList.contains('action-item-badge')).map(element => element.textContent), + }; + }; + const initial = read(); + widget.updateItems([{ ...action('Resolved title'), badge: '#123', badgeBeforeLabel: true }]); + const resolved = read(); + widget.updateItems([{ ...action('Ordinary action'), badge: 'New' }]); + assert.deepStrictEqual({ initial, resolved, reused: read() }, { + initial: { ariaLabel: '#123, Pull Request', order: ['#123', 'Pull Request'] }, + resolved: { ariaLabel: '#123, Resolved title', order: ['#123', 'Resolved title'] }, + reused: { ariaLabel: 'Ordinary action, New', order: ['Ordinary action', 'New'] }, + }); + }); + test('toolbar labels remain actionable and revert to icons when a row is reused', () => { const selected: string[] = []; let configured = 0; diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 663f681015d8f..75a00fb7325b1 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -115,6 +115,8 @@ by the chat UI; see [the OTel contract](OTEL.md#user-perceived-first-progress). Artifact removal uses the VS Code-only `vscode/removeSessionArtifact` extension RPC with `{ session: string, artifactId: string }` and a void result. Clients gate the optional `removeSessionArtifact(URI, string)` connection method with `supportsAgentHostArtifactRemoval(initializeResult)` (`_meta['vscode.removeSessionArtifact'] === true`). This does not extend the generated AHP protocol. +Stopping background work uses the VS Code-only `vscode/stopBackgroundWork` extension RPC with `{ chat: string, id: string }` and a `{ stopped: boolean }` result, which is false when the work had already finished. Providers mark each stoppable `BackgroundWork` entry with `_meta['vscode.stopBackgroundWork'] === true` (`canStopBackgroundWork`), and clients offer Stop only for marked entries, so hosts without the extension show none. Copilot marks attached background shells and stops them through the SDK's `session.rpc.tasks.cancel`; detached shells are not offered yet. This does not extend the generated AHP protocol. + The shared `node/shared/sessionArtifacts.ts` path serializes artifact mutations per session across tools and direct user requests. Each mutation reads the latest collection, awaits ordered catalog synchronization (including the legacy-first `sessionArtifacts` metadata write), then publishes `SessionMetaChanged` merged with the latest independent metadata. Failed local persistence leaves the artifact visible and retryable; failures are logged and propagated without blocking queued additions. Central synchronization uses the usual pending receipts for repair. Independent GitHub associations and unrelated artifacts/references are preserved. No model turn or tool invocation is involved in direct user removal. Artifact tools read and mutate only the invoking chat's artifacts. The session database keeps one `sessionArtifacts` JSON array; each new entry carries its owning chat URI, and the session-level `ISession.artifacts` projection remains the combined list across every chat. Unscoped legacy entries are assigned to the default chat and migrated in that same collection during catalog synchronization. @@ -243,15 +245,17 @@ Migration returns known native entries as plain metadata. Discovery classifies u ### Automatic titles -`chat.agentHost.experimental.deferredTitleGeneration` (host root key `deferredTitleGeneration`) is an opt-in, default-off host scheduling experiment. It takes precedence over `chat.agentHost.experimental.activeAgentTitleGeneration`. With deferred naming off, the active-agent setting keeps the legacy choice between foreground `rename_chat` naming and immediate utility-model naming; its workbench default is enabled outside Stable, while the standalone root schema defaults to disabled. +`chat.agentHost.experimental.titleGeneration` (host root key `titleGeneration`) selects one automatic title strategy for new sessions: `utility` (immediate utility-model naming), `activeAgent` (foreground `rename_chat` naming), or `agentReview` (deferred host naming that the active agent may correct). Its workbench default is `activeAgent` outside Stable and `utility` in Stable, while the standalone root schema defaults to `utility`. It replaces the boolean `activeAgentTitleGeneration` and `deferredTitleGeneration` settings: the workbench migrates their user values, mapping deferred naming to `agentReview`, and the host still reads their root keys (deferred first) when a client that predates the strategy key sends no `titleGeneration` value. Because hosts persist root config, the workbench also keeps writing those legacy keys, derived from the strategy (`activeAgent` sets `activeAgentTitleGeneration`, `agentReview` sets `deferredTitleGeneration`), so an older host follows the current setting instead of its last stored legacy value. Conversely, a client root-config patch that changes a legacy key without sending `titleGeneration` comes from an older client, so the host drops its stored strategy for that update and falls back to the legacy keys instead of keeping a value a newer client persisted. The `deferred` strategy, deferred host naming without agent review, is no longer selectable; it remains for sessions that already persisted it and for that legacy root key. -The workbench setting opts into automatic experiment overrides via `experiment: { mode: 'auto' }`, using treatment name `config.chat.agentHost.experimental.deferredTitleGeneration`. The effective setting is synced to the host root key; the `experimental` tag alone does not enable experiment overrides. +The workbench setting opts into automatic experiment overrides via `experiment: { mode: 'auto' }`, using treatment name `config.chat.agentHost.experimental.titleGeneration` with a strategy value. The effective setting is synced to the host root key; the `experimental` tag alone does not enable experiment overrides. The title controller snapshots `titleGenerationStrategy` on the first session-scoped lookup, including provider creation before state registration, and persists it once session state exists. Failed creation clears the snapshot. All its chats, including peers added later, share that strategy; root changes affect new sessions only. Restore hydrates the strategy before the provider materializes its tool inventory. Older sessions without this metadata remain on utility naming, never implicitly opting into deferred naming. Existing materialized legacy sessions use their advertised rename-tool membership as a compatibility fallback only. -Deferred mode synchronously publishes and starts persisting an automatic fallback title, without a utility request, GitHub enrichment, foreground rename reminder, or automatic-naming tool guidance. The existing `SessionTitleContribution` starts at most one non-awaited utility refinement after the first new successful response with nonempty markdown. Forks wait for their first new response rather than titling the inherited history during creation; locally handled commands do not consume this opportunity. A separate `deferredTitleSeed` record preserves the seed and first-response index across restart. Hydration restores eligibility only when that record still matches the persisted title and automatic provenance; it never generates a title itself. Terminal outcomes consume eligibility before any utility call, so completed, failed, cancelled, or empty first turns are not retried after restart. Cancellation, errors, empty responses, unavailable utility credentials, and disposal retain the fallback. +Deferred host naming, used by `agentReview` and legacy `deferred`, synchronously publishes and starts persisting an automatic fallback title, without a utility request, GitHub enrichment, foreground rename reminder, or automatic-naming tool guidance. The existing `SessionTitleContribution` starts at most one non-awaited utility refinement after the first new successful response with nonempty markdown. Forks wait for their first new response rather than titling the inherited history during creation; locally handled commands do not consume this opportunity. A separate `deferredTitleSeed` record preserves the seed and first-response index across restart. Hydration restores eligibility only when that record still matches the persisted title and automatic provenance; it never generates a title itself. Terminal outcomes consume eligibility before any utility call, so completed, failed, cancelled, or empty first turns are not retried after restart. Cancellation, errors, empty responses, unavailable utility credentials, and disposal retain the fallback. + +Explicit UI rename, `/rename`, and user-requested `rename_chat` remain available and invalidate automatic work. Tool availability is independent of automatic scheduling: legacy `deferred` sessions advertise an explicit-only rename description without the `automatic` argument. `activeAgent` sessions load `rename_chat` eagerly, because their first-turn reminder asks the agent to call it; `agentReview` and legacy `deferred` sessions keep it deferred behind tool search, and `utility` sessions do not offer it. If peers are added before completion, refinement follows the default chat's now-independent title snapshot; multi-chat default and peer refinements do not rename the session. Ephemeral sessions remain excluded. This is host/editor behavior, not a runtime managed setting or SDK policy. -Explicit UI rename, `/rename`, and user-requested `rename_chat` remain available and invalidate automatic work. Tool availability is independent of automatic scheduling: deferred sessions advertise an explicit-only rename description without the `automatic` argument. If peers are added before completion, refinement follows the default chat's now-independent title snapshot; multi-chat default and peer refinements do not rename the session. Ephemeral sessions remain excluded. This is host/editor behavior, not a runtime managed setting or SDK policy. +The `agentReview` strategy uses deferred host naming unchanged, and additionally lets the active agent override a host-generated title it judges inaccurate or off-goal. `rename_chat` stays deferred and keeps its `automatic` argument, so first-turn requests carry no naming instruction or eager tool schema; the agent pays the tool-search round-trip only when it decides to rename. The soft review reminder, which includes the current title and asks the agent to put any rename at the end of its answer rather than in a step before it, is withheld until the first response after the seed (or after chat creation, for chats without a seed) has finished, and while a host refinement for that chat is still in flight, including a refinement still keyed by the session after a peer made the default chat independent. A cancelled or failed first response consumes refinement without running it, so it also satisfies the gate and the unrefined seed is reviewed on the next turn. It is then offered on at most three turns per chat (an in-memory count, reset on host restart) and stops early once the user or agent renames the chat. ### Server-tool creation provenance @@ -487,6 +491,8 @@ No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibli Chronicle commands and subcommands come from the SDK command catalog and execute through `rpc.commands.invoke`; agent-prompt results retain the SDK's `displayPrompt` when sent so history shows the command rather than its expanded instructions. The SDK owns Chronicle indexing and retrieval. +Before a message is admitted, a Copilot session can prepare plugins required by the organization, reporting progress as `session.info` and failures as `session.warning` with the `managed_plugins` type. While the current turn is still pending, `CopilotAgentSession` shows the progress message as the chat activity, which the waiting request's progress row reads (session activity does not reach it), and adds a failure as a warning-severity `ManagedPluginPreparationFailure` system notification. The root `user.message` echo, root `assistant.turn_start`, a failure, or the end or abort of that turn clears that activity; a late abort or idle from an earlier turn does not, and a failure reported for a waiting replacement turn is shown even while late events of the aborted turn are dropped. There is no structured plugin list, so the English message text is shown as is. Progress outside a pending turn, such as preparation started by custom agent selection, is only logged. A failure reported while no turn is waiting, such as after a background policy refresh, is kept and shown when the next message is admitted (its root `user.message` echo), unless that message's own preparation reports a newer failure or a `managed_plugins_complete` info arrives first. + ### Codex (`node/codex/codexAgent.ts`) Client-synced skills are advertised through `turn/start.additionalContext`, using the enabled plugins' skill names, descriptions, and file paths. Every turn receives the current catalog, including an explicit empty catalog after removal; older catalogs can remain in conversation history but no longer describe the current selection. Native skills discovery remains unchanged and separate from the session's client-plugin customization projection. diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index 75d20898ca68e..d1b2c59eba935 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -127,7 +127,7 @@ the deadline remain missing; the marker is not proof that a turn completed. | Host `sendStageCheckpointMs` | **Residual critical-path wait** for the checkpoint after overlap with earlier preparation, not the entire checkpoint operation | | Host `sendStageProviderPreparationMs` | **Residual critical-path wait** for provider turn preparation (`IAgentChats.prepareTurn`, enabled by `chat.agentHost.experimental.overlapProviderPreparation`) after it overlapped the earlier pre-send stages and the checkpoint capture; absent when preparation did not run. Provider stage marks made during preparation precede dispatch and are not reported as `providerStage*Ms` | | Host `providerStageQueueMs`, `providerStageClientMs`, `providerStageSnapshotMs`, `providerStageConfigMs`, `providerStageCreateMs`, `providerStageFinalizeMs`, `providerStagePersistMs`, `providerStageRefreshMs`, `providerStageTurnPrepareMs`, `providerStageModelResponseMs` | Sequential provider-marked stages between provider dispatch and first progress (chat queue wait, SDK client acquisition, customization snapshot, session config, SDK create/resume, post-create setup, session registration/persistence, live-session refresh, per-turn preparation, and SDK send until first progress). Only stages the provider ran are present; a turn ending before first progress retains its partial open stage. Copilot marks all of them; other providers currently mark none | -| Host `hostRootTurnOrdinal`, `hostProcessAgeMs`, `titleGenerationStrategy` | Existing root ordinal and process age captured at turn start, and effective `activeAgent`, `utility`, or `deferred` strategy when observed | +| Host `hostRootTurnOrdinal`, `hostProcessAgeMs`, `titleGenerationStrategy` | Existing root ordinal and process age captured at turn start, and effective `activeAgent`, `utility`, `deferred`, or `agentReview` strategy when observed | | Renderer `requestId` | Exact client request ID, duplicated as `turnId` for joins | | Renderer `connectionKind` | Existing bounded `AgentHostClientConnectionKind`, captured from the client connection at invocation start; `unknown` when unavailable, absent in older diagnostic payloads | | Renderer `outcome`, `sessionTurnKind`, `invocationKind` | Existing diagnostic classifications described below | @@ -168,6 +168,94 @@ locally, or use file/HTTP JSON. Console export remains a summary, not a structured measurement sink. Export destination/resource configuration is user-owned; the diagnostics add no workspace paths, content or credentials. +### VS Code provider preparation and progress diagnostics + +Copilot sends also export `vscode.agent_host.provider_timing` metadata spans +and `agentHost.providerTiming` product events. They use the existing independent +OTel and product-telemetry consent paths, with no new setting or protocol change. +`[AgentHostProviderTiming]` logs contain the same bounded measurements at Debug +level. The log-level check precedes JSON serialization; normal Info logging does +not construct or write this payload. +Filter product events by `initiatorConnectionKind == 'local'` for local-host +analysis; do not infer the host OS from a remote client's OS. + +Each terminal turn emits at most one OTel span per observed `kind`/`name` pair. +The existing turn span and provider spans are queued and exported as one batch, +with one shared resource envelope for OTLP JSON. Individual spans and SQLite +rows remain available; `flush()` drains the batch through the existing queue. +Disabled OTel diagnostics return before inspecting provider rows. +Names are allowlisted in `common/agentHostProviderTiming.ts`; no prompts, +commands, paths, tool names, MCP server names or credentials are recorded. +An absent operation did not run with this recorder; an observed zero is retained. +The additional records are correlated by provider/session/chat/turn, not by +their export timestamps. The OTel spans have zero duration: read measurements, +not span durations. Existing coarse `providerStage*Ms` semantics are unchanged. + +Product events use **schema version 2**, with at most seven events per turn. +The `group` property is `input`, `permissions`, `sandboxShell`, `mcp`, +`execution`, `interactions`, or `milestones`; empty groups are omitted. +Operations retain all nine measurements below as numeric +`.` keys, for example `permission.queueMs`. +Milestones use `milestone.` for their first-observation offset; their +other row fields are redundant (end equals start, count is one, and duration, +error and incomplete counts are zero). The largest group has 54 timing +measurements. Shared turn/model/client metadata is computed once per turn. +The schema remains explicitly classified, and no numeric payload is packed +into a JSON string. Kusto keys are lowercased, for example +`Measures["permission.queuems"]` and `Measures["milestone.sdktext"]`. +OTel spans and Debug logs retain the following row-based version-1 contract: + +| Field | Meaning | +|---|---| +| `schemaVersion` | Numeric `1` | +| `kind`, `name` | `operation` aggregate or first-observation `milestone`, and an allowlisted name | +| `startMs`, `endMs` | First admission and last settlement offsets on the host turn clock; equal for a milestone | +| `queueMs`, `executionMs` | Cumulative wait for the VS Code sequencer and admitted execution time | +| `queueBeforeProgressMs`, `executionBeforeProgressMs` | The same intervals clipped at first host progress, or terminal observation when no progress occurs | +| `count`, `errorCount` | Invocation count and observed thrown/rejected operation count; an RPC returning an unsuccessful value is not necessarily a rejection | +| `incompleteCount` | Invocations still queued/executing at terminal observation; their durations are partial, not successful completion | +| `result` | Terminal **turn** outcome, independent of operation rejections | + +Preparation includes attachment conversion; command resolution/invocation; +agent-mode RPC; permission queue, options RPC, mode RPC and managed-settings +retry wait; sandbox queue, RPC and diagnostics; shell queue, file materialization +and RPC; MCP queue, listing and enable/disable work; execution admission and +the persistence marker; SDK send; and permission, input, elicitation, MCP-auth, +exit-plan and unsandboxed-confirmation callbacks. Callback durations include +VS Code handling and user waiting, not just CPU execution. Repeated operations aggregate, +including retries. Parent operations include their children: **never sum nested +operation rows**. No-op queue callbacks can have zero execution time; individual +RPC rows distinguish them from actual SDK calls. + +Milestones include SDK send invocation/return/rejection, first root SDK callback, +user-message acknowledgement, assistant-turn start, nonempty text/reasoning, +tool start, error and idle, prompt-hook invocation, permission/input/elicitation/ +MCP-auth callbacks, first mapped root model-call completion, and existing host +progress/substantive-progress observations. SDK callbacks are observed after +send invocation and exclude subagent envelopes and cancelled-root events. +Permission/input hooks are session callbacks during the active turn, not proof +that a human saw or answered a prompt. Milestones remain first-only even if SDK +events repeat. A resumed/replaced turn does not inherit an earlier recorder. +Zero-message continuations receive a fresh execution recorder through the resume +context, covering preparation, execution admission and SDK send/return/rejection +milestones just like new sends. The protocol turn ID can remain unchanged across +these separately timed executions. + +The SDK send promise is an API acknowledgement, **not model-request dispatch or +first token**. An assistant-turn-start callback is likewise not a network +dispatch timestamp. SDK text/reasoning observations precede VS Code processing +and can differ from host progress (for example, provisional output). Host +progress is not renderer receipt or paint. Join the existing renderer/UI events +for those separately defined measurements; never subtract independent clocks. +Native model-call telemetry retains its own model-call-to-turn correlation. +Exact transport dispatch, response headers, first protocol chunk and runtime +internal preparation remain unknown when the SDK does not expose them. + +Records are emitted at terminal observation, including errors/cancellation. +Pending work is clipped there and late settlements cannot modify exported rows. +Crashes, permanent hangs and turns dropped by session disposal can still have +no terminal record. These events do not establish an uncensored latency sample. + ### Existing product telemetry and logs The renderer reports `agentHost.firstResponse` and writes a content-free @@ -234,7 +322,7 @@ progress, `[AgentHostFirstProgress]` JSON records `timeToFirstProgress`, `providerStages` durations observed so far, for attributing local latency without product telemetry. The first strategy capture adds an enriched marker with the same start values and `titleGenerationStrategy` -(`activeAgent`, `utility`, or `deferred`). Merge compatible markers for one turn, +(`activeAgent`, `utility`, `deferred`, or `agentReview`). Merge compatible markers for one turn, retaining the known strategy rather than counting them as separate observations. Resuming the same turn retains its original host timing and strategy without advancing the ordinal. This identity is retained until chat teardown or truncation. diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 0180506b3fa81..7b846f4a495b2 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -24,7 +24,7 @@ import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../. import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, StopBackgroundWorkExtensionMethod, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest } from '../common/agentHostExtensionProtocol.js'; import { McpAuthRequiredReason } from '../common/state/protocol/channels-session/state.js'; import { supportsAgentHostTiming, supportsChatUserInteractionTiming } from '../common/meta/agentHostTimingMeta.js'; import { readCodexSessionModel } from '../common/meta/codexSessionModel.js'; @@ -53,7 +53,7 @@ import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ip import { ITelemetryService, TelemetryLevel, 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 { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostWorkspaceTrustConfigKey, getAgentHostTerminalAutoApproveRulesConfig, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; -import { formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, getAgentHostConfigurationSyncTarget, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; +import { addAgentHostConfigurationSyncValue, formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, getAgentHostConfigurationSyncTarget, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; import { managedPermissionsConfigurationIds, resolveManagedSettingsPermissions, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { AgentHostClientConnectionKind, toAgentHostClientMeta } from '../common/agentHostTelemetry.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; @@ -524,8 +524,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } const value = resolveAgentHostConfigurationSyncValue(this._configurationService, entry); if (value !== undefined) { - patch[entry.sync.key] = value; - mirrored.push(`${entry.sync.key}=${formatAgentHostConfigurationSyncValueForLog(entry.settingId, value)} (${entry.settingId})`); + for (const key of addAgentHostConfigurationSyncValue(patch, entry, value)) { + mirrored.push(`${key}=${formatAgentHostConfigurationSyncValueForLog(entry.settingId, patch[key])} (${entry.settingId})`); + } } } } @@ -1625,6 +1626,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect await this._sendExtensionRequest(RemoveSessionArtifactExtensionMethod, { session: session.toString(), artifactId }); } + async stopBackgroundWork(chat: URI, id: string): Promise { + const result = await this._sendExtensionRequest(StopBackgroundWorkExtensionMethod, { chat: chat.toString(), id }); + return result.stopped; + } + refreshSubscription(resource: URI): Promise { return this._subscriptionManager.refreshSubscription(resource); } diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 396741527afc5..815d8b622bfaf 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -64,6 +64,7 @@ export class NullAgentHostService implements IAgentHostService { async listSessions(): Promise { return []; } async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } async removeSessionArtifact(_session: URI, _artifactId: string): Promise { return notSupported(); } + async stopBackgroundWork(_chat: URI, _id: string): Promise { return notSupported(); } async importSession(_session: URI): Promise { return notSupported(); } async createDetachedWorktree(_session: URI, _prompt: string): Promise<{ handle: string; worktree: URI }> { return notSupported(); } async claimDetachedWorktree(_handle: string): Promise { return notSupported(); } diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 8f6397d75c14e..2e7048b5e54e4 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -546,7 +546,7 @@ export interface IAgentChatContext { readonly customizations?: readonly Customization[]; /** Per-operation host instructions that providers add to model context without persisting as user content. */ readonly hostInstructions?: readonly string[]; - /** Records provider stage timing for the turn being sent; supplied only for a send. */ + /** Records provider timing for the current send or resumed execution. */ readonly sendStageRecorder?: IAgentProviderSendStageRecorder; /** Whether the current turn is an automated Agent Merge repair turn. */ readonly agentMergeTurn?: boolean; @@ -1372,6 +1372,12 @@ export interface IAgent { /** Optional history mutation for providers with a native truncation operation. */ truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise; + /** + * Stops an entry of the chat's background work that this provider marked + * stoppable. Resolves false when the entry is unknown or had already finished. + */ + stopBackgroundWork?(chat: URI, id: string): Promise; + /** * Changes the working directory of an exact chat's existing provider-native * backing. Callers MUST gate this operation on diff --git a/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts b/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts index ebe09e5f39cbc..72c622f6d8726 100644 --- a/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts +++ b/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts @@ -182,8 +182,25 @@ export function resolveAgentHostConfigurationSyncPatch(configurationService: ICo // nothing to mirror; leave the key absent so a previously stored host value // is preserved rather than clobbered with `undefined`. if (value !== undefined) { - patch[entry.sync.key] = value; + addAgentHostConfigurationSyncValue(patch, entry, value); } } return patch; } + +/** + * Writes a mirrored `value` for `entry` into `patch`, including any + * {@link IAgentHostConfigurationSync.derivedKeys}. Returns the keys written. + */ +export function addAgentHostConfigurationSyncValue(patch: Record, entry: IAgentHostConfigurationSyncEntry, value: unknown): string[] { + patch[entry.sync.key] = value; + const keys = [entry.sync.key]; + for (const [key, derive] of Object.entries(entry.sync.derivedKeys ?? {})) { + const derived = derive(value); + if (derived !== undefined) { + patch[key] = derived; + keys.push(key); + } + } + return keys; +} diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index 19d50f6a2a9d8..f9da1e3157bce 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -54,6 +54,7 @@ export const SetAgentHostDetachedWorktreeArchivedExtensionMethod = 'vscode/setAg export const RequestAgentHostWorkspaceTrustExtensionMethod = 'vscode/requestWorkspaceTrust'; export const RequestAgentHostMcpAuthenticationExtensionMethod = 'vscode/requestMcpAuthentication'; export const RemoveSessionArtifactExtensionMethod = 'vscode/removeSessionArtifact'; +export const StopBackgroundWorkExtensionMethod = 'vscode/stopBackgroundWork'; export const ImportSessionExtensionMethod = 'vscode/importSession'; export const ReportAgentHostFirstResponseExtensionMethod = 'vscode/reportAgentHostFirstResponse'; export const ReportChatUserInteractionExtensionMethod = 'vscode/reportChatUserInteraction'; @@ -118,6 +119,12 @@ export const removeSessionArtifactParamsValidator = vObj({ artifactId: vString(), }); +/** Stops a chat's background work entry that the host marked stoppable in its `_meta`. */ +export const stopBackgroundWorkParamsValidator = vObj({ + chat: vString(), + id: vString(), +}); + export const importSessionParamsValidator = vObj({ session: vString() }); export interface IAgentHostExtensionCommandMap { @@ -134,6 +141,11 @@ export interface IAgentHostExtensionCommandMap { params: ValidatorType; result: void; }; + [StopBackgroundWorkExtensionMethod]: { + params: ValidatorType; + /** `stopped` is false when the work had already finished or the host no longer tracks it. */ + result: { stopped: boolean }; + }; 'shutdown': { params: undefined; result: void }; 'getNetworkDiagnosticsInfo': { params: undefined; result: IAgentHostNetworkDiagnosticsInfo }; 'getManagedSettingsDiagnostics': { params: undefined; result: readonly IAgentHostManagedSettingsDiagnostics[] }; diff --git a/src/vs/platform/agentHost/common/agentHostProviderTiming.ts b/src/vs/platform/agentHost/common/agentHostProviderTiming.ts new file mode 100644 index 0000000000000..6b28c4d2fb035 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostProviderTiming.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Sequencer } from '../../../base/common/async.js'; +import type { IAgentProviderSendStageRecorder } from './agentHostTelemetry.js'; + +export const agentHostProviderOperations = [ + 'attachments', 'command', 'mode', 'permission', 'permissionOptions', 'permissionRpc', 'managedSettings', + 'sandbox', 'sandboxRpc', 'sandboxDiagnostics', 'shell', 'shellFile', 'shellRpc', + 'mcp', 'mcpList', 'mcpEnable', 'mcpDisable', 'execution', 'executionMarker', 'sdkSend', + 'permissionRequest', 'userInputRequest', 'elicitationRequest', 'mcpAuthRequest', 'exitPlanModeRequest', 'unsandboxedConfirmation', +] as const; +export type AgentHostProviderOperation = typeof agentHostProviderOperations[number]; + +export const agentHostProviderMilestones = [ + 'sdkSend', 'sdkSendReturned', 'sdkSendRejected', 'sdkFirstEvent', 'sdkUserMessage', + 'sdkAssistantTurnStart', 'sdkText', 'sdkReasoning', 'sdkTool', 'sdkError', 'sdkIdle', + 'permissionRequest', 'userInputRequest', 'elicitationRequest', 'mcpAuthRequest', 'promptHook', + 'exitPlanModeRequest', 'unsandboxedConfirmation', + 'hostProgress', 'hostSubstantiveProgress', 'modelCallFinished', +] as const; +export type AgentHostProviderMilestone = typeof agentHostProviderMilestones[number]; + +export const agentHostProviderTimingMeasurements = [ + 'startMs', 'endMs', 'queueMs', 'executionMs', 'queueBeforeProgressMs', 'executionBeforeProgressMs', + 'count', 'errorCount', 'incompleteCount', +] as const; + +type ProviderOperationMeasurement = `${AgentHostProviderOperation}.${typeof agentHostProviderTimingMeasurements[number]}`; +type ProviderMilestoneMeasurement = `milestone.${AgentHostProviderMilestone}`; +export type AgentHostProviderTimingMeasurement = ProviderOperationMeasurement | ProviderMilestoneMeasurement; +export type AgentHostProviderTimingMeasurements = Partial>; +type ProviderTimingGroup = 'input' | 'permissions' | 'sandboxShell' | 'mcp' | 'execution' | 'interactions' | 'milestones'; + +const operationGroups: Record> = { + attachments: 'input', command: 'input', mode: 'input', + permission: 'permissions', permissionOptions: 'permissions', permissionRpc: 'permissions', managedSettings: 'permissions', + sandbox: 'sandboxShell', sandboxRpc: 'sandboxShell', sandboxDiagnostics: 'sandboxShell', + shell: 'sandboxShell', shellFile: 'sandboxShell', shellRpc: 'sandboxShell', + mcp: 'mcp', mcpList: 'mcp', mcpEnable: 'mcp', mcpDisable: 'mcp', + execution: 'execution', executionMarker: 'execution', sdkSend: 'execution', + permissionRequest: 'interactions', userInputRequest: 'interactions', elicitationRequest: 'interactions', + mcpAuthRequest: 'interactions', exitPlanModeRequest: 'interactions', unsandboxedConfirmation: 'interactions', +}; + +/** Packs sanitized timings into at most seven events, with at most 54 timing measurements per event. */ +export function groupAgentHostProviderTimings(timings: readonly IAgentHostProviderTiming[]): Map { + const groups = new Map(); + for (const timing of timings) { + if (timing.kind === 'milestone') { + const measurements = groups.get('milestones') ?? {}; + measurements[`milestone.${timing.name as AgentHostProviderMilestone}`] = timing.startMs; + groups.set('milestones', measurements); + } else { + const name = timing.name as AgentHostProviderOperation; + const group = operationGroups[name]; + const measurements = groups.get(group) ?? {}; + for (const key of agentHostProviderTimingMeasurements) { + measurements[`${name}.${key}`] = timing[key]; + } + groups.set(group, measurements); + } + } + return groups; +} + +export interface IAgentHostProviderTiming { + readonly kind: 'operation' | 'milestone'; + readonly name: AgentHostProviderOperation | AgentHostProviderMilestone; + readonly startMs: number; + readonly endMs: number; + readonly queueMs: number; + readonly executionMs: number; + readonly queueBeforeProgressMs: number; + readonly executionBeforeProgressMs: number; + readonly count: number; + readonly errorCount: number; + readonly incompleteCount: number; +} + +export interface IAgentProviderOperationTiming { + start(): void; + end(failed: boolean): void; +} + +export function sanitizeAgentHostProviderTiming(timing: IAgentHostProviderTiming): IAgentHostProviderTiming | undefined { + const names: readonly string[] = timing.kind === 'operation' ? agentHostProviderOperations : timing.kind === 'milestone' ? agentHostProviderMilestones : []; + if (!names.includes(timing.name) || agentHostProviderTimingMeasurements.some(key => !Number.isFinite(timing[key]) || timing[key] < 0)) { + return undefined; + } + return { + kind: timing.kind, name: timing.name, startMs: timing.startMs, endMs: timing.endMs, + queueMs: timing.queueMs, executionMs: timing.executionMs, + queueBeforeProgressMs: timing.queueBeforeProgressMs, executionBeforeProgressMs: timing.executionBeforeProgressMs, + count: timing.count, errorCount: timing.errorCount, incompleteCount: timing.incompleteCount, + }; +} + +interface IOperation { + readonly name: AgentHostProviderOperation; + readonly queued: number; + started?: number; +} + +type MutableTiming = { -readonly [K in keyof IAgentHostProviderTiming]: IAgentHostProviderTiming[K] }; + +/** Bounded, turn-local timings on the host turn's clock; nested operations are not additive. */ +export class AgentHostProviderTiming { + private readonly operations = new Map(); + private readonly milestones = new Map(); + private readonly pending = new Set(); + private firstProgress: number | undefined; + private finished = false; + + constructor(private readonly now: () => number) { } + + get hasTimings(): boolean { + return this.operations.size > 0 || this.pending.size > 0 || this.milestones.size > 0; + } + + markFirstProgress(time: number): void { + this.firstProgress ??= time; + } + + markMilestone(name: AgentHostProviderMilestone): void { + if (!this.finished && agentHostProviderMilestones.includes(name) && !this.milestones.has(name)) { + this.milestones.set(name, this.now()); + } + } + + startOperation(name: AgentHostProviderOperation): IAgentProviderOperationTiming | undefined { + if (this.finished || !agentHostProviderOperations.includes(name)) { + return undefined; + } + const operation: IOperation = { name, queued: this.now() }; + this.pending.add(operation); + return { + start: () => { + if (!this.finished && this.pending.has(operation)) { + operation.started ??= this.now(); + } + }, + end: failed => { + if (!this.finished && this.pending.delete(operation)) { + this.record(operation, this.now(), failed, false); + } + }, + }; + } + + finish(time: number): readonly IAgentHostProviderTiming[] { + if (this.finished) { + return []; + } + this.finished = true; + for (const operation of this.pending) { + this.record(operation, time, false, true); + } + this.pending.clear(); + return [ + ...this.operations.values(), + ...[...this.milestones].map(([name, time]): IAgentHostProviderTiming => ({ + kind: 'milestone', name, startMs: time, endMs: time, queueMs: 0, executionMs: 0, + queueBeforeProgressMs: 0, executionBeforeProgressMs: 0, count: 1, errorCount: 0, incompleteCount: 0, + })), + ]; + } + + private record(operation: IOperation, end: number, failed: boolean, incomplete: boolean): void { + const start = Math.min(operation.started ?? end, end); + const cutoff = this.firstProgress ?? end; + const timing = this.operations.get(operation.name) ?? { + kind: 'operation', name: operation.name, startMs: operation.queued, endMs: end, + queueMs: 0, executionMs: 0, queueBeforeProgressMs: 0, executionBeforeProgressMs: 0, + count: 0, errorCount: 0, incompleteCount: 0, + }; + timing.startMs = Math.min(timing.startMs, operation.queued); + timing.endMs = Math.max(timing.endMs, end); + timing.queueMs += Math.max(0, start - operation.queued); + timing.executionMs += Math.max(0, end - start); + timing.queueBeforeProgressMs += Math.max(0, Math.min(start, cutoff) - operation.queued); + timing.executionBeforeProgressMs += Math.max(0, Math.min(end, cutoff) - start); + timing.count++; + timing.errorCount += Number(failed); + timing.incompleteCount += Number(incomplete); + this.operations.set(operation.name, timing); + } +} + +/** Measures queue admission separately from execution, retaining the operation's result or rejection. */ +export function measureAgentProviderOperation(recorder: IAgentProviderSendStageRecorder | undefined, name: AgentHostProviderOperation, operation: () => Promise, sequencer?: Sequencer): Promise { + const timing = recorder?.startOperation?.(name); + if (!timing) { + return sequencer ? sequencer.queue(operation) : operation(); + } + const run = async () => { + timing?.start(); + let failed = true; + try { + const result = await operation(); + failed = false; + return result; + } finally { + timing?.end(failed); + } + }; + return sequencer ? sequencer.queue(run) : run(); +} diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 3e77553ade8de..0233eb963dcd1 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -556,10 +556,26 @@ const agentHostProxyConfigDefinition = { }; export const agentHostProxyConfigSchema = createSchema(agentHostProxyConfigDefinition); -/** Root config keys forwarded from the renderer for automatic title generation. */ +/** Root config key selecting the automatic title generation strategy for new sessions. */ +export const AgentHostTitleGenerationConfigKey = 'titleGeneration'; + +/** Legacy boolean root config keys, still read for clients that predate {@link AgentHostTitleGenerationConfigKey}. */ export const AgentHostActiveAgentTitleGenerationConfigKey = 'activeAgentTitleGeneration'; export const AgentHostDeferredTitleGenerationConfigKey = 'deferredTitleGeneration'; +/** + * Strategies selectable for new sessions: + * - `utility`: a utility model names chats immediately. + * - `activeAgent`: the active agent names chats through `rename_chat`. + * - `agentReview`: the host seeds a title and refines it with a utility model after the first + * response, and a soft reminder lets the active agent rename the chat when that title no longer fits. + */ +export const AgentHostTitleGenerationStrategies = ['utility', 'activeAgent', 'agentReview'] as const; +export type AgentHostSelectableTitleGenerationStrategy = typeof AgentHostTitleGenerationStrategies[number]; + +/** `deferred` is `agentReview` without the reminder; it is kept for existing sessions and the legacy boolean key. */ +export type AgentHostTitleGenerationStrategy = AgentHostSelectableTitleGenerationStrategy | 'deferred'; + /** Root config key controlling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledConfigKey = 'markdownPlanRichLinksEnabled'; @@ -917,6 +933,18 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.deferredTitleGeneration.description', "Seed titles immediately and refine them in the background if the first response turn completes successfully, without asking the active agent to name chats. Explicit rename tools remain available. Overrides active agent title generation for new sessions; existing sessions and their chats retain their strategy."), default: false, }), + [AgentHostTitleGenerationConfigKey]: schemaProperty({ + type: 'string', + title: localize('agentHost.config.titleGeneration.title', "Title Generation"), + description: localize('agentHost.config.titleGeneration.description', "Controls how new sessions and chats get an automatic title. Takes precedence over the legacy active agent and deferred title generation keys. Changes apply to new sessions; existing sessions and their chats retain their strategy."), + enum: [...AgentHostTitleGenerationStrategies], + enumDescriptions: [ + localize('agentHost.config.titleGeneration.utility', "A utility model generates titles immediately."), + localize('agentHost.config.titleGeneration.activeAgent', "The active agent names sessions and chats with rename tools."), + localize('agentHost.config.titleGeneration.agentReview', "Seed titles immediately, refine them in the background after the first response turn completes successfully, and remind the active agent that it may rename a chat when the generated title is inaccurate or no longer reflects the user's goal."), + ], + default: 'utility', + }), [AgentHostMcpConnectorsEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.mcpConnectorsEnabled.title', "Copilot Connectors"), diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts index d9d030ce955aa..98eac91a474d7 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetry.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts @@ -6,6 +6,7 @@ import { vEnum } from '../../../base/common/validation.js'; import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js'; import type { AgentHostClientType } from './agentHostClientInfo.js'; +import type { AgentHostProviderMilestone, AgentHostProviderOperation, IAgentProviderOperationTiming } from './agentHostProviderTiming.js'; export const enum AgentHostLaunchKind { VSCodeMainProcess = 'vscode_main_process', @@ -116,7 +117,7 @@ export type AgentHostProviderSendStage = | 'refresh' /** Per-turn preparation after the session is ready and before the SDK send. */ | 'turnPrepare' - /** From the SDK send until the first visible progress (model latency). */ + /** From the SDK send until the first visible progress, including runtime and model waiting. */ | 'modelResponse'; /** @@ -125,6 +126,8 @@ export type AgentHostProviderSendStage = */ export interface IAgentProviderSendStageRecorder { mark(stage: AgentHostProviderSendStage): void; + startOperation?(operation: AgentHostProviderOperation): IAgentProviderOperationTiming | undefined; + markMilestone?(milestone: AgentHostProviderMilestone): void; } export interface IAgentHostClientTelemetryContext { diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e85924d71b783..b19f51fd9ef10 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -115,7 +115,10 @@ export const AgentHostSystemProxyEnabledSettingId = 'chat.agentHost.systemProxy. /** Configuration key controlling the GitHub MCP server in agent-host sessions. */ export const AgentHostGitHubMcpServerEnabledSettingId = 'chat.agentHost.githubMcpServer.enabled'; -/** Configuration keys controlling automatic session and chat title generation. */ +/** Configuration key selecting the automatic session and chat title generation strategy. */ +export const AgentHostTitleGenerationSettingId = 'chat.agentHost.experimental.titleGeneration'; + +/** Legacy boolean settings migrated to {@link AgentHostTitleGenerationSettingId}. */ export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.experimental.activeAgentTitleGeneration'; export const AgentHostDeferredTitleGenerationSettingId = 'chat.agentHost.experimental.deferredTitleGeneration'; @@ -805,6 +808,7 @@ export interface IConnectionTrackerService { */ export interface IMissionControlOptions { readonly baseUrl: string; + readonly name?: string; readonly accountId: string; readonly credential: string; readonly roots: readonly string[]; @@ -890,6 +894,8 @@ export interface IAgentService { importSession?(session: URI): Promise; /** Removes a recorded artifact or reference, awaiting host metadata persistence. */ removeSessionArtifact?(session: URI, artifactId: string): Promise; + /** Stops a chat's background work entry. Resolves false when it had already finished or is not tracked. */ + stopBackgroundWork?(chat: URI, id: string): Promise; createDetachedWorktree?(session: URI, prompt: string): Promise<{ handle: string; worktree: URI }>; claimDetachedWorktree?(handle: string): Promise; setDetachedWorktreeArchived?(handle: string, archived: boolean): Promise; @@ -1221,6 +1227,8 @@ export interface IAgentConnection { importSession?(session: URI): Promise; /** Requires the VS Code artifact removal capability advertised by initialize. */ removeSessionArtifact?(session: URI, artifactId: string): Promise; + /** Only for background work whose `_meta` the host marked stoppable. Resolves false when it had already finished. */ + stopBackgroundWork?(chat: URI, id: string): Promise; /** Refresh a held subscription through the standard subscribe request, preserving pending actions. */ refreshSubscription?(resource: URI): Promise; createDetachedWorktree?(session: URI, prompt: string): Promise<{ handle: string; worktree: URI }>; diff --git a/src/vs/platform/agentHost/common/fileEditAttribution.ts b/src/vs/platform/agentHost/common/fileEditAttribution.ts index 7d41b0905afd9..076b471901dc9 100644 --- a/src/vs/platform/agentHost/common/fileEditAttribution.ts +++ b/src/vs/platform/agentHost/common/fileEditAttribution.ts @@ -5,6 +5,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { stringHash } from '../../../base/common/hash.js'; +import type { AutoModeRoutingTier } from './autoModeTiers.js'; import { IOffsetEdit } from './diffComputeService.js'; import { ToolResultFileEditContent } from './state/sessionState.js'; import { URI } from '../../../base/common/uri.js'; @@ -23,6 +24,7 @@ interface IFileEditAttributionMarkerBase { export interface IFileEditAttributionSource { readonly modelId?: string; + readonly autoTier?: string; readonly conversationId: string; readonly chatSessionId?: string; readonly requestId: string; @@ -61,6 +63,7 @@ export interface IAgentEditAttribution { readonly afterText: string; readonly changes: readonly IOffsetEdit[]; readonly modelId: string | undefined; + readonly autoTier?: AutoModeRoutingTier; readonly toolName: string; } @@ -172,6 +175,7 @@ function isFileEditAttributionSource(source: unknown): source is IFileEditAttrib } const candidate = source as Partial; return (candidate.modelId === undefined || typeof candidate.modelId === 'string') && + (candidate.autoTier === undefined || typeof candidate.autoTier === 'string') && typeof candidate.conversationId === 'string' && (candidate.chatSessionId === undefined || typeof candidate.chatSessionId === 'string') && typeof candidate.requestId === 'string' && diff --git a/src/vs/platform/agentHost/common/meta/agentHostBackgroundWorkStopMeta.ts b/src/vs/platform/agentHost/common/meta/agentHostBackgroundWorkStopMeta.ts new file mode 100644 index 0000000000000..4dfce43a8e802 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentHostBackgroundWorkStopMeta.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { canStopBackgroundWork, toStoppableBackgroundWorkMeta } from './vscode/agentHostBackgroundWorkStopMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentHostBackgroundWorkStopMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentHostBackgroundWorkStopMeta.ts new file mode 100644 index 0000000000000..1e7b22ea788c7 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/vscode/agentHostBackgroundWorkStopMeta.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Marks background work that the host stops through the VS Code-only `vscode/stopBackgroundWork` request. */ +const BACKGROUND_WORK_STOPPABLE_META_KEY = 'vscode.stopBackgroundWork'; + +/** Builds the `_meta` that lets clients stop a background work entry. */ +export function toStoppableBackgroundWorkMeta(): Record { + return { [BACKGROUND_WORK_STOPPABLE_META_KEY]: true }; +} + +/** Whether the host accepts `vscode/stopBackgroundWork` for this entry. */ +export function canStopBackgroundWork(work: { readonly _meta?: Record }): boolean { + return work._meta?.[BACKGROUND_WORK_STOPPABLE_META_KEY] === true; +} diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentSystemNotificationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentSystemNotificationMeta.ts index 545c6ee77aecf..4053f6134105c 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/agentSystemNotificationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/agentSystemNotificationMeta.ts @@ -27,6 +27,8 @@ export const enum AgentSystemNotificationKind { ResponseRoundEnded = 'responseRoundEnded', /** More tools were enabled than a bring-your-own-key model accepts, so some were not sent to it. */ ByokToolLimitExceeded = 'byokToolLimitExceeded', + /** Plugins required by the organization could not be checked or prepared; the turn continues without them. */ + ManagedPluginPreparationFailure = 'managedPluginPreparationFailure', } export const enum AgentSystemNotificationWorkspaceKind { @@ -55,6 +57,7 @@ const knownKinds: ReadonlySet = new Set([ AgentSystemNotificationKind.AgentMergePullRequestMerged, AgentSystemNotificationKind.ResponseRoundEnded, AgentSystemNotificationKind.ByokToolLimitExceeded, + AgentSystemNotificationKind.ManagedPluginPreparationFailure, ]); interface IHasSystemNotificationMeta { diff --git a/src/vs/platform/agentHost/common/missionControlEnvironment.ts b/src/vs/platform/agentHost/common/missionControlEnvironment.ts index 1e09f199befb8..aa342ad9768e5 100644 --- a/src/vs/platform/agentHost/common/missionControlEnvironment.ts +++ b/src/vs/platform/agentHost/common/missionControlEnvironment.ts @@ -9,7 +9,11 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IMissionControlEnvironment } from './cloudSandboxAgentHost.js'; export const AgentHostRemoteConnectionsSettingId = 'chat.agentHost.remoteConnections'; -export type AgentHostRemoteConnectionsBackend = 'devTunnel' | 'missionControl'; +export type AgentHostRemoteConnectionsBackend = 'devTunnel' | 'githubEnvironment' | 'missionControl'; + +export function isGitHubEnvironmentBackend(backend: AgentHostRemoteConnectionsBackend): boolean { + return backend === 'githubEnvironment' || backend === 'missionControl'; +} export const IMissionControlSharingService = createDecorator('missionControlSharingService'); diff --git a/src/vs/platform/agentHost/common/otel/agentHostTiming.ts b/src/vs/platform/agentHost/common/otel/agentHostTiming.ts index e7c15bcecf24c..766c57ef2b8d4 100644 --- a/src/vs/platform/agentHost/common/otel/agentHostTiming.ts +++ b/src/vs/platform/agentHost/common/otel/agentHostTiming.ts @@ -5,9 +5,11 @@ import { vBoolean, vEnum, vNumber, vObj, vOptionalProp, vString, type ValidatorType } from '../../../../base/common/validation.js'; import { hasKey } from '../../../../base/common/types.js'; +import { agentHostProviderTimingMeasurements, sanitizeAgentHostProviderTiming, type IAgentHostProviderTiming } from '../agentHostProviderTiming.js'; import { agentHostClientConnectionKindValidator } from '../agentHostTelemetry.js'; export const AgentHostTurnTimingSpanName = 'vscode.agent_host.turn_timing'; +export const AgentHostProviderTimingSpanName = 'vscode.agent_host.provider_timing'; export const AgentHostFirstResponseSpanName = 'vscode.agent_host.first_response'; export const AgentHostTimingAttributePrefix = 'vscode.agent_host.'; @@ -32,6 +34,8 @@ export const agentHostFirstResponseValidator = vObj({ export type IAgentHostFirstResponseDiagnostic = ValidatorType; export interface IAgentHostTurnTimingDiagnostic { + providerTimings?: readonly IAgentHostProviderTiming[]; + providerTiming?: IAgentHostProviderTiming; provider: string; turnId: string; agentSessionId: string; @@ -60,7 +64,7 @@ export interface IAgentHostTurnTimingDiagnostic { providerStageModelResponseMs?: number; hostRootTurnOrdinal?: number; hostProcessAgeMs?: number; - titleGenerationStrategy?: 'activeAgent' | 'utility' | 'deferred'; + titleGenerationStrategy?: 'activeAgent' | 'utility' | 'deferred' | 'agentReview'; } /** Project an allowlist, preserving observed zeroes and omitting invalid or unavailable values. */ @@ -88,6 +92,19 @@ export function agentHostTimingAttributes(diagnostic: IAgentHostTurnTimingDiagno } }; if (hasKey(diagnostic, { turnId: true })) { + if (diagnostic.providerTiming) { + const timing = sanitizeAgentHostProviderTiming(diagnostic.providerTiming); + if (!timing) { + return undefined; + } + put('kind', timing.kind); + put('name', timing.name); + for (const key of agentHostProviderTimingMeasurements) { + measurement(key, timing[key]); + } + put('result', diagnostic.result); + return attributes; + } put('result', diagnostic.result); put('isSubagentSession', diagnostic.isSubagentSession); if (diagnostic.titleGenerationStrategy !== undefined) { diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts index af0c5d10586b2..01022bb5b8c51 100644 --- a/src/vs/platform/agentHost/common/state/sessionTransport.ts +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -46,7 +46,10 @@ export interface IProtocolTransport extends IDisposable { readonly relayPassive?: boolean; readonly relayHandshakeMeta?: Record; /** Owner-validated identity for the current relay handshake, independent of transport access. */ - readonly relayAuthenticated?: boolean; + readonly relayAuthentication?: { readonly authenticated: boolean; readonly resource: string }; + /** Captures the current credential lifetime for checks after awaited work. */ + relayCaptureAuthorization?(): () => void; + readonly onDidRelayAuthenticationExpire?: Event; relayAuthenticate?(params: AuthenticateParams): Promise; /** Diagnostic metadata can arrive after onClose has already reported a transport failure. */ readonly onDidCloseDetails?: Event; diff --git a/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts b/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts index 957a512a4d4c3..bb967d25f5115 100644 --- a/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts +++ b/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts @@ -6,26 +6,72 @@ import * as nls from '../../../nls.js'; import { ConfigurationScope, IConfigurationPropertySchema } from '../../configuration/common/configurationRegistry.js'; import product from '../../product/common/product.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostDeferredTitleGenerationConfigKey } from './agentHostSchema.js'; -import { AgentHostActiveAgentTitleGenerationSettingId, AgentHostDeferredTitleGenerationSettingId } from './agentService.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostDeferredTitleGenerationConfigKey, AgentHostTitleGenerationConfigKey, AgentHostTitleGenerationStrategies, type AgentHostSelectableTitleGenerationStrategy, type AgentHostTitleGenerationStrategy } from './agentHostSchema.js'; +import { AgentHostTitleGenerationSettingId } from './agentService.js'; +import type { IRootConfigChangedAction } from './state/sessionActions.js'; export const titleGenerationConfigurationProperties = { - [AgentHostActiveAgentTitleGenerationSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.experimental.activeAgentTitleGeneration', "When enabled, the active agent names new sessions and chats using rename tools. When disabled, a utility model generates titles immediately. Deferred title generation takes precedence. Changes apply to new sessions; existing sessions and their chats retain their strategy."), - default: product.quality !== 'stable', + [AgentHostTitleGenerationSettingId]: { + type: 'string', + enum: [...AgentHostTitleGenerationStrategies], + enumDescriptions: [ + nls.localize('chat.agentHost.experimental.titleGeneration.utility', "A utility model generates titles immediately."), + nls.localize('chat.agentHost.experimental.titleGeneration.activeAgent', "The active agent names new sessions and chats using rename tools."), + nls.localize('chat.agentHost.experimental.titleGeneration.agentReview', "Seed titles immediately and refine them in the background if the first response turn completes successfully. On later turns, remind the active agent that it may rename a chat when the generated title is inaccurate or no longer reflects the user's goal."), + ], + description: nls.localize('chat.agentHost.experimental.titleGeneration', "Controls how new sessions and chats get an automatic title. Changes apply to new sessions; existing sessions and their chats retain their strategy."), + default: (product.quality !== 'stable' ? 'activeAgent' : 'utility') satisfies AgentHostSelectableTitleGenerationStrategy, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], experiment: { mode: 'auto' }, - agentHost: { key: AgentHostActiveAgentTitleGenerationConfigKey }, - }, - [AgentHostDeferredTitleGenerationSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.experimental.deferredTitleGeneration', "Seed titles immediately and refine them in the background if the first response turn completes successfully, without asking the active agent to name chats. Explicit rename tools remain available. Overrides active agent title generation for new sessions; existing sessions and their chats retain their strategy."), - default: false, - scope: ConfigurationScope.APPLICATION, - tags: ['experimental', 'advanced'], - experiment: { mode: 'auto' }, - agentHost: { key: AgentHostDeferredTitleGenerationConfigKey }, + agentHost: { + key: AgentHostTitleGenerationConfigKey, + // Older hosts persist and still read the legacy boolean keys, so keep them in step with the strategy. + derivedKeys: { + [AgentHostActiveAgentTitleGenerationConfigKey]: value => value === 'activeAgent', + [AgentHostDeferredTitleGenerationConfigKey]: value => value === 'agentReview', + }, + }, }, } satisfies Record; + +/** Maps the legacy boolean title settings onto a strategy, or `undefined` when they select none. */ +function titleGenerationStrategyFromLegacySettings(deferred: unknown, activeAgent: unknown): AgentHostTitleGenerationStrategy | undefined { + if (deferred === true) { + return 'deferred'; + } + if (typeof activeAgent === 'boolean') { + return activeAgent ? 'activeAgent' : 'utility'; + } + return undefined; +} + +/** Migrates the legacy boolean title settings; deferred naming moves to `agentReview`, its selectable successor. */ +export function migrateLegacyTitleGenerationSettings(deferred: unknown, activeAgent: unknown): AgentHostSelectableTitleGenerationStrategy | undefined { + const strategy = titleGenerationStrategyFromLegacySettings(deferred, activeAgent); + return strategy === 'deferred' ? 'agentReview' : strategy; +} + +/** + * Lets a client that predates `titleGeneration` change the strategy on a host that persisted one from a newer client. + * Newer clients always send the strategy with its derived legacy keys, so a merge patch that changes only the legacy + * keys comes from an older client. It is rewritten as a replacement of the merged values without the stored strategy, + * which makes the host fall back to the legacy keys and keeps client mirrors of the root config in step. + */ +export function supersedeTitleGenerationStrategyForLegacyUpdate(currentValues: Readonly> | undefined, action: IRootConfigChangedAction): IRootConfigChangedAction { + const patch = action.config; + if (action.replace || !currentValues || !Object.hasOwn(currentValues, AgentHostTitleGenerationConfigKey) || Object.hasOwn(patch, AgentHostTitleGenerationConfigKey)) { + return action; + } + const changesLegacyKey = [AgentHostDeferredTitleGenerationConfigKey, AgentHostActiveAgentTitleGenerationConfigKey].some(key => Object.hasOwn(patch, key) && patch[key] !== currentValues[key]); + if (!changesLegacyKey) { + return action; + } + const { [AgentHostTitleGenerationConfigKey]: _supersededStrategy, ...config } = { ...currentValues, ...patch }; + return { ...action, config, replace: true }; +} + +/** Resolves the strategy for new sessions, falling back to the legacy boolean root keys sent by older clients. */ +export function resolveTitleGenerationStrategy(strategy: AgentHostSelectableTitleGenerationStrategy | undefined, legacyDeferred: boolean | undefined, legacyActiveAgent: boolean | undefined): AgentHostTitleGenerationStrategy { + return strategy ?? titleGenerationStrategyFromLegacySettings(legacyDeferred, legacyActiveAgent) ?? 'utility'; +} diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index e1b44e45e05ea..85f595292e015 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -484,6 +484,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().removeSessionArtifact(session, artifactId); } + stopBackgroundWork(chat: URI, id: string): Promise { + return this._requireClient().stopBackgroundWork(chat, id); + } + importSession(session: URI): Promise { return this._requireClient().importSession(session); } diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 5347f9c470a87..a5fdbbf9df14a 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -21,7 +21,8 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentService } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import type { IAgent } from '../common/agent.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostDeferredTitleGenerationConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostDeferredTitleGenerationConfigKey, AgentHostTitleGenerationConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { resolveTitleGenerationStrategy } from '../common/titleGenerationConfiguration.js'; import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; import { AgentService, IAgentServiceOptions } from './agentService.js'; import { createAgentServiceComposition } from './agentServiceComposition.js'; @@ -189,9 +190,14 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt getGitHubHost: () => foundation.gitHubEndpointService.getEnterpriseHost() ?? 'github.com', gitHubService, copilotApiService, - getInitialTitleGenerationStrategy: () => foundation.configurationService.getRootValue(platformRootSchema, AgentHostDeferredTitleGenerationConfigKey) === true - ? 'deferred' - : foundation.configurationService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true ? 'activeAgent' : 'utility', + getInitialTitleGenerationStrategy: () => { + const configurationService = foundation.configurationService; + return resolveTitleGenerationStrategy( + configurationService.getRootValue(platformRootSchema, AgentHostTitleGenerationConfigKey), + configurationService.getRootValue(platformRootSchema, AgentHostDeferredTitleGenerationConfigKey), + configurationService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey), + ); + }, }))); const localTurns = new AgentHostLocalTurns(sessionDataService, logService); services.set(IAgentHostLocalTurns, localTurns); diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 8b99aa6c724fd..12986d152ebcd 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -448,26 +448,24 @@ async function startAgentHost(): Promise { } }, }; - const missionControl = server instanceof UtilityProcessServer - ? protocolIngressDisposables.add(instantiationService.createInstance(MissionControlHost, { - hostLaunchKind, - clientFileSystemProvider, - trackProtocolHandler: handler => { - protocolHandlers.push(handler); - return toDisposable(() => { - protocolHandlers.splice(protocolHandlers.indexOf(handler), 1); - handler.dispose(); - }); - }, - })) - : undefined; + const missionControl = protocolIngressDisposables.add(instantiationService.createInstance(MissionControlHost, { + hostLaunchKind, + clientFileSystemProvider, + trackProtocolHandler: handler => { + protocolHandlers.push(handler); + return toDisposable(() => { + protocolHandlers.splice(protocolHandlers.indexOf(handler), 1); + handler.dispose(); + }); + }, + })); const management = instantiationService.createInstance( AgentHostManagementService, agentService, connectionTrackerService, async () => { try { - await missionControl?.environment.configure(undefined); + await missionControl.environment.configure(undefined); } catch (error) { logService.error('[AgentHost] Failed to unregister Mission Control environment', error); } @@ -475,7 +473,7 @@ async function startAgentHost(): Promise { await Promise.all(protocolHandlers.map(handler => handler.whenIdle())); }, ); - management.setMissionControl(missionControl?.environment); + management.setMissionControl(missionControl.environment); server.registerChannel(AgentHostIpcChannels.Management, ProxyChannel.fromService(management, disposables)); if (!(server instanceof UtilityProcessServer)) { server.registerChannel(AgentHostIpcChannels.ConnectionTracker, ProxyChannel.fromService(connectionTrackerService, disposables)); diff --git a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts index 2e835e48fb65e..fd097eef730a1 100644 --- a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { LogLevel as ProxyLogLevel, ProxyAgentParams, createFetchPatch, createProxyAuthorizationLookup, createProxyResolver, loadSystemCertificates } from '@vscode/proxy-agent'; +import { getCACertificates } from 'tls'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; @@ -19,6 +20,19 @@ export const IAgentHostProxyResolver = createDecorator( type AgentHostProxyConfigurationKey = keyof typeof agentHostProxyConfigSchema.definition & string; +/** Load system and process-supplied CA certificates for Agent Host networking. */ +export async function loadAgentHostCertificates( + logService: ILogService, + extraCertificates: readonly string[] = getCACertificates('extra'), + systemCertificateLoader: typeof loadSystemCertificates = loadSystemCertificates, +): Promise { + const systemCertificates = await systemCertificateLoader({ + loadSystemCertificatesFromNode: () => systemCertificatesNodeDefault, + log: logService, + }); + return [...systemCertificates, ...extraCertificates]; +} + /** * Node-side registry of renderer {@link IAgentHostClientProxyConnection}s keyed * by client id. Populated by the agent host's connection lifecycle (one entry @@ -138,10 +152,7 @@ export class AgentHostProxyResolver extends Disposable implements IAgentHostProx addCertificatesV1: () => true, addCertificatesV2: () => false, loadSystemCertificatesFromNode: () => systemCertificatesNodeDefault, - loadAdditionalCertificates: async () => loadSystemCertificates({ - loadSystemCertificatesFromNode: () => systemCertificatesNodeDefault, - log: this._logService, - }), + loadAdditionalCertificates: () => loadAgentHostCertificates(this._logService), log: this._logService, getLogLevel: () => { switch (this._logService.getLevel()) { diff --git a/src/vs/platform/agentHost/node/agentHostRequestService.ts b/src/vs/platform/agentHost/node/agentHostRequestService.ts index 4e51d42a7ea9b..e0278466de761 100644 --- a/src/vs/platform/agentHost/node/agentHostRequestService.ts +++ b/src/vs/platform/agentHost/node/agentHostRequestService.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { loadSystemCertificates } from '@vscode/proxy-agent'; import { newWriteableBufferStream, VSBuffer, VSBufferWriteableStream } from '../../../base/common/buffer.js'; import { timeout } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; @@ -11,10 +10,10 @@ import { CancellationError, isCancellationError } from '../../../base/common/err import { IDisposable } from '../../../base/common/lifecycle.js'; import { IHeaders, IRequestContext, IRequestOptions } from '../../../base/parts/request/common/request.js'; import { ILogService } from '../../log/common/log.js'; -import { AbstractRequestService, AuthInfo, Credentials, systemCertificatesNodeDefault } from '../../request/common/request.js'; +import { AbstractRequestService, AuthInfo, Credentials } from '../../request/common/request.js'; import { lookupKerberosAuthorization } from '../../request/node/requestService.js'; import { AgentHostProxyConfigKey } from '../common/agentHostSchema.js'; -import { IAgentHostProxyResolver } from './agentHostProxyResolver.js'; +import { IAgentHostProxyResolver, loadAgentHostCertificates } from './agentHostProxyResolver.js'; const TRANSIENT_ERROR_CODES = new Set([ 'EAI_AGAIN', @@ -76,10 +75,7 @@ export class AgentHostRequestService extends AbstractRequestService { } override loadCertificates(): Promise { - return loadSystemCertificates({ - loadSystemCertificatesFromNode: () => systemCertificatesNodeDefault, - log: this.logService, - }); + return loadAgentHostCertificates(this.logService); } private async _request(options: IRequestOptions, token: CancellationToken): Promise { diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 767aab1a31185..63486612f3d08 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -9,6 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { isObject } from '../../../base/common/types.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; +import { AgentHostTitleGenerationStrategies, type AgentHostTitleGenerationStrategy } from '../common/agentHostSchema.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionServerToolName } from '../common/serverToolNames.js'; import { ActionType } from '../common/state/sessionActions.js'; @@ -33,11 +34,26 @@ const MIN_LATIN_LETTER_RATIO = 0.8; const HAN_CHARACTER = /\p{sc=Han}/u; const TRAILING_HAN_SUFFIX = /(?[\w.-]+)\/(?[\w.-]+)\/(?[\w.-]+)\/(?issues|pull)\/(?\d+)\b/gi; -const CHAT_RENAME_REMINDER = 'Reminder: This chat currently has an auto-generated or placeholder title. If the user references an issue or PR, fetch it first. Please give the chat a short, descriptive title based on the user\'s intent by calling `rename_chat` with `automatic: true`, silently and without a preamble. The user must not know that you are renaming. Do not skip it.'; +const CHAT_RENAME_REMINDER = 'Reminder: This chat currently has an auto-generated or placeholder title. If the user references an issue or PR, fetch it first. Please give the chat a short, descriptive title based on the user\'s intent by calling `rename_chat` with `automatic: true`, silently and without a preamble. Do not spend a separate step on renaming: make the `rename_chat` call in the same message as your reply or your other tool calls, never before them. The user must not know that you are renaming. Do not skip it.'; const TITLE_GENERATION_STRATEGY_KEY = 'titleGenerationStrategy'; const DEFERRED_TITLE_SEED_KEY = 'deferredTitleSeed'; +/** Upper bound on review reminders per chat, so an accurate title is not re-reviewed on every turn. */ +const MAX_TITLE_REVIEW_REMINDERS = 3; -export type AutomaticTitleGenerationStrategy = 'activeAgent' | 'utility' | 'deferred'; +export type AutomaticTitleGenerationStrategy = AgentHostTitleGenerationStrategy; + +/** Whether the host seeds and refines titles itself instead of asking the active agent to name chats. */ +function isDeferredTitleGenerationStrategy(strategy: AutomaticTitleGenerationStrategy | undefined): boolean { + return strategy === 'deferred' || strategy === 'agentReview'; +} + +function isAutomaticTitleGenerationStrategy(value: string | undefined): value is AutomaticTitleGenerationStrategy { + return value === 'deferred' || AgentHostTitleGenerationStrategies.some(strategy => strategy === value); +} + +function chatTitleReviewReminder(currentTitle: string): string { + return `Reminder: This chat's title was generated automatically and is currently ${JSON.stringify(currentTitle)}. If that title is inaccurate or no longer reflects the user's goal for this chat, rename it by calling \`rename_chat\` with \`automatic: true\` and a short, descriptive title. Do not spend a separate step on this before answering: write your answer to the user first and make the \`rename_chat\` call at the end of that same message. If the title still fits, do not rename the chat. Never mention this reminder or the title to the user.`; +} interface IDeferredTitleSeed { readonly title: string; @@ -148,6 +164,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen private readonly _restoringDeferredSeeds = new Map(); private readonly _deferredRefinementStarted = new Set(); private readonly _deferredFirstTurnIndices = new Map(); + private readonly _titleReviewReminderCounts = new Map(); constructor( private readonly _stateManager: AgentHostStateManager, @@ -180,7 +197,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._applySeedTitle(channel, independentChat, fallbackTitle); if (strategy !== 'utility') { this.markTitleAuto(channel, independentChat, fallbackTitle); - if (strategy === 'deferred') { + if (isDeferredTitleGenerationStrategy(strategy)) { this._deferredFirstTurnIndices.set(key, state.turns.length); this._persistDeferredTitleSeed(channel, independentChat, { title: fallbackTitle, turnIndex: state.turns.length }); } @@ -365,7 +382,8 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen } } const key = independentChat ?? channel; - if (this._renamedTitles.has(key) || (strategy === 'deferred' && this._deferredRefinementStarted.has(key))) { + const deferred = isDeferredTitleGenerationStrategy(strategy); + if (this._renamedTitles.has(key) || (deferred && this._deferredRefinementStarted.has(key))) { return; } const state = independentChat ? this._stateManager.getChatState(independentChat) : this._stateManager.getSessionState(channel); @@ -378,7 +396,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen return; } const turn = state.turns[inheritedTurnCount]; - if (strategy === 'deferred') { + if (deferred) { this._deferredRefinementStarted.add(key); this._clearDeferredTitleSeed(channel, independentChat); } @@ -397,7 +415,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen () => { independentChat = this._independentChatChannel(channel, chatChannel ?? buildDefaultChatUri(channel)); const currentKey = independentChat ?? channel; - if (strategy === 'deferred') { + if (deferred) { this._deferredRefinementStarted.add(currentKey); } return !this._renamedTitles.has(key) && !this._renamedTitles.has(currentKey) @@ -415,7 +433,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen const strategy = this.getAutomaticTitleGenerationStrategy(channel); if (strategy !== 'utility') { this.markTitleAuto(channel, chatChannel, fallbackTitle); - if (strategy === 'deferred') { + if (isDeferredTitleGenerationStrategy(strategy)) { const independentChat = this._independentChatChannel(channel, chatChannel); this._deferredFirstTurnIndices.set(independentChat ?? channel, turns.length); this._persistDeferredTitleSeed(channel, independentChat, { title: fallbackTitle, turnIndex: turns.length }); @@ -512,6 +530,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._renamedTitles.delete(key); this._deferredRefinementStarted.delete(key); this._deferredFirstTurnIndices.delete(key); + this._titleReviewReminderCounts.delete(key); this._restoringDeferredSeeds.delete(key); } this._titleGenerationStrategies.delete(session); @@ -535,7 +554,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._autoTitles.delete(key); this._provisionalTitles.delete(key); this._renamedTitles.add(key); - if (this._titleGenerationStrategies.get(channel) === 'deferred') { + if (isDeferredTitleGenerationStrategy(this._titleGenerationStrategies.get(channel))) { this._clearDeferredTitleSeed(channel, independentChat); } if (independentChat && title !== undefined) { @@ -550,7 +569,8 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (this._isEphemeralSession(channel)) { return undefined; } - if (this.getAutomaticTitleGenerationStrategy(channel) !== 'activeAgent') { + const strategy = this.getAutomaticTitleGenerationStrategy(channel); + if (strategy !== 'activeAgent' && strategy !== 'agentReview') { return undefined; } const independentChat = this._independentChatChannel(channel, chatChannel); @@ -558,6 +578,11 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (this._renamedTitles.has(key)) { return undefined; } + this._syncDefaultChatTitleReviewReminderCount(channel, key); + // Review mode leaves a fresh seed to the host's first-response refinement before inviting a rename. + if (strategy === 'agentReview' && !this._canOfferTitleReview(channel, independentChat, key)) { + return undefined; + } const sourceKey = independentChat ? customChatTitleSourceMetadataKey(independentChat) : SESSION_CUSTOM_TITLE_SOURCE_KEY; const source = await this._readPersistedTitleMetadata(channel, sourceKey); if (source === AGENT_HOST_TITLE_SOURCE_USER || source === AGENT_HOST_TITLE_SOURCE_AGENT) { @@ -568,9 +593,54 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen return undefined; } + if (strategy === 'agentReview') { + const currentTitle = this._currentSeedTitle(channel, independentChat); + if (!currentTitle) { + return undefined; + } + this._titleReviewReminderCounts.set(key, (this._titleReviewReminderCounts.get(key) ?? 0) + 1); + return chatTitleReviewReminder(currentTitle); + } return CHAT_RENAME_REMINDER; } + /** + * Whether review mode may invite a rename for `key`: only after the first + * response since the seed has finished (successfully or not), never while a + * host refinement is still in flight, and for at most + * {@link MAX_TITLE_REVIEW_REMINDERS} turns. + */ + private _canOfferTitleReview(channel: ProtocolURI, independentChat: ProtocolURI | undefined, key: ProtocolURI): boolean { + if ((this._titleReviewReminderCounts.get(key) ?? 0) >= MAX_TITLE_REVIEW_REMINDERS || this._titleGenerationCancellationSources.has(key)) { + return false; + } + // A refinement started before a peer made the default chat independent is still keyed by the session. + if (independentChat && isDefaultChatUri(independentChat) && this._titleGenerationCancellationSources.has(channel)) { + return false; + } + const state = independentChat ? this._stateManager.getChatState(independentChat) : this._stateManager.getSessionState(channel); + // Any finished response settles first-response refinement: a cancelled or failed one consumes it without refining. + const seedTurnIndex = this._deferredFirstTurnIndices.get(key) ?? 0; + return !!state && state.turns.length > seedTurnIndex; + } + + /** + * The default chat is titled under the session key while it is the only chat + * and under its own URI once a peer exists. Adding or removing peers moves + * between the two keys, so carry the higher count onto the active `key` to + * keep the per-chat cap across the transition. + */ + private _syncDefaultChatTitleReviewReminderCount(channel: ProtocolURI, key: ProtocolURI): void { + if (key !== channel && !isDefaultChatUri(key)) { + return; + } + const defaultChat = key === channel ? buildDefaultChatUri(channel) : key; + const count = Math.max(this._titleReviewReminderCounts.get(channel) ?? 0, this._titleReviewReminderCounts.get(defaultChat) ?? 0); + if (count > 0) { + this._titleReviewReminderCounts.set(key, count); + } + } + private _generateTitleSoon( key: ProtocolURI, prompt: ITitlePromptContext, @@ -938,7 +1008,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen /** Restores scheduling without opting legacy sessions into the deferred experiment. */ async restoreTitleGenerationStrategy(channel: ProtocolURI, chatChannel?: ProtocolURI): Promise { await this._restoreTitleGenerationStrategy(channel); - if (chatChannel && this._titleGenerationStrategies.get(channel) === 'deferred') { + if (chatChannel && isDeferredTitleGenerationStrategy(this._titleGenerationStrategies.get(channel))) { await this._restoreDeferredTitleSeed(channel, chatChannel); } } @@ -956,9 +1026,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (this._store.isDisposed || this._restoringTitleStrategies.get(channel) !== pending || this._titleGenerationStrategies.has(channel)) { return; } - const strategy = persisted === 'activeAgent' || persisted === 'utility' || persisted === 'deferred' - ? persisted - : 'utility'; + const strategy = isAutomaticTitleGenerationStrategy(persisted) ? persisted : 'utility'; this._titleGenerationStrategies.set(channel, strategy); this._persistSessionFlag(channel, TITLE_GENERATION_STRATEGY_KEY, strategy); }; @@ -1025,7 +1093,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen ref.object.getMetadata(independentChat ? customChatTitleSourceMetadataKey(independentChat) : SESSION_CUSTOM_TITLE_SOURCE_KEY), ]); const key = independentChat ?? channel; - if (this._store.isDisposed || this._restoringDeferredSeeds.get(chatChannel) !== restore || this._titleGenerationStrategies.get(channel) !== 'deferred' || this._lastAppliedTitle.has(key) || this._renamedTitles.has(key) || this._deferredRefinementStarted.has(key)) { + if (this._store.isDisposed || this._restoringDeferredSeeds.get(chatChannel) !== restore || !isDeferredTitleGenerationStrategy(this._titleGenerationStrategies.get(channel)) || this._lastAppliedTitle.has(key) || this._renamedTitles.has(key) || this._deferredRefinementStarted.has(key)) { return; } if (source === AGENT_HOST_TITLE_SOURCE_AUTO && title === seed.title) { @@ -1089,6 +1157,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._restoringDeferredSeeds.clear(); this._deferredRefinementStarted.clear(); this._deferredFirstTurnIndices.clear(); + this._titleReviewReminderCounts.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 8f490d774bdbf..6a400997ab9b6 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -26,6 +26,36 @@ import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type CodexModelProvider, type AgentHostProviderSendStage, type AgentHostTurnFailureStage, type AgentHostTurnSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderTurnTelemetryContext, type ICodexAccountTelemetryContext } from '../common/agentHostTelemetry.js'; import { isAgentHostTelemetryService } from './agentHostTelemetryService.js'; import { getCodexAccountTelemetryData, type CodexAccountTelemetryClassification } from './codex/codexAccountTelemetry.js'; +import { groupAgentHostProviderTimings, sanitizeAgentHostProviderTiming, type AgentHostProviderTimingMeasurement, type AgentHostProviderTimingMeasurements, type IAgentHostProviderTiming } from '../common/agentHostProviderTiming.js'; + +type AgentHostProviderTimingEvent = IAgentHostEventTelemetry & AgentHostProviderTimingMeasurements & { + schemaVersion: number; + group: string; + provider: string; + agentSessionId: string; + chatSessionId: string; + turnId: string; + result: AgentHostTurnResult; + model: string | TelemetryTrustedValue | undefined; +}; + +type AgentHostProviderTimingClassification = IAgentHostEventClassification & { + [K in AgentHostProviderTimingMeasurement]?: { + classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; + comment: 'Allowlisted operation aggregate: start/end offsets and queue/execution durations in milliseconds, or invocation/error/incomplete counts. Milestone fields are first-observation offsets in milliseconds. See the provider timing contract.'; + }; +} & { + owner: 'roblourens'; + comment: 'Grouped VS Code provider-operation and SDK-observation timings, correlated to a completed host turn.'; + schemaVersion: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Version of the provider timing contract.' }; + group: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Code-defined timing group: input, permissions, sandboxShell, mcp, execution, interactions or milestones.' }; + provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Agent provider identifier.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Opaque agent session identifier.' }; + chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Opaque chat correlation identifier.' }; + turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Host turn correlation identifier.' }; + result: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Terminal host turn outcome, not the operation outcome.' }; + model: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Privacy-normalized selected model.' }; +}; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; @@ -360,7 +390,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostEventClassificatio codexModelProvider?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded model provider actually used by a Codex turn: openai, copilot, other, or unknown.' }; hostRootTurnOrdinal?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'One-based root-turn ordinal over the agent host process lifetime, captured at turn start and excluding subagent turns.' }; hostProcessAgeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Agent host process age in milliseconds captured at root turn start, not completion.' }; - titleGenerationStrategy?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective persisted automatic title generation strategy captured before sending the root turn: activeAgent, utility, or deferred.' }; + titleGenerationStrategy?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective persisted automatic title generation strategy captured before sending the root turn: activeAgent, utility, deferred, or agentReview.' }; provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; chatSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat identifier within the agent host session.' }; @@ -482,6 +512,7 @@ export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedR sendDispatchedMs?: number; /** Elapsed time of each provider stage between dispatch and first progress, in milliseconds. */ providerStageDurationsMs?: ReadonlyMap; + providerTimings?: readonly IAgentHostProviderTiming[]; totalTime: number; result: AgentHostTurnResult; model: string | undefined; @@ -1614,6 +1645,7 @@ export class AgentHostTelemetryReporter { const subagentKindTelemetry = toSubagentKindTelemetry(isSubagent, report.subagentKind); const model = toTelemetryModel(report.model, report.modelTelemetryKind); const providerStages = toProviderStageMeasurements(report.providerStageDurationsMs); + const providerTimings = report.providerTimings?.length ? report.providerTimings.map(sanitizeAgentHostProviderTiming).filter(timing => timing !== undefined) : undefined; this._otelService?.emitTurnTiming({ provider: report.provider, agentSessionId: AgentSession.id(session), @@ -1632,6 +1664,7 @@ export class AgentHostTelemetryReporter { sendStageProviderPreparationMs: report.sendStageDurationsMs?.get('providerPreparation'), sendStageCheckpointMs: report.sendStageDurationsMs?.get('checkpoint'), ...providerStages, + providerTimings, hostRootTurnOrdinal: report.hostRootTurnOrdinal, hostProcessAgeMs: report.hostProcessAgeMs, titleGenerationStrategy: report.titleGenerationStrategy, @@ -1685,6 +1718,24 @@ export class AgentHostTelemetryReporter { directBilledNanoAiu: report.directBilledNanoAiu, modelCallCount: report.modelCallCount, }); + if (providerTimings?.length) { + const providerTimingContext = { + ...toInitiatorTelemetry(report.clientContext), + ...report.telemetryContext, + schemaVersion: 2, + provider: report.provider, + agentSessionId: AgentSession.id(session), + chatSessionId, + turnId: report.turnId, + result: report.result, + model, + }; + for (const [group, measurements] of groupAgentHostProviderTimings(providerTimings)) { + this._telemetryService.publicLog2('agentHost.providerTiming', { + ...providerTimingContext, group, ...measurements, + }); + } + } if (report.failure) { const { providerCallId, serviceRequestId } = readAgentErrorTelemetryMeta(report.failure.error); this._telemetryService.publicLogError2('agentHost.turnFailed', { diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 3a72eff28040d..4058a66e62522 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -8,6 +8,7 @@ import { getErrorMessage } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, type IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; +import { AgentHostProviderTiming } from '../common/agentHostProviderTiming.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { URI } from '../../../base/common/uri.js'; import type { AgentModelCallFinishedOutcome, AgentSubagentKind, AgentSubagentTaskModelSource, IAgent, IAgentTelemetryContext, IAgentTokenUsageSummary, IAgentTurnDiagnosticSnapshot, IAgentTurnTokenUsage } from '../common/agent.js'; @@ -15,7 +16,7 @@ import type { SessionMode } from '../common/agentHostSchema.js'; import { type CodexModelProvider, createUnknownAgentHostClientTelemetryContext, type AgentHostProviderSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder, type IAgentProviderTurnTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; -import { ILogService } from '../../log/common/log.js'; +import { ILogService, LogLevel } from '../../log/common/log.js'; import type { AutomaticTitleGenerationStrategy } from './agentHostSessionTitleController.js'; import { captureProviderTurnTelemetryContext, getModelTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { canRefineContributor, toolSourceKindFromContributor } from './shared/toolCallContributor.js'; @@ -65,6 +66,7 @@ interface IRootTurnTiming { /** Per-turn timing state, keyed by `session:turnId`. */ interface ITurnTiming { readonly stopWatch: StopWatch; + readonly providerTiming: AgentHostProviderTiming; readonly agent: IAgent; readonly session: string; readonly providerChat: URI; @@ -241,6 +243,9 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr ) { super(); this._register(toDisposable(() => { + for (const timing of this._turnTimings.values()) { + timing.providerTiming.finish(timing.stopWatch.elapsed()); + } this._turnTimings.clear(); this._rootTurnTimings.clear(); this._turnUsages.clear(); @@ -260,8 +265,10 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr }; this._rootTurnTimings.set(key, rootTiming); } + const stopWatch = StopWatch.create(false); const timing: ITurnTiming = { - stopWatch: StopWatch.create(false), + stopWatch, + providerTiming: new AgentHostProviderTiming(() => stopWatch.elapsed()), agent, session, providerChat, @@ -373,11 +380,18 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr const elapsed = timing.stopWatch.elapsed(); if (timing.firstProgressMs === undefined) { timing.firstProgressMs = elapsed; + timing.providerTiming.markFirstProgress(elapsed); + if (timing.providerTiming.hasTimings) { + timing.providerTiming.markMilestone('hostProgress'); + } this._closeProviderStage(timing, elapsed); this._logProviderStages(timing); } if (substantive && timing.firstSubstantiveProgressMs === undefined) { timing.firstSubstantiveProgressMs = elapsed; + if (timing.providerTiming.hasTimings) { + timing.providerTiming.markMilestone('hostSubstantiveProgress'); + } } } @@ -455,7 +469,22 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr /** Creates a recorder that forwards a provider's stage marks for one turn. */ createProviderStageRecorder(session: string, turnId: string): IAgentProviderSendStageRecorder { - return { mark: stage => this.markProviderStage(session, turnId, stage) }; + const key = this._key(session, turnId); + const timing = this._turnTimings.get(key); + const active = () => timing && this._turnTimings.get(key) === timing; + return { + mark: stage => { + if (active()) { + this.markProviderStage(session, turnId, stage); + } + }, + startOperation: operation => active() ? timing?.providerTiming.startOperation(operation) : undefined, + markMilestone: milestone => { + if (active()) { + timing?.providerTiming.markMilestone(milestone); + } + }, + }; } private _closeProviderStage(timing: ITurnTiming, now: number): void { @@ -719,6 +748,10 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr this._closeSendStage(timing); // Capture terminal timing before collecting or reporting additional telemetry. const totalTime = timing.stopWatch.elapsed(); + const providerTimings = timing.providerTiming.finish(totalTime); + if (providerTimings.length > 0 && this._logService.getLevel() <= LogLevel.Debug) { + this._logService.debug(`[AgentHostProviderTiming] ${JSON.stringify({ schemaVersion: 1, provider: timing.agent.id, turnId, result, timings: providerTimings })}`); + } // A turn that ends before first progress retains its partial provider stage. this._closeProviderStage(timing, totalTime); const timeAfterHangMs = timing.lastHangStopWatch?.elapsed() ?? 0; @@ -759,6 +792,7 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr sendStageDurationsMs: timing.sendStageDurationsMs, sendDispatchedMs: timing.sendDispatchedMs, providerStageDurationsMs: timing.providerStageDurationsMs, + providerTimings, totalTime, result, model: timing.model, @@ -860,6 +894,7 @@ export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTr } private _disposeTurn(key: string, timing: ITurnTiming): void { + timing.providerTiming.finish(timing.stopWatch.elapsed()); this._turnTimings.delete(key); this._turnUsages.delete(key); this._hangWatchdogs.deleteAndDispose(key); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 07a36893f636e..e163a9bf1d65e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -122,6 +122,7 @@ import { AgentHostAuthenticationService } from './agentHostAuthenticationService import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; import { AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { supersedeTitleGenerationStrategyForLegacyUpdate } from '../common/titleGenerationConfiguration.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, CHANGES_SUMMARY_METADATA_KEYS, getChatChangesSummaryMetadataKey, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_DATA_STATE, META_GIT_STATE, META_GITHUB_DATA_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; @@ -1654,6 +1655,12 @@ export class AgentService extends Disposable implements IAgentService { } } + async stopBackgroundWork(chat: URI, id: string): Promise { + // A chat that lists background work is live, so there is nothing to restore first. + const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); + return await this._providerService.getProviderForSession(session)?.stopBackgroundWork?.(chat, id) ?? false; + } + private _isArtifactToolsEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) === true; } @@ -8247,6 +8254,9 @@ export class AgentService extends Disposable implements IAgentService { return; } } + if (action.type === ActionType.RootConfigChanged) { + action = supersedeTitleGenerationStrategyForLegacyUpdate(this._stateManager.rootState.config?.values, action); + } this._stateManager.dispatchClientAction(channel, action, origin, clientContext); if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a5e190be808dd..6594f13c95d2b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1677,6 +1677,7 @@ export class AgentSideEffects extends Disposable { ...this._chatContext(sessionChannel, channel), clientTelemetryContext: clientContext, agentMergeTurn: resumedTurn.message.origin.kind === MessageKind.SystemNotification && isAgentMergeMessage(resumedTurn.message), + sendStageRecorder: this._turnTracker.createProviderStageRecorder(channel, action.turnId), }, clientId, clientContext.clientType, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e6f41807e92d4..ca3b1a5b8a5fe 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3149,6 +3149,10 @@ export class CopilotAgent extends Disposable implements IAgent { return true; } + async stopBackgroundWork(chat: URI, id: string): Promise { + return await this._findChatByUri(chat)?.stopBackgroundWork(id) ?? false; + } + private readonly _copilotChatDiscovery: CopilotChatDiscovery; private readonly _discoveredChats = new Map(); @@ -4143,6 +4147,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _resumeTurnOnce(chat: URI, turnId: string, operationContext: URI | IAgentChatContext, senderClientId?: string, clientType = AgentHostClientType.Unknown): Promise { const context = this._resolveChatContext(chat, operationContext); const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext.clientTelemetryContext; + const stageRecorder = URI.isUri(operationContext) ? undefined : operationContext.sendStageRecorder; await this._queueChatTurn(context, 'resumeTurn', turnId, async token => { const current = this._resolveChatContext(chat, operationContext); const cachedEntry = current.target; @@ -4158,7 +4163,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (token.isCancellationRequested) { throw new CancellationError(); } - await entry.resume(turnId, this._resolveSdkMode(current.configurationResource), senderClientId, clientType, clientTelemetryContext, !URI.isUri(operationContext) && operationContext.agentMergeTurn === true); + await entry.resume(turnId, this._resolveSdkMode(current.configurationResource), senderClientId, clientType, clientTelemetryContext, !URI.isUri(operationContext) && operationContext.agentMergeTurn === true, stageRecorder); }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 28aa7894d6cff..c580c9f2decfc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -39,15 +39,17 @@ import { getCopilotHomePath, getCopilotMcpConfigurationPath } from '../../../env import { AgentHostHydraFusionEnabledSettingId, CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import { logSettingExperimentTrigger } from '../../../telemetry/common/experimentTrigger.js'; import { withCustomizationEnablement } from '../../common/customizationEnablement.js'; -import type { AutoModeTier } from '../../common/autoModeTiers.js'; +import { isAutoModeRoutingTier, type AutoModeRoutingTier, type AutoModeTier } from '../../common/autoModeTiers.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; -import { readCopilotShellAttachment, toCopilotBackgroundShellMeta } from '../../common/meta/copilotBackgroundWorkMeta.js'; +import { canStopBackgroundWork, toStoppableBackgroundWorkMeta } from '../../common/meta/agentHostBackgroundWorkStopMeta.js'; +import { readCopilotShellAttachment, readCopilotShellId, toCopilotBackgroundShellMeta } from '../../common/meta/copilotBackgroundWorkMeta.js'; import { getSessionSandboxConfig } from '../sessionSandbox.js'; import { getCopilotBrowserSandboxNetworkRestrictions } from './copilotSandboxPolicy.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; +import { measureAgentProviderOperation } from '../../common/agentHostProviderTiming.js'; import { AgentSession, COPILOT_CLI_AGENT_PROVIDER_ID, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; import { isReasoningEffortLevel } from '../../common/reasoningEffort.js'; import { agentModelConfigurationMetaKey, IAgentRuntimeModelConfiguration, readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; @@ -77,7 +79,7 @@ import { allowCopilotSdkExecution, restoreDeferredCopilotSdkExecution } from './ import { getCopilotSdkToolResourceUri } from './copilotSdkMeta.js'; import { isAutoModel } from './modelIdentifiers.js'; import { applySandboxConfig, clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, mergeByokSessionConfig, toSessionConfigMcpServers, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; -import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME, searchToolsWithoutClient } from './toolSearchDeferral.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry, type ICanvasExtensionsReadyEvent } from '../agentHostTelemetryReporter.js'; import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; @@ -154,6 +156,16 @@ function readSubagentTaskModelSource(data: object): AgentSubagentTaskModelSource } } +/** The runtime-reported Auto tier, only while the model is Auto; never inferred or defaulted. */ +function getCommittedAutoTier(model: string | undefined, autoTier: unknown): AutoModeRoutingTier | undefined { + return isAutoModel(model) && isAutoModeRoutingTier(autoTier) ? autoTier : undefined; +} + +/** A known tier, or the pending runtime read for the configuration in effect when it was captured. */ +type AutoTierSnapshot = AutoModeRoutingTier | undefined | Promise; + +const AUTO_TIER_READ_TIMEOUT = 5_000; + function getClientToolSdkPolicy(toolName: string): IClientToolSdkPolicy { return CLIENT_TOOL_SDK_POLICIES.get(toolName) ?? DEFAULT_CLIENT_TOOL_SDK_POLICY; } @@ -203,6 +215,7 @@ interface ICopilotActiveToolCall { readonly mcpServerName: string | undefined; readonly contributor: ToolCallContributor | undefined; readonly intention: string | undefined; + readonly autoTier: AutoTierSnapshot; meta: IToolCallMeta | undefined; } @@ -515,6 +528,10 @@ const realpath = promisify(fsRealpath); // A non-settling control RPC must not permanently block the per-chat sequencer. const CONTROL_PLANE_RPC_TIMEOUT_MS = 30_000; const SUBAGENT_TASK_COMPLETION_DELAY_MS = 250; +/** Type of the `session.info` and `session.warning` events about preparing plugins required by the organization. */ +const MANAGED_PLUGINS_EVENT_TYPE = 'managed_plugins'; +/** Type of the `session.info` event sent when plugins required by the organization are ready. */ +const MANAGED_PLUGINS_COMPLETE_EVENT_TYPE = 'managed_plugins_complete'; function hasParentPathSegment(filePath: string): boolean { return filePath.split(/[\\/]/).includes('..'); @@ -721,6 +738,8 @@ class DirectUsageAccumulator { } class CopilotTurn extends Disposable { + timingRecorder: IAgentProviderSendStageRecorder | undefined; + sdkSendInvoked = false; private _state: CopilotTurnState = 'pending'; private _providerCallState: AgentTurnProviderCallState = 'notStarted'; @@ -1014,6 +1033,8 @@ export class CopilotAgentSession extends Disposable { * subagent outlives the root turn that spawned it when steering mints a new one. */ private readonly _autoModeResolvedByToolCallId = new Map>(); + private _committedAutoTier: AutoTierSnapshot; + private readonly _committedAutoTierBySubagentToolCallId = new Map(); private readonly _activeSubagentAgentIds = new Set(); private readonly _subagentTaskCompletionSchedulers = this._register(new DisposableMap()); private readonly _pendingSubagentLifecycleCompletions = new Map(); @@ -1439,6 +1460,10 @@ export class CopilotAgentSession extends Disposable { private _hasFusionRootTurnBoundary = false; private readonly _activities: Record<'intent' | 'command' | 'fusion', string | undefined> = { intent: undefined, command: undefined, fusion: undefined }; private _publishedActivity: string | undefined; + /** Plugin preparation message shown while the current turn waits for its message to be admitted. */ + private _managedPluginActivity: string | undefined; + /** Plugin preparation failure reported while no turn was waiting; shown in the next sent turn. */ + private _unshownManagedPluginFailure: string | undefined; /** * Provisional Fusion tool starts held back from the transcript until a * phase chat, a permission request, or a client tool handler surfaces the tool, keyed by tool call id. @@ -1701,6 +1726,9 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${this.sessionId}] Ignoring model.call_finished without a host turn mapping: sdkTurnId=${event.data.turnId}`); return; } + if (!event.agentId && turn?.id === turnId) { + turn.timingRecorder?.markMilestone?.('modelCallFinished'); + } this._onDidSessionProgress.fire({ kind: 'model_call_finished', resource: this._chatChannelUri, @@ -2197,6 +2225,21 @@ export class CopilotAgentSession extends Disposable { }); } + /** + * Stops a background shell this session published as stoppable. Resolves + * false when the entry is unknown or its command had already finished. The + * runtime then reports the change, which removes the shell's entry. + */ + async stopBackgroundWork(id: string): Promise { + const work = this._backgroundWork.get(id); + const shellId = work?.kind === BackgroundWorkKind.Shell && canStopBackgroundWork(work) ? readCopilotShellId(work) : undefined; + if (shellId === undefined) { + return false; + } + const { cancelled } = await this._wrapper.session.rpc.tasks.cancel({ id: shellId }); + return cancelled; + } + private _publishBackgroundWork(tasks: Awaited>['tasks']): void { const entries = new Map(); for (const task of tasks) { @@ -2230,6 +2273,7 @@ export class CopilotAgentSession extends Disposable { private _toBackgroundWork(task: Awaited>['tasks'][number]): BackgroundWork | undefined { if (task.type === 'shell' && task.executionMode !== 'sync' && (task.status === 'running' || task.status === 'idle')) { const terminal = this._nonPtyShellTerminals.getBackgroundShellTerminal(task.id); + const attachment = task.attachmentMode === 'detached' ? 'detached' : 'attached'; return { kind: BackgroundWorkKind.Shell, id: `shell:${task.id}`, @@ -2237,7 +2281,8 @@ export class CopilotAgentSession extends Disposable { command: task.command, startedAt: task.startedAt, ...(terminal ? { terminal } : {}), - _meta: toCopilotBackgroundShellMeta(task.id, task.attachmentMode === 'detached' ? 'detached' : 'attached'), + // Stopping detached shells isn't offered yet. + _meta: { ...toCopilotBackgroundShellMeta(task.id, attachment), ...(attachment === 'attached' ? toStoppableBackgroundWorkMeta() : {}) }, }; } // An idle background agent has already reported back; only a running one will resume the chat. @@ -2402,6 +2447,7 @@ export class CopilotAgentSession extends Disposable { // Labels outlive a closed chat within the turn so a resumed workflow reopens it rather than routing to the root. this._fusionPhaseLabels.clear(); this._clearActivity(); + this._setManagedPluginActivity(undefined); this._detectInterruptedTurnOnRestore = false; this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); @@ -2532,6 +2578,7 @@ export class CopilotAgentSession extends Disposable { this._fusionProgress.reset(); this._clearProvisionalFusionState(); this._clearActivity(); + this._setManagedPluginActivity(undefined); if (turn) { this._cacheTokenUsage(turn.id, turn.observedTokenUsage.snapshot()); } @@ -2816,10 +2863,14 @@ export class CopilotAgentSession extends Disposable { overridesBuiltInTool: true, defer: 'never', skipPermission: true, - handler: this._guarded(async (_args: Record, invocation) => { + handler: this._guarded(async (args: Record, invocation) => { try { this._surfaceUnobservedClientToolStart(invocation); const candidates = this._toToolSearchCandidates(invocation.availableTools); + const tracked = this._activeToolCalls.get(invocation.toolCallId); + if (tracked && !tracked.contributor) { + return this._searchToolsOnHost(args, candidates); + } const clientResult = await this._pendingClientToolCalls.registerAndFire( invocation.toolCallId, () => this._emitToolSearchReady(invocation.toolCallId, candidates), @@ -2976,6 +3027,17 @@ export class CopilotAgentSession extends Disposable { return { textResultForLlm: message, resultType: 'failure', error: message, toolReferences: [] }; } + private _searchToolsOnHost(args: Record, candidates: readonly IToolSearchCandidate[]): ToolResultObject { + const query = isString(args.query) ? args.query.trim() : ''; + if (!query) { + return this._toolSearchFailure('Error: query parameter is required'); + } + const hostCandidates = candidates.filter(candidate => !this._clientToolNames.has(candidate.name)); + const { toolNames, textResultForLlm } = searchToolsWithoutClient(query, hostCandidates); + this._logService.info(`[Copilot:${this.sessionId}] tool_search override: no connected client, matched [${toolNames.join(', ')}] among ${hostCandidates.length} host-runnable deferred tools`); + return { textResultForLlm, resultType: 'success', toolReferences: [...toolNames] }; + } + private _toToolSearchResult(clientResult: ToolResultObject, availableTools: readonly CurrentToolMetadata[] | undefined): ToolResultObject { const deferred = new Map(); for (const tool of availableTools ?? []) { @@ -3200,6 +3262,7 @@ export class CopilotAgentSession extends Disposable { })); this._subscribeToEvents(); this._subscribeToSdkEvents(); + this._readCommittedAutoTier(wrapper); this._subscribeForMemoInvalidation(); this._subscribeForInstructionsCollectedTelemetry(); this._subscribeToPermissionConfigChanges(); @@ -3344,22 +3407,83 @@ export class CopilotAgentSession extends Disposable { this._promptCacheState = this._promptCache.write(this.resourceUri, promptCache); } + private _recordSdkTiming(event: SessionEvent): void { + const turn = this._currentTurn.value; + if (!turn?.sdkSendInvoked || event.agentId || this._dropLateRootTurnEvents) { + return; + } + const recorder = turn.timingRecorder; + recorder?.markMilestone?.('sdkFirstEvent'); + switch (event.type) { + case 'user.message': recorder?.markMilestone?.('sdkUserMessage'); break; + case 'assistant.turn_start': recorder?.markMilestone?.('sdkAssistantTurnStart'); break; + case 'assistant.message_delta': + if (event.data.deltaContent.length > 0) { + recorder?.markMilestone?.('sdkText'); + } + break; + case 'assistant.reasoning_delta': + if (event.data.deltaContent.length > 0) { + recorder?.markMilestone?.('sdkReasoning'); + } + break; + case 'assistant.message': + if (event.data.content.length > 0) { + recorder?.markMilestone?.('sdkText'); + } + break; + case 'assistant.reasoning': + if (event.data.content.length > 0) { + recorder?.markMilestone?.('sdkReasoning'); + } + break; + case 'tool.execution_start': recorder?.markMilestone?.('sdkTool'); break; + case 'session.error': recorder?.markMilestone?.('sdkError'); break; + case 'session.idle': recorder?.markMilestone?.('sdkIdle'); break; + } + } + private _createRuntimeAdapter(): ICopilotSessionRuntime { return { onSessionEvent: event => { if (!this._store.isDisposed) { + this._recordSdkTiming(event); this._onSessionEvent?.(event); } }, chatUri: this._chatChannelUri, configurationResource: this._ownerSessionUri, - handlePermissionRequest: this._guarded(request => this._handlePermissionRequest(request), attributePermissionResult({ kind: 'reject' }, 'unattended_fallback'), 'permission'), - handleExitPlanModeRequest: this._guarded((request, invocation) => this._handleExitPlanModeRequest(request, invocation), { approved: false } satisfies CopilotExitPlanModeResponse, 'exit-plan-mode'), - handleUserInputRequest: this._guarded((request, invocation) => this._handleUserInputRequest(request, invocation), { answer: '', wasFreeform: true } satisfies UserInputResponse, 'user-input'), - handleElicitationRequest: this._guarded(context => this._handleElicitationRequest(context), { action: 'cancel' } satisfies ElicitationResult, 'elicitation'), + handlePermissionRequest: this._guarded(request => { + const recorder = this._currentTurn.value?.timingRecorder; + recorder?.markMilestone?.('permissionRequest'); + return measureAgentProviderOperation(recorder, 'permissionRequest', () => this._handlePermissionRequest(request)); + }, attributePermissionResult({ kind: 'reject' }, 'unattended_fallback'), 'permission'), + handleExitPlanModeRequest: this._guarded((request, invocation) => { + const recorder = this._currentTurn.value?.timingRecorder; + recorder?.markMilestone?.('exitPlanModeRequest'); + return measureAgentProviderOperation(recorder, 'exitPlanModeRequest', () => this._handleExitPlanModeRequest(request, invocation)); + }, { approved: false } satisfies CopilotExitPlanModeResponse, 'exit-plan-mode'), + handleUserInputRequest: this._guarded((request, invocation) => { + const recorder = this._currentTurn.value?.timingRecorder; + recorder?.markMilestone?.('userInputRequest'); + return measureAgentProviderOperation(recorder, 'userInputRequest', () => this._handleUserInputRequest(request, invocation)); + }, { answer: '', wasFreeform: true } satisfies UserInputResponse, 'user-input'), + handleElicitationRequest: this._guarded(context => { + const recorder = this._currentTurn.value?.timingRecorder; + recorder?.markMilestone?.('elicitationRequest'); + return measureAgentProviderOperation(recorder, 'elicitationRequest', () => this._handleElicitationRequest(context)); + }, { action: 'cancel' } satisfies ElicitationResult, 'elicitation'), setMcpServerDisplayNames: displayNames => this._setMcpServerDisplayNames(displayNames), - handleMcpAuthRequest: this._guarded(request => this._handleMcpAuthRequest(request), { kind: 'cancelled' } satisfies McpAuthResult, 'mcp-auth'), - requestUnsandboxedCommandConfirmation: this._guarded(request => this._requestUnsandboxedCommandConfirmation(request), false, 'unsandboxed-command-confirmation'), + handleMcpAuthRequest: this._guarded(request => { + const recorder = this._currentTurn.value?.timingRecorder; + recorder?.markMilestone?.('mcpAuthRequest'); + return measureAgentProviderOperation(recorder, 'mcpAuthRequest', () => this._handleMcpAuthRequest(request)); + }, { kind: 'cancelled' } satisfies McpAuthResult, 'mcp-auth'), + requestUnsandboxedCommandConfirmation: this._guarded(request => { + const recorder = this._currentTurn.value?.timingRecorder; + recorder?.markMilestone?.('unsandboxedConfirmation'); + return measureAgentProviderOperation(recorder, 'unsandboxedConfirmation', () => this._requestUnsandboxedCommandConfirmation(request)); + }, false, 'unsandboxed-command-confirmation'), createClientSdkTools: toolSearchActive => this._createClientSdkTools(toolSearchActive), createServerSdkTools: () => this._createServerSdkTools(), reloadExtensions: () => this.reloadExtensions(), @@ -3741,6 +3865,7 @@ export class CopilotAgentSession extends Disposable { const currentTurn = this._currentTurn.value; if (currentTurn) { currentTurn.messageCharLen = prompt.length; + currentTurn.timingRecorder = stageRecorder; } const turn = this._currentTurn.value; this._hostInstructions = hostInstructions; @@ -3767,6 +3892,7 @@ export class CopilotAgentSession extends Disposable { } handleUserPromptSubmitted(): { readonly additionalContext: string } | undefined { + this._currentTurn.value?.timingRecorder?.markMilestone?.('promptHook'); const parts = [ ...(this._hostInstructions ?? []), ...(this._pendingSnapshotReminder ? [this._pendingSnapshotReminder] : []), @@ -3827,6 +3953,7 @@ export class CopilotAgentSession extends Disposable { /** Clears recovery eligibility before execution; preflight alone must leave the backing recoverable. */ private async _executeSdkOperation(operation: () => Promise, turn: CopilotTurn | undefined, abortToken: CancellationToken, providerCall?: CopilotTurn): Promise> { + const recorder = turn?.timingRecorder; const execute = async (): Promise => { providerCall?.markProviderCallPending(); try { @@ -3838,15 +3965,15 @@ export class CopilotAgentSession extends Disposable { throw error; } }; - const execution = await this._sdkExecutionSequencer.queue(async () => { + const execution = await measureAgentProviderOperation(recorder, 'execution', async () => { if (!this._canSendTurn(turn, abortToken)) { return undefined; } if (!this._sdkExecutionStarted) { - const marker = await allowCopilotSdkExecution(this._sessionDataService, this._ownerSessionUri, this.sessionId, this._logService); + const marker = await measureAgentProviderOperation(recorder, 'executionMarker', () => allowCopilotSdkExecution(this._sessionDataService, this._ownerSessionUri, this.sessionId, this._logService)); if (!this._canSendTurn(turn, abortToken)) { if (marker !== undefined) { - await restoreDeferredCopilotSdkExecution(this._sessionDataService, this._ownerSessionUri, marker); + await measureAgentProviderOperation(recorder, 'executionMarker', () => restoreDeferredCopilotSdkExecution(this._sessionDataService, this._ownerSessionUri, marker)); } return undefined; } @@ -3854,7 +3981,7 @@ export class CopilotAgentSession extends Disposable { } // Serialize marker changes and dispatch, but let SDK operations finish concurrently. return { result: execute() }; - }); + }, this._sdkExecutionSequencer); return execution ? { kind: 'executed', value: await execution.result } : { kind: 'skipped' }; } @@ -3872,7 +3999,7 @@ export class CopilotAgentSession extends Disposable { if (!this._canSendTurn(sendingTurn, abortToken)) { return; } - const execution = await this._executeSdkOperation(() => this._wrapper.session.rpc.history.compact(), sendingTurn, abortToken); + const execution = await this._executeSdkOperation(() => measureAgentProviderOperation(stageRecorder, 'command', () => this._wrapper.session.rpc.history.compact()), sendingTurn, abortToken); if (execution.kind === 'skipped') { return; } @@ -3941,13 +4068,13 @@ export class CopilotAgentSession extends Disposable { } prompt = configAction.strippedPrompt; } else if (slashCommand) { - const runtimeSlashCommand = await this._slashCommandProvider.resolveSlashCommand(slashCommand.command); + const runtimeSlashCommand = await measureAgentProviderOperation(stageRecorder, 'command', () => this._slashCommandProvider.resolveSlashCommand(slashCommand.command)); if (!this._canSendTurn(sendingTurn, abortToken)) { return; } // TEMPORARY WORKAROUND (#8837): route built-in /fleet via fleet.start to keep the AHP turn open; this bypasses commands.invoke telemetry/gating and should be removed once invoke returns agent-prompt. if (runtimeSlashCommand && runtimeSlashCommand.kind === 'builtin' && runtimeSlashCommand.name === 'fleet') { - await this._startFleet(slashCommand.rest, attachments, mode, abortToken); + await this._startFleet(slashCommand.rest, attachments, mode, abortToken, stageRecorder); return; } // Skills are routed through `commands.invoke` like every other runtime @@ -3965,7 +4092,7 @@ export class CopilotAgentSession extends Disposable { // Apply the effective mode before invoking the runtime command so it runs // under the correct SDK mode (issue #8837). An `agent-prompt` result may // override the mode; that override is applied again before `session.send`. - await this.applyMode(mode); + await this.applyMode(mode, stageRecorder); if (!this._canSendTurn(sendingTurn, abortToken)) { return; } @@ -3984,7 +4111,7 @@ export class CopilotAgentSession extends Disposable { : undefined; commandProgressScheduler?.schedule(); try { - const execution = await this._executeSdkOperation(async () => await runtimeSlashCommand.invoke?.(slashCommand.rawRest) ?? this._wrapper.session.rpc.commands.invoke(invocation), sendingTurn, abortToken); + const execution = await this._executeSdkOperation(() => measureAgentProviderOperation(stageRecorder, 'command', async () => await runtimeSlashCommand.invoke?.(slashCommand.rawRest) ?? this._wrapper.session.rpc.commands.invoke(invocation)), sendingTurn, abortToken); if (execution.kind === 'skipped') { return; } @@ -4058,26 +4185,28 @@ export class CopilotAgentSession extends Disposable { } } - const sdkAttachments = await this._toSdkAttachments(attachments); + const sdkAttachments = await measureAgentProviderOperation(stageRecorder, 'attachments', () => this._toSdkAttachments(attachments)); if (!this._canSendTurn(sendingTurn, abortToken)) { return; } - await this._prepareSdkTurn(mode); + await this._prepareSdkTurn(mode, stageRecorder); if (!this._canSendTurn(sendingTurn, abortToken)) { return; } const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); - const execution = await this._executeSdkOperation(() => this._otelService.withTraceContext(traceContext, async () => { + const execution = await this._executeSdkOperation(() => this._otelService.withTraceContext(traceContext, () => { stageRecorder?.mark('modelResponse'); - if (!this._environmentService.isBuilt && prompt === '$error') { - await this._wrapper.session.rpc.sendMessages({ - messages: [{ prompt }], - requestHeaders: { Authorization: '******' }, - }); - } else { - await this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined, ...(displayPrompt ? { displayPrompt } : {}) }); - } + return this._measureSdkSend(sendingTurn, stageRecorder, async () => { + if (!this._environmentService.isBuilt && prompt === '$error') { + await this._wrapper.session.rpc.sendMessages({ + messages: [{ prompt }], + requestHeaders: { Authorization: '******' }, + }); + } else { + await this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined, ...(displayPrompt ? { displayPrompt } : {}) }); + } + }); }), sendingTurn, abortToken, sendingTurn); if (execution.kind === 'skipped') { return; @@ -4085,6 +4214,21 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } + private async _measureSdkSend(turn: CopilotTurn | undefined, recorder: IAgentProviderSendStageRecorder | undefined, operation: () => Promise): Promise { + if (turn) { + turn.sdkSendInvoked = true; + } + recorder?.markMilestone?.('sdkSend'); + try { + const result = await measureAgentProviderOperation(recorder, 'sdkSend', operation); + recorder?.markMilestone?.('sdkSendReturned'); + return result; + } catch (error) { + recorder?.markMilestone?.('sdkSendRejected'); + throw error; + } + } + private _syncObservedMcpServerEnablement(serverName: string, previousEnabled: boolean | undefined, enabled: boolean): void { if (previousEnabled === undefined || previousEnabled === enabled) { return; @@ -4136,25 +4280,28 @@ export class CopilotAgentSession extends Disposable { } } - async resume(turnId: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType), agentMergeTurn = false): Promise { + async resume(turnId: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType), agentMergeTurn = false, stageRecorder?: IAgentProviderSendStageRecorder): Promise { this._resetAbortToken(); const abortToken = this._abortToken; this.resetTurnState(turnId, senderClientId, clientType, clientContext); this._agentMergeTurn = agentMergeTurn; + const turn = this._currentTurn.value; + if (turn) { + turn.timingRecorder = stageRecorder; + } if (this._tryContinueDevelopmentRecoverableError(turnId)) { return; } - const turn = this._currentTurn.value; this._resumingTurnAwaitingProviderStart = turn; turn?.markProviderCallPending(); try { - await this._prepareSdkTurn(mode); + await this._prepareSdkTurn(mode, stageRecorder); if (!this._canSendTurn(turn, abortToken)) { return; } const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); const execution = await this._executeSdkOperation( - () => this._otelService.withTraceContext(traceContext, () => this._wrapper.session.rpc.sendMessages({ messages: [] })), + () => this._otelService.withTraceContext(traceContext, () => this._measureSdkSend(turn, stageRecorder, () => this._wrapper.session.rpc.sendMessages({ messages: [] }))), turn, abortToken, ); if (execution.kind === 'skipped') { @@ -4265,12 +4412,12 @@ export class CopilotAgentSession extends Disposable { * permission mode, sandbox, shell init script, and MCP enablement. * Permission and sandbox failures prevent the turn from starting. */ - private async _prepareSdkTurn(mode: CopilotSdkMode | undefined): Promise { - await this.applyMode(mode); - await this.syncPermissionMode('turn-start'); - await this._applyEffectiveSandboxConfig(); - await this._syncShellInitScript(); - await this._reconcileMcpServerEnablement(); + private async _prepareSdkTurn(mode: CopilotSdkMode | undefined, recorder?: IAgentProviderSendStageRecorder): Promise { + await this.applyMode(mode, recorder); + await this.syncPermissionMode('turn-start', recorder); + await this._applyEffectiveSandboxConfig(true, false, recorder); + await this._syncShellInitScript(recorder); + await this._reconcileMcpServerEnablement(recorder); } /** @@ -4279,7 +4426,7 @@ export class CopilotAgentSession extends Disposable { * until the SDK's terminal `session.idle`, rather than completing it as `commands.invoke` * would. Remove once the runtime returns an `agent-prompt` result for `/fleet`. */ - private async _startFleet(rest: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined, abortToken: CancellationToken): Promise { + private async _startFleet(rest: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined, abortToken: CancellationToken, recorder?: IAgentProviderSendStageRecorder): Promise { if (attachments?.length) { // `rpc.fleet.start` accepts only a prompt; fail loudly rather than silently dropping attachments. throw new Error(localize('copilotAgent.fleet.attachmentsUnsupported', "Attachments are not supported with the /fleet command.")); @@ -4289,7 +4436,7 @@ export class CopilotAgentSession extends Disposable { // resolution), so it reliably reflects an abort that raced that await: an aborted // `session.idle` resets the live token, so reading `this._abortToken` here could // observe a fresh post-abort token and miss the cancellation. - await this._prepareSdkTurn(mode); + await this._prepareSdkTurn(mode, recorder); // Preflight awaits several RPCs; if an abort or terminal idle raced it, do not // start the fleet loop at all — starting it would orphan an autonomous run. if (!startingTurn || this._currentTurn.value !== startingTurn) { @@ -4305,7 +4452,7 @@ export class CopilotAgentSession extends Disposable { let result: { started: boolean }; try { const execution = await this._executeSdkOperation( - () => this._otelService.withTraceContext(traceContext, () => this._wrapper.session.rpc.fleet.start(rest ? { prompt: rest } : {})), + () => this._otelService.withTraceContext(traceContext, () => this._measureSdkSend(startingTurn, recorder, () => this._wrapper.session.rpc.fleet.start(rest ? { prompt: rest } : {}))), startingTurn, abortToken, startingTurn, ); if (execution.kind === 'skipped') { @@ -4480,12 +4627,12 @@ export class CopilotAgentSession extends Disposable { * last applied value. Failures are logged and swallowed so that mode * propagation does not block the turn. */ - async applyMode(mode: CopilotSdkMode | undefined): Promise { + async applyMode(mode: CopilotSdkMode | undefined, recorder?: IAgentProviderSendStageRecorder): Promise { if (!mode || mode === this._lastAppliedMode) { return; } try { - await this._wrapper.session.rpc.mode.set({ mode }); + await measureAgentProviderOperation(recorder, 'mode', () => this._wrapper.session.rpc.mode.set({ mode })); this._lastAppliedMode = mode; this._logService.info(`[Copilot:${this.sessionId}] rpc.mode.set succeeded: mode=${mode}`); } catch (err) { @@ -4934,13 +5081,13 @@ export class CopilotAgentSession extends Disposable { }), token); } - private _reconcileMcpServerEnablement(): Promise { - return this._mcpEnablementSequencer.queue(() => this._doReconcileMcpServerEnablement()); + private _reconcileMcpServerEnablement(recorder?: IAgentProviderSendStageRecorder): Promise { + return measureAgentProviderOperation(recorder, 'mcp', () => this._doReconcileMcpServerEnablement(recorder), this._mcpEnablementSequencer); } - private async _doReconcileMcpServerEnablement(): Promise { + private async _doReconcileMcpServerEnablement(recorder?: IAgentProviderSendStageRecorder): Promise { this._markMcpLaunchConfigurationDirty(); - const { servers } = await this._wrapper.session.rpc.mcp.listConfigured(); + const { servers } = await measureAgentProviderOperation(recorder, 'mcpList', () => this._wrapper.session.rpc.mcp.listConfigured()); this._markMcpLaunchConfigurationDirty(); const desiredEnablement = this._getDesiredMcpServerEnablementByName(servers.map(server => server.name), false); if (desiredEnablement.size === 0) { @@ -4959,12 +5106,12 @@ export class CopilotAgentSession extends Disposable { // connect live (`pending` -> `connected`/`failed`), so no // optimistic state is written here. changed = true; - await this._enableMcpServer(serverName); + await measureAgentProviderOperation(recorder, 'mcpEnable', () => this._enableMcpServer(serverName)); } else { if (enabled === false) { continue; } - await this._disableMcpServer(serverName); + await measureAgentProviderOperation(recorder, 'mcpDisable', () => this._disableMcpServer(serverName)); changed = true; } } catch (e) { @@ -5710,11 +5857,11 @@ export class CopilotAgentSession extends Disposable { })); } - private _syncShellInitScript(): Promise { + private _syncShellInitScript(recorder?: IAgentProviderSendStageRecorder): Promise { if (this._shellInitScriptDisposing) { return Promise.resolve(); } - return this._shellInitScriptSequencer.queue(() => this._applyEffectiveShellInitScripts()); + return measureAgentProviderOperation(recorder, 'shell', () => this._applyEffectiveShellInitScripts(recorder), this._shellInitScriptSequencer); } private _disposeShellInitScript(): Promise { @@ -5763,14 +5910,14 @@ export class CopilotAgentSession extends Disposable { this._autoApprovals.set(toolCallId, autoApproval ?? null); } - syncPermissionMode(source: 'config-change' | 'turn-start'): Promise { - return this._permissionModeSequencer.queue(async () => { + syncPermissionMode(source: 'config-change' | 'turn-start', recorder?: IAgentProviderSendStageRecorder): Promise { + return measureAgentProviderOperation(recorder, 'permission', async () => { const mode = this._getSdkPermissionMode(); const configuredLevel = this._getConfiguredApprovalLevel(); this._logService.info(`[Copilot:${this.sessionId}] Syncing permission mode: source=${source}, agentMode=${this._getConfiguredAgentMode()}, configuredLevel=${configuredLevel}, sdkMode=${mode}, previousSdkMode=${this._lastAppliedPermissionMode ?? 'unknown'}, globalAutoApprove=${this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true}`); const experimentalModeEnabled = mode === 'assisted' || this._isHydraFusionEnabled(); if (this._experimentalModeEnabled !== experimentalModeEnabled) { - const experimentalResult = await this._wrapper.session.rpc.options.update({ isExperimentalMode: experimentalModeEnabled }); + const experimentalResult = await measureAgentProviderOperation(recorder, 'permissionOptions', () => this._wrapper.session.rpc.options.update({ isExperimentalMode: experimentalModeEnabled })); if (!experimentalResult.success) { throw new Error('Copilot SDK rejected experimental mode required by the current session configuration'); } @@ -5781,20 +5928,20 @@ export class CopilotAgentSession extends Disposable { return; } const managedSettingsResolvedBeforeSet = this._managedSettingsResolved.isSettled; - let applied = await this._trySetSdkPermissionMode(mode); + let applied = await measureAgentProviderOperation(recorder, 'permissionRpc', () => this._trySetSdkPermissionMode(mode)); if (!applied && !managedSettingsResolvedBeforeSet) { // Mitigation: the runtime can reject a mode (e.g. `allow-all`) while it // is still resolving managed settings in the background. Give it a short // window to finish, then retry once before failing the turn. this._logService.warn(`[Copilot:${this.sessionId}] SDK rejected permission mode '${mode}' before managed settings resolved; retrying once`); - await raceTimeout(this._managedSettingsResolved.p, managedSettingsPermissionRetryTimeoutMs); - applied = await this._trySetSdkPermissionMode(mode); + await measureAgentProviderOperation(recorder, 'managedSettings', () => raceTimeout(this._managedSettingsResolved.p, managedSettingsPermissionRetryTimeoutMs)); + applied = await measureAgentProviderOperation(recorder, 'permissionRpc', () => this._trySetSdkPermissionMode(mode)); } if (!applied) { throw new Error(`Copilot SDK rejected permission mode '${mode}'`); } this._lastAppliedPermissionMode = mode; - }); + }, this._permissionModeSequencer); } private async _trySetSdkPermissionMode(mode: PermissionMode): Promise { @@ -5805,11 +5952,11 @@ export class CopilotAgentSession extends Disposable { /** * Apply the SDK sandbox policy before a request or after configuration changes, including while idle. */ - private async _applyEffectiveSandboxConfig(failOnError = true, requestDisable = false): Promise { - return this._sandboxConfigSequencer.queue(() => this._updateEffectiveSandboxConfig(failOnError, requestDisable)); + private async _applyEffectiveSandboxConfig(failOnError = true, requestDisable = false, recorder?: IAgentProviderSendStageRecorder): Promise { + return measureAgentProviderOperation(recorder, 'sandbox', () => this._updateEffectiveSandboxConfig(failOnError, requestDisable, recorder), this._sandboxConfigSequencer); } - private async _updateEffectiveSandboxConfig(failOnError: boolean, requestDisable: boolean): Promise { + private async _updateEffectiveSandboxConfig(failOnError: boolean, requestDisable: boolean, recorder?: IAgentProviderSendStageRecorder): Promise { const base = this._computeSdkSandboxConfig(); if (!base && this._sandboxDisabledForSession) { // disableForSession already applied the opt-out. A bare disabled @@ -5820,10 +5967,10 @@ export class CopilotAgentSession extends Disposable { ? { enabled: false } : base ?? { enabled: false }; try { - if (await applySandboxConfig(this._wrapper.session, sandboxConfig, this.sessionId, this._logService)) { + if (await measureAgentProviderOperation(recorder, 'sandboxRpc', () => applySandboxConfig(this._wrapper.session, sandboxConfig, this.sessionId, this._logService))) { this._sandboxDisabledForSession = false; this._configurationService.setSessionSandboxEnabled(this._ownerSessionUri.toString(), sandboxConfig.enabled); - await this._sandboxDiagnostics.update(this._isCustomTerminalToolEnabled() ? { enabled: false } : sandboxConfig); + await measureAgentProviderOperation(recorder, 'sandboxDiagnostics', () => this._sandboxDiagnostics.update(this._isCustomTerminalToolEnabled() ? { enabled: false } : sandboxConfig)); } } catch (err) { if (failOnError) { @@ -5837,7 +5984,7 @@ export class CopilotAgentSession extends Disposable { * Applies the transient shell init script published by the active client. * Best-effort: failures are logged and retried on the next turn. */ - private async _applyEffectiveShellInitScripts(): Promise { + private async _applyEffectiveShellInitScripts(recorder?: IAgentProviderSendStageRecorder): Promise { if (this._shellInitScriptDisposing) { return; } @@ -5864,7 +6011,7 @@ export class CopilotAgentSession extends Disposable { // The file and its sandbox grant stay until dispose, so a command // that already captured the path can still source it. if (this._registeredShellInitScriptPath) { - const result = await this._wrapper.session.rpc.options.update({ shell: { initScripts: [] } }); + const result = await measureAgentProviderOperation(recorder, 'shellRpc', () => this._wrapper.session.rpc.options.update({ shell: { initScripts: [] } })); if (!result.success) { throw new Error('Copilot SDK rejected shell init script update'); } @@ -5875,7 +6022,7 @@ export class CopilotAgentSession extends Disposable { } this._shellInitScriptMaterialized = true; - const ref = await this._materializeShellInitScript(scripts[0]); + const ref = await measureAgentProviderOperation(recorder, 'shellFile', () => this._materializeShellInitScript(scripts[0])); if (!ref) { // Leave the cache unchanged so the next turn retries. return; @@ -5884,8 +6031,8 @@ export class CopilotAgentSession extends Disposable { // file before each command, so only a new path needs the RPC. if (ref.path !== this._registeredShellInitScriptPath) { // The SDK requires init scripts to be readable when registered. - await this._applyEffectiveSandboxConfig(true); - const result = await this._wrapper.session.rpc.options.update({ shell: { initScripts: [ref] } }); + await this._applyEffectiveSandboxConfig(true, false, recorder); + const result = await measureAgentProviderOperation(recorder, 'shellRpc', () => this._wrapper.session.rpc.options.update({ shell: { initScripts: [ref] } })); if (!result.success) { throw new Error('Copilot SDK rejected shell init script update'); } @@ -6570,8 +6717,13 @@ export class CopilotAgentSession extends Disposable { // normal send. Zero-message continuation has no such echo and remains // quarantined until assistant.turn_start instead. this._dropLateRootTurnEvents = false; + // Show a failure held from between turns only now, so that a newer + // failure from this message's own preparation replaces it. + this._showUnshownManagedPluginFailure(); // First SDK event for the loop: promote the turn out of `pending`. this._currentTurn.value?.markRunning(); + // The message is admitted only after required plugins are prepared. + this._setManagedPluginActivity(undefined); const steering = this._takeMatchingPendingSteering(e.data.content); if (steering) { this._beginSteeringTurn(steering); @@ -6888,6 +7040,9 @@ export class CopilotAgentSession extends Disposable { mcpServerName, contributor, intention, + autoTier: parentToolCallId && !this._fusionPhaseLabels.has(parentToolCallId) + ? this._committedAutoTierBySubagentToolCallId.get(parentToolCallId) + : this._committedAutoTier, meta: undefined, }); const existingApproval = this._toolApprovalRecords.get(e.data.toolCallId); @@ -6957,7 +7112,7 @@ export class CopilotAgentSession extends Disposable { // server-side disconnect timeout fires. We emit the completion // ourselves and drop the active-tool entry so the SDK's own // tool.execution_complete for this id is suppressed. - if (isClientTool && !contributor) { + if (isClientTool && !contributor && !isToolSearch) { this._logService.warn(`[Copilot:${sessionId}] Client tool '${e.data.toolName}' started with no connected client; failing it immediately.`); this._reportToolApprovalIfNoPermission(e.data.toolCallId); this._toolApprovalRecords.delete(e.data.toolCallId); @@ -7188,17 +7343,18 @@ export class CopilotAgentSession extends Disposable { outputDatabase.dispose(); } } - for (const filePath of filePaths) { - if (!isCurrent()) { - return; - } - try { - const fileEdit = await this._editTracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, modelId, turn?.clientContext, chatUri); - if (fileEdit) { - content.push(fileEdit); - } - } catch (err) { + if (!isCurrent()) { + return; + } + // Claim every file before awaiting so a later tool cannot take this tool's snapshots. + const fileEdits = filePaths.map(filePath => this._editTracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath, tracked.toolName, tracked.parameters, modelId, turn?.clientContext, chatUri, tracked.autoTier) + .catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to take completed edit`, err); + return undefined; + })); + for (const fileEdit of await Promise.all(fileEdits)) { + if (fileEdit) { + content.push(fileEdit); } } if (isCurrent()) { @@ -7299,6 +7455,9 @@ export class CopilotAgentSession extends Disposable { // - any other pending turn is a queued message started after the // abort; leave it open for its own non-abort idle. if (e.data.aborted && (!abortingTurn || turn === abortingTurn)) { + if (!e.agentId) { + this._setManagedPluginActivity(undefined); + } this._cancelActiveRepoInfoTelemetry(); if (turn.isRunning || turn === this._resumingTurnAwaitingProviderStart) { this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down cancelled turn ${turn.id}`); @@ -8554,6 +8713,84 @@ export class CopilotAgentSession extends Disposable { this._publishActivity('fusion', undefined); } + /** + * Publishes plugin preparation as chat activity, like worktree creation + * progress. The progress row of a waiting request reads chat activity; + * session activity does not reach it. The activity belongs to the waiting + * turn: a new turn, admission, a failure, or the end or abort of that turn + * clears it, but a late abort or idle from an earlier turn does not. + */ + private _setManagedPluginActivity(activity: string | undefined): void { + if (activity !== this._managedPluginActivity) { + this._managedPluginActivity = activity; + this._emitAction({ type: ActionType.ChatActivityChanged, activity }); + } + } + + /** + * Returns the current turn while its message has not been admitted yet. + * Plugins required by the organization are prepared before a message is + * admitted, but also before custom agent requests that can arrive outside + * a turn; only preparation a turn waits on belongs in its transcript. + */ + private _getManagedPluginAdmissionTurn(): CopilotTurn | undefined { + const turn = this._currentTurn.value; + return turn?.isPending ? turn : undefined; + } + + /** Shows the plugin preparation message as the waiting turn's activity. */ + private _reportManagedPluginProgress(message: string): void { + if (this._getManagedPluginAdmissionTurn()) { + this._setManagedPluginActivity(message); + } + } + + /** + * Ends the plugin preparation activity and adds the failure to the + * waiting turn. The turn continues without the plugins that could not be + * prepared. A failure reported while no turn is waiting, for example + * after a background policy refresh, is shown when the next message is + * admitted, unless that message's own preparation reports a newer one. + */ + private _reportManagedPluginFailure(message: string): void { + this._setManagedPluginActivity(undefined); + const turn = this._getManagedPluginAdmissionTurn(); + if (!turn) { + this._unshownManagedPluginFailure = message; + return; + } + this._unshownManagedPluginFailure = undefined; + this._emitManagedPluginFailure(turn, message); + } + + /** Shows a plugin preparation failure that was reported while no turn was waiting. */ + private _showUnshownManagedPluginFailure(): void { + const message = this._unshownManagedPluginFailure; + const turn = this._getManagedPluginAdmissionTurn(); + if (message === undefined || !turn) { + return; + } + this._unshownManagedPluginFailure = undefined; + this._emitManagedPluginFailure(turn, message); + } + + private _emitManagedPluginFailure(turn: CopilotTurn, message: string): void { + // The failure belongs to the turn waiting for admission, not to a late + // event of an aborted turn, so it must not be dropped after an abort. + this._emitAction({ + type: ActionType.ChatResponsePart, + turnId: turn.id, + part: { + kind: ResponsePartKind.SystemNotification, + content: message, + _meta: toAgentSystemNotificationMeta({ + kind: AgentSystemNotificationKind.ManagedPluginPreparationFailure, + severity: AgentSystemNotificationSeverity.Warning, + }), + }, + }, undefined, true); + } + private _emitFusionProgress(update: ICopilotFusionProgressUpdate, trustedRootTurn = false): void { const turn = this._currentTurn.value; if (!turn) { @@ -8842,6 +9079,26 @@ export class CopilotAgentSession extends Disposable { }); } + /** `session.start`/`session.resume` fire before this session subscribes. A result superseded by a later model change resolves as unknown. */ + private _readCommittedAutoTier(wrapper: CopilotSessionWrapper): void { + const result = new DeferredPromise(); + const read = result.p; + this._committedAutoTier = read; + void (async () => { + let tier: AutoModeRoutingTier | undefined; + try { + const current = await raceTimeout(wrapper.session.rpc.model.getCurrent(), AUTO_TIER_READ_TIMEOUT); + tier = current && this._committedAutoTier === read && this._wrapper === wrapper ? getCommittedAutoTier(current.modelId, current.autoTier) : undefined; + } catch (error) { + this._logService.trace(`[Copilot:${this.sessionId}] Failed to read the committed Auto tier: ${getErrorMessage(error)}`); + } + if (this._committedAutoTier === read) { + this._committedAutoTier = tier; + } + await result.complete(tier); + })(); + } + private _subscribeToSdkEvents(): void { const wrapper = this._wrapper; const sessionId = this.sessionId; @@ -8978,28 +9235,52 @@ export class CopilotAgentSession extends Disposable { } const message = `[Copilot:${sessionId}] [${e.data.infoType}]: ${e.data.message}`; const otelData = new OtelData(attributes); - if (e.data.infoType === 'mcp') { + if (e.data.infoType === 'mcp' || e.data.infoType === MANAGED_PLUGINS_EVENT_TYPE) { this._logService.info(message, otelData); } else { this._logService.trace(message, otelData); } + if (e.data.infoType === MANAGED_PLUGINS_EVENT_TYPE && !e.agentId) { + this._reportManagedPluginProgress(e.data.message); + } + if (e.data.infoType === MANAGED_PLUGINS_COMPLETE_EVENT_TYPE && !e.agentId) { + this._unshownManagedPluginFailure = undefined; + } })); this._register(wrapper.onSessionWarning(e => { this._logService.warn(`[Copilot:${sessionId}] ${e.data.message}`, new OtelData({ warningType: e.data.warningType })); + if (e.data.warningType === MANAGED_PLUGINS_EVENT_TYPE && !e.agentId) { + this._reportManagedPluginFailure(e.data.message); + } })); this._register(wrapper.onSessionModelChange(e => { this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); + const committedAutoTier = getCommittedAutoTier(e.data.newModel, e.data.autoTier); + // The runtime omits `autoTier` on Auto-to-Auto changes that keep its committed preference. + const keepsAutoTier = e.data.autoTier === undefined && isAutoModel(e.data.previousModel) && isAutoModel(e.data.newModel); if (e.agentId) { const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); if (parentToolCallId) { if (e.data.previousModel !== e.data.newModel) { this._autoModeResolvedByToolCallId.delete(parentToolCallId); } + if (committedAutoTier) { + this._committedAutoTierBySubagentToolCallId.set(parentToolCallId, committedAutoTier); + } else if (!keepsAutoTier) { + this._committedAutoTierBySubagentToolCallId.delete(parentToolCallId); + } this._updateSubagentModel(parentToolCallId, e.data.newModel); } } else { + if (!keepsAutoTier) { + if (e.data.autoTier === undefined && isAutoModel(e.data.newModel)) { + this._readCommittedAutoTier(wrapper); + } else { + this._committedAutoTier = committedAutoTier; + } + } this._promptCacheRefreshGeneration++; if (e.data.previousModel !== e.data.newModel) { this._setPromptCacheState(undefined); @@ -9081,6 +9362,8 @@ export class CopilotAgentSession extends Disposable { if (this._resumingTurnAwaitingProviderStart === turn) { this._resumingTurnAwaitingProviderStart = undefined; } + // Continuations have no user-message echo to end plugin preparation. + this._setManagedPluginActivity(undefined); } this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); this._resumeSubagentForEvent(e); @@ -9141,6 +9424,9 @@ export class CopilotAgentSession extends Disposable { this._cancelFusionEvents(); const update = this._fusionProgress.interrupt(e.timestamp); this._clearActivity(); + if (!this._abortingTurn || this._abortingTurn === this._currentTurn.value) { + this._setManagedPluginActivity(undefined); + } if (update) { this._emitFusionProgress(update, true); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index c3e0ddf7134d7..e5ce917837bc0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -989,6 +989,8 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { CONNECTORS: copilotConnectorsEnabled, TGREP: tgrepEnabled, CONTENT_EXCLUSION: true, + // Attached long-lived services prevent the runtime from reaching session idle. + DETACH_LONG_LIVED_SERVICES: true, // When on, the runtime uses the in-repo memory store instead of cloud memory. // Always explicit so only the VS Code opt-in can switch the store. [COPILOT_LOCAL_MEMORY_FEATURE_FLAG]: localMemoryEnabled, diff --git a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md index 52700b4fc9ce3..680841fc8b36e 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md +++ b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md @@ -53,6 +53,7 @@ When `chat.agentHost.copilot.toolSearch.enabled` is on AND the session's model s - **The override** (`copilotAgentSession._createClientSdkTools`): the client's forwarded `toolSearch` tool is registered as `tool_search_tool` with `overridesBuiltInTool: true` and `defer: 'never'`, so the runtime routes the model's search to the client's semantic search. The SDK supplies the runtime's live deferred-tool metadata to the override handler; Agent Host carries that corpus as transient tool-call metadata and injects it only into the local `toolSearch` invocation, so embeddings rank the runtime/MCP tools rather than the extension's registry. The corpus is never added to model-facing tool input. Every other client tool gets `defer: 'never'` if it is in `NON_DEFERRED_CLIENT_TOOL_NAMES` (`runTests`, `semanticSearch`), else `defer: 'auto'`. Built-in runtime tools are never deferred. The renderer (`agentHostSessionHandler._setupClientToolCall`) maps `tool_search_tool` back to `toolSearch` to execute the real VS Code tool. - **The prompt** (this folder): `toolSearchInstructionLines(toolSearchActive)` adds a `tool_instructions` line (`toolSearchToolInstructions`) telling the model to load deferred tools via `tool_search_tool` first — gated on `context.toolSearchActive` because the `toolSearch` tool is *always* forwarded, so presence alone can't gate it. The runtime already emits its own deferred-tools reminder (`build_deferred_tools_user_message`) with the accurate deferred set, so this layer intentionally does NOT re-list the deferred tools. +- **The host fallback** (`copilotAgentSession._searchToolsOnHost`): the tool-search decision is fixed when the SDK session launches, so a session can outlive the client that runs its search — e.g. an autonomous Agent Merge turn after the window closes. Deferred server and MCP tools need no client to *run*, so when no client can take a search call the override answers it on the host (`searchToolsWithoutClient`) instead of failing the call: it loads the host-runnable deferred tools the query names exactly, leaves out client tools, and tells the model why. When nothing matches, the result lists the loadable names so the model can search again by name. An error result would carry no tool references, so every deferred tool would stay unreachable until the next turn relaunches the session with tool search off. A search already routed to a client that then leaves keeps the failure Agent Host publishes for it, so chat state and the model agree; the next search has no client and takes the fallback. The two identity/count levers are independent: `deferThreshold` is a total tool-count gate (1 ⇒ always active), while each tool's `defer` flag decides whether *that* tool is deferred. diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts index bf85a4e35dfbb..6087af2085f3c 100644 --- a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -5,6 +5,7 @@ import { isGpt56Model } from './modelIdentifiers.js'; import { COPILOT_HYDRA_FUSION_MODEL_ID } from '../../common/copilotCliConfig.js'; +import type { IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -18,6 +19,35 @@ export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet = new Set [candidate.name.toLowerCase(), candidate.name])); + const toolNames = new Set(); + for (const word of query.split(/[^\w.\-/]+/)) { + const name = namesByLowerCase.get(word.replace(/^[.\-/]+|[.\-/]+$/g, '').toLowerCase()); + if (name) { + toolNames.add(name); + } + } + if (toolNames.size > 0) { + return { toolNames: [...toolNames], textResultForLlm: `${HOST_TOOL_SEARCH_PREAMBLE} Loaded: ${[...toolNames].join(', ')}.` }; + } + if (candidates.length === 0) { + return { toolNames: [], textResultForLlm: `${HOST_TOOL_SEARCH_PREAMBLE} No other deferred tools can be loaded.` }; + } + return { + toolNames: [], + textResultForLlm: `${HOST_TOOL_SEARCH_PREAMBLE} No tool name matched. Search again with the exact name of one of these tools: ${candidates.map(candidate => candidate.name).join(', ')}.`, + }; +} + /** Mirrors the Copilot extension's string-form `modelSupportsToolSearch`. */ export function agentHostModelSupportsToolSearch(modelId: string | undefined): boolean { if (!modelId) { diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md index 66a7b9d9a3b2b..ca09f0daeded3 100644 --- a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md @@ -13,8 +13,8 @@ Registration is opt-in and default-off. The activation path is available to buil ## Enable the real service 1. Sign in to GitHub in the owning VS Code window. -2. Set `chat.agentHost.remoteConnections` to `missionControl` (the default is `devTunnel`). -3. Enable **Allow Remote Connections** in the Agents Window title bar or the local Agent Host chat toolbar. Selecting Mission Control in settings does not register the environment. +2. Set `chat.agentHost.remoteConnections` to `githubEnvironment` (the default is `devTunnel`). +3. Enable **Allow Remote Connections** in the Agents Window title bar or the local Agent Host chat toolbar. Selecting GitHub environment in settings does not register the environment. 4. Wait for `Mission Control ready; environmentId=...` in the Agent Host log. 5. Create/use native sessions normally. From another compatible MC client, discover the environment, connect, authenticate with a sealed credential, and use standard AHP session operations. @@ -42,12 +42,22 @@ Selection revalidates availability. An offline user-local host is not woken or r New native allocations negotiate standard `ahp-session` resources with a separate provider. Existing legacy and standard session/chat resources, SDK backings, and storage remain immutable. The client consumes exact advertised resources; MC does not impose a global Copilot alias. See the [identity contract](../../../../sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md#identity). +### Model advertisements + +Mission Control connections advertise model lists only for the native Copilot agent (`copilotcli`). Other agents remain advertised with their identities, capabilities and protected resources, but with empty model lists. This is an interoperability workaround for mobile clients that combine every agent's models into the picker for an existing Copilot session without switching that session's agent. Initial and subscribed root snapshots, reconnect snapshots/replay and live agent updates use the same projection; the host's authoritative catalog and local, SSH and tunnel connections remain unchanged. + +Existing sessions for other agents remain accessible and retain their model selection. This limits model discovery, not which agents can execute work; it does not correct a credential-specific empty Copilot catalog. + ## Security and data handling The [security requirements matrix](./MISSION_CONTROL_SECURITY_REQUIREMENTS.md) links each requirement to implementation and tests. The supported boundary includes canonical-owner sealed authentication, signed control requests, lane/client-ID binding, passive mutation refusal, locally known workspace/resource grants, minimal pre-authentication state, and host-wide root-configuration exclusion. Remote windows cannot overwrite the owning machine's root settings or managed permissions. +Live relay identity expires at the deadline returned by GitHub in `GitHub-Authentication-Token-Expiration`, not a client-supplied timeout. Supported deadlines are calendar-valid UTC timestamps in GitHub's `YYYY-MM-DD HH:MM:SS UTC` format or ISO `YYYY-MM-DDTHH:MM:SS[.sss]Z` format with up to three fractional digits. Invalid or elapsed deadlines fail authentication; an absent header does not create a deadline. Expiry stops observation and new protected work, removes subscriptions, releases active-client/tool ownership not retained by another live connection's subscriptions, and sends one lane-targeted `auth/required` notification with `reason: expired`. The client must renew its credential and subscribe again. Awaited requests cannot return protected results under an expired or replaced credential. Work already started in a provider is not rolled back, and shared provider credential sponsorship/retention remains a separate limitation. + Remote owner access includes native sessions, tools, and granted workspace resources. Credentials are sealed before WPS publication using either NaCl sealed boxes or HPKE (X25519 / HKDF-SHA256 / AES-256-GCM). The host advertises independent keys for each algorithm and for each of the `auth-token` and `mcp-auth-token` purposes, allowing clients such as iOS to use HPKE while sealed-box clients remain compatible. Recipient keys come from authenticated MC HTTPS rather than being trusted solely because they appeared in the relay. Private sealing keys stay host-local. This does not provide per-client/session credential sponsorship, OS confinement, or conversation end-to-end encryption. +Clients must authenticate the relay identity after each initialization before requesting session access, independently of agent-provider authentication. Unauthenticated requests return `AuthRequired` with standard `data.resources` identifying the configured GitHub identity authority, including hosts without a Copilot provider. Owner-validated identity authentication succeeds even if no agent provider accepts that resource; credentials are still forwarded to providers, and other provider/MCP resources require provider acceptance. + Both algorithms use the same `copilot-sealed.v1` envelope and purpose/resource checks. MC registration/heartbeat keys and AHP root metadata identify the same recipients. Unknown or wrong-purpose key IDs return `CONFLICT` so clients can refresh trusted keys, repeat the handshake, and seal again; malformed, undecryptable, or context-mismatched values return `INVALID_PARAMS`. Private key bytes and owned plaintext buffers are scrubbed on disposal or after use. HPKE opening uses non-extractable WebCrypto private keys; their native storage is managed by the runtime and has no explicit zeroization API. Native authoritative AHP actions and selected genuine SDK metadata are mirrored to MC for history and task title/activity. Approval/input metadata may contain commands, diffs, plans and questions. The SDK adapter excludes assistant/system messages, auth/configuration, subagents, per-token deltas and routine backing shutdown events; AHP mirroring still carries authoritative conversation content. @@ -66,6 +76,8 @@ The unregistered `chat.agentHost.experimentalMissionControl.useLocalCredentials` Remote credentials are still required, decrypted, checked for purpose/resource and any supplied connection binding, and validated directly with GitHub against the registered owner. For the exact Copilot resource, the host also validates the current local credential and forwards it internally without the remote token's scopes or expiry. The local credential is never sent to the mobile client or published on the relay. Repository-specific and MCP credentials are not substituted. All providers sharing the Copilot protected resource can receive the local credential, and SDK agent work can use its broader GitHub permissions; this is not an inference-only grant. +Delegation does not extend the remote lane's identity deadline. The forwarded local credential uses its own GitHub-reported expiration, when present. + Changing this setting, rotating the local registration credential, signing out, or withdrawing registration closes the affected relay lanes. Reconnect the mobile client after changes. A missing, rejected, foreign-owner, or concurrently changed local credential fails authentication instead of borrowing a previous credential. Ongoing provider credential retention and per-session sponsorship remain subject to S03's shared-store limitations. This explicit host-owner delegation is a VS Code testing exception to the user-local sponsorship and control-plane separation contract (S03/S05), not the normal client-token mode or a managed-sandbox principal. Existing enterprise remote-control checks, workspace grants, and tool permissions still apply. Remove the setting or set it to `false` to restore client credentials. diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_GAPS_EXPLAINED.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_GAPS_EXPLAINED.md new file mode 100644 index 0000000000000..5d5e1066aad51 --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_GAPS_EXPLAINED.md @@ -0,0 +1,344 @@ +# Mission Control gaps, explained + +This is a plain-language companion to the [security requirements](./MISSION_CONTROL_SECURITY_REQUIREMENTS.md). It covers only the requirements that are not fully implemented. Each section explains: + +- what the requirement means and why it exists; +- where VS Code stands; +- what the upstream Copilot Host (`copilotd`) does; +- what the GitHub Copilot desktop app does; +- roughly what VS Code would have to build. + +## The short version + +| Gap | One-line summary | `copilotd` has it? | Copilot app has it? | Suggested priority | +| --- | --- | --- | --- | --- | +| [S30 Per-session sharing opt-out](#s30-per-session-sharing-opt-out) | Turning Mission Control on uploads *every* session; you can't keep one private. | Yes | Yes | High | +| [S04 Credential expiry](#s04-credential-expiry) | GitHub's reported expiry is now enforced ([#340411](https://github.com/microsoft/vscode/pull/340411)); revocation before that deadline, and tokens with no deadline, aren't detected. | Same limits | Same limits (via `copilotd`) | Medium | +| [S17/S28 Local policy and limits](#s17s28-local-policy-and-limits) | VS Code lacks configurable host-local policies and quotas for remote work. | Yes (opt-in) | Available but not turned on | Medium | +| [S15 GitHub Enterprise endpoints](#s15-github-enterprise-endpoints) | Enterprise accounts aren't supported, and one path sends a github.com token to the enterprise server. | Yes | Yes | Medium (fix the token bug now) | +| [S03 Per-device credentials](#s03-per-device-credentials) | All connected devices share one credential pool instead of each using its own. | Yes | Yes (via `copilotd`) | Low–medium | +| [S08 Connection binding](#s08-connection-binding) | A captured sign-in message could be replayed. | Off by default | Off for this machine; on for SSH hosts | Low | +| [S09 Credential hygiene](#s09-credential-hygiene) | Tokens can linger in memory; there's no universal secret scrubber. | Better (Rust) | Mixed | Low | +| [S21 OS sandboxing](#s21-os-sandboxing) | Remote clients are limited by our checks, not by the operating system. | No | No | Defer | +| [S31 Mirror survives restart](#s31-mirror-survives-restart) | Restarting VS Code can leave gaps in the copy Mission Control stores. | Further along | Same as `copilotd` | Defer | +| [S33 Reconnect continuity](#s33-reconnect-continuity) | After a dropped connection, some state may not line up perfectly. | Also partial | Same as `copilotd` | Defer | +| [S29 Privacy approval](#s29-privacy-approval) | Uploading conversations needs a formal privacy/product sign-off. | n/a | Feature is still experimental | Required before release | +| [S34 Release qualification](#s34-release-qualification) | Only one packaged build on one platform has been tested end to end. | n/a | n/a | Required before release | + +The two "has it?" columns tell you whether a gap is an accepted limitation of the overall design, or something VS Code simply hasn't caught up on yet. `copilotd` results were checked at the revision the requirements doc pins. Copilot app results were checked at `github/github-app` `main` on 2026-10-08, together with the `copilotd` 0.9.4 binary that the app pins. Nothing was built or run. + +## How the Copilot app compares + +The Copilot desktop app (`github/github-app`, built with Tauri, React and Rust) is the closest product to what VS Code is building. Its architecture differs in two ways, and that changes how to read the comparison. + +1. **Your normal app sessions are separate from "expose this machine".** Sessions you start in the app run in-process through the Copilot CLI runtime. Their sharing is controlled by the runtime's own `remoteSession` mode: Disabled, View only (export to GitHub), or Remote control. Exposing the machine to Mission Control is a separate, experimental switch (Settings → Experimental → Environments). When it's on, the app downloads a pinned `copilotd` 0.9.4 binary, checks its checksum, runs it as a child process, and connects to it as one more host. Turning that switch on does **not** publish your existing app sessions; only sessions created on that daemon live there. +2. **The hosting side is `copilotd` itself.** The app doesn't reimplement the host the way VS Code does, so most security behaviour comes from `copilotd` automatically. What the app controls is how it launches the daemon: `--relay`, `--require-sealed-auth`, the Mission Control token in a private config file, and `COPILOT_GH_HOST` set to the signed-in account's host. It does **not** pass `--require-connection-binding` or any `--policy-*` flags. + +VS Code has an equivalent of point 1. The `chat.sessionSync.enabled` setting, which an organization policy can also control and which has an exclude-repositories list, sets `remoteSession: 'export'` for Copilot sessions. VS Code's Mission Control mirror is a separate, second channel on top of that. + +## Background: the moving parts + +You need a handful of terms to follow the rest of this document. + +- **Agent Host.** The background process in VS Code that runs chat agent sessions (Copilot, Claude, and so on). Local VS Code windows talk to it over local IPC. +- **Mission Control (MC).** A GitHub service that keeps a list of your machines ("environments") and lets other devices, such as the Copilot app on your phone or another computer, find and drive them. +- **Registration.** When you set `chat.agentHost.remoteConnections` to `missionControl` and turn on **Allow Remote Connections**, the Agent Host tells MC "I'm here, I belong to this GitHub user", and then sends a heartbeat regularly. +- **Web PubSub (WPS).** An Azure message relay. Remote devices and your Agent Host never connect directly; both connect *out* to WPS, and WPS forwards messages between them. Because MC and WPS sit in the middle, they can see whatever isn't encrypted. +- **AHP (Agent Host Protocol).** The message format both local windows and remote devices use to list sessions, send prompts, read files, open terminals, and so on. +- **Lane.** One remote device's connection through the relay. Each lane has to prove who it is before it can do anything. +- **Sealed token.** When a remote device signs in, it encrypts its GitHub token so that only your Agent Host can decrypt it. The WPS relay just passes along an opaque blob. MC usually does too, with one exception: when MC re-encrypts a token on a client's behalf ("broker resealing", see S07 in the requirements), MC sees the plaintext token. +- **Signed control message.** Some instructions come from MC itself, for example "a new device wants to connect". MC digitally signs these so the Agent Host can tell they're genuine. +- **Session mirror.** A copy of your session activity that the Agent Host uploads to MC so remote devices can see history. + +What a remote device can do: once it proves it is the same GitHub user as the machine's owner, it gets roughly what the owner has. That includes chat sessions, tool calls, files in the granted workspace folders, and **terminals**. That level of access is the reason several of the items below matter. + +## S30: Per-session sharing opt-out + +> **In short:** Turning Mission Control on uploads *every* session, and you can't keep one private. Both `copilotd` and the Copilot app let you opt out per session. **Priority: high.** + +**What it is.** A way to mark an individual session as "don't share this with Mission Control", and have that choice stick, even after VS Code restarts and the session is reloaded from disk (a "cold resume"). + +**Why it's needed.** MC stores what the mirror sends, and that content is not end-to-end encrypted, so GitHub's service can read it. People often have one sensitive conversation (credentials, a private customer repo, personal notes) on an otherwise shareable machine. Without a per-session switch, the only choice is all or nothing. + +**Where VS Code is.** Today the switch is host-wide. Once Mission Control is on, the mirror listens to *every* session event in the Agent Host ([missionControlHost.ts](./missionControlHost.ts)). That covers: + +- sessions you started locally in a VS Code window, not just ones started remotely; +- sessions from every agent provider, not just Copilot; +- older sessions: the AHP conversation mirror starts publishing them as soon as they produce any new activity, but the SDK metadata source also replays retained metadata for sessions already loaded when sharing starts, *without* any new activity ([missionControlSdkEventSource.ts](./missionControlSdkEventSource.ts)). An idle sensitive session that's loaded is therefore not guaranteed to stay unpublished. + +The only exclusion is new, empty drafts: they aren't mirrored until their first message. + +**Upstream.** Implemented. Every session has a `remoteSession` setting of `off`, `export` or `on`. When MC is connected the default is `on`, but an explicit `off` is always honored and is saved with the session so it survives a cold resume. + +**Copilot app.** Yes, in both of its roles. + +- *Normal app sessions.* Settings → Sessions → "New sessions remote access" picks the default for new sessions: Disabled, View only, or Remote control. A project-level "Remote control" switch, the `/remote` command, and a "Set remote control" command all adjust it. Each session's choice is saved in the app's database (`sessions.remote_session_mode`) and reapplied when the session is resumed. +- Two caveats. First, the default is **View only**, so a new user's session transcripts *are* exported to GitHub unless they change the setting (accounts without Copilot access are switched to Disabled). Second, mid-session the toggle only moves between View only and Remote control. Fully "Disabled" is chosen when the session is created, through the default. +- *Sessions on the exposed machine.* These run on `copilotd`, so they get the `copilotd` behaviour described above. +- The bigger difference is architectural. Exposing the machine in the app doesn't upload the app's existing sessions at all, whereas VS Code's mirror covers every session in its Agent Host. + +**What VS Code would need to do.** + +1. Add a per-session "share with Mission Control" value. It is decided when the session is created, saved in the session database, and restored on resume. +2. Give the user a way to set it: a default setting plus a per-session toggle in the chat UI. +3. Check it in the mirror *before* admitting anything for that session. That covers the AHP mirror in [missionControlHost.ts](./missionControlHost.ts), the SDK metadata source in [missionControlSdkEventSource.ts](./missionControlSdkEventSource.ts), and lifecycle events. +4. Add tests showing that an opted-out session publishes nothing, including after a cold resume. + +Note that the Copilot SDK already has its own `remoteSession: 'export'` option for its *own* session sync. That is a separate channel and does not control this mirror. + +**Priority: high.** This is the clearest privacy gap, and upstream already solved it. At minimum, the description of the `missionControl` option of `chat.agentHost.remoteConnections` should say that turning on sharing mirrors *all* sessions on the machine. Today it only says "native session actions are mirrored". + +## S04: Credential expiry + +> **In short:** Implemented in [#340411](https://github.com/microsoft/vscode/pull/340411): a remote device loses access when its token reaches the expiry time GitHub reported. Revocation before that time, and tokens GitHub gives no expiry for, are still not detected. `copilotd` has the same limits. **Priority: medium — the remaining gap is revocation.** + +**What it is.** When GitHub validates a token, it can report when that token expires, using the `GitHub-Authentication-Token-Expiration` response header. The requirement is that once that time passes, the connection stops being treated as signed in. The host then tells that device "please sign in again" with an AHP `auth/required` notification. A stronger version of the requirement also covers tokens that are revoked early. + +**Why it's needed.** A remote lane is checked once, when it connects. Without an expiry, it stays trusted until it disconnects. Since lanes can open terminals, that means a stale credential can keep shell access. Enforcing expiry puts an upper bound on how long one sign-in lasts. + +**Where VS Code is.** Implemented for GitHub's reported expiry ([#340411](https://github.com/microsoft/vscode/pull/340411)): + +- **Reading the deadline.** Identity validation reads GitHub's header ([missionControlAuthentication.ts](./missionControlAuthentication.ts)) and rejects malformed or already-passed deadlines. +- **At the deadline.** The host drops the lane's subscriptions and active-client/tool ownership, rejects requests still waiting for the client, and sends exactly one `auth/required` (reason `expired`) to that lane. The device must sign in again and resubscribe. +- **Late results.** Requests that were already in flight can't return protected results after expiry. +- **Scope.** It applies only to Mission Control lanes. + +What it does **not** do: + +- **Revocation isn't detected.** If you sign out on your phone or revoke the token in GitHub settings, the lane keeps access until the recorded deadline or until it disconnects. The host doesn't re-check the token with GitHub in between. +- **No deadline, no expiry.** If GitHub doesn't send the header for a token, no deadline is invented, so that lane never expires on its own. +- **Started work isn't rolled back,** and there's no warning before expiry. + +**Upstream.** `copilotd` reads the header, fails new protected work closed after the deadline, and sends `auth/required` to the one affected connection. It also treats a missing header as "no deadline". Its design calls for reacting when GitHub reports a revocation, but like VS Code it learns that only when a later GitHub call is rejected. Renewing *before* expiry, and ending existing subscriptions at expiry, are still open upstream. On that last point VS Code now does more than `copilotd`. + +**Copilot app.** Same as `copilotd`. The app's hosting is the `copilotd` 0.9.4 binary, and the expiry watcher (`credential_expiry.rs`) is present in 0.9.4. + +**What VS Code would need to do.** For the remaining gaps: + +1. Periodically re-validate each lane's token with GitHub (or check on sensitive actions), and treat a rejection as a revocation: revoke the lane and send `auth/required`. +2. Decide on a maximum lane lifetime for tokens without a reported expiry. +3. Optionally, warn clients before expiry so they can renew without interruption. + +**Priority: medium.** Ordinary expiry is now enforced. The remaining exposure is a revoked or signed-out device keeping access until its deadline, or forever for tokens without one. Upstream doesn't handle that either, but it's the case that matters most if a token is stolen. + +## S17/S28: Local policy and limits + +> **In short:** VS Code lacks configurable host-local policies and quotas for remote work. It already enforces owner authentication, workspace grants, and fixed transport limits, but you can't add rules such as "only these repos" or "at most N sessions". `copilotd` supports such rules, but the Copilot app doesn't turn them on either. **Priority: medium.** + +**What it is.** Rules that live on *your* machine and that Mission Control cannot override. For example: "only allow these repos", "never more than 3 active sessions", "at most 5 new sessions a minute". A separate, smaller part is reporting your organization's device policy to MC; that part is done. + +**Why it's needed.** The upstream design treats MC as "a peer, not a trusted controller". If MC were compromised or buggy, your machine should still refuse things you never allowed. This matters more because MC can see a remote device's GitHub token at one point in the flow (when it re-encrypts it for your host). So a compromised MC could, in principle, connect as you. + +**Where VS Code is.** The host already limits remote work in fixed ways: + +- remote devices must authenticate as the owner (S01); +- file and terminal access is limited to granted workspace folders (S21); +- lane counts, message sizes and queue sizes are capped to prevent flooding (S28, [missionControlProtocolServer.ts](./missionControlProtocolServer.ts)). + +What's missing is *configurable* policy: there's no user- or admin-set rule about which repos, session types or how many sessions remote clients may use. + +**Upstream.** Implemented, but opt-in. Allowed session types, allowed and denied repos, maximum active sessions, and a spawn-rate limit can all be set from flags or a policy file. The local policy always wins over anything MC sends. + +**Copilot app.** It has the capability but doesn't turn it on. The `copilotd` 0.9.4 binary supports the policy flags, but the app launches it without any `--policy-*` flags and offers no UI for them. In practice, the app is in the same position as VS Code today: fixed technical limits, and no user- or admin-configurable rules. That makes this gap less unusual than it first looks, but it's still a gap in both products. + +**What VS Code would need to do.** + +1. Define a small set of settings, ideally ones that can be enforced by enterprise policy: maximum active remote sessions, a new-session rate limit, and allowed/denied repositories or folders. Possibly also "allow remote terminals: yes/no". +2. Enforce them in the Agent Host when a request comes over the relay, for example in `createSession`, `createTerminal`, and changes to a session's working directory ([protocolServerHandler.ts](../protocolServerHandler.ts)). +3. Never let MC-provided values loosen them. + +**Priority: medium.** It is reasonable to defer while the feature is off by default and experimental. It should exist before a broad release, and a "no remote terminals" switch would be the most valuable single control. + +## S15: GitHub Enterprise endpoints + +> **In short:** Enterprise accounts don't work, and one path sends a github.com token to the enterprise server. The Copilot app keeps token, identity and Mission Control on one GitHub. **Priority: medium; fix the token bug now.** + +**What it is.** Companies using GitHub Enterprise sign in to their own GitHub server, not github.com. The requirement is that Mission Control uses the GitHub server the user actually signed in to by default. If someone deliberately points it elsewhere, the user should be told where their conversations will be stored. + +**Why it's needed.** Two reasons: + +- *Correctness:* identity has to be checked against the same GitHub that issued the token. +- *Data residency:* enterprises often require their data to stay in their own tenant. + +**Where VS Code is.** The Mission Control address is hard-coded to `https://api.github.com`, and the token always comes from the github.com sign-in. In practice, Enterprise is not supported; it fails instead of working. + +**Bug to fix now:** if your main account is a GitHub Enterprise account, VS Code tells the Agent Host to check identities against your enterprise server. But it still hands over the *github.com* token. That github.com token is then sent to the enterprise server's `/user` endpoint ([missionControlEnvironment.ts](./missionControlEnvironment.ts)). The check fails, so nothing gets registered, but a token has been sent somewhere it doesn't belong. + +**Upstream.** Implemented. The MC address defaults to the resolved GitHub host. You can override it, and if the override points somewhere different, `copilotd` logs a warning. + +**Copilot app.** Yes. When it launches the daemon, the app sets `COPILOT_GH_HOST`/`GH_HOST` to the host of the account that's signed in. It also passes that same account's token, and strips any inherited environment variables that could redirect the Mission Control address or the GitHub API. Token, identity check, and Mission Control address therefore always point at the same GitHub. This is the model VS Code should copy. + +**What VS Code would need to do.** + +1. Immediately: refuse to register (or skip the identity call) whenever the token's origin and the identity server don't match. +2. To support Enterprise: choose the right sign-in provider (`github` or `github-enterprise`) based on the user's account, and derive the MC address from the same GitHub host. +3. If an override is ever offered again, show a clear warning when it differs from the user's GitHub host. + +**Priority: medium overall. Fix the token-mismatch bug now.** Full Enterprise support can be explicitly marked unsupported for this release. + +## S03: Per-device credentials + +> **In short:** All connected devices share one credential pool instead of each using its own. `copilotd` keeps them separate; risk is low because every device must be the same GitHub user. **Priority: low–medium.** + +**What it is.** Each connected device should use *its own* GitHub credential for the work it starts. The credential is tracked per device and per connection, and when a device signs out, only that device's credentials should be discarded. + +**Why it's needed.** It keeps things accountable and contained. If your phone signs out, sessions it started shouldn't keep running on its token. And a token with narrow permissions from one device shouldn't get quietly upgraded by another device's broader token. + +**Where VS Code is.** The Agent Host keeps one shared store of credentials, keyed by "which GitHub resource and which permissions", not by device ([agentHostAuthenticationService.ts](../agentHostAuthenticationService.ts)). A remote device's `authenticate` call feeds that shared store. + +There is also a deliberate exception for testing. The hidden, default-off setting `chat.agentHost.experimentalMissionControl.useLocalCredentials` makes the host use the *desktop's own* GitHub credential for Copilot work started from a remote device. The remote device still has to prove it is the owner, and the desktop token never leaves the machine. But Copilot work then runs on the desktop's credential rather than the device's. That is the opposite of per-device sponsorship, so it should stay a testing-only override (see [operation and configuration](./MISSION_CONTROL.md#explicit-local-credential-delegation)). + +**Upstream.** Implemented. Credentials are stored per device ID, per connection generation, and per resource. + +**Copilot app.** Yes, through `copilotd` 0.9.4, which has the same per-device credential store. + +**What VS Code would need to do.** Redesign the shared credential store so each credential records which client and connection supplied it. Pin each session to the credential of the device that started it. Define when that pin may move (for example, when the same device reconnects with a new token). Drop a device's credentials on explicit sign-out. This touches the core authentication service, not just Mission Control, which is why it was deferred. + +**Priority: low–medium.** VS Code already requires every remote device to be the *same GitHub user* as the machine's owner (S01). So the worst case is "your own other device's token was used", not "someone else's". It is reasonable to defer, but it is a VS Code gap rather than something upstream lacks. + +## S08: Connection binding + +> **In short:** A captured sign-in message could be replayed on another connection. Optional everywhere for now; VS Code currently can't turn it on. **Priority: low.** + +**What it is.** When a remote device sends its sealed token, it can also include the current connection's one-time "challenge". That ties the token to this one connection, so it can't be reused. + +**Why it's needed.** Without binding, someone who captures the sealed blob could replay it later on a different connection and get signed in. Realistically that "someone" is whoever controls the relay. They still can't *read* the token, but they could reuse it as-is. + +**Where VS Code is.** The host checks a binding whenever the client includes one, but accepts sealed tokens without a binding. That's because the Mission Control clients in use today don't send one. There is no longer a setting to require it. + +**Upstream.** The same default: transition mode, off by default. It has a `--require-connection-binding` flag to turn it on. + +**Copilot app.** For "expose this machine", it's the same as VS Code: the app passes `--require-sealed-auth` but not `--require-connection-binding`, so unbound tokens are accepted. Daemons the app installs on remote machines over SSH *do* get `--require-connection-binding`. So the app's team considers binding workable where it controls both ends, and VS Code could reasonably turn it on in the same situations. + +**What VS Code would need to do.** Once MC's clients support binding, require it. Until then, optionally restore an advanced setting to require it, for people testing with compatible clients. The checking code already exists. + +**Priority: low.** This matches upstream's current posture, so it is an acceptable deferral. + +## S09: Credential hygiene + +> **In short:** Tokens can linger in memory, and nothing scrubs secrets from all output. Mostly a JavaScript limitation. **Priority: low.** + +**What it is.** Tokens should never be written to logs or disk, and should be wiped from memory when no longer needed. + +**Why it's needed.** Logs get attached to bug reports, and disk contents get backed up and synced. Leftover secrets in memory can end up in crash dumps. + +**Where VS Code is.** The important paths are covered. The relay redacts tokens before logging, the encryption keys and decrypted buffers we own are wiped, and the discovery files contain no credentials. What isn't guaranteed: + +- JavaScript strings can't be wiped; they stay in memory until garbage-collected. +- Other parts of VS Code's authentication may keep their own copies. +- A tool or agent that prints a secret into chat output isn't filtered. + +**Upstream.** It uses Rust types that zero memory on drop, plus a runtime "secret filter" that scrubs known secrets from agent output. + +**Copilot app.** Mixed. + +- Better than VS Code: the daemon is Rust, so it uses the zeroizing types, and the app redacts the tokens from the daemon's log output. +- Worse than VS Code: to start the daemon, the app writes the Mission Control token into a private temporary config file. That file stays on disk for as long as the daemon runs. VS Code keeps that credential in memory only. + +**What VS Code would need to do.** Keep secrets in `Buffer`s where practical and zero them on disposal. Audit logging and telemetry on the credential paths. Consider a secret-scrubbing pass on content headed to the mirror. Complete zeroization isn't possible in JavaScript. + +**Priority: low.** Acceptable to defer; most of it is a language limitation. + +## S21: OS sandboxing + +> **In short:** Remote clients are limited by VS Code's own checks, not by the operating system. Nobody upstream has this either. **Priority: defer.** + +**What it is.** Making the *operating system* enforce what a remote client or agent can touch, using things like the macOS sandbox, Linux namespaces, or Windows restricted tokens. VS Code's own checks are the other half. + +**Why it's needed.** Code-level checks are only as good as their coverage. VS Code checks that file requests stay inside granted folders, but: + +- a symlink swapped between the check and the use can slip through; +- a terminal is a full shell running as you, so once a remote client has a terminal, folder checks no longer apply. + +**Where VS Code is.** VS Code checks every file request, watch, and terminal starting folder from the relay against the granted workspace folders ([protocolServerHandler.ts](../protocolServerHandler.ts)). Nothing is OS-enforced. + +Remote clients can also add or clone projects through the [Copilot Host extension methods](./copilotExtensions.md). A ready project extends the grants to its own folder only, not the whole home directory. That adds to the set of folders a remote device can reach, still enforced only by code. + +**Upstream.** Not implemented either. Its docs describe it as a goal. + +**Copilot app.** No. It passes workspace and project folders to `copilotd`, which restricts file access to them in code, the same kind of check VS Code does. Nothing is enforced by the OS. + +**What VS Code would need to do.** This is a large, platform-specific project. It might build on the sandboxing the Copilot CLI already supports for shell tools. A cheaper interim step is the "allow remote terminals" switch suggested under S17/S28. + +**Priority: defer.** This is parity with upstream. + +## S31: Mirror survives restart + +> **In short:** Restarting VS Code can leave gaps in the copy Mission Control stores. A data-completeness issue, not a security hole. **Priority: defer.** + +**What it is.** Each mirrored message carries a sequence number, so MC can detect gaps and ask for missing pieces (a "backfill"). The requirement is that these numbers and the not-yet-acknowledged messages survive a restart of the Agent Host. + +**Why it's needed.** Without it, a restart can leave holes in MC's copy of a session, or make MC unsure whether it has everything. + +**Where VS Code is.** It works while the process is running: acknowledgements, backfill, and bounded queues are all implemented ([missionControlSessionMirror.ts](./missionControlSessionMirror.ts)). Sequence state for the separate SDK metadata channel is saved to disk. The main AHP mirror's counters and queue are in memory only. History from before Mission Control was turned on isn't uploaded as a complete baseline. + +**Upstream.** Further along. It has tests for the mirror across cold resume, though the protocol spec itself is still a draft. + +**Copilot app.** Same as `copilotd` for sessions on the exposed machine. For its normal sessions, the app relies on the Copilot CLI runtime's own export, which is a different channel. + +**What VS Code would need to do.** Save the AHP mirror's per-session sequence counters, and enough unacknowledged data, to disk. Restore them on start. Define what baseline to upload when a session first becomes shared. + +**Priority: defer.** This is data completeness, not a security hole. + +## S33: Reconnect continuity + +> **In short:** After a dropped connection, a retried action might run twice or the restored history might not line up. Also partial upstream. **Priority: defer.** + +**What it is.** Two related problems that can happen when a connection drops in the middle of an action: + +- If the reply to "create session" or "send message" is lost, the client can't tell whether it happened. Retrying might do it twice. +- When a session is rebuilt from saved history, the IDs of individual response parts can differ from the IDs the client already displayed. The client may then reject the rebuilt snapshot as "missing content". + +**Why it's needed.** It's about reliability and not losing or duplicating user actions, rather than security. + +**Where VS Code is.** Session identities are preserved. The two problems above are not solved ([mapSessionEvents.ts](../copilot/mapSessionEvents.ts)). + +**Upstream.** Also partial. + +**Copilot app.** Same as `copilotd`. The app also contains its own recovery code for when a backfill leaves a hole in a remote session's history: it shows a "backfill gap" warning instead of pretending the history is complete. + +**What VS Code would need to do.** + +1. Let clients attach an idempotency key to actions like create and send, so a retry is recognized as a duplicate instead of running twice. +2. Generate response-part IDs from stable data, so rebuilding a session produces the same IDs every time. + +**Priority: defer.** This is parity with upstream. + +## S29: Privacy approval + +> **In short:** Uploading conversations to Mission Control needs a formal privacy/product sign-off. **Required before release.** + +**What it is.** A formal product, privacy, and security sign-off before conversation content is uploaded to and stored by Mission Control for real users. The sign-off should cover what's stored, for how long, and who can see it. + +**Why it's needed.** Encrypting tokens doesn't encrypt conversations. MC and the relay can read mirrored content. The setting text discloses this, but disclosure isn't the same as approval. + +**Where VS Code is.** The setting description says conversation content is not end-to-end encrypted. No release approval has been recorded. + +**Copilot app.** Its code can't show whether it has an approval. Two observations: + +- The app's normal sessions already export transcripts to GitHub by default (View only). +- Exposing a machine to Mission Control is still behind an experimental flag, and remote control behind another one. + +So the app has shipped default-on *export*, but not default-on *machine exposure*. That is a useful reference point for the review. + +**What VS Code would need to do.** Get the review. Make sure the setting text matches the real scope: *all* sessions, including local ones (see S30), and remote terminal access. + +**Priority: required before release.** + +## S34: Release qualification + +> **In short:** Only one packaged build on one platform has been tested end to end. **Required before release.** + +**What it is.** Proving the feature works in real, signed, packaged builds on every platform we ship. The encryption library in particular has to load correctly from the packaged app. + +**Why it's needed.** Things that work from source can break once packaged, signed, or notarized. Native crypto modules are a common example. + +**Where VS Code is.** One signed macOS arm64 build ([480503](https://dev.azure.com/monacotools/Monaco/_build/results?buildId=480503)) was tested end to end. The current code, and Windows and Linux builds, haven't been. + +**Copilot app.** Not directly comparable. The app downloads a pinned `copilotd` release and checks it against a hard-coded SHA-256 checksum before running it. VS Code ships its host code inside its own signed package, so it has no extra download to verify. + +**What VS Code would need to do.** Repeat that exercise on the current code for Windows, Linux, and macOS: registration, remote discovery, running a session, and turning the feature off. Use isolated profiles with Settings Sync disabled. + +**Priority: required before release.** This is a release process step, not a design decision. + +(Written by Copilot) diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md index b3cd206b5f154..58f32581c6f58 100644 --- a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md @@ -1,6 +1,6 @@ # Mission Control remaining gaps -Only outstanding work and intentionally deferred limitations belong here. [Security requirement coverage](./MISSION_CONTROL_SECURITY_REQUIREMENTS.md) provides the implementation and test references; [operation and configuration](./MISSION_CONTROL.md) describes usage. +Only outstanding work and intentionally deferred limitations belong here. [Security requirement coverage](./MISSION_CONTROL_SECURITY_REQUIREMENTS.md) provides the implementation and test references; [operation and configuration](./MISSION_CONTROL.md) describes usage. [Gaps, explained](./MISSION_CONTROL_GAPS_EXPLAINED.md) describes each open item in plain language and compares it with Copilot Host and the Copilot app. ## Validation and release gaps @@ -16,7 +16,6 @@ No code-design decision from the maintainer is currently blocking these items. M | Remaining gap | Current limitation | Help needed from the maintainer | | --- | --- | --- | -| Authoritative credential expiry and live authorization lapse | GitHub's expiration header is not consumed; authenticated lanes do not lose observation/steering/MCP authorization at that deadline, and no integrated targeted lapse notification exists. Caller-provided expiry and relay-ticket renewal are not substitutes. | Scope decision: whether this is required for the continuing-development merge or explicitly deferred with the shared credential work. Security S04. | | Durable per-session mirror opt-out | Sharing is enabled for the entire host; there is no persisted per-session choice suppressing AHP/SDK/lifecycle publication after cold resume. | Product decision: whether host-wide consent is sufficient for this increment or per-session opt-out must precede merge. Security S30. | | Enterprise endpoint coherence and disclosure | MC independently defaults to dotcom rather than the configured GitHub identity origin; deliberate cross-host content residency and identity provenance are not explicitly surfaced. | Scope decision on enterprise support for this increment; supported topology/endpoint access is needed for deployment validation. Security S15. | | Independent host-local policy and quotas | Fixed transport bounds do not enforce operator-owned session/repository restrictions or configurable active-session/spawn quotas. Reporting device policy to MC does not provide independent enforcement against a compromised MC signer. | Applicability decision for this user-local increment. Security S17/S28. | diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md index b571ac90f3b04..f9bc472a65b59 100644 --- a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md @@ -15,9 +15,9 @@ Copilot Host file/line citations refer to revision `db3e5df9d3f3c5581d0a5ce96bdb | ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | | --- | --- | --- | --- | | S01. GitHub is the identity authority: match the canonical environment owner; MCP cannot establish it. | **Copilot Host architecture:** [canonical owner and direct GitHub validation, token-model.md:32-66](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L32-L66). **Protocol specification:** [separate identity/MCP purposes](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/encryption/README.md?plain=1#L120-L127). | **Implemented for principal equality.** [GitHub identity validation/lane auth](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) requires a User matching the owner and fences late results after handshake, close or authority change. MCP waits for identity. | [Owner/foreign/non-user/stale/concurrent-MCP tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts). Equality does not establish equal scopes or sponsorship. | -| S02. Authorize steering and observation before session access; withhold host-wide secrets. | **Copilot Host architecture:** [identity authority/secret compartmentalization, token-model.md:32-66](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L32-L66); [live authorization, token-model.md:99-120](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L99-L120). **Assistant proposal:** project minimal pre-auth root state and strip host configuration even after auth. | **Implemented for lane admission/root projection.** [Requests, root snapshots and envelopes](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) gate session access and omit root config; pre-auth roots omit terminals/customizations. | [Identity/minimal-root tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), [post-auth refresh tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts). Not blanket redaction of session/tool fields or deadline-based authorization lapse; see S04. | +| S02. Authorize steering and observation before session access; withhold host-wide secrets. | **Copilot Host architecture:** [identity authority/secret compartmentalization, token-model.md:32-66](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L32-L66); [live authorization, token-model.md:99-120](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L99-L120). **Assistant proposal:** project minimal pre-auth root state and strip host configuration even after auth. | **Implemented for lane admission/root projection.** [Requests, root snapshots and envelopes](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) gate session access and omit root config; pre-auth roots omit terminals/customizations. | [Identity/minimal-root tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), [post-auth refresh tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts). Not blanket redaction of session/tool fields. Deadline-based authorization lapse is covered by S04. | | S03. New work follows its sponsor: credential slots, safe re-pin and lease invalidation. | **Copilot Host architecture:** [pin/re-pin/safe boundaries, token-model.md:190-221](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L190-L221); [disconnect versus sign-out, token-model.md:353-375](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L353-L375). ordinary transport disconnect does not require immediate revocation of an existing valid session lease. | **Not satisfied; accepted deferral.** The shared native credential store needs redesign. The [native token store](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts) is resource/scope-keyed, not client/generation/session-keyed; [relay authentication](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) uses that shared provider path. | Owner rebinding is refused, but no per-client scope isolation, driver credential pinning or lineage-aware sign-out/revocation guarantee exists. Lane closure ends lane authorization, not necessarily shared provider credential retention. | -| S04. Credential expiry: use the GitHub-reported deadline, revoke live authorization and request reauthentication. | **Copilot Host architecture:** [GitHub header, notification and point-of-use checks, token-model.md:99-120](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L99-L120). **Important source limitation:** [line 270](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L270) says upstream already reads GitHub's deadline, rejects new protected work after expiry and sends `auth/required`. Early renewal and ending existing subscriptions remain outstanding upstream. | **Not satisfied.** [Identity resolution](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) does not consume `GitHub-Authentication-Token-Expiration`; lane authorization persists until re-handshake/close/authority change. [Shared store](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts) uses caller-supplied `expiresIn`. | No integrated deadline checks across awaited work, observation, steering/MCP completion or exactly-once targeted `auth/required` on lapse. Distinct from sponsorship S03 and WPS ticket renewal S14. | +| S04. Credential expiry: use the GitHub-reported deadline, revoke live authorization and request reauthentication. | **Copilot Host architecture:** [GitHub header, notification and point-of-use checks, token-model.md:99-120](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L99-L120). **Important source limitation:** [line 270](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L270) says upstream already reads GitHub's deadline, rejects new protected work after expiry and sends `auth/required`. Early renewal and ending existing subscriptions remain outstanding upstream. | **Implemented for live relay authorization.** [Identity validation](./missionControlAuthentication.ts) consumes `GitHub-Authentication-Token-Expiration`, rejects malformed/elapsed deadlines, and supplies authoritative remaining lifetime to the native store instead of caller-supplied identity expiry. Missing headers do not invent a deadline. Timers and point-of-use checks expire lane authorization; [lane transport](./missionControlProtocolServer.ts) sends one targeted `auth/required` with `reason: expired` per lapse. [Native handler](../protocolServerHandler.ts) removes subscriptions, rejects pending reverse RPCs, and checks credential lifetime after awaited grants and before returning protected results. [Project operations](./missionControlProjects.ts) recheck before queued mutations and clone launch. | [Deadline, renewal, clock-jump, concurrency and delegation tests](../../test/node/missionControlAuthentication.test.ts), [targeted notification/disposal tests](../../test/node/missionControlProtocolServer.test.ts), [observation, re-subscription, steering/grant and completion tests](../../test/node/protocolServerHandler.test.ts), [project preparation tests](../../test/node/missionControlProjects.test.ts). Renewed authentication cannot revive work admitted under an older credential or restore expired subscriptions. Already-started provider work is not rolled back; per-session credential sponsorship/retention remains deferred under S03. No early-renewal notification is scheduled. Distinct from WPS ticket renewal S14. | | S05. Keep the host control-plane credential separate from remote client authentication. | **Copilot Host architecture:** [four distinct credentials, token-model.md:23-31](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L23-L31); [operator-side rotation, mission-control.md:24-26](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L24-L26). **Assistant proposal:** resolve the local credential in the trusted workbench and install it over dedicated management IPC, not from a remote authenticate request. | **Implemented.** The [workbench contribution](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts) resolves local sessions and uses the [management service](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostManagementService.ts). Lane authentication cannot configure registration. | [Owner/rebinding/withdrawal tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Registration credentials remain in process memory while needed; no claim of eliminating all native provider copies. | ## 2. Sealed authentication and credential handling diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts b/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts index 7304098b62a2b..9f714edee4d33 100644 --- a/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts +++ b/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts @@ -5,6 +5,8 @@ import { createHash, randomBytes } from 'crypto'; import type { CipherSuite } from '@hpke/core'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import type { IMissionControlCredentialSealingRequest } from '../../common/agentService.js'; import type { AuthenticateParams } from '../../common/agent.js'; @@ -189,6 +191,24 @@ function isObject(value: unknown): value is Record { /** Validates a GitHub credential without retaining it or exposing response content. */ export async function resolveMissionControlOwner(fetcher: typeof fetch, apiOrigin: string, credential: string): Promise { + return (await resolveMissionControlIdentity(fetcher, apiOrigin, credential)).owner; +} + +function parseMissionControlExpiration(expiration: string): number { + const normalized = expiration.replace(/^(?\d{4}-\d{2}-\d{2}) (?