From 809e70116390801b878b031e3df18e24fd43f263 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 31 Mar 2026 13:49:21 +0200 Subject: [PATCH] acp: Notify when we receive new versions from the registry (#52818) Wires up the missing version notifications for registry + extension agents. The UI part of this is already setup, we were just never triggering it. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Smit Barmase --- .../src/conversation_view/thread_view.rs | 16 +- crates/project/src/agent_registry_store.rs | 6 + crates/project/src/agent_server_store.rs | 684 ++++++++++++++---- .../tests/integration/ext_agent_tests.rs | 4 + .../integration/extension_agent_tests.rs | 10 + crates/proto/proto/ai.proto | 1 + 6 files changed, 586 insertions(+), 135 deletions(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index c14636be8c94caf6c588c9e1c8c939a69049b7a5..afd508e47366d3217df6e5f1eb4cd0128c479895 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -8281,6 +8281,18 @@ impl ThreadView { fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context) -> Div { let server_view = self.server_view.clone(); + let has_version = !version.is_empty(); + let title = if has_version { + "New version available" + } else { + "Agent update available" + }; + let button_label = if has_version { + format!("Update to v{}", version) + } else { + "Reconnect".to_string() + }; + v_flex().w_full().justify_end().child( h_flex() .p_2() @@ -8299,10 +8311,10 @@ impl ThreadView { .color(Color::Accent) .size(IconSize::Small), ) - .child(Label::new("New version available").size(LabelSize::Small)), + .child(Label::new(title).size(LabelSize::Small)), ) .child( - Button::new("update-button", format!("Update to v{}", version)) + Button::new("update-button", button_label) .label_size(LabelSize::Small) .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(move |_, window, cx| { diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 9569798e1cf7b49d7f4ae8d2737f75b00eaffe63..b2010da65d9477859eceab166de6e0819617e4da 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -168,6 +168,12 @@ impl AgentRegistryStore { store } + #[cfg(any(test, feature = "test-support"))] + pub fn set_agents(&mut self, agents: Vec, cx: &mut Context) { + self.agents = agents; + cx.notify(); + } + pub fn agents(&self) -> &[RegistryAgent] { &self.agents } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index a41c34826ca5c9db918f1e699fca126dd0c06b62..2b7a0c4e1c7189f5ba3643a8d853dda6ed03537b 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -123,13 +123,28 @@ pub trait ExternalAgentServer { cx: &mut AsyncApp, ) -> Task>; + fn version(&self) -> Option<&SharedString> { + None + } + + fn take_new_version_available_tx(&mut self) -> Option>> { + None + } + + fn set_new_version_available_tx(&mut self, _tx: watch::Sender>) {} + + fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; } -impl dyn ExternalAgentServer { - fn downcast_mut(&mut self) -> Option<&mut T> { - self.as_any_mut().downcast_mut() - } +struct ExtensionAgentEntry { + agent_name: Arc, + extension_id: String, + targets: HashMap, + env: HashMap, + icon_path: Option, + display_name: Option, + version: Option, } enum AgentServerStoreState { @@ -140,14 +155,7 @@ enum AgentServerStoreState { downstream_client: Option<(u64, AnyProtoClient)>, settings: Option, http_client: Arc, - extension_agents: Vec<( - Arc, - String, - HashMap, - HashMap, - Option, - Option, - )>, + extension_agents: Vec, _subscriptions: Vec, }, Remote { @@ -221,14 +229,15 @@ impl AgentServerStore { resolve_extension_icon_path(&extensions_dir, ext_id, icon) }); - extension_agents.push(( - agent_name.clone(), - ext_id.to_owned(), - agent_entry.targets.clone(), - agent_entry.env.clone(), + extension_agents.push(ExtensionAgentEntry { + agent_name: agent_name.clone(), + extension_id: ext_id.to_owned(), + targets: agent_entry.targets.clone(), + env: agent_entry.env.clone(), icon_path, - Some(display_name), - )); + display_name: Some(display_name), + version: Some(SharedString::from(manifest.version.clone())), + }); } } self.reregister_agents(cx); @@ -287,6 +296,7 @@ impl AgentServerStore { .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), + version: Some(manifest.version.to_string()), }); } } @@ -442,15 +452,28 @@ impl AgentServerStore { }) .unwrap_or_default(); - self.external_agents.clear(); + // Drain the existing versioned agents, extracting reconnect state + // from any active connection so we can preserve it or trigger a + // reconnect when the version changes. + let mut old_versioned_agents: HashMap< + AgentId, + (SharedString, watch::Sender>), + > = HashMap::default(); + for (name, mut entry) in self.external_agents.drain() { + if let Some(version) = entry.server.version().cloned() { + if let Some(tx) = entry.server.take_new_version_available_tx() { + old_versioned_agents.insert(name, (version, tx)); + } + } + } // Insert extension agents before custom/registry so registry entries override extensions. - for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() { - let name = AgentId(agent_name.clone().into()); - let mut env = env.clone(); + for entry in extension_agents.iter() { + let name = AgentId(entry.agent_name.clone().into()); + let mut env = entry.env.clone(); if let Some(settings_env) = new_settings - .get(agent_name.as_ref()) + .get(entry.agent_name.as_ref()) .and_then(|settings| match settings { CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()), _ => None, @@ -458,7 +481,8 @@ impl AgentServerStore { { env.extend(settings_env); } - let icon = icon_path + let icon = entry + .icon_path .as_ref() .map(|path| SharedString::from(path.clone())); @@ -470,14 +494,16 @@ impl AgentServerStore { http_client: http_client.clone(), node_runtime: node_runtime.clone(), project_environment: project_environment.clone(), - extension_id: Arc::from(&**ext_id), - targets: targets.clone(), + extension_id: Arc::from(&*entry.extension_id), + targets: entry.targets.clone(), env, - agent_id: agent_name.clone(), + agent_id: entry.agent_name.clone(), + version: entry.version.clone(), + new_version_available_tx: None, }) as Box, ExternalAgentSource::Extension, icon, - display_name.clone(), + entry.display_name.clone(), ), ); } @@ -527,8 +553,10 @@ impl AgentServerStore { node_runtime: node_runtime.clone(), project_environment: project_environment.clone(), registry_id: Arc::from(name.as_str()), + version: agent.metadata.version.clone(), targets: agent.targets.clone(), env: env.clone(), + new_version_available_tx: None, }) as Box, ExternalAgentSource::Registry, @@ -544,10 +572,12 @@ impl AgentServerStore { Box::new(LocalRegistryNpxAgent { node_runtime: node_runtime.clone(), project_environment: project_environment.clone(), + version: agent.metadata.version.clone(), package: agent.package.clone(), args: agent.args.clone(), distribution_env: agent.env.clone(), settings_env: env.clone(), + new_version_available_tx: None, }) as Box, ExternalAgentSource::Registry, @@ -562,6 +592,24 @@ impl AgentServerStore { } } + // For each rebuilt versioned agent, compare the version. If it + // changed, notify the active connection to reconnect. Otherwise, + // transfer the channel to the new entry so future updates can use it. + for (name, entry) in &mut self.external_agents { + let Some((old_version, mut tx)) = old_versioned_agents.remove(name) else { + continue; + }; + let Some(new_version) = entry.server.version() else { + continue; + }; + + if new_version != &old_version { + tx.send(Some(new_version.to_string())).ok(); + } else { + entry.server.set_new_version_available_tx(tx); + } + } + *old_settings = Some(new_settings); if let Some((project_id, downstream_client)) = downstream_client { @@ -798,9 +846,8 @@ impl AgentServerStore { let mut metadata = HashMap::default(); for (name, mut entry) in previous_entries.drain() { - if let Some(agent) = entry.server.downcast_mut::() { - new_version_available_txs - .insert(name.clone(), agent.new_version_available_tx.take()); + if let Some(tx) = entry.server.take_new_version_available_tx() { + new_version_available_txs.insert(name.clone(), tx); } metadata.insert(name, (entry.icon, entry.display_name, entry.source)); @@ -831,9 +878,7 @@ impl AgentServerStore { upstream_client: upstream_client.clone(), worktree_store: worktree_store.clone(), name: agent_id.clone(), - new_version_available_tx: new_version_available_txs - .remove(&agent_id) - .flatten(), + new_version_available_tx: new_version_available_txs.remove(&agent_id), }; ( agent_id, @@ -867,25 +912,28 @@ impl AgentServerStore { ); }; + extension_agents.clear(); for ExternalExtensionAgent { name, icon_path, extension_id, targets, env, + version, } in envelope.payload.agents { - extension_agents.push(( - Arc::from(&*name), + extension_agents.push(ExtensionAgentEntry { + agent_name: Arc::from(&*name), extension_id, - targets + targets: targets .into_iter() .map(|(k, v)| (k, extension::TargetConfig::from_proto(v))) .collect(), - env.into_iter().collect(), + env: env.into_iter().collect(), icon_path, - None, - )); + display_name: None, + version: version.map(SharedString::from), + }); } this.reregister_agents(cx); @@ -900,23 +948,21 @@ impl AgentServerStore { mut cx: AsyncApp, ) -> Result<()> { this.update(&mut cx, |this, _| { - if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) - && let Some(agent) = agent.server.downcast_mut::() - && let Some(new_version_available_tx) = &mut agent.new_version_available_tx + if let Some(entry) = this.external_agents.get_mut(&*envelope.payload.name) + && let Some(mut tx) = entry.server.take_new_version_available_tx() { - new_version_available_tx - .send(Some(envelope.payload.version)) - .ok(); + tx.send(Some(envelope.payload.version)).ok(); + entry.server.set_new_version_available_tx(tx); } }); Ok(()) } - pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option> { - self.external_agents.get_mut(name).and_then(|entry| { + pub fn get_extension_id_for_agent(&self, name: &AgentId) -> Option> { + self.external_agents.get(name).and_then(|entry| { entry .server - .as_any_mut() + .as_any() .downcast_ref::() .map(|ext_agent| ext_agent.extension_id.clone()) }) @@ -932,6 +978,14 @@ struct RemoteExternalAgentServer { } impl ExternalAgentServer for RemoteExternalAgentServer { + fn take_new_version_available_tx(&mut self) -> Option>> { + self.new_version_available_tx.take() + } + + fn set_new_version_available_tx(&mut self, tx: watch::Sender>) { + self.new_version_available_tx = Some(tx); + } + fn get_command( &mut self, extra_env: HashMap, @@ -982,6 +1036,10 @@ impl ExternalAgentServer for RemoteExternalAgentServer { }) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -1039,6 +1097,45 @@ fn github_release_archive_from_url(archive_url: &str) -> Option String { + let sanitized = version + .chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character, + _ => '-', + }) + .collect::(); + + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } +} + +fn versioned_archive_cache_dir( + base_dir: &Path, + version: Option<&str>, + archive_url: &str, +) -> PathBuf { + let version = version.unwrap_or_default(); + let sanitized_version = sanitized_version_component(version); + + let mut version_hasher = Sha256::new(); + version_hasher.update(version.as_bytes()); + let version_hash = format!("{:x}", version_hasher.finalize()); + + let mut url_hasher = Sha256::new(); + url_hasher.update(archive_url.as_bytes()); + let url_hash = format!("{:x}", url_hasher.finalize()); + + base_dir.join(format!( + "v_{sanitized_version}_{}_{}", + &version_hash[..16], + &url_hash[..16], + )) +} + pub struct LocalExtensionArchiveAgent { pub fs: Arc, pub http_client: Arc, @@ -1048,15 +1145,30 @@ pub struct LocalExtensionArchiveAgent { pub agent_id: Arc, pub targets: HashMap, pub env: HashMap, + pub version: Option, + pub new_version_available_tx: Option>>, } impl ExternalAgentServer for LocalExtensionArchiveAgent { + fn version(&self) -> Option<&SharedString> { + self.version.as_ref() + } + + fn take_new_version_available_tx(&mut self) -> Option>> { + self.new_version_available_tx.take() + } + + fn set_new_version_available_tx(&mut self, tx: watch::Sender>) { + self.new_version_available_tx = Some(tx); + } + fn get_command( &mut self, extra_env: HashMap, - _new_version_available_tx: Option>>, + new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { + self.new_version_available_tx = new_version_available_tx; let fs = self.fs.clone(); let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); @@ -1065,6 +1177,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { let agent_id = self.agent_id.clone(); let targets = self.targets.clone(); let base_env = self.env.clone(); + let version = self.version.clone(); cx.spawn(async move |cx| { // Get project environment @@ -1120,13 +1233,11 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { })?; let archive_url = &target_config.archive; - - // Use URL as version identifier for caching - // Hash the URL to get a stable directory name - let mut hasher = Sha256::new(); - hasher.update(archive_url.as_bytes()); - let url_hash = format!("{:x}", hasher.finalize()); - let version_dir = dir.join(format!("v_{}", url_hash)); + let version_dir = versioned_archive_cache_dir( + &dir, + version.as_ref().map(|version| version.as_ref()), + archive_url, + ); if !fs.is_dir(&version_dir).await { // Determine SHA256 for verification @@ -1213,6 +1324,10 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { }) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -1224,17 +1339,32 @@ struct LocalRegistryArchiveAgent { node_runtime: NodeRuntime, project_environment: Entity, registry_id: Arc, + version: SharedString, targets: HashMap, env: HashMap, + new_version_available_tx: Option>>, } impl ExternalAgentServer for LocalRegistryArchiveAgent { + fn version(&self) -> Option<&SharedString> { + Some(&self.version) + } + + fn take_new_version_available_tx(&mut self) -> Option>> { + self.new_version_available_tx.take() + } + + fn set_new_version_available_tx(&mut self, tx: watch::Sender>) { + self.new_version_available_tx = Some(tx); + } + fn get_command( &mut self, extra_env: HashMap, - _new_version_available_tx: Option>>, + new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { + self.new_version_available_tx = new_version_available_tx; let fs = self.fs.clone(); let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); @@ -1242,6 +1372,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { let registry_id = self.registry_id.clone(); let targets = self.targets.clone(); let settings_env = self.env.clone(); + let version = self.version.clone(); cx.spawn(async move |cx| { let mut env = project_environment @@ -1296,11 +1427,8 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { env.extend(settings_env); let archive_url = &target_config.archive; - - let mut hasher = Sha256::new(); - hasher.update(archive_url.as_bytes()); - let url_hash = format!("{:x}", hasher.finalize()); - let version_dir = dir.join(format!("v_{}", url_hash)); + let version_dir = + versioned_archive_cache_dir(&dir, Some(version.as_ref()), archive_url); if !fs.is_dir(&version_dir).await { let sha256 = if let Some(provided_sha) = &target_config.sha256 { @@ -1377,6 +1505,10 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { }) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -1385,19 +1517,34 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { struct LocalRegistryNpxAgent { node_runtime: NodeRuntime, project_environment: Entity, + version: SharedString, package: SharedString, args: Vec, distribution_env: HashMap, settings_env: HashMap, + new_version_available_tx: Option>>, } impl ExternalAgentServer for LocalRegistryNpxAgent { + fn version(&self) -> Option<&SharedString> { + Some(&self.version) + } + + fn take_new_version_available_tx(&mut self) -> Option>> { + self.new_version_available_tx.take() + } + + fn set_new_version_available_tx(&mut self, tx: watch::Sender>) { + self.new_version_available_tx = Some(tx); + } + fn get_command( &mut self, extra_env: HashMap, - _new_version_available_tx: Option>>, + new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { + self.new_version_available_tx = new_version_available_tx; let node_runtime = self.node_runtime.clone(); let project_environment = self.project_environment.downgrade(); let package = self.package.clone(); @@ -1442,6 +1589,10 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { }) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -1479,6 +1630,10 @@ impl ExternalAgentServer for LocalCustomAgent { }) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -1687,9 +1842,168 @@ impl CustomAgentServerSettings { } } +impl From for CustomAgentServerSettings { + fn from(value: settings::CustomAgentServerSettings) -> Self { + match value { + settings::CustomAgentServerSettings::Custom { + path, + args, + env, + default_mode, + default_model, + favorite_models, + default_config_options, + favorite_config_option_values, + } => CustomAgentServerSettings::Custom { + command: AgentServerCommand { + path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()), + args, + env: Some(env), + }, + default_mode, + default_model, + favorite_models, + default_config_options, + favorite_config_option_values, + }, + settings::CustomAgentServerSettings::Extension { + env, + default_mode, + default_model, + default_config_options, + favorite_models, + favorite_config_option_values, + } => CustomAgentServerSettings::Extension { + env, + default_mode, + default_model, + default_config_options, + favorite_models, + favorite_config_option_values, + }, + settings::CustomAgentServerSettings::Registry { + env, + default_mode, + default_model, + default_config_options, + favorite_models, + favorite_config_option_values, + } => CustomAgentServerSettings::Registry { + env, + default_mode, + default_model, + default_config_options, + favorite_models, + favorite_config_option_values, + }, + } + } +} + +impl settings::Settings for AllAgentServersSettings { + fn from_settings(content: &settings::SettingsContent) -> Self { + let agent_settings = content.agent_servers.clone().unwrap(); + Self( + agent_settings + .0 + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(), + ) + } +} + #[cfg(test)] mod tests { use super::*; + use crate::agent_registry_store::{ + AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, + }; + use crate::worktree_store::{WorktreeIdCounter, WorktreeStore}; + use gpui::{AppContext as _, TestAppContext}; + use node_runtime::NodeRuntime; + use settings::Settings as _; + + fn make_npx_agent(id: &str, version: &str) -> RegistryAgent { + let id = SharedString::from(id.to_string()); + RegistryAgent::Npx(RegistryNpxAgent { + metadata: RegistryAgentMetadata { + id: AgentId::new(id.clone()), + name: id.clone(), + description: SharedString::from(""), + version: SharedString::from(version.to_string()), + repository: None, + website: None, + icon_path: None, + }, + package: id, + args: Vec::new(), + env: HashMap::default(), + }) + } + + fn init_test_settings(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + fn init_registry( + cx: &mut TestAppContext, + agents: Vec, + ) -> gpui::Entity { + cx.update(|cx| AgentRegistryStore::init_test_global(cx, agents)) + } + + fn set_registry_settings(cx: &mut TestAppContext, agent_names: &[&str]) { + cx.update(|cx| { + AllAgentServersSettings::override_global( + AllAgentServersSettings( + agent_names + .iter() + .map(|name| { + ( + name.to_string(), + settings::CustomAgentServerSettings::Registry { + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + .into(), + ) + }) + .collect(), + ), + cx, + ); + }); + } + + fn create_agent_server_store(cx: &mut TestAppContext) -> gpui::Entity { + cx.update(|cx| { + let fs: Arc = fs::FakeFs::new(cx.background_executor().clone()); + let worktree_store = + cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx))); + let project_environment = cx.new(|cx| { + crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx) + }); + let http_client = http_client::FakeHttpClient::with_404_response(); + + cx.new(|cx| { + AgentServerStore::local( + NodeRuntime::unavailable(), + fs, + project_environment, + http_client, + cx, + ) + }) + }) + } #[test] fn detects_supported_archive_suffixes() { @@ -1755,78 +2069,182 @@ mod tests { assert_eq!( error, - Some("unsupported archive type in URL: https://example.com/agent.tar.xz".to_string()) + Some("unsupported archive type in URL: https://example.com/agent.tar.xz".to_string()), ); } -} -impl From for CustomAgentServerSettings { - fn from(value: settings::CustomAgentServerSettings) -> Self { - match value { - settings::CustomAgentServerSettings::Custom { - path, - args, - env, - default_mode, - default_model, - favorite_models, - default_config_options, - favorite_config_option_values, - } => CustomAgentServerSettings::Custom { - command: AgentServerCommand { - path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()), - args, - env: Some(env), - }, - default_mode, - default_model, - favorite_models, - default_config_options, - favorite_config_option_values, - }, - settings::CustomAgentServerSettings::Extension { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - } => CustomAgentServerSettings::Extension { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - }, - settings::CustomAgentServerSettings::Registry { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - } => CustomAgentServerSettings::Registry { - env, - default_mode, - default_model, - default_config_options, - favorite_models, - favorite_config_option_values, - }, - } + #[test] + fn versioned_archive_cache_dir_includes_version_before_url_hash() { + let slash_version_dir = versioned_archive_cache_dir( + Path::new("/tmp/agents"), + Some("release/2.3.5"), + "https://example.com/agent.zip", + ); + let colon_version_dir = versioned_archive_cache_dir( + Path::new("/tmp/agents"), + Some("release:2.3.5"), + "https://example.com/agent.zip", + ); + let file_name = slash_version_dir + .file_name() + .and_then(|name| name.to_str()) + .expect("cache directory should have a file name"); + + assert!(file_name.starts_with("v_release-2.3.5_")); + assert_ne!(slash_version_dir, colon_version_dir); } -} -impl settings::Settings for AllAgentServersSettings { - fn from_settings(content: &settings::SettingsContent) -> Self { - let agent_settings = content.agent_servers.clone().unwrap(); - Self( - agent_settings - .0 - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect(), - ) + #[gpui::test] + fn test_version_change_sends_notification(cx: &mut TestAppContext) { + init_test_settings(cx); + let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]); + set_registry_settings(cx, &["test-agent"]); + let store = create_agent_server_store(cx); + + // Verify the agent was registered with version 1.0.0. + store.read_with(cx, |store, _| { + let entry = store + .external_agents + .get(&AgentId::new("test-agent")) + .expect("agent should be registered"); + assert_eq!( + entry.server.version().map(|v| v.to_string()), + Some("1.0.0".to_string()) + ); + }); + + // Set up a watch channel and store the tx on the agent. + let (tx, mut rx) = watch::channel::>(None); + store.update(cx, |store, _| { + let entry = store + .external_agents + .get_mut(&AgentId::new("test-agent")) + .expect("agent should be registered"); + entry.server.set_new_version_available_tx(tx); + }); + + // Update the registry to version 2.0.0. + registry.update(cx, |store, cx| { + store.set_agents(vec![make_npx_agent("test-agent", "2.0.0")], cx); + }); + cx.run_until_parked(); + + // The watch channel should have received the new version. + assert_eq!(rx.borrow().as_deref(), Some("2.0.0")); + } + + #[gpui::test] + fn test_same_version_preserves_tx(cx: &mut TestAppContext) { + init_test_settings(cx); + let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]); + set_registry_settings(cx, &["test-agent"]); + let store = create_agent_server_store(cx); + + let (tx, mut rx) = watch::channel::>(None); + store.update(cx, |store, _| { + let entry = store + .external_agents + .get_mut(&AgentId::new("test-agent")) + .expect("agent should be registered"); + entry.server.set_new_version_available_tx(tx); + }); + + // "Refresh" the registry with the same version. + registry.update(cx, |store, cx| { + store.set_agents(vec![make_npx_agent("test-agent", "1.0.0")], cx); + }); + cx.run_until_parked(); + + // No notification should have been sent. + assert_eq!(rx.borrow().as_deref(), None); + + // The tx should have been transferred to the rebuilt agent entry. + store.update(cx, |store, _| { + let entry = store + .external_agents + .get_mut(&AgentId::new("test-agent")) + .expect("agent should be registered"); + assert!( + entry.server.take_new_version_available_tx().is_some(), + "tx should have been transferred to the rebuilt agent" + ); + }); + } + + #[gpui::test] + fn test_no_tx_stored_does_not_panic_on_version_change(cx: &mut TestAppContext) { + init_test_settings(cx); + let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]); + set_registry_settings(cx, &["test-agent"]); + let _store = create_agent_server_store(cx); + + // Update the registry without having stored any tx — should not panic. + registry.update(cx, |store, cx| { + store.set_agents(vec![make_npx_agent("test-agent", "2.0.0")], cx); + }); + cx.run_until_parked(); + } + + #[gpui::test] + fn test_multiple_agents_independent_notifications(cx: &mut TestAppContext) { + init_test_settings(cx); + let registry = init_registry( + cx, + vec![ + make_npx_agent("agent-a", "1.0.0"), + make_npx_agent("agent-b", "3.0.0"), + ], + ); + set_registry_settings(cx, &["agent-a", "agent-b"]); + let store = create_agent_server_store(cx); + + let (tx_a, mut rx_a) = watch::channel::>(None); + let (tx_b, mut rx_b) = watch::channel::>(None); + store.update(cx, |store, _| { + store + .external_agents + .get_mut(&AgentId::new("agent-a")) + .expect("agent-a should be registered") + .server + .set_new_version_available_tx(tx_a); + store + .external_agents + .get_mut(&AgentId::new("agent-b")) + .expect("agent-b should be registered") + .server + .set_new_version_available_tx(tx_b); + }); + + // Update only agent-a to a new version; agent-b stays the same. + registry.update(cx, |store, cx| { + store.set_agents( + vec![ + make_npx_agent("agent-a", "2.0.0"), + make_npx_agent("agent-b", "3.0.0"), + ], + cx, + ); + }); + cx.run_until_parked(); + + // agent-a should have received a notification. + assert_eq!(rx_a.borrow().as_deref(), Some("2.0.0")); + + // agent-b should NOT have received a notification. + assert_eq!(rx_b.borrow().as_deref(), None); + + // agent-b's tx should have been transferred. + store.update(cx, |store, _| { + assert!( + store + .external_agents + .get_mut(&AgentId::new("agent-b")) + .expect("agent-b should be registered") + .server + .take_new_version_available_tx() + .is_some(), + "agent-b tx should have been transferred" + ); + }); } } diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index 38da460023ebb6c4d24dd02f21928db7e3cd54e3..bd4acf2b3e9419b62ff676331383b48f98874345 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -20,6 +20,10 @@ impl ExternalAgentServer for NoopExternalAgent { })) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index 1824fbec0d172e2bac626e726d305883818d51ad..577bc3b2901c52f4f47d9d0c82ef89fc66e2c21a 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -36,6 +36,10 @@ impl ExternalAgentServer for NoopExternalAgent { })) } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -138,6 +142,7 @@ async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAp project_environment, extension_id: Arc::from("my-extension"), agent_id: Arc::from("my-agent"), + version: Some(SharedString::from("1.0.0")), targets: { let mut map = HashMap::default(); map.insert( @@ -157,6 +162,7 @@ async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAp map.insert("PORT".into(), "8080".into()); map }, + new_version_available_tx: None, }; // Verify agent is properly constructed @@ -220,6 +226,7 @@ async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) { project_environment, extension_id: Arc::from("node-extension"), agent_id: Arc::from("node-agent"), + version: Some(SharedString::from("1.0.0")), targets: { let mut map = HashMap::default(); map.insert( @@ -235,6 +242,7 @@ async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) { map }, env: HashMap::default(), + new_version_available_tx: None, }; // Verify that when cmd is "node", it attempts to use the node runtime @@ -264,6 +272,7 @@ async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) { project_environment, extension_id: Arc::from("test-ext"), agent_id: Arc::from("test-agent"), + version: Some(SharedString::from("1.0.0")), targets: { let mut map = HashMap::default(); map.insert( @@ -283,6 +292,7 @@ async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) { map }, env: Default::default(), + new_version_available_tx: None, }; // Verify the agent is configured with relative paths in args diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto index 8db36153b5ef75218f0c007e113f1c2c06ded7eb..65433a4c36df40d49db0cf209eebd635369609f1 100644 --- a/crates/proto/proto/ai.proto +++ b/crates/proto/proto/ai.proto @@ -212,6 +212,7 @@ message ExternalExtensionAgent { string extension_id = 3; map targets = 4; map env = 5; + optional string version = 6; } message ExternalExtensionAgentsUpdated {