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}