acp: Notify when we receive new versions from the registry (#52818)

Ben Brandt and Smit Barmase created

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 <heysmitbarmase@gmail.com>

Change summary

crates/agent_ui/src/conversation_view/thread_view.rs      |  16 
crates/project/src/agent_registry_store.rs                |   6 
crates/project/src/agent_server_store.rs                  | 684 +++++++-
crates/project/tests/integration/ext_agent_tests.rs       |   4 
crates/project/tests/integration/extension_agent_tests.rs |  10 
crates/proto/proto/ai.proto                               |   1 
6 files changed, 586 insertions(+), 135 deletions(-)

Detailed changes

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<Self>) -> 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| {

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<RegistryAgent>, cx: &mut Context<Self>) {
+        self.agents = agents;
+        cx.notify();
+    }
+
     pub fn agents(&self) -> &[RegistryAgent] {
         &self.agents
     }

crates/project/src/agent_server_store.rs 🔗

@@ -123,13 +123,28 @@ pub trait ExternalAgentServer {
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>>;
 
+    fn version(&self) -> Option<&SharedString> {
+        None
+    }
+
+    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
+        None
+    }
+
+    fn set_new_version_available_tx(&mut self, _tx: watch::Sender<Option<String>>) {}
+
+    fn as_any(&self) -> &dyn Any;
     fn as_any_mut(&mut self) -> &mut dyn Any;
 }
 
-impl dyn ExternalAgentServer {
-    fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
-        self.as_any_mut().downcast_mut()
-    }
+struct ExtensionAgentEntry {
+    agent_name: Arc<str>,
+    extension_id: String,
+    targets: HashMap<String, extension::TargetConfig>,
+    env: HashMap<String, String>,
+    icon_path: Option<String>,
+    display_name: Option<SharedString>,
+    version: Option<SharedString>,
 }
 
 enum AgentServerStoreState {
@@ -140,14 +155,7 @@ enum AgentServerStoreState {
         downstream_client: Option<(u64, AnyProtoClient)>,
         settings: Option<AllAgentServersSettings>,
         http_client: Arc<dyn HttpClient>,
-        extension_agents: Vec<(
-            Arc<str>,
-            String,
-            HashMap<String, extension::TargetConfig>,
-            HashMap<String, String>,
-            Option<String>,
-            Option<SharedString>,
-        )>,
+        extension_agents: Vec<ExtensionAgentEntry>,
         _subscriptions: Vec<Subscription>,
     },
     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<Option<String>>),
+        > = 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<dyn ExternalAgentServer>,
                     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<dyn ExternalAgentServer>,
                                     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<dyn ExternalAgentServer>,
                                     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::<RemoteExternalAgentServer>() {
-                    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::<RemoteExternalAgentServer>()
-                && 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<Arc<str>> {
-        self.external_agents.get_mut(name).and_then(|entry| {
+    pub fn get_extension_id_for_agent(&self, name: &AgentId) -> Option<Arc<str>> {
+        self.external_agents.get(name).and_then(|entry| {
             entry
                 .server
-                .as_any_mut()
+                .as_any()
                 .downcast_ref::<LocalExtensionArchiveAgent>()
                 .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<watch::Sender<Option<String>>> {
+        self.new_version_available_tx.take()
+    }
+
+    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
+        self.new_version_available_tx = Some(tx);
+    }
+
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
@@ -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<GithubReleaseArc
     })
 }
 
+fn sanitized_version_component(version: &str) -> String {
+    let sanitized = version
+        .chars()
+        .map(|character| match character {
+            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
+            _ => '-',
+        })
+        .collect::<String>();
+
+    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<dyn Fs>,
     pub http_client: Arc<dyn HttpClient>,
@@ -1048,15 +1145,30 @@ pub struct LocalExtensionArchiveAgent {
     pub agent_id: Arc<str>,
     pub targets: HashMap<String, extension::TargetConfig>,
     pub env: HashMap<String, String>,
+    pub version: Option<SharedString>,
+    pub new_version_available_tx: Option<watch::Sender<Option<String>>>,
 }
 
 impl ExternalAgentServer for LocalExtensionArchiveAgent {
+    fn version(&self) -> Option<&SharedString> {
+        self.version.as_ref()
+    }
+
+    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
+        self.new_version_available_tx.take()
+    }
+
+    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
+        self.new_version_available_tx = Some(tx);
+    }
+
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+        new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
+        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<ProjectEnvironment>,
     registry_id: Arc<str>,
+    version: SharedString,
     targets: HashMap<String, RegistryTargetConfig>,
     env: HashMap<String, String>,
+    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 }
 
 impl ExternalAgentServer for LocalRegistryArchiveAgent {
+    fn version(&self) -> Option<&SharedString> {
+        Some(&self.version)
+    }
+
+    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
+        self.new_version_available_tx.take()
+    }
+
+    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
+        self.new_version_available_tx = Some(tx);
+    }
+
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+        new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
+        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<ProjectEnvironment>,
+    version: SharedString,
     package: SharedString,
     args: Vec<String>,
     distribution_env: HashMap<String, String>,
     settings_env: HashMap<String, String>,
+    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 }
 
 impl ExternalAgentServer for LocalRegistryNpxAgent {
+    fn version(&self) -> Option<&SharedString> {
+        Some(&self.version)
+    }
+
+    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
+        self.new_version_available_tx.take()
+    }
+
+    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
+        self.new_version_available_tx = Some(tx);
+    }
+
     fn get_command(
         &mut self,
         extra_env: HashMap<String, String>,
-        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+        new_version_available_tx: Option<watch::Sender<Option<String>>>,
         cx: &mut AsyncApp,
     ) -> Task<Result<AgentServerCommand>> {
+        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<settings::CustomAgentServerSettings> 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<RegistryAgent>,
+    ) -> gpui::Entity<AgentRegistryStore> {
+        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<AgentServerStore> {
+        cx.update(|cx| {
+            let fs: Arc<dyn Fs> = 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<settings::CustomAgentServerSettings> 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::<Option<String>>(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::<Option<String>>(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::<Option<String>>(None);
+        let (tx_b, mut rx_b) = watch::channel::<Option<String>>(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"
+            );
+        });
     }
 }

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

crates/proto/proto/ai.proto 🔗

@@ -212,6 +212,7 @@ message ExternalExtensionAgent {
   string extension_id = 3;
   map<string, ExternalExtensionAgentTarget> targets = 4;
   map<string, string> env = 5;
+  optional string version = 6;
 }
 
 message ExternalExtensionAgentsUpdated {