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