acp: Add e2e test support for NativeAgent (#36635)

Ben Brandt created

Release Notes:

- N/A

Change summary

Cargo.lock                                |   4 
crates/agent2/Cargo.toml                  |   2 
crates/agent2/src/native_agent_server.rs  |  49 +++++++++
crates/agent_servers/Cargo.toml           |  11 +
crates/agent_servers/src/agent_servers.rs |   4 
crates/agent_servers/src/claude.rs        |   2 
crates/agent_servers/src/e2e_tests.rs     | 136 +++++++++++++++++++-----
crates/agent_servers/src/gemini.rs        |   2 
8 files changed, 173 insertions(+), 37 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -268,11 +268,14 @@ dependencies = [
  "agent_settings",
  "agentic-coding-protocol",
  "anyhow",
+ "client",
  "collections",
  "context_server",
  "env_logger 0.11.8",
+ "fs",
  "futures 0.3.31",
  "gpui",
+ "gpui_tokio",
  "indoc",
  "itertools 0.14.0",
  "language",
@@ -284,6 +287,7 @@ dependencies = [
  "paths",
  "project",
  "rand 0.8.5",
+ "reqwest_client",
  "schemars",
  "semver",
  "serde",

crates/agent2/Cargo.toml 🔗

@@ -10,6 +10,7 @@ path = "src/agent2.rs"
 
 [features]
 test-support = ["db/test-support"]
+e2e = []
 
 [lints]
 workspace = true
@@ -72,6 +73,7 @@ zstd.workspace = true
 
 [dev-dependencies]
 agent = { workspace = true, "features" = ["test-support"] }
+agent_servers = { workspace = true, "features" = ["test-support"] }
 assistant_context = { workspace = true, "features" = ["test-support"] }
 ctor.workspace = true
 client = { workspace = true, "features" = ["test-support"] }

crates/agent2/src/native_agent_server.rs 🔗

@@ -73,3 +73,52 @@ impl AgentServer for NativeAgentServer {
         self
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use assistant_context::ContextStore;
+    use gpui::AppContext;
+
+    agent_servers::e2e_tests::common_e2e_tests!(
+        async |fs, project, cx| {
+            let auth = cx.update(|cx| {
+                prompt_store::init(cx);
+                terminal::init(cx);
+
+                let registry = language_model::LanguageModelRegistry::read_global(cx);
+                let auth = registry
+                    .provider(&language_model::ANTHROPIC_PROVIDER_ID)
+                    .unwrap()
+                    .authenticate(cx);
+
+                cx.spawn(async move |_| auth.await)
+            });
+
+            auth.await.unwrap();
+
+            cx.update(|cx| {
+                let registry = language_model::LanguageModelRegistry::global(cx);
+
+                registry.update(cx, |registry, cx| {
+                    registry.select_default_model(
+                        Some(&language_model::SelectedModel {
+                            provider: language_model::ANTHROPIC_PROVIDER_ID,
+                            model: language_model::LanguageModelId("claude-sonnet-4-latest".into()),
+                        }),
+                        cx,
+                    );
+                });
+            });
+
+            let history = cx.update(|cx| {
+                let context_store = cx.new(move |cx| ContextStore::fake(project.clone(), cx));
+                cx.new(move |cx| HistoryStore::new(context_store, cx))
+            });
+
+            NativeAgentServer::new(fs.clone(), history)
+        },
+        allow_option_id = "allow"
+    );
+}

crates/agent_servers/Cargo.toml 🔗

@@ -6,7 +6,7 @@ publish.workspace = true
 license = "GPL-3.0-or-later"
 
 [features]
-test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support"]
+test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
 e2e = []
 
 [lints]
@@ -23,10 +23,14 @@ agent-client-protocol.workspace = true
 agent_settings.workspace = true
 agentic-coding-protocol.workspace = true
 anyhow.workspace = true
+client = { workspace = true, optional = true }
 collections.workspace = true
 context_server.workspace = true
+env_logger = { workspace = true, optional = true }
+fs = { workspace = true, optional = true }
 futures.workspace = true
 gpui.workspace = true
+gpui_tokio = { workspace = true, optional = true }
 indoc.workspace = true
 itertools.workspace = true
 language.workspace = true
@@ -36,6 +40,7 @@ log.workspace = true
 paths.workspace = true
 project.workspace = true
 rand.workspace = true
+reqwest_client = { workspace = true, optional = true }
 schemars.workspace = true
 semver.workspace = true
 serde.workspace = true
@@ -57,8 +62,12 @@ libc.workspace = true
 nix.workspace = true
 
 [dev-dependencies]
+client = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
+fs.workspace = true
 language.workspace = true
 indoc.workspace = true
 acp_thread = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
+gpui_tokio.workspace = true
+reqwest_client = { workspace = true, features = ["test-support"] }

crates/agent_servers/src/agent_servers.rs 🔗

@@ -3,8 +3,8 @@ mod claude;
 mod gemini;
 mod settings;
 
-#[cfg(test)]
-mod e2e_tests;
+#[cfg(any(test, feature = "test-support"))]
+pub mod e2e_tests;
 
 pub use claude::*;
 pub use gemini::*;

crates/agent_servers/src/claude.rs 🔗

@@ -1093,7 +1093,7 @@ pub(crate) mod tests {
     use gpui::TestAppContext;
     use serde_json::json;
 
-    crate::common_e2e_tests!(ClaudeCode, allow_option_id = "allow");
+    crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow");
 
     pub fn local_command() -> AgentServerCommand {
         AgentServerCommand {

crates/agent_servers/src/e2e_tests.rs 🔗

@@ -4,21 +4,30 @@ use std::{
     time::Duration,
 };
 
-use crate::{AgentServer, AgentServerSettings, AllAgentServersSettings};
+use crate::AgentServer;
 use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
 use agent_client_protocol as acp;
 
 use futures::{FutureExt, StreamExt, channel::mpsc, select};
-use gpui::{Entity, TestAppContext};
+use gpui::{AppContext, Entity, TestAppContext};
 use indoc::indoc;
 use project::{FakeFs, Project};
-use settings::{Settings, SettingsStore};
 use util::path;
 
-pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
-    let fs = init_test(cx).await;
-    let project = Project::test(fs, [], cx).await;
-    let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
+pub async fn test_basic<T, F>(server: F, cx: &mut TestAppContext)
+where
+    T: AgentServer + 'static,
+    F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+    let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+    let project = Project::test(fs.clone(), [], cx).await;
+    let thread = new_test_thread(
+        server(&fs, &project, cx).await,
+        project.clone(),
+        "/private/tmp",
+        cx,
+    )
+    .await;
 
     thread
         .update(cx, |thread, cx| thread.send_raw("Hello from Zed!", cx))
@@ -42,8 +51,12 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont
     });
 }
 
-pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
-    let _fs = init_test(cx).await;
+pub async fn test_path_mentions<T, F>(server: F, cx: &mut TestAppContext)
+where
+    T: AgentServer + 'static,
+    F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+    let fs = init_test(cx).await as _;
 
     let tempdir = tempfile::tempdir().unwrap();
     std::fs::write(
@@ -56,7 +69,13 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes
     )
     .expect("failed to write file");
     let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
-    let thread = new_test_thread(server, project.clone(), tempdir.path(), cx).await;
+    let thread = new_test_thread(
+        server(&fs, &project, cx).await,
+        project.clone(),
+        tempdir.path(),
+        cx,
+    )
+    .await;
     thread
         .update(cx, |thread, cx| {
             thread.send(
@@ -110,15 +129,25 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes
     drop(tempdir);
 }
 
-pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
-    let _fs = init_test(cx).await;
+pub async fn test_tool_call<T, F>(server: F, cx: &mut TestAppContext)
+where
+    T: AgentServer + 'static,
+    F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+    let fs = init_test(cx).await as _;
 
     let tempdir = tempfile::tempdir().unwrap();
     let foo_path = tempdir.path().join("foo");
     std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file");
 
     let project = Project::example([tempdir.path()], &mut cx.to_async()).await;
-    let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
+    let thread = new_test_thread(
+        server(&fs, &project, cx).await,
+        project.clone(),
+        "/private/tmp",
+        cx,
+    )
+    .await;
 
     thread
         .update(cx, |thread, cx| {
@@ -152,14 +181,23 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp
     drop(tempdir);
 }
 
-pub async fn test_tool_call_with_permission(
-    server: impl AgentServer + 'static,
+pub async fn test_tool_call_with_permission<T, F>(
+    server: F,
     allow_option_id: acp::PermissionOptionId,
     cx: &mut TestAppContext,
-) {
-    let fs = init_test(cx).await;
-    let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
-    let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
+) where
+    T: AgentServer + 'static,
+    F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+    let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+    let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await;
+    let thread = new_test_thread(
+        server(&fs, &project, cx).await,
+        project.clone(),
+        "/private/tmp",
+        cx,
+    )
+    .await;
     let full_turn = thread.update(cx, |thread, cx| {
         thread.send_raw(
             r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
@@ -247,11 +285,21 @@ pub async fn test_tool_call_with_permission(
     });
 }
 
-pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
-    let fs = init_test(cx).await;
-
-    let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
-    let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
+pub async fn test_cancel<T, F>(server: F, cx: &mut TestAppContext)
+where
+    T: AgentServer + 'static,
+    F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+    let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+
+    let project = Project::test(fs.clone(), [path!("/private/tmp").as_ref()], cx).await;
+    let thread = new_test_thread(
+        server(&fs, &project, cx).await,
+        project.clone(),
+        "/private/tmp",
+        cx,
+    )
+    .await;
     let _ = thread.update(cx, |thread, cx| {
         thread.send_raw(
             r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#,
@@ -316,10 +364,20 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon
     });
 }
 
-pub async fn test_thread_drop(server: impl AgentServer + 'static, cx: &mut TestAppContext) {
-    let fs = init_test(cx).await;
-    let project = Project::test(fs, [], cx).await;
-    let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await;
+pub async fn test_thread_drop<T, F>(server: F, cx: &mut TestAppContext)
+where
+    T: AgentServer + 'static,
+    F: AsyncFn(&Arc<dyn fs::Fs>, &Entity<Project>, &mut TestAppContext) -> T,
+{
+    let fs = init_test(cx).await as Arc<dyn fs::Fs>;
+    let project = Project::test(fs.clone(), [], cx).await;
+    let thread = new_test_thread(
+        server(&fs, &project, cx).await,
+        project.clone(),
+        "/private/tmp",
+        cx,
+    )
+    .await;
 
     thread
         .update(cx, |thread, cx| thread.send_raw("Hello from test!", cx))
@@ -386,25 +444,39 @@ macro_rules! common_e2e_tests {
         }
     };
 }
+pub use common_e2e_tests;
 
 // Helpers
 
 pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
+    #[cfg(test)]
+    use settings::Settings;
+
     env_logger::try_init().ok();
 
     cx.update(|cx| {
-        let settings_store = SettingsStore::test(cx);
+        let settings_store = settings::SettingsStore::test(cx);
         cx.set_global(settings_store);
         Project::init_settings(cx);
         language::init(cx);
+        gpui_tokio::init(cx);
+        let http_client = reqwest_client::ReqwestClient::user_agent("agent tests").unwrap();
+        cx.set_http_client(Arc::new(http_client));
+        client::init_settings(cx);
+        let client = client::Client::production(cx);
+        let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx));
+        language_model::init(client.clone(), cx);
+        language_models::init(user_store, client, cx);
+        agent_settings::init(cx);
         crate::settings::init(cx);
 
+        #[cfg(test)]
         crate::AllAgentServersSettings::override_global(
-            AllAgentServersSettings {
-                claude: Some(AgentServerSettings {
+            crate::AllAgentServersSettings {
+                claude: Some(crate::AgentServerSettings {
                     command: crate::claude::tests::local_command(),
                 }),
-                gemini: Some(AgentServerSettings {
+                gemini: Some(crate::AgentServerSettings {
                     command: crate::gemini::tests::local_command(),
                 }),
             },

crates/agent_servers/src/gemini.rs 🔗

@@ -108,7 +108,7 @@ pub(crate) mod tests {
     use crate::AgentServerCommand;
     use std::path::Path;
 
-    crate::common_e2e_tests!(Gemini, allow_option_id = "proceed_once");
+    crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once");
 
     pub fn local_command() -> AgentServerCommand {
         let cli_path = Path::new(env!("CARGO_MANIFEST_DIR"))