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", "coordinator", "research"]
438 .map(str::to_string)
439 .to_vec()
440 );
441 assert!(primary_agent_select_labels(&config_options).contains(&"Research primary".into()));
442 assert!(primary_agent_select_labels(&config_options).contains(&"Coordinator".into()));
443 assert_eq!(primary_agent_current_value(&config_options), Some("research".to_string()));
444 }
445
446 #[tokio::test]
447 async fn session_config_options_use_overridden_builtin_primary_agent_metadata() {
448 let temp = TempDir::new().unwrap();
449 fs::create_dir_all(temp.path().join(".vtcode/agents")).await.unwrap();
450 fs::write(
451 temp.path().join(".vtcode/agents/build.md"),
452 r#"---
453name: build
454description: Project Build
455mode: primary
456permissions:
457 default: deny
458aliases:
459 - project-builder
460---
461Project build prompt."#,
462 )
463 .await
464 .unwrap();
465 let agent = build_agent(temp.path()).await;
466 let session_id = agent.register_session();
467 let session = agent.session_handle(&session_id).unwrap();
468
469 let response = agent
470 .set_session_config_option(SetSessionConfigOptionRequest::new(
471 session_id,
472 SESSION_CONFIG_PRIMARY_AGENT_ID,
473 "project-builder",
474 ))
475 .await
476 .unwrap();
477
478 assert_eq!(session.data.lock().unwrap().primary_agent, "build");
479 assert!(primary_agent_select_labels(&response.config_options).contains(&"Project Build".into()));
480 }
481
482 #[tokio::test]
483 async fn set_session_config_option_updates_reasoning_effort() {
484 let temp = TempDir::new().unwrap();
485 let agent = build_agent(temp.path()).await;
486 let session_id = agent.register_session();
487
488 let response = agent
489 .set_session_config_option(SetSessionConfigOptionRequest::new(
490 session_id.clone(),
491 SESSION_CONFIG_THOUGHT_LEVEL_ID,
492 "xhigh",
493 ))
494 .await
495 .unwrap();
496
497 let session = agent.session_handle(&session_id).unwrap();
498 assert_eq!(session.data.lock().unwrap().reasoning_effort, ReasoningEffortLevel::XHigh);
499 assert!(response.config_options.iter().any(|option| {
500 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
501 && matches!(
502 &option.kind,
503 SessionConfigKind::Select(select)
504 if select.current_value
505 == crate::acp::SessionConfigValueId::new("xhigh")
506 )
507 }));
508 }
509
510 #[tokio::test]
511 async fn set_session_config_option_updates_provider_and_auto_switches_model() {
512 let temp = TempDir::new().unwrap();
513 let agent = build_agent(temp.path()).await;
514 let session_id = agent.register_session();
515 let anthropic_default = ModelId::default_single_for_provider(Provider::Anthropic).as_str().into_owned();
516
517 let response = agent
518 .set_session_config_option(SetSessionConfigOptionRequest::new(
519 session_id.clone(),
520 SESSION_CONFIG_PROVIDER_ID,
521 "anthropic",
522 ))
523 .await
524 .unwrap();
525
526 let session = agent.session_handle(&session_id).unwrap();
527 assert_eq!(session.data.lock().unwrap().provider, "anthropic");
528 assert_eq!(session.data.lock().unwrap().model, anthropic_default);
529 assert!(response.config_options.iter().any(|option| {
530 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
531 && matches!(
532 &option.kind,
533 SessionConfigKind::Select(select)
534 if select.current_value
535 == crate::acp::SessionConfigValueId::new("anthropic")
536 )
537 }));
538 assert!(response.config_options.iter().any(|option| {
539 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
540 && matches!(
541 &option.kind,
542 SessionConfigKind::Select(select)
543 if select.current_value
544 == crate::acp::SessionConfigValueId::new(anthropic_default.as_str())
545 )
546 }));
547 }
548
549 #[tokio::test]
550 async fn set_session_config_option_updates_model_for_provider() {
551 let temp = TempDir::new().unwrap();
552 let agent = build_agent(temp.path()).await;
553 let session_id = agent.register_session();
554
555 let response = agent
556 .set_session_config_option(SetSessionConfigOptionRequest::new(
557 session_id.clone(),
558 SESSION_CONFIG_MODEL_ID,
559 "gpt-5.6-luna",
560 ))
561 .await
562 .unwrap();
563
564 let session = agent.session_handle(&session_id).unwrap();
565 assert_eq!(session.data.lock().unwrap().provider, "openai");
566 assert_eq!(session.data.lock().unwrap().model, "gpt-5.6-luna");
567 assert!(response.config_options.iter().any(|option| {
568 option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
569 && matches!(
570 &option.kind,
571 SessionConfigKind::Select(select)
572 if select.current_value
573 == crate::acp::SessionConfigValueId::new("gpt-5.6-luna")
574 )
575 }));
576 }
577}