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}