From 5f48087ba33f063ce9f140a754ffb079bcec2e61 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 5 Mar 2026 15:32:06 -0500 Subject: [PATCH] fix: compare extension configs before skipping add_extension (#7650) --- crates/goose/src/agents/extension_manager.rs | 96 +++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 77ce253742..91983033d3 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -538,8 +538,14 @@ impl ExtensionManager { ) -> ExtensionResult<()> { let sanitized_name = config.key(); - if self.extensions.lock().await.contains_key(&sanitized_name) { - return Ok(()); + if let Some(existing) = self.extensions.lock().await.get(&sanitized_name) { + if existing.config == config { + return Ok(()); + } + tracing::debug!( + name = sanitized_name, + "extension config changed, restarting with updated config" + ); } let mut temp_dir = None; @@ -2235,4 +2241,90 @@ mod tests { assert!(tool_names.iter().any(|n| n.starts_with("ext_a__"))); assert!(!tool_names.iter().any(|n| n.starts_with("ext_b__"))); } + + #[tokio::test] + async fn test_add_extension_noop_on_identical_config() { + // When add_extension is called with a config that is byte-for-byte identical to + // the already-loaded one, it must return Ok(()) without removing the extension. + let temp_dir = tempfile::tempdir().unwrap(); + let em = Arc::new(ExtensionManager::new_without_provider( + temp_dir.path().to_path_buf(), + )); + + let config = ExtensionConfig::Frontend { + name: "test-ext".to_string(), + description: "original".to_string(), + tools: vec![], + instructions: None, + bundled: None, + available_tools: vec![], + }; + + em.add_client( + "test-ext".to_string(), + config.clone(), + Arc::new(MockClient {}), + None, + None, + ) + .await; + assert_eq!(em.extensions.lock().await.len(), 1); + + // Calling add_extension with the same config must be a no-op (Ok, count unchanged). + let result = em.add_extension(config, None, None, None).await; + assert!(result.is_ok(), "identical config should be a no-op"); + assert_eq!( + em.extensions.lock().await.len(), + 1, + "extension must not be removed on no-op" + ); + } + + #[tokio::test] + async fn test_add_extension_replaces_extension_on_config_change() { + // When add_extension is called with an updated config (same name, different fields), + // the existing extension must be removed so the caller can re-add with new config. + let temp_dir = tempfile::tempdir().unwrap(); + let em = Arc::new(ExtensionManager::new_without_provider( + temp_dir.path().to_path_buf(), + )); + + let config_a = ExtensionConfig::Frontend { + name: "test-ext".to_string(), + description: "version-a".to_string(), + tools: vec![], + instructions: None, + bundled: None, + available_tools: vec![], + }; + let config_b = ExtensionConfig::Frontend { + name: "test-ext".to_string(), + description: "version-b".to_string(), // changed + tools: vec![], + instructions: None, + bundled: None, + available_tools: vec![], + }; + + em.add_client( + "test-ext".to_string(), + config_a, + Arc::new(MockClient {}), + None, + None, + ) + .await; + assert_eq!(em.extensions.lock().await.len(), 1); + + // add_extension with changed config attempts to create a new client (fails here + // because Frontend configs cannot be added as server extensions), but must preserve + // the old extension so the session isn't left without it. + let result = em.add_extension(config_b, None, None, None).await; + assert!(result.is_err(), "Frontend add_extension must return Err"); + assert_eq!( + em.extensions.lock().await.len(), + 1, + "old extension must be preserved when replacement client creation fails" + ); + } }