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}