1use anyhow::Result;
2use async_trait::async_trait;
3use vtcode_core::core::interfaces::acp::{AcpClientAdapter, AcpLaunchParams};
4
5mod agent;
6pub(crate) mod connection;
7pub(crate) mod constants;
8mod helpers;
9mod session;
10mod types;
11
12pub use agent::lifecycle::lifecycle_schema_document;
13use session::run_acp_agent;
14
15#[derive(Debug, Default, Clone, Copy)]
16pub struct ZedAcpAdapter;
17
18#[async_trait(?Send)]
19impl AcpClientAdapter for ZedAcpAdapter {
20 async fn serve(&self, params: AcpLaunchParams<'_>) -> Result<()> {
21 run_acp_agent(params.agent_config, params.runtime_config, Some("Zed".to_string())).await
22 }
23}
24
25#[derive(Debug, Default, Clone, Copy)]
26pub struct StandardAcpAdapter;
27
28#[async_trait(?Send)]
29impl AcpClientAdapter for StandardAcpAdapter {
30 async fn serve(&self, params: AcpLaunchParams<'_>) -> Result<()> {
31 run_acp_agent(params.agent_config, params.runtime_config, None).await
32 }
33}
34
35#[cfg(test)]
36#[allow(
37 unused_imports,
38 reason = "Intentional compatibility, platform, or test-only suppression."
39)]
40mod tests {
41 use super::agent::ZedAgent;
42 use super::*;
43 use crate::tooling::{TOOL_LIST_FILES_ITEMS_KEY, TOOL_LIST_FILES_RESULT_KEY, TOOL_LIST_FILES_URI_ARG};
44 use crate::zed::helpers::{
45 PrimaryAgentCatalog, SESSION_CONFIG_MODEL_ID, SESSION_CONFIG_PRIMARY_AGENT_ID, SESSION_CONFIG_PROVIDER_ID,
46 SESSION_CONFIG_THOUGHT_LEVEL_ID,
47 };
48 use agent_client_protocol::schema::v1::{
49 LoadSessionRequest, NewSessionRequest, SetSessionConfigOptionRequest, ToolCallStatus,
50 };
51 use agent_client_protocol::schema::v1::{SessionConfigKind, SessionConfigSelectOptions};
52 use assert_fs::TempDir;
53 use serde_json::{Value, json};
54 use std::collections::BTreeMap;
55 use std::path::Path;
56 use tokio::fs;
57 use vtcode_config::auth::AuthCredentialsStoreMode;
58 use vtcode_config::{SubagentDiscoveryInput, discover_subagents};
59 use vtcode_core::config::core::PromptCachingConfig;
60 use vtcode_core::config::models::{ModelId, Provider};
61 use vtcode_core::config::types::{
62 AgentConfig as CoreAgentConfig, ModelSelectionSource, ReasoningEffortLevel, UiSurfacePreference,
63 };
64 use vtcode_core::config::{AgentClientProtocolZedConfig, CommandsConfig, ToolsConfig};
65 use vtcode_core::core::agent::snapshots::{
66 DEFAULT_CHECKPOINTS_ENABLED, DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SNAPSHOTS,
67 };
68
69 async fn build_agent(workspace: &Path) -> ZedAgent {
70 let core_config = CoreAgentConfig {
71 model: "gpt-5.6-sol".to_string(),
72 api_key: String::new(),
73 provider: "openai".to_string(),
74 api_key_env: "TEST_API_KEY".to_string(),
75 workspace: workspace.to_path_buf(),
76 verbose: false,
77 quiet: false,
78 theme: "test".to_string(),
79 reasoning_effort: ReasoningEffortLevel::Low,
80 ui_surface: UiSurfacePreference::default(),
81 prompt_cache: PromptCachingConfig::default(),
82 model_source: ModelSelectionSource::WorkspaceConfig,
83 custom_api_keys: BTreeMap::new(),
84 checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
85 checkpointing_storage_dir: None,
86 checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
87 checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
88 max_conversation_turns: 1000,
89 model_behavior: None,
90 openai_chatgpt_auth: None,
91 };
92
93 let mut zed_config = AgentClientProtocolZedConfig::default();
94 zed_config.tools.list_files = true;
95 zed_config.tools.read_file = false;
96
97 let tools_config = ToolsConfig::default();
98 let mut discovery_input = SubagentDiscoveryInput::new(workspace.to_path_buf());
99 discovery_input.include_user_agents = false;
100 let discovered = discover_subagents(&discovery_input).expect("discover primary agents");
101 let primary_agents = PrimaryAgentCatalog::from_specs_with_default(&discovered.effective, "build");
102
103 ZedAgent::new(
104 core_config,
105 false,
106 AuthCredentialsStoreMode::default(),
107 zed_config,
108 tools_config,
109 CommandsConfig::default(),
110 String::new(),
111 Some("Zed".to_string()),
112 primary_agents,
113 )
114 .await
115 }
116
117 fn list_items_from_payload(payload: &Value) -> Vec<Value> {
118 payload
119 .get(TOOL_LIST_FILES_RESULT_KEY)
120 .and_then(Value::as_object)
121 .and_then(|result| result.get(TOOL_LIST_FILES_ITEMS_KEY))
122 .and_then(Value::as_array)
123 .cloned()
124 .unwrap_or_default()
125 }
126
127 fn primary_agent_select_values(config_options: &[crate::acp::SessionConfigOption]) -> Vec<String> {
128 config_options
129 .iter()
130 .find_map(|option| {
131 (option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PRIMARY_AGENT_ID)).then_some(&option.kind)
132 })
133 .and_then(|kind| match kind {
134 SessionConfigKind::Select(select) => Some(match &select.options {
135 SessionConfigSelectOptions::Ungrouped(options) => {
136 options.iter().map(|option| option.value.0.as_ref().to_string()).collect()
137 }
138 _ => Vec::new(),
139 }),
140 _ => None,
141 })
142 .unwrap_or_default()
143 }
144
145 fn primary_agent_select_labels(config_options: &[crate::acp::SessionConfigOption]) -> Vec<String> {
146 config_options
147 .iter()
148 .find_map(|option| {
149 (option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PRIMARY_AGENT_ID)).then_some(&option.kind)
150 })
151 .and_then(|kind| match kind {
152 SessionConfigKind::Select(select) => Some(match &select.options {
153 SessionConfigSelectOptions::Ungrouped(options) => {
154 options.iter().map(|option| option.name.clone()).collect()
155 }
156 _ => Vec::new(),
157 }),
158 _ => None,
159 })
160 .unwrap_or_default()
161 }
162
163 fn primary_agent_current_value(config_options: &[crate::acp::SessionConfigOption]) -> Option<String> {
164 config_options.iter().find_map(|option| {
165 if option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PRIMARY_AGENT_ID) {
166 match &option.kind {
167 SessionConfigKind::Select(select) => Some(select.current_value.0.as_ref().to_string()),
168 _ => None,
169 }
170 } else {
171 None
172 }
173 })
174 }
175
176 #[tokio::test]
177 async fn run_list_files_defaults_to_workspace_root() {
178 let temp = TempDir::new().unwrap();
179 let subdir = temp.path().join("src");
180 fs::create_dir(&subdir).await.unwrap();
181 let file_path = subdir.join("sample.txt");
182 fs::write(&file_path, "hello").await.unwrap();
183
184 let agent = build_agent(temp.path()).await;
185 let report = agent.run_list_files(&json!({"path": "src"})).await.unwrap();
186
187 assert!(matches!(report.status, ToolCallStatus::Completed));
188 let payload = report.raw_output.unwrap();
189 let items = list_items_from_payload(&payload);
190 assert!(
191 items.iter().any(|item| {
192 item.get("name")
193 .and_then(Value::as_str)
194 .map(|name| name == "sample.txt")
195 .or_else(|| {
196 item.get("path")
197 .and_then(Value::as_str)
198 .map(|path| path.ends_with("sample.txt"))
199 })
200 .unwrap_or(false)
201 }),
202 "payload: {payload}"
203 );
204 }
205
206 #[tokio::test]
207 async fn run_list_files_accepts_uri_argument() {
208 let temp = TempDir::new().unwrap();
209 let nested = temp.path().join("nested");
210 fs::create_dir_all(&nested).await.unwrap();
211 let inner = nested.join("inner.txt");
212 fs::write(&inner, "data").await.unwrap();
213
214 let agent = build_agent(temp.path()).await;
215 let uri = format!("file://{}", nested.to_string_lossy());
216 let report = agent.run_list_files(&json!({ TOOL_LIST_FILES_URI_ARG: uri })).await.unwrap();
217
218 assert!(matches!(report.status, ToolCallStatus::Completed));
219 let payload = report.raw_output.unwrap();
220 let items = list_items_from_payload(&payload);
221 assert!(items.iter().any(|item| {
222 item.get("path")
223 .and_then(Value::as_str)
224 .map(|path| path.contains("inner.txt"))
225 .unwrap_or(false)
226 }));
227 }
228
229 #[tokio::test]
230 async fn load_session_returns_existing_session_state() {
231 let temp = TempDir::new().unwrap();
232 let agent = build_agent(temp.path()).await;
233 let session_id = agent.register_session();
234
235 {
236 let session = agent.session_handle(&session_id).unwrap();
237 let mut data = session.data.lock().unwrap();
238 data.primary_agent = "build".to_string();
239 data.reasoning_effort = ReasoningEffortLevel::High;
240 }
241
242 let args = LoadSessionRequest::new(session_id, temp.path());
243 let response = agent.load_session(args).await.unwrap();
244
245 assert!(response.modes.is_none());
246 let config_options = response.config_options.unwrap();
247 assert_eq!(config_options.len(), 4);
248 assert!(config_options.iter().any(|option| {
249 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PRIMARY_AGENT_ID)
250 && matches!(
251 &option.kind,
252 SessionConfigKind::Select(select)
253 if select.current_value
254 == crate::acp::SessionConfigValueId::new("build")
255 )
256 }));
257 assert!(config_options.iter().any(|option| {
258 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
259 && matches!(
260 &option.kind,
261 SessionConfigKind::Select(select)
262 if select.current_value
263 == crate::acp::SessionConfigValueId::new("high")
264 )
265 }));
266 assert!(config_options.iter().any(|option| {
267 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
268 && matches!(
269 &option.kind,
270 SessionConfigKind::Select(select)
271 if select.current_value
272 == crate::acp::SessionConfigValueId::new("openai")
273 )
274 }));
275 assert!(config_options.iter().any(|option| {
276 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
277 && matches!(
278 &option.kind,
279 SessionConfigKind::Select(select)
280 if select.current_value
281 == crate::acp::SessionConfigValueId::new("gpt-5.6-sol")
282 )
283 }));
284 }
285
286 #[tokio::test]
287 async fn new_session_returns_config_options() {
288 let temp = TempDir::new().unwrap();
289 let agent = build_agent(temp.path()).await;
290
291 let response = agent.new_session(NewSessionRequest::new(temp.path())).await.unwrap();
292
293 assert!(response.modes.is_none());
294 let config_options = response.config_options.unwrap();
295 assert_eq!(config_options.len(), 4);
296 assert!(config_options.iter().any(|option| {
297 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PRIMARY_AGENT_ID)
298 && matches!(
299 &option.kind,
300 SessionConfigKind::Select(select)
301 if select.current_value
302 == crate::acp::SessionConfigValueId::new("build")
303 )
304 }));
305 assert!(config_options.iter().any(|option| {
306 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
307 && matches!(
308 &option.kind,
309 SessionConfigKind::Select(select)
310 if select.current_value
311 == crate::acp::SessionConfigValueId::new("low")
312 )
313 }));
314 assert!(config_options.iter().any(|option| {
315 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
316 && matches!(
317 &option.kind,
318 SessionConfigKind::Select(select)
319 if select.current_value
320 == crate::acp::SessionConfigValueId::new("openai")
321 )
322 }));
323 assert!(config_options.iter().any(|option| {
324 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
325 && matches!(
326 &option.kind,
327 SessionConfigKind::Select(select)
328 if select.current_value
329 == crate::acp::SessionConfigValueId::new("gpt-5.6-sol")
330 )
331 }));
332 }
333
334 #[tokio::test]
335 async fn set_session_config_option_updates_primary_agent() {
336 let temp = TempDir::new().unwrap();
337 let agent = build_agent(temp.path()).await;
338 let session_id = agent.register_session();
339
340 let response = agent
341 .set_session_config_option(SetSessionConfigOptionRequest::new(
342 session_id.clone(),
343 SESSION_CONFIG_PRIMARY_AGENT_ID,
344 "build",
345 ))
346 .await
347 .unwrap();
348
349 let session = agent.session_handle(&session_id).unwrap();
350 assert_eq!(session.data.lock().unwrap().primary_agent, "build");
351 assert!(response.config_options.iter().any(|option| {
352 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PRIMARY_AGENT_ID)
353 && matches!(
354 &option.kind,
355 SessionConfigKind::Select(select)
356 if select.current_value
357 == crate::acp::SessionConfigValueId::new("build")
358 )
359 }));
360 }
361
362 #[tokio::test]
363 async fn set_session_config_option_accepts_only_known_primary_agent_ids() {
364 let temp = TempDir::new().unwrap();
365 let agent = build_agent(temp.path()).await;
366 let session_id = agent.register_session();
367
368 for primary_agent in ["duck", "plan", "build", "auto"] {
369 let response = agent
370 .set_session_config_option(SetSessionConfigOptionRequest::new(
371 session_id.clone(),
372 SESSION_CONFIG_PRIMARY_AGENT_ID,
373 primary_agent,
374 ))
375 .await
376 .unwrap();
377
378 let session = agent.session_handle(&session_id).unwrap();
379 assert_eq!(session.data.lock().unwrap().primary_agent, primary_agent);
380 assert_eq!(primary_agent_current_value(&response.config_options), Some(primary_agent.to_string()));
381 }
382 }
383
384 #[tokio::test]
385 async fn set_session_config_option_rejects_unknown_primary_agent() {
386 let temp = TempDir::new().unwrap();
387 let agent = build_agent(temp.path()).await;
388 let session_id = agent.register_session();
389 let session = agent.session_handle(&session_id).unwrap();
390 session.data.lock().unwrap().primary_agent = "build".to_string();
391
392 let result = agent
393 .set_session_config_option(SetSessionConfigOptionRequest::new(
394 session_id.clone(),
395 SESSION_CONFIG_PRIMARY_AGENT_ID,
396 "research",
397 ))
398 .await;
399
400 let error = result.expect_err("unknown primary agent should be rejected");
401 let error = format!("{error:?}");
402 assert!(error.contains("unknown_primary_agent"));
403 assert!(error.contains("research"));
404 assert_eq!(session.data.lock().unwrap().primary_agent, "build");
405 }
406
407 #[tokio::test]
408 async fn session_config_options_include_custom_primary_agent() {
409 let temp = TempDir::new().unwrap();
410 fs::create_dir_all(temp.path().join(".vtcode/agents")).await.unwrap();
411 fs::write(
412 temp.path().join(".vtcode/agents/research.md"),
413 r#"---
414name: research
415description: Research primary
416mode: primary
417permissions:
418 default: deny
419---
420Research primary prompt."#,
421 )
422 .await
423 .unwrap();
424 let agent = build_agent(temp.path()).await;
425 let session_id = agent.register_session();
426 let session = agent.session_handle(&session_id).unwrap();
427 session.data.lock().unwrap().primary_agent = "research".to_string();
428
429 let response = agent
430 .load_session(LoadSessionRequest::new(session_id, temp.path()))
431 .await
432 .unwrap();
433 let config_options = response.config_options.unwrap();
434
435 assert_eq!(
436 primary_agent_select_values(&config_options),
437 ["duck", "plan", "build", "auto", "research"].map(str::to_string).to_vec()
438 );
439 assert!(primary_agent_select_labels(&config_options).contains(&"Research primary".into()));
440 assert_eq!(primary_agent_current_value(&config_options), Some("research".to_string()));
441 }
442
443 #[tokio::test]
444 async fn session_config_options_use_overridden_builtin_primary_agent_metadata() {
445 let temp = TempDir::new().unwrap();
446 fs::create_dir_all(temp.path().join(".vtcode/agents")).await.unwrap();
447 fs::write(
448 temp.path().join(".vtcode/agents/build.md"),
449 r#"---
450name: build
451description: Project Build
452mode: primary
453permissions:
454 default: deny
455aliases:
456 - project-builder
457---
458Project build prompt."#,
459 )
460 .await
461 .unwrap();
462 let agent = build_agent(temp.path()).await;
463 let session_id = agent.register_session();
464 let session = agent.session_handle(&session_id).unwrap();
465
466 let response = agent
467 .set_session_config_option(SetSessionConfigOptionRequest::new(
468 session_id,
469 SESSION_CONFIG_PRIMARY_AGENT_ID,
470 "project-builder",
471 ))
472 .await
473 .unwrap();
474
475 assert_eq!(session.data.lock().unwrap().primary_agent, "build");
476 assert!(primary_agent_select_labels(&response.config_options).contains(&"Project Build".into()));
477 }
478
479 #[tokio::test]
480 async fn set_session_config_option_updates_reasoning_effort() {
481 let temp = TempDir::new().unwrap();
482 let agent = build_agent(temp.path()).await;
483 let session_id = agent.register_session();
484
485 let response = agent
486 .set_session_config_option(SetSessionConfigOptionRequest::new(
487 session_id.clone(),
488 SESSION_CONFIG_THOUGHT_LEVEL_ID,
489 "xhigh",
490 ))
491 .await
492 .unwrap();
493
494 let session = agent.session_handle(&session_id).unwrap();
495 assert_eq!(session.data.lock().unwrap().reasoning_effort, ReasoningEffortLevel::XHigh);
496 assert!(response.config_options.iter().any(|option| {
497 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
498 && matches!(
499 &option.kind,
500 SessionConfigKind::Select(select)
501 if select.current_value
502 == crate::acp::SessionConfigValueId::new("xhigh")
503 )
504 }));
505 }
506
507 #[tokio::test]
508 async fn set_session_config_option_updates_provider_and_auto_switches_model() {
509 let temp = TempDir::new().unwrap();
510 let agent = build_agent(temp.path()).await;
511 let session_id = agent.register_session();
512 let anthropic_default = ModelId::default_single_for_provider(Provider::Anthropic).as_str().into_owned();
513
514 let response = agent
515 .set_session_config_option(SetSessionConfigOptionRequest::new(
516 session_id.clone(),
517 SESSION_CONFIG_PROVIDER_ID,
518 "anthropic",
519 ))
520 .await
521 .unwrap();
522
523 let session = agent.session_handle(&session_id).unwrap();
524 assert_eq!(session.data.lock().unwrap().provider, "anthropic");
525 assert_eq!(session.data.lock().unwrap().model, anthropic_default);
526 assert!(response.config_options.iter().any(|option| {
527 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
528 && matches!(
529 &option.kind,
530 SessionConfigKind::Select(select)
531 if select.current_value
532 == crate::acp::SessionConfigValueId::new("anthropic")
533 )
534 }));
535 assert!(response.config_options.iter().any(|option| {
536 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
537 && matches!(
538 &option.kind,
539 SessionConfigKind::Select(select)
540 if select.current_value
541 == crate::acp::SessionConfigValueId::new(anthropic_default.as_str())
542 )
543 }));
544 }
545
546 #[tokio::test]
547 async fn set_session_config_option_updates_model_for_provider() {
548 let temp = TempDir::new().unwrap();
549 let agent = build_agent(temp.path()).await;
550 let session_id = agent.register_session();
551
552 let response = agent
553 .set_session_config_option(SetSessionConfigOptionRequest::new(
554 session_id.clone(),
555 SESSION_CONFIG_MODEL_ID,
556 "gpt-5.6-luna",
557 ))
558 .await
559 .unwrap();
560
561 let session = agent.session_handle(&session_id).unwrap();
562 assert_eq!(session.data.lock().unwrap().provider, "openai");
563 assert_eq!(session.data.lock().unwrap().model, "gpt-5.6-luna");
564 assert!(response.config_options.iter().any(|option| {
565 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
566 && matches!(
567 &option.kind,
568 SessionConfigKind::Select(select)
569 if select.current_value
570 == crate::acp::SessionConfigValueId::new("gpt-5.6-luna")
571 )
572 }));
573 }
574}