extension_agent_tests.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use gpui::{AppContext, AsyncApp, SharedString, Task, TestAppContext};
  4use node_runtime::NodeRuntime;
  5use project::worktree_store::WorktreeStore;
  6use project::{agent_server_store::*, worktree_store::WorktreeIdCounter};
  7use std::{any::Any, path::PathBuf, sync::Arc};
  8
  9#[test]
 10fn extension_agent_constructs_proper_display_names() {
 11    // Verify the display name format for extension-provided agents
 12    let name1 = AgentId(SharedString::from("Extension: Agent"));
 13    assert!(name1.0.contains(": "));
 14
 15    let name2 = AgentId(SharedString::from("MyExt: MyAgent"));
 16    assert_eq!(name2.0, "MyExt: MyAgent");
 17
 18    // Non-extension agents shouldn't have the separator
 19    let custom = AgentId(SharedString::from("custom"));
 20    assert!(!custom.0.contains(": "));
 21}
 22
 23struct NoopExternalAgent;
 24
 25impl ExternalAgentServer for NoopExternalAgent {
 26    fn get_command(
 27        &self,
 28        _extra_args: Vec<String>,
 29        _extra_env: HashMap<String, String>,
 30        _cx: &mut AsyncApp,
 31    ) -> Task<Result<AgentServerCommand>> {
 32        Task::ready(Ok(AgentServerCommand {
 33            path: PathBuf::from("noop"),
 34            args: Vec::new(),
 35            env: None,
 36        }))
 37    }
 38
 39    fn as_any(&self) -> &dyn Any {
 40        self
 41    }
 42
 43    fn as_any_mut(&mut self) -> &mut dyn Any {
 44        self
 45    }
 46}
 47
 48#[test]
 49fn sync_removes_only_extension_provided_agents() {
 50    let mut store = AgentServerStore::collab();
 51
 52    // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
 53    store.external_agents.insert(
 54        AgentId(SharedString::from("Ext1: Agent1")),
 55        ExternalAgentEntry::new(
 56            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 57            ExternalAgentSource::Extension,
 58            None,
 59            None,
 60        ),
 61    );
 62    store.external_agents.insert(
 63        AgentId(SharedString::from("Ext2: Agent2")),
 64        ExternalAgentEntry::new(
 65            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 66            ExternalAgentSource::Extension,
 67            None,
 68            None,
 69        ),
 70    );
 71    store.external_agents.insert(
 72        AgentId(SharedString::from("custom-agent")),
 73        ExternalAgentEntry::new(
 74            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 75            ExternalAgentSource::Custom,
 76            None,
 77            None,
 78        ),
 79    );
 80
 81    // Simulate removal phase
 82    store
 83        .external_agents
 84        .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
 85
 86    // Only custom-agent should remain
 87    assert_eq!(store.external_agents.len(), 1);
 88    assert!(
 89        store
 90            .external_agents
 91            .contains_key(&AgentId(SharedString::from("custom-agent")))
 92    );
 93}
 94
 95#[test]
 96fn archive_launcher_constructs_with_all_fields() {
 97    use extension::AgentServerManifestEntry;
 98
 99    let mut env = HashMap::default();
100    env.insert("GITHUB_TOKEN".into(), "secret".into());
101
102    let mut targets = HashMap::default();
103    targets.insert(
104        "darwin-aarch64".to_string(),
105        extension::TargetConfig {
106            archive:
107                "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
108                    .into(),
109            cmd: "./agent".into(),
110            args: vec![],
111            sha256: None,
112            env: Default::default(),
113        },
114    );
115
116    let _entry = AgentServerManifestEntry {
117        name: "GitHub Agent".into(),
118        targets,
119        env,
120        icon: None,
121    };
122
123    // Verify display name construction
124    let expected_name = AgentId(SharedString::from("GitHub Agent"));
125    assert_eq!(expected_name.0, "GitHub Agent");
126}
127
128#[gpui::test]
129async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
130    let fs = fs::FakeFs::new(cx.background_executor.clone());
131    let http_client = http_client::FakeHttpClient::with_404_response();
132    let worktree_store =
133        cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
134    let project_environment = cx.new(|cx| {
135        crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
136    });
137
138    let agent = LocalExtensionArchiveAgent {
139        fs,
140        http_client,
141        node_runtime: node_runtime::NodeRuntime::unavailable(),
142        project_environment,
143        extension_id: Arc::from("my-extension"),
144        agent_id: Arc::from("my-agent"),
145        version: Some(SharedString::from("1.0.0")),
146        targets: {
147            let mut map = HashMap::default();
148            map.insert(
149                "darwin-aarch64".to_string(),
150                extension::TargetConfig {
151                    archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
152                    cmd: "./my-agent".into(),
153                    args: vec!["--serve".into()],
154                    sha256: None,
155                    env: Default::default(),
156                },
157            );
158            map
159        },
160        env: {
161            let mut map = HashMap::default();
162            map.insert("PORT".into(), "8080".into());
163            map
164        },
165        new_version_available_tx: None,
166    };
167
168    // Verify agent is properly constructed
169    assert_eq!(agent.extension_id.as_ref(), "my-extension");
170    assert_eq!(agent.agent_id.as_ref(), "my-agent");
171    assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
172    assert!(agent.targets.contains_key("darwin-aarch64"));
173}
174
175#[test]
176fn sync_extension_agents_registers_archive_launcher() {
177    use extension::AgentServerManifestEntry;
178
179    let expected_name = AgentId(SharedString::from("Release Agent"));
180    assert_eq!(expected_name.0, "Release Agent");
181
182    // Verify the manifest entry structure for archive-based installation
183    let mut env = HashMap::default();
184    env.insert("API_KEY".into(), "secret".into());
185
186    let mut targets = HashMap::default();
187    targets.insert(
188            "linux-x86_64".to_string(),
189            extension::TargetConfig {
190                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
191                cmd: "./release-agent".into(),
192                args: vec!["serve".into()],
193                sha256: None,
194                env: Default::default(),
195            },
196        );
197
198    let manifest_entry = AgentServerManifestEntry {
199        name: "Release Agent".into(),
200        targets: targets.clone(),
201        env,
202        icon: None,
203    };
204
205    // Verify target config is present
206    assert!(manifest_entry.targets.contains_key("linux-x86_64"));
207    let target = manifest_entry.targets.get("linux-x86_64").unwrap();
208    assert_eq!(target.cmd, "./release-agent");
209}
210
211#[gpui::test]
212async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
213    let fs = fs::FakeFs::new(cx.background_executor.clone());
214    let http_client = http_client::FakeHttpClient::with_404_response();
215    let node_runtime = NodeRuntime::unavailable();
216    let worktree_store =
217        cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
218    let project_environment = cx.new(|cx| {
219        crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
220    });
221
222    let agent = LocalExtensionArchiveAgent {
223        fs: fs.clone(),
224        http_client,
225        node_runtime,
226        project_environment,
227        extension_id: Arc::from("node-extension"),
228        agent_id: Arc::from("node-agent"),
229        version: Some(SharedString::from("1.0.0")),
230        targets: {
231            let mut map = HashMap::default();
232            map.insert(
233                "darwin-aarch64".to_string(),
234                extension::TargetConfig {
235                    archive: "https://example.com/node-agent.zip".into(),
236                    cmd: "node".into(),
237                    args: vec!["index.js".into()],
238                    sha256: None,
239                    env: Default::default(),
240                },
241            );
242            map
243        },
244        env: HashMap::default(),
245        new_version_available_tx: None,
246    };
247
248    // Verify that when cmd is "node", it attempts to use the node runtime
249    assert_eq!(agent.extension_id.as_ref(), "node-extension");
250    assert_eq!(agent.agent_id.as_ref(), "node-agent");
251
252    let target = agent.targets.get("darwin-aarch64").unwrap();
253    assert_eq!(target.cmd, "node");
254    assert_eq!(target.args, vec!["index.js"]);
255}
256
257#[gpui::test]
258async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
259    let fs = fs::FakeFs::new(cx.background_executor.clone());
260    let http_client = http_client::FakeHttpClient::with_404_response();
261    let node_runtime = NodeRuntime::unavailable();
262    let worktree_store =
263        cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
264    let project_environment = cx.new(|cx| {
265        crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
266    });
267
268    let agent = LocalExtensionArchiveAgent {
269        fs: fs.clone(),
270        http_client,
271        node_runtime,
272        project_environment,
273        extension_id: Arc::from("test-ext"),
274        agent_id: Arc::from("test-agent"),
275        version: Some(SharedString::from("1.0.0")),
276        targets: {
277            let mut map = HashMap::default();
278            map.insert(
279                "darwin-aarch64".to_string(),
280                extension::TargetConfig {
281                    archive: "https://example.com/test.zip".into(),
282                    cmd: "node".into(),
283                    args: vec![
284                        "server.js".into(),
285                        "--config".into(),
286                        "./config.json".into(),
287                    ],
288                    sha256: None,
289                    env: Default::default(),
290                },
291            );
292            map
293        },
294        env: Default::default(),
295        new_version_available_tx: None,
296    };
297
298    // Verify the agent is configured with relative paths in args
299    let target = agent.targets.get("darwin-aarch64").unwrap();
300    assert_eq!(target.args[0], "server.js");
301    assert_eq!(target.args[2], "./config.json");
302    // These relative paths will resolve relative to the extraction directory
303    // when the command is executed
304}
305
306#[test]
307fn test_tilde_expansion_in_settings() {
308    let settings = settings::CustomAgentServerSettings::Custom {
309        path: PathBuf::from("~/custom/agent"),
310        args: vec!["serve".into()],
311        env: Default::default(),
312        default_mode: None,
313        default_model: None,
314        favorite_models: vec![],
315        default_config_options: Default::default(),
316        favorite_config_option_values: Default::default(),
317    };
318
319    let converted: CustomAgentServerSettings = settings.into();
320    let CustomAgentServerSettings::Custom {
321        command: AgentServerCommand { path, .. },
322        ..
323    } = converted
324    else {
325        panic!("Expected Custom variant");
326    };
327
328    assert!(
329        !path.to_string_lossy().starts_with("~"),
330        "Tilde should be expanded for custom agent path"
331    );
332}