test: add ACP integration test (#6150)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2025-12-18 09:26:46 +08:00
committed by GitHub
parent 92537b74fe
commit dfd207bc0f
4 changed files with 205 additions and 5 deletions
Generated
+6 -5
View File
@@ -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",
+2
View File
@@ -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"
+170
View File
@@ -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<Mutex<Vec<SessionNotification>>>,
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<Mutex<Vec<SessionNotification>>>,
}
impl TestClient {
fn new() -> (Self, Arc<Mutex<Vec<SessionNotification>>>) {
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<acp::RequestPermissionResponse> {
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<F, Fut>(client: TestClient, mut child: Child, work_dir: &Path, test_fn: F)
where
F: FnOnce(ClientSideConnection, acp::SessionId) -> Fut,
Fut: std::future::Future<Output = ()>,
{
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;
}
@@ -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]