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 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        // Spawn a task to handle session updates so send_update doesn't fail
103        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}