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