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 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
 13    assert!(name1.0.contains(": "));
 14
 15    let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
 16    assert_eq!(name2.0, "MyExt: MyAgent");
 17
 18    // Non-extension agents shouldn't have the separator
 19    let custom = ExternalAgentServerName(SharedString::from("custom"));
 20    assert!(!custom.0.contains(": "));
 21}
 22
 23struct NoopExternalAgent;
 24
 25impl ExternalAgentServer for NoopExternalAgent {
 26    fn get_command(
 27        &mut self,
 28        _root_dir: Option<&str>,
 29        _extra_env: HashMap<String, String>,
 30        _status_tx: Option<watch::Sender<SharedString>>,
 31        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
 32        _cx: &mut AsyncApp,
 33    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 34        Task::ready(Ok((
 35            AgentServerCommand {
 36                path: PathBuf::from("noop"),
 37                args: Vec::new(),
 38                env: None,
 39            },
 40            "".to_string(),
 41            None,
 42        )))
 43    }
 44
 45    fn as_any_mut(&mut self) -> &mut dyn Any {
 46        self
 47    }
 48}
 49
 50#[test]
 51fn sync_removes_only_extension_provided_agents() {
 52    let mut store = AgentServerStore::collab();
 53
 54    // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
 55    store.external_agents.insert(
 56        ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
 57        ExternalAgentEntry::new(
 58            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 59            ExternalAgentSource::Extension,
 60            None,
 61            None,
 62        ),
 63    );
 64    store.external_agents.insert(
 65        ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
 66        ExternalAgentEntry::new(
 67            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 68            ExternalAgentSource::Extension,
 69            None,
 70            None,
 71        ),
 72    );
 73    store.external_agents.insert(
 74        ExternalAgentServerName(SharedString::from("custom-agent")),
 75        ExternalAgentEntry::new(
 76            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
 77            ExternalAgentSource::Custom,
 78            None,
 79            None,
 80        ),
 81    );
 82
 83    // Simulate removal phase
 84    store
 85        .external_agents
 86        .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
 87
 88    // Only custom-agent should remain
 89    assert_eq!(store.external_agents.len(), 1);
 90    assert!(
 91        store
 92            .external_agents
 93            .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
 94    );
 95}
 96
 97#[test]
 98fn archive_launcher_constructs_with_all_fields() {
 99    use extension::AgentServerManifestEntry;
100
101    let mut env = HashMap::default();
102    env.insert("GITHUB_TOKEN".into(), "secret".into());
103
104    let mut targets = HashMap::default();
105    targets.insert(
106        "darwin-aarch64".to_string(),
107        extension::TargetConfig {
108            archive:
109                "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
110                    .into(),
111            cmd: "./agent".into(),
112            args: vec![],
113            sha256: None,
114            env: Default::default(),
115        },
116    );
117
118    let _entry = AgentServerManifestEntry {
119        name: "GitHub Agent".into(),
120        targets,
121        env,
122        icon: None,
123    };
124
125    // Verify display name construction
126    let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
127    assert_eq!(expected_name.0, "GitHub Agent");
128}
129
130#[gpui::test]
131async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
132    let fs = fs::FakeFs::new(cx.background_executor.clone());
133    let http_client = http_client::FakeHttpClient::with_404_response();
134    let worktree_store =
135        cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
136    let project_environment = cx.new(|cx| {
137        crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
138    });
139
140    let agent = LocalExtensionArchiveAgent {
141        fs,
142        http_client,
143        node_runtime: node_runtime::NodeRuntime::unavailable(),
144        project_environment,
145        extension_id: Arc::from("my-extension"),
146        agent_id: Arc::from("my-agent"),
147        targets: {
148            let mut map = HashMap::default();
149            map.insert(
150                "darwin-aarch64".to_string(),
151                extension::TargetConfig {
152                    archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
153                    cmd: "./my-agent".into(),
154                    args: vec!["--serve".into()],
155                    sha256: None,
156                    env: Default::default(),
157                },
158            );
159            map
160        },
161        env: {
162            let mut map = HashMap::default();
163            map.insert("PORT".into(), "8080".into());
164            map
165        },
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 = ExternalAgentServerName(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        targets: {
230            let mut map = HashMap::default();
231            map.insert(
232                "darwin-aarch64".to_string(),
233                extension::TargetConfig {
234                    archive: "https://example.com/node-agent.zip".into(),
235                    cmd: "node".into(),
236                    args: vec!["index.js".into()],
237                    sha256: None,
238                    env: Default::default(),
239                },
240            );
241            map
242        },
243        env: HashMap::default(),
244    };
245
246    // Verify that when cmd is "node", it attempts to use the node runtime
247    assert_eq!(agent.extension_id.as_ref(), "node-extension");
248    assert_eq!(agent.agent_id.as_ref(), "node-agent");
249
250    let target = agent.targets.get("darwin-aarch64").unwrap();
251    assert_eq!(target.cmd, "node");
252    assert_eq!(target.args, vec!["index.js"]);
253}
254
255#[gpui::test]
256async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
257    let fs = fs::FakeFs::new(cx.background_executor.clone());
258    let http_client = http_client::FakeHttpClient::with_404_response();
259    let node_runtime = NodeRuntime::unavailable();
260    let worktree_store =
261        cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
262    let project_environment = cx.new(|cx| {
263        crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
264    });
265
266    let agent = LocalExtensionArchiveAgent {
267        fs: fs.clone(),
268        http_client,
269        node_runtime,
270        project_environment,
271        extension_id: Arc::from("test-ext"),
272        agent_id: Arc::from("test-agent"),
273        targets: {
274            let mut map = HashMap::default();
275            map.insert(
276                "darwin-aarch64".to_string(),
277                extension::TargetConfig {
278                    archive: "https://example.com/test.zip".into(),
279                    cmd: "node".into(),
280                    args: vec![
281                        "server.js".into(),
282                        "--config".into(),
283                        "./config.json".into(),
284                    ],
285                    sha256: None,
286                    env: Default::default(),
287                },
288            );
289            map
290        },
291        env: Default::default(),
292    };
293
294    // Verify the agent is configured with relative paths in args
295    let target = agent.targets.get("darwin-aarch64").unwrap();
296    assert_eq!(target.args[0], "server.js");
297    assert_eq!(target.args[2], "./config.json");
298    // These relative paths will resolve relative to the extraction directory
299    // when the command is executed
300}
301
302#[test]
303fn test_tilde_expansion_in_settings() {
304    let settings = settings::BuiltinAgentServerSettings {
305        path: Some(PathBuf::from("~/bin/agent")),
306        args: Some(vec!["--flag".into()]),
307        env: None,
308        ignore_system_version: None,
309        default_mode: None,
310        default_model: None,
311        favorite_models: vec![],
312        default_config_options: Default::default(),
313        favorite_config_option_values: Default::default(),
314    };
315
316    let BuiltinAgentServerSettings { path, .. } = settings.into();
317
318    let path = path.unwrap();
319    assert!(
320        !path.to_string_lossy().starts_with("~"),
321        "Tilde should be expanded for builtin agent path"
322    );
323
324    let settings = settings::CustomAgentServerSettings::Custom {
325        path: PathBuf::from("~/custom/agent"),
326        args: vec!["serve".into()],
327        env: Default::default(),
328        default_mode: None,
329        default_model: None,
330        favorite_models: vec![],
331        default_config_options: Default::default(),
332        favorite_config_option_values: Default::default(),
333    };
334
335    let converted: CustomAgentServerSettings = settings.into();
336    let CustomAgentServerSettings::Custom {
337        command: AgentServerCommand { path, .. },
338        ..
339    } = converted
340    else {
341        panic!("Expected Custom variant");
342    };
343
344    assert!(
345        !path.to_string_lossy().starts_with("~"),
346        "Tilde should be expanded for custom agent path"
347    );
348}