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        SESSION_CONFIG_MODE_ID, SESSION_CONFIG_MODEL_ID, SESSION_CONFIG_PROVIDER_ID,
47        SESSION_CONFIG_THOUGHT_LEVEL_ID,
48    };
49    use crate::zed::types::NotificationEnvelope;
50    use agent_client_protocol::{
51        Agent, LoadSessionRequest, NewSessionRequest, SessionConfigKind, SessionModeId,
52        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_core::config::core::PromptCachingConfig;
61    use vtcode_core::config::models::{ModelId, Provider};
62    use vtcode_core::config::types::{
63        AgentConfig as CoreAgentConfig, ModelSelectionSource, ReasoningEffortLevel,
64        UiSurfacePreference,
65    };
66    use vtcode_core::config::{AgentClientProtocolZedConfig, CommandsConfig, ToolsConfig};
67    use vtcode_core::core::agent::snapshots::{
68        DEFAULT_CHECKPOINTS_ENABLED, DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SNAPSHOTS,
69    };
70    use vtcode_core::core::interfaces::SessionMode;
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
109        ZedAgent::new(
110            core_config,
111            zed_config,
112            tools_config,
113            CommandsConfig::default(),
114            String::new(),
115            tx,
116            Some("Zed".to_string()),
117        )
118        .await
119    }
120
121    fn list_items_from_payload(payload: &Value) -> Vec<Value> {
122        payload
123            .get(TOOL_LIST_FILES_RESULT_KEY)
124            .and_then(Value::as_object)
125            .and_then(|result| result.get(TOOL_LIST_FILES_ITEMS_KEY))
126            .and_then(Value::as_array)
127            .cloned()
128            .unwrap_or_default()
129    }
130
131    #[tokio::test]
132    async fn run_list_files_defaults_to_workspace_root() {
133        let temp = TempDir::new().unwrap();
134        let subdir = temp.path().join("src");
135        fs::create_dir(&subdir).await.unwrap();
136        let file_path = subdir.join("example.txt");
137        fs::write(&file_path, "hello").await.unwrap();
138
139        let agent = build_agent(temp.path()).await;
140        let report = agent.run_list_files(&json!({"path": "src"})).await.unwrap();
141
142        assert!(matches!(report.status, ToolCallStatus::Completed));
143        let payload = report.raw_output.unwrap();
144        let items = list_items_from_payload(&payload);
145        assert!(items.iter().any(|item| {
146            item.get("name")
147                .and_then(Value::as_str)
148                .map(|name| name == "example.txt")
149                .unwrap_or(false)
150        }));
151    }
152
153    #[tokio::test]
154    async fn run_list_files_accepts_uri_argument() {
155        let temp = TempDir::new().unwrap();
156        let nested = temp.path().join("nested");
157        fs::create_dir_all(&nested).await.unwrap();
158        let inner = nested.join("inner.txt");
159        fs::write(&inner, "data").await.unwrap();
160
161        let agent = build_agent(temp.path()).await;
162        let uri = format!("file://{}", nested.to_string_lossy());
163        let report = agent
164            .run_list_files(&json!({ TOOL_LIST_FILES_URI_ARG: uri }))
165            .await
166            .unwrap();
167
168        assert!(matches!(report.status, ToolCallStatus::Completed));
169        let payload = report.raw_output.unwrap();
170        let items = list_items_from_payload(&payload);
171        assert!(items.iter().any(|item| {
172            item.get("path")
173                .and_then(Value::as_str)
174                .map(|path| path.contains("inner.txt"))
175                .unwrap_or(false)
176        }));
177    }
178
179    #[tokio::test]
180    async fn load_session_returns_existing_session_state() {
181        let temp = TempDir::new().unwrap();
182        let agent = build_agent(temp.path()).await;
183        let session_id = agent.register_session();
184
185        // Change the mode of the registered session
186        {
187            let session = agent.session_handle(&session_id).unwrap();
188            let mut data = session.data.borrow_mut();
189            data.current_mode = SessionMode::Architect;
190            data.reasoning_effort = ReasoningEffortLevel::High;
191        }
192
193        let args = LoadSessionRequest::new(session_id, temp.path());
194        let response = agent.load_session(args).await.unwrap();
195
196        assert_eq!(
197            response.modes.unwrap().current_mode_id,
198            SessionModeId::new("architect")
199        );
200        let config_options = response.config_options.unwrap();
201        assert_eq!(config_options.len(), 4);
202        assert!(config_options.iter().any(|option| {
203            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODE_ID)
204                && matches!(
205                    &option.kind,
206                    SessionConfigKind::Select(select)
207                        if select.current_value
208                            == crate::acp::SessionConfigValueId::new("architect")
209                )
210        }));
211        assert!(config_options.iter().any(|option| {
212            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
213                && matches!(
214                    &option.kind,
215                    SessionConfigKind::Select(select)
216                        if select.current_value
217                            == crate::acp::SessionConfigValueId::new("high")
218                )
219        }));
220        assert!(config_options.iter().any(|option| {
221            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
222                && matches!(
223                    &option.kind,
224                    SessionConfigKind::Select(select)
225                        if select.current_value
226                            == crate::acp::SessionConfigValueId::new("openai")
227                )
228        }));
229        assert!(config_options.iter().any(|option| {
230            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
231                && matches!(
232                    &option.kind,
233                    SessionConfigKind::Select(select)
234                        if select.current_value
235                            == crate::acp::SessionConfigValueId::new("gpt-5.4")
236                )
237        }));
238    }
239
240    #[tokio::test]
241    async fn new_session_returns_config_options() {
242        let temp = TempDir::new().unwrap();
243        let agent = build_agent(temp.path()).await;
244
245        let response = agent
246            .new_session(NewSessionRequest::new(temp.path()))
247            .await
248            .unwrap();
249
250        assert_eq!(
251            response.modes.unwrap().current_mode_id,
252            SessionModeId::new("code")
253        );
254        let config_options = response.config_options.unwrap();
255        assert_eq!(config_options.len(), 4);
256        assert!(config_options.iter().any(|option| {
257            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODE_ID)
258                && matches!(
259                    &option.kind,
260                    SessionConfigKind::Select(select)
261                        if select.current_value
262                            == crate::acp::SessionConfigValueId::new("code")
263                )
264        }));
265        assert!(config_options.iter().any(|option| {
266            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
267                && matches!(
268                    &option.kind,
269                    SessionConfigKind::Select(select)
270                        if select.current_value
271                            == crate::acp::SessionConfigValueId::new("low")
272                )
273        }));
274        assert!(config_options.iter().any(|option| {
275            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
276                && matches!(
277                    &option.kind,
278                    SessionConfigKind::Select(select)
279                        if select.current_value
280                            == crate::acp::SessionConfigValueId::new("openai")
281                )
282        }));
283        assert!(config_options.iter().any(|option| {
284            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
285                && matches!(
286                    &option.kind,
287                    SessionConfigKind::Select(select)
288                        if select.current_value
289                            == crate::acp::SessionConfigValueId::new("gpt-5.4")
290                )
291        }));
292    }
293
294    #[tokio::test]
295    async fn set_session_config_option_updates_mode() {
296        let temp = TempDir::new().unwrap();
297        let agent = build_agent(temp.path()).await;
298        let session_id = agent.register_session();
299
300        let response = agent
301            .set_session_config_option(SetSessionConfigOptionRequest::new(
302                session_id.clone(),
303                SESSION_CONFIG_MODE_ID,
304                "architect",
305            ))
306            .await
307            .unwrap();
308
309        let session = agent.session_handle(&session_id).unwrap();
310        assert_eq!(session.data.borrow().current_mode, SessionMode::Architect);
311        assert!(response.config_options.iter().any(|option| {
312            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODE_ID)
313                && matches!(
314                    &option.kind,
315                    SessionConfigKind::Select(select)
316                        if select.current_value
317                            == crate::acp::SessionConfigValueId::new("architect")
318                )
319        }));
320    }
321
322    #[tokio::test]
323    async fn set_session_config_option_updates_reasoning_effort() {
324        let temp = TempDir::new().unwrap();
325        let agent = build_agent(temp.path()).await;
326        let session_id = agent.register_session();
327
328        let response = agent
329            .set_session_config_option(SetSessionConfigOptionRequest::new(
330                session_id.clone(),
331                SESSION_CONFIG_THOUGHT_LEVEL_ID,
332                "xhigh",
333            ))
334            .await
335            .unwrap();
336
337        let session = agent.session_handle(&session_id).unwrap();
338        assert_eq!(
339            session.data.borrow().reasoning_effort,
340            ReasoningEffortLevel::XHigh
341        );
342        assert!(response.config_options.iter().any(|option| {
343            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_THOUGHT_LEVEL_ID)
344                && matches!(
345                    &option.kind,
346                    SessionConfigKind::Select(select)
347                        if select.current_value
348                            == crate::acp::SessionConfigValueId::new("xhigh")
349                )
350        }));
351    }
352
353    #[tokio::test]
354    async fn set_session_config_option_updates_provider_and_auto_switches_model() {
355        let temp = TempDir::new().unwrap();
356        let agent = build_agent(temp.path()).await;
357        let session_id = agent.register_session();
358        let anthropic_default = ModelId::default_single_for_provider(Provider::Anthropic).as_str();
359
360        let response = agent
361            .set_session_config_option(SetSessionConfigOptionRequest::new(
362                session_id.clone(),
363                SESSION_CONFIG_PROVIDER_ID,
364                "anthropic",
365            ))
366            .await
367            .unwrap();
368
369        let session = agent.session_handle(&session_id).unwrap();
370        assert_eq!(session.data.borrow().provider, "anthropic");
371        assert_eq!(session.data.borrow().model, anthropic_default);
372        assert!(response.config_options.iter().any(|option| {
373            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_PROVIDER_ID)
374                && matches!(
375                    &option.kind,
376                    SessionConfigKind::Select(select)
377                        if select.current_value
378                            == crate::acp::SessionConfigValueId::new("anthropic")
379                )
380        }));
381        assert!(response.config_options.iter().any(|option| {
382            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
383                && matches!(
384                    &option.kind,
385                    SessionConfigKind::Select(select)
386                        if select.current_value
387                            == crate::acp::SessionConfigValueId::new(anthropic_default)
388                )
389        }));
390    }
391
392    #[tokio::test]
393    async fn set_session_config_option_updates_model_for_provider() {
394        let temp = TempDir::new().unwrap();
395        let agent = build_agent(temp.path()).await;
396        let session_id = agent.register_session();
397
398        let response = agent
399            .set_session_config_option(SetSessionConfigOptionRequest::new(
400                session_id.clone(),
401                SESSION_CONFIG_MODEL_ID,
402                "gpt-5.4-mini",
403            ))
404            .await
405            .unwrap();
406
407        let session = agent.session_handle(&session_id).unwrap();
408        assert_eq!(session.data.borrow().provider, "openai");
409        assert_eq!(session.data.borrow().model, "gpt-5.4-mini");
410        assert!(response.config_options.iter().any(|option| {
411            option.id == crate::acp::SessionConfigId::new(SESSION_CONFIG_MODEL_ID)
412                && matches!(
413                    &option.kind,
414                    SessionConfigKind::Select(select)
415                        if select.current_value
416                            == crate::acp::SessionConfigValueId::new("gpt-5.4-mini")
417                )
418        }));
419    }
420
421    #[tokio::test]
422    async fn run_switch_mode_updates_session_mode() {
423        let temp = TempDir::new().unwrap();
424        let agent = build_agent(temp.path()).await;
425        let session_id = agent.register_session();
426
427        // Verify initial mode is "code" (default in register_session)
428        {
429            let session = agent.session_handle(&session_id).unwrap();
430            assert_eq!(session.data.borrow().current_mode, SessionMode::Code);
431        }
432
433        // Switch to "architect"
434        let args = json!({ "mode_id": "architect" });
435        let report = agent.run_switch_mode(&session_id, &args).await.unwrap();
436
437        assert!(matches!(report.status, ToolCallStatus::Completed));
438
439        // Verify session mode was updated
440        {
441            let session = agent.session_handle(&session_id).unwrap();
442            assert_eq!(session.data.borrow().current_mode, SessionMode::Architect);
443        }
444    }
445}