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