Skip to main content

vtcode_acp/zed/
mod.rs

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}