From dfd207bc0fd1961b996e2141df71e38c7156935e Mon Sep 17 00:00:00 2001 From: Adrian Cole <64215+codefromthecrypt@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:26:46 +0800 Subject: [PATCH] test: add ACP integration test (#6150) Signed-off-by: Adrian Cole --- Cargo.lock | 11 +- crates/goose/Cargo.toml | 2 + crates/goose/tests/acp_integration_test.rs | 170 ++++++++++++++++++ .../openai_chat_completion_streaming.txt | 27 +++ 4 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 crates/goose/tests/acp_integration_test.rs create mode 100644 crates/goose/tests/test_data/openai_chat_completion_streaming.txt diff --git a/Cargo.lock b/Cargo.lock index cc623853a6..f87f17aa35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,9 +30,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ffe7d502c1e451aafc5aff655000f84d09c9af681354ac0012527009b1af13" +checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -47,9 +47,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.10.2" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cbb9a55080793c01e5e97c6fb0f1032d1f2460f7e3869225813cfa29001585" +checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4" dependencies = [ "anyhow", "derive_more", @@ -2308,7 +2308,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3069,6 +3069,7 @@ dependencies = [ name = "goose" version = "1.16.0" dependencies = [ + "agent-client-protocol", "ahash", "anyhow", "async-stream", diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 418d692fd2..6c8e27e4d7 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -119,11 +119,13 @@ boa_gc = "0.21" winapi = { version = "0.3", features = ["wincred"] } [dev-dependencies] +agent-client-protocol = "0.9.2" criterion = "0.5" serial_test = "3.2.0" mockall = "0.13.1" wiremock = "0.6.0" tokio = { version = "1.43", features = ["full"] } +tokio-util = { version = "0.7.15", features = ["compat"] } temp-env = "0.3.6" dotenvy = "0.15.7" ctor = "0.2.9" diff --git a/crates/goose/tests/acp_integration_test.rs b/crates/goose/tests/acp_integration_test.rs new file mode 100644 index 0000000000..6af737becb --- /dev/null +++ b/crates/goose/tests/acp_integration_test.rs @@ -0,0 +1,170 @@ +use agent_client_protocol::{ + self as acp, Agent, Client, ClientSideConnection, ContentBlock, InitializeRequest, + NewSessionRequest, PromptRequest, ProtocolVersion, SessionNotification, SessionUpdate, + TextContent, +}; +use std::path::Path; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::process::{Child, Command}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const BASIC_RESPONSE: &str = include_str!("./test_data/openai_chat_completion_streaming.txt"); +const BASIC_TEXT: &str = "Hello! How can I assist you today? 🌍"; + +#[tokio::test] +async fn test_acp_basic_completion() { + let mock_server = setup_mock_openai(BASIC_RESPONSE).await; + let work_dir = tempfile::tempdir().unwrap(); + + let (client, updates) = TestClient::new(); + let child = spawn_goose_acp(&mock_server).await; + + run_acp_session( + client, + child, + work_dir.path(), + |conn, session_id| async move { + let response = conn + .prompt(PromptRequest::new( + session_id, + vec![ContentBlock::Text(TextContent::new("test message"))], + )) + .await + .unwrap(); + + assert_eq!(response.stop_reason, acp::StopReason::EndTurn); + + wait_for_text(&updates, BASIC_TEXT, Duration::from_secs(5)).await; + }, + ) + .await; +} + +async fn wait_for_text( + updates: &Arc>>, + expected: &str, + timeout: Duration, +) { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let actual = extract_text(&updates.lock().unwrap()); + if actual == expected { + return; + } + if tokio::time::Instant::now() > deadline { + assert_eq!(actual, expected); + return; + } + tokio::task::yield_now().await; + } +} + +async fn setup_mock_openai(streaming_response: &str) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(streaming_response), + ) + .mount(&mock_server) + .await; + + mock_server +} + +fn extract_text(updates: &[SessionNotification]) -> String { + updates + .iter() + .filter_map(|n| match &n.update { + SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { + ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }, + _ => None, + }) + .collect() +} + +struct TestClient { + updates: Arc>>, +} + +impl TestClient { + fn new() -> (Self, Arc>>) { + let updates = Arc::new(Mutex::new(Vec::new())); + ( + Self { + updates: updates.clone(), + }, + updates, + ) + } +} + +#[async_trait::async_trait(?Send)] +impl Client for TestClient { + async fn request_permission( + &self, + _args: acp::RequestPermissionRequest, + ) -> acp::Result { + Err(acp::Error::method_not_found()) + } + + async fn session_notification(&self, args: SessionNotification) -> acp::Result<()> { + self.updates.lock().unwrap().push(args); + Ok(()) + } +} + +async fn spawn_goose_acp(mock_server: &MockServer) -> Child { + Command::new("cargo") + .args(["run", "-p", "goose-cli", "--", "acp"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("GOOSE_PROVIDER", "openai") + .env("GOOSE_MODEL", "gpt-5-nano") + .env("OPENAI_HOST", mock_server.uri()) + .env("OPENAI_API_KEY", "test-key") + .kill_on_drop(true) + .spawn() + .unwrap() +} + +async fn run_acp_session(client: TestClient, mut child: Child, work_dir: &Path, test_fn: F) +where + F: FnOnce(ClientSideConnection, acp::SessionId) -> Fut, + Fut: std::future::Future, +{ + let outgoing = child.stdin.take().unwrap().compat_write(); + let incoming = child.stdout.take().unwrap().compat(); + + let work_dir = work_dir.to_path_buf(); + let local_set = tokio::task::LocalSet::new(); + local_set + .run_until(async move { + let (conn, handle_io) = ClientSideConnection::new(client, outgoing, incoming, |fut| { + tokio::task::spawn_local(fut); + }); + tokio::task::spawn_local(handle_io); + + conn.initialize(InitializeRequest::new(ProtocolVersion::V1)) + .await + .unwrap(); + + let session = conn + .new_session(NewSessionRequest::new(&work_dir)) + .await + .unwrap(); + + test_fn(conn, session.session_id).await; + }) + .await; +} diff --git a/crates/goose/tests/test_data/openai_chat_completion_streaming.txt b/crates/goose/tests/test_data/openai_chat_completion_streaming.txt new file mode 100644 index 0000000000..5f33282d95 --- /dev/null +++ b/crates/goose/tests/test_data/openai_chat_completion_streaming.txt @@ -0,0 +1,27 @@ +data: {"choices":[],"created":0,"id":"","prompt_filter_results":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"prompt_index":0}]} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"","role":"assistant"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Hello"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"!"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" How"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" can"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" I"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" assist"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" you"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" today"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"?"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":" 🌍"}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: {"choices":[{"finish_reason":"stop","index":0,"content_filter_offsets":{"check_offset":3458,"start_offset":3458,"end_offset":3494},"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":null}}],"created":1747592466,"id":"chatcmpl-BYcvCkaKJjQIM7e2j6vg08RIcY8qp","usage":{"completion_tokens":13,"completion_tokens_details":{"accepted_prediction_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens":1675,"prompt_tokens_details":{"cached_tokens":1536},"total_tokens":1688},"model":"gpt-4o-2024-11-20","system_fingerprint":"fp_ee1d74bde0"} + +data: [DONE]