acp: Set agent server cwd from project paths (#52005)

Ben Brandt created

## Context

Add Project::default_path_list and reuse it for agent session work
directories and ACP server startup so agent processes start in the
project context by default

We previously removed all cwd from this hoping to have a global process
shared, but it doesn't work for remote projects. Since we're at the
project boundary anyway, we might as well start it up in a similar spot
as a new thread.


## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- acp: Make sure the agent server is started in a project directory

Change summary

crates/agent/src/native_agent_server.rs           |  3 
crates/agent_servers/src/acp.rs                   | 12 +++
crates/agent_servers/src/agent_servers.rs         |  3 
crates/agent_servers/src/custom.rs                |  9 ++
crates/agent_servers/src/e2e_tests.rs             |  5 +
crates/agent_ui/src/agent_connection_store.rs     |  2 
crates/agent_ui/src/conversation_view.rs          | 31 +--------
crates/agent_ui/src/mention_set.rs                |  2 
crates/agent_ui/src/test_support.rs               |  2 
crates/project/src/project.rs                     | 29 ++++++++
crates/project/tests/integration/project_tests.rs | 57 +++++++++++++++++
crates/zed/src/visual_test_runner.rs              |  7 +
12 files changed, 124 insertions(+), 38 deletions(-)

Detailed changes

crates/agent/src/native_agent_server.rs 🔗

@@ -7,7 +7,7 @@ use anyhow::Result;
 use collections::HashSet;
 use fs::Fs;
 use gpui::{App, Entity, Task};
-use project::AgentId;
+use project::{AgentId, Project};
 use prompt_store::PromptStore;
 use settings::{LanguageModelSelection, Settings as _, update_settings_file};
 
@@ -37,6 +37,7 @@ impl AgentServer for NativeAgentServer {
     fn connect(
         &self,
         _delegate: AgentServerDelegate,
+        _project: Entity<Project>,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
         log::debug!("NativeAgentServer::connect");

crates/agent_servers/src/acp.rs 🔗

@@ -166,6 +166,7 @@ impl AgentSessionList for AcpSessionList {
 
 pub async fn connect(
     agent_id: AgentId,
+    project: Entity<Project>,
     display_name: SharedString,
     command: AgentServerCommand,
     default_mode: Option<acp::SessionModeId>,
@@ -175,6 +176,7 @@ pub async fn connect(
 ) -> Result<Rc<dyn AgentConnection>> {
     let conn = AcpConnection::stdio(
         agent_id,
+        project,
         display_name,
         command.clone(),
         default_mode,
@@ -191,6 +193,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1
 impl AcpConnection {
     pub async fn stdio(
         agent_id: AgentId,
+        project: Entity<Project>,
         display_name: SharedString,
         command: AgentServerCommand,
         default_mode: Option<acp::SessionModeId>,
@@ -203,6 +206,15 @@ impl AcpConnection {
         let mut child =
             builder.build_std_command(Some(command.path.display().to_string()), &command.args);
         child.envs(command.env.iter().flatten());
+        if let Some(cwd) = project.update(cx, |project, cx| {
+            project
+                .default_path_list(cx)
+                .ordered_paths()
+                .next()
+                .cloned()
+        }) {
+            child.current_dir(cwd);
+        }
         let mut child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?;
 
         let stdout = child.stdout.take().context("Failed to take stdout")?;

crates/agent_servers/src/agent_servers.rs 🔗

@@ -9,7 +9,7 @@ use collections::{HashMap, HashSet};
 pub use custom::*;
 use fs::Fs;
 use http_client::read_no_proxy_from_env;
-use project::{AgentId, agent_server_store::AgentServerStore};
+use project::{AgentId, Project, agent_server_store::AgentServerStore};
 
 use acp_thread::AgentConnection;
 use anyhow::Result;
@@ -42,6 +42,7 @@ pub trait AgentServer: Send {
     fn connect(
         &self,
         delegate: AgentServerDelegate,
+        project: Entity<Project>,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>>;
 

crates/agent_servers/src/custom.rs 🔗

@@ -5,9 +5,12 @@ use anyhow::{Context as _, Result};
 use collections::HashSet;
 use credentials_provider::CredentialsProvider;
 use fs::Fs;
-use gpui::{App, AppContext as _, Task};
+use gpui::{App, AppContext as _, Entity, Task};
 use language_model::{ApiKey, EnvVar};
-use project::agent_server_store::{AgentId, AllAgentServersSettings};
+use project::{
+    Project,
+    agent_server_store::{AgentId, AllAgentServersSettings},
+};
 use settings::{SettingsStore, update_settings_file};
 use std::{rc::Rc, sync::Arc};
 use ui::IconName;
@@ -289,6 +292,7 @@ impl AgentServer for CustomAgentServer {
     fn connect(
         &self,
         delegate: AgentServerDelegate,
+        project: Entity<Project>,
         cx: &mut App,
     ) -> Task<Result<Rc<dyn AgentConnection>>> {
         let agent_id = self.agent_id();
@@ -371,6 +375,7 @@ impl AgentServer for CustomAgentServer {
                 .await?;
             let connection = crate::acp::connect(
                 agent_id,
+                project,
                 display_name,
                 command,
                 default_mode,

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -434,7 +434,10 @@ pub async fn new_test_thread(
     let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
     let delegate = AgentServerDelegate::new(store, None);
 
-    let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap();
+    let connection = cx
+        .update(|cx| server.connect(delegate, project.clone(), cx))
+        .await
+        .unwrap();
 
     cx.update(|cx| {
         connection.new_session(project.clone(), PathList::new(&[current_dir.as_ref()]), cx)

crates/agent_ui/src/agent_connection_store.rs 🔗

@@ -160,7 +160,7 @@ impl AgentConnectionStore {
         let agent_server_store = self.project.read(cx).agent_server_store().clone();
         let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx));
 
-        let connect_task = server.connect(delegate, cx);
+        let connect_task = server.connect(delegate, self.project.clone(), cx);
         let connect_task = cx.spawn(async move |_this, cx| match connect_task.await {
             Ok(connection) => cx.update(|cx| {
                 let history = connection

crates/agent_ui/src/conversation_view.rs 🔗

@@ -619,32 +619,7 @@ impl ConversationView {
                 session_id: resume_session_id.clone(),
             };
         }
-        let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
-        // Pick the first non-single-file worktree for the root directory if there are any,
-        // and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
-        worktrees.sort_by(|l, r| {
-            l.read(cx)
-                .is_single_file()
-                .cmp(&r.read(cx).is_single_file())
-        });
-        let worktree_roots: Vec<Arc<Path>> = worktrees
-            .iter()
-            .filter_map(|worktree| {
-                let worktree = worktree.read(cx);
-                if worktree.is_single_file() {
-                    Some(worktree.abs_path().parent()?.into())
-                } else {
-                    Some(worktree.abs_path())
-                }
-            })
-            .collect();
-        let session_work_dirs = work_dirs.unwrap_or_else(|| {
-            if worktree_roots.is_empty() {
-                PathList::new(&[paths::home_dir().as_path()])
-            } else {
-                PathList::new(&worktree_roots)
-            }
-        });
+        let session_work_dirs = work_dirs.unwrap_or_else(|| project.read(cx).default_path_list(cx));
 
         let connection_entry = connection_store.update(cx, |store, cx| {
             store.request_connection(connection_key, agent.clone(), cx)
@@ -1624,7 +1599,7 @@ impl ConversationView {
             .read(cx)
             .work_dirs()
             .cloned()
-            .unwrap_or_else(|| PathList::new(&[paths::home_dir().as_path()]));
+            .unwrap_or_else(|| self.project.read(cx).default_path_list(cx));
 
         let subagent_thread_task = connected.connection.clone().load_session(
             subagent_id.clone(),
@@ -3651,6 +3626,7 @@ pub(crate) mod tests {
         fn connect(
             &self,
             _delegate: AgentServerDelegate,
+            _project: Entity<Project>,
             _cx: &mut App,
         ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
             Task::ready(Ok(Rc::new(self.connection.clone())))
@@ -3675,6 +3651,7 @@ pub(crate) mod tests {
         fn connect(
             &self,
             _delegate: AgentServerDelegate,
+            _project: Entity<Project>,
             _cx: &mut App,
         ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
             Task::ready(Err(anyhow!(

crates/agent_ui/src/mention_set.rs 🔗

@@ -562,7 +562,7 @@ impl MentionSet {
         ));
         let delegate =
             AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None);
-        let connection = server.connect(delegate, cx);
+        let connection = server.connect(delegate, project.clone(), cx);
         cx.spawn(async move |_, cx| {
             let agent = connection.await?;
             let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();

crates/agent_ui/src/test_support.rs 🔗

@@ -3,6 +3,7 @@ use agent_client_protocol as acp;
 use agent_servers::{AgentServer, AgentServerDelegate};
 use gpui::{Entity, Task, TestAppContext, VisualTestContext};
 use project::AgentId;
+use project::Project;
 use settings::SettingsStore;
 use std::any::Any;
 use std::rc::Rc;
@@ -45,6 +46,7 @@ where
     fn connect(
         &self,
         _delegate: AgentServerDelegate,
+        _project: Entity<Project>,
         _cx: &mut gpui::App,
     ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
         Task::ready(Ok(Rc::new(self.connection.clone())))

crates/project/src/project.rs 🔗

@@ -33,7 +33,7 @@ pub mod search_history;
 pub mod yarn;
 
 use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope};
-use itertools::Either;
+use itertools::{Either, Itertools};
 
 use crate::{
     git_store::GitStore,
@@ -134,6 +134,7 @@ use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope};
 use toolchain_store::EmptyToolchainStore;
 use util::{
     ResultExt as _, maybe,
+    path_list::PathList,
     paths::{PathStyle, SanitizedPath, is_absolute},
     rel_path::RelPath,
 };
@@ -2286,6 +2287,32 @@ impl Project {
         self.worktree_store.read(cx).visible_worktrees(cx)
     }
 
+    pub fn default_path_list(&self, cx: &App) -> PathList {
+        let worktree_roots = self
+            .visible_worktrees(cx)
+            .sorted_by(|left, right| {
+                left.read(cx)
+                    .is_single_file()
+                    .cmp(&right.read(cx).is_single_file())
+            })
+            .filter_map(|worktree| {
+                let worktree = worktree.read(cx);
+                let path = worktree.abs_path();
+                if worktree.is_single_file() {
+                    Some(path.parent()?.to_path_buf())
+                } else {
+                    Some(path.to_path_buf())
+                }
+            })
+            .collect::<Vec<_>>();
+
+        if worktree_roots.is_empty() {
+            PathList::new(&[paths::home_dir().as_path()])
+        } else {
+            PathList::new(&worktree_roots)
+        }
+    }
+
     #[inline]
     pub fn worktree_for_root_name(&self, root_name: &str, cx: &App) -> Option<Entity<Worktree>> {
         self.visible_worktrees(cx)

crates/project/tests/integration/project_tests.rs 🔗

@@ -126,6 +126,63 @@ async fn test_block_via_smol(cx: &mut gpui::TestAppContext) {
     task.await;
 }
 
+#[gpui::test]
+async fn test_default_session_work_dirs_prefers_directory_worktrees_over_single_file_parents(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "dir-project": {
+                "src": {
+                    "main.rs": "fn main() {}"
+                }
+            },
+            "single-file.rs": "fn helper() {}"
+        }),
+    )
+    .await;
+
+    let project = Project::test(
+        fs,
+        [
+            Path::new(path!("/root/single-file.rs")),
+            Path::new(path!("/root/dir-project")),
+        ],
+        cx,
+    )
+    .await;
+
+    let work_dirs = project.read_with(cx, |project, cx| project.default_path_list(cx));
+    let ordered_paths = work_dirs.ordered_paths().cloned().collect::<Vec<_>>();
+
+    assert_eq!(
+        ordered_paths,
+        vec![
+            PathBuf::from(path!("/root/dir-project")),
+            PathBuf::from(path!("/root")),
+        ]
+    );
+}
+
+#[gpui::test]
+async fn test_default_session_work_dirs_falls_back_to_home_for_empty_project(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    let project = Project::test(fs, [], cx).await;
+
+    let work_dirs = project.read_with(cx, |project, cx| project.default_path_list(cx));
+    let ordered_paths = work_dirs.ordered_paths().cloned().collect::<Vec<_>>();
+
+    assert_eq!(ordered_paths, vec![paths::home_dir().to_path_buf()]);
+}
+
 // NOTE:
 // While POSIX symbolic links are somewhat supported on Windows, they are an opt in by the user, and thus
 // we assume that they are not supported out of the box.

crates/zed/src/visual_test_runner.rs 🔗

@@ -103,11 +103,11 @@ use {
     feature_flags::FeatureFlagAppExt as _,
     git_ui::project_diff::ProjectDiff,
     gpui::{
-        App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext, WindowBounds,
-        WindowHandle, WindowOptions, point, px, size,
+        App, AppContext as _, Bounds, Entity, KeyBinding, Modifiers, VisualTestAppContext,
+        WindowBounds, WindowHandle, WindowOptions, point, px, size,
     },
     image::RgbaImage,
-    project::AgentId,
+    project::{AgentId, Project},
     project_panel::ProjectPanel,
     settings::{NotifyWhenAgentWaiting, Settings as _},
     settings_ui::SettingsWindow,
@@ -1966,6 +1966,7 @@ impl AgentServer for StubAgentServer {
     fn connect(
         &self,
         _delegate: AgentServerDelegate,
+        _project: Entity<Project>,
         _cx: &mut App,
     ) -> gpui::Task<gpui::Result<Rc<dyn AgentConnection>>> {
         gpui::Task::ready(Ok(Rc::new(self.connection.clone())))