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 &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 AgentId(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 AgentId(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 AgentId(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(&AgentId(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 = AgentId(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 = AgentId(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}