Files
tkmind_go/crates/goose-acp/tests/fixtures/mod.rs
T
2026-03-11 13:21:00 +00:00

372 lines
13 KiB
Rust

#![recursion_limit = "256"]
#![allow(unused_attributes)]
use async_trait::async_trait;
use fs_err as fs;
pub use goose::acp::{map_permission_response, PermissionDecision, PermissionMapping};
use goose::builtin_extension::register_builtin_extensions;
use goose::config::{GooseMode, PermissionManager};
use goose::providers::api_client::{ApiClient, AuthMethod as ApiAuthMethod};
use goose::providers::base::Provider;
use goose::providers::openai::OpenAiProvider;
use goose::providers::provider_registry::ProviderConstructor;
use goose::session_context::SESSION_ID_HEADER;
use goose_acp::server::{serve, GooseAcpAgent};
use goose_test_support::{ExpectedSessionId, TEST_MODEL};
use sacp::schema::{
AuthMethod, McpServer, ReadTextFileRequest, ReadTextFileResponse, SessionModelState,
ToolCallStatus, WriteTextFileRequest, WriteTextFileResponse,
};
use std::collections::VecDeque;
use std::future::Future;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
pub struct OpenAiFixture {
_server: MockServer,
base_url: String,
exchanges: Vec<(String, &'static str)>,
queue: Arc<Mutex<VecDeque<(String, &'static str)>>>,
}
impl OpenAiFixture {
/// Mock OpenAI streaming endpoint. Exchanges are (pattern, response) pairs.
/// On mismatch, returns 417 of the diff in OpenAI error format.
pub async fn new(
exchanges: Vec<(String, &'static str)>,
expected_session_id: ExpectedSessionId,
) -> Self {
let mock_server = MockServer::start().await;
let queue = Arc::new(Mutex::new(VecDeque::from(exchanges.clone())));
// Always return the models when asked, as there is no POST data to validate
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_string(include_str!("../test_data/openai_models.json")),
)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with({
let queue = queue.clone();
let expected_session_id = expected_session_id.clone();
move |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
// Validate session ID header
let actual = req
.headers
.get(SESSION_ID_HEADER)
.and_then(|v| v.to_str().ok());
if let Err(e) = expected_session_id.validate(actual) {
return ResponseTemplate::new(417)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": {"message": e}}));
}
// See if the actual request matches the expected pattern
let mut q = queue.lock().unwrap();
let (expected_body, response) = q.front().cloned().unwrap_or_default();
if !expected_body.is_empty() && body.contains(&expected_body) {
q.pop_front();
return ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(response);
}
drop(q);
// If there was no body, the request was unexpected. Otherwise, it is a mismatch.
let message = if expected_body.is_empty() {
format!("Unexpected request:\n {}", body)
} else {
format!(
"Expected body to contain:\n {}\n\nActual body:\n {}",
expected_body, body
)
};
// Use OpenAI's error response schema so the provider will pass the error through.
ResponseTemplate::new(417)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": {"message": message}}))
}
})
.mount(&mock_server)
.await;
let base_url = mock_server.uri();
Self {
_server: mock_server,
base_url,
exchanges,
queue,
}
}
pub fn uri(&self) -> &str {
&self.base_url
}
pub fn reset(&self) {
let mut queue = self.queue.lock().unwrap();
*queue = VecDeque::from(self.exchanges.clone());
}
}
pub type DuplexTransport = sacp::ByteStreams<
tokio_util::compat::Compat<tokio::io::DuplexStream>,
tokio_util::compat::Compat<tokio::io::DuplexStream>,
>;
/// Wires up duplex streams, spawns `serve` for the given agent, and returns
/// a ready-to-use sacp transport plus the server handle.
#[allow(dead_code)]
pub async fn serve_agent_in_process(
agent: Arc<GooseAcpAgent>,
) -> (DuplexTransport, JoinHandle<()>) {
let (client_read, server_write) = tokio::io::duplex(64 * 1024);
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
let handle = tokio::spawn(async move {
if let Err(e) = serve(agent, server_read.compat(), server_write.compat_write()).await {
tracing::error!("ACP server error: {e}");
}
});
let transport = sacp::ByteStreams::new(client_write.compat_write(), client_read.compat());
(transport, handle)
}
#[allow(dead_code)]
pub async fn spawn_acp_server_in_process(
openai_base_url: &str,
builtins: &[String],
data_root: &std::path::Path,
goose_mode: GooseMode,
provider_factory: Option<ProviderConstructor>,
) -> (DuplexTransport, JoinHandle<()>, Arc<PermissionManager>) {
fs::create_dir_all(data_root).unwrap();
let config_path = data_root.join(goose::config::base::CONFIG_YAML_NAME);
if !config_path.exists() {
fs::write(
&config_path,
format!("GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\n"),
)
.unwrap();
}
let provider_factory = provider_factory.unwrap_or_else(|| {
let base_url = openai_base_url.to_string();
Arc::new(move |model_config, _extensions| {
let base_url = base_url.clone();
Box::pin(async move {
let api_client =
ApiClient::new(base_url, ApiAuthMethod::BearerToken("test-key".to_string()))
.unwrap();
let provider: Arc<dyn Provider> =
Arc::new(OpenAiProvider::new(api_client, model_config));
Ok(provider)
})
})
});
let agent = Arc::new(
GooseAcpAgent::new(
provider_factory,
builtins.to_vec(),
data_root.to_path_buf(),
data_root.to_path_buf(),
goose_mode,
true,
)
.await
.unwrap(),
);
let permission_manager = agent.permission_manager();
let (transport, handle) = serve_agent_in_process(agent).await;
(transport, handle, permission_manager)
}
pub struct TestOutput {
pub text: String,
pub tool_status: Option<ToolCallStatus>,
}
type ReadTextFileHandler =
Arc<dyn Fn(&ReadTextFileRequest) -> Result<ReadTextFileResponse, String> + Send + Sync>;
type WriteTextFileHandler =
Arc<dyn Fn(&WriteTextFileRequest) -> Result<WriteTextFileResponse, String> + Send + Sync>;
#[derive(Clone)]
pub struct FsFixture {
calls: Arc<Mutex<Vec<Result<(), String>>>>,
}
impl FsFixture {
pub fn new() -> Self {
Self {
calls: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn read_handler(&self, expected_path: &str, content: &str) -> ReadTextFileHandler {
let calls = self.calls.clone();
let expected_path = expected_path.to_string();
let content = content.to_string();
Arc::new(move |req: &ReadTextFileRequest| {
let path = req.path.to_str().unwrap_or("");
if path != expected_path {
let err = format!("expected path {expected_path}, got {path}");
calls.lock().unwrap().push(Err(err.clone()));
return Err(err);
}
calls.lock().unwrap().push(Ok(()));
Ok(ReadTextFileResponse::new(&content))
})
}
pub fn write_handler(
&self,
expected_path: &str,
expected_content: &str,
) -> WriteTextFileHandler {
let calls = self.calls.clone();
let expected_path = expected_path.to_string();
let expected_content = expected_content.to_string();
Arc::new(move |req: &WriteTextFileRequest| {
let path = req.path.to_str().unwrap_or("");
if path != expected_path {
let err = format!("expected path {expected_path}, got {path}");
calls.lock().unwrap().push(Err(err.clone()));
return Err(err);
}
if req.content != expected_content {
let err = format!("expected content {expected_content}, got {}", req.content);
calls.lock().unwrap().push(Err(err.clone()));
return Err(err);
}
calls.lock().unwrap().push(Ok(()));
Ok(WriteTextFileResponse::new())
})
}
pub fn assert_called(&self) {
let calls = self.calls.lock().unwrap();
assert!(!calls.is_empty(), "fs handler was never called");
let errors: Vec<_> = calls.iter().filter_map(|c| c.as_ref().err()).collect();
assert!(errors.is_empty(), "fs handler errors: {errors:?}");
}
}
pub struct TestConnectionConfig {
pub mcp_servers: Vec<McpServer>,
pub builtins: Vec<String>,
pub goose_mode: GooseMode,
pub data_root: PathBuf,
pub provider_factory: Option<ProviderConstructor>,
pub read_text_file: Option<ReadTextFileHandler>,
pub write_text_file: Option<WriteTextFileHandler>,
}
impl Default for TestConnectionConfig {
fn default() -> Self {
Self {
mcp_servers: Vec::new(),
builtins: Vec::new(),
goose_mode: GooseMode::Auto,
data_root: PathBuf::new(),
provider_factory: None,
read_text_file: None,
write_text_file: None,
}
}
}
#[async_trait]
pub trait Connection: Sized {
type Session: Session;
async fn new(config: TestConnectionConfig, openai: OpenAiFixture) -> Self;
async fn new_session(&mut self) -> (Self::Session, Option<SessionModelState>);
async fn load_session(
&mut self,
session_id: &str,
) -> (Self::Session, Option<SessionModelState>);
fn auth_methods(&self) -> &[AuthMethod];
fn reset_openai(&self);
fn reset_permissions(&self);
}
#[async_trait]
pub trait Session {
fn session_id(&self) -> &sacp::schema::SessionId;
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput;
async fn prompt_with_image(
&mut self,
text: &str,
image_b64: &str,
mime_type: &str,
decision: PermissionDecision,
) -> TestOutput;
async fn set_model(&self, model_id: &str);
}
#[allow(dead_code)]
pub fn run_test<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
let handle = std::thread::Builder::new()
.name("acp-test".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_stack_size(8 * 1024 * 1024)
.enable_all()
.build()
.unwrap();
runtime.block_on(fut);
})
.unwrap();
if let Err(err) = handle.join() {
// Re-raise the original panic so the test shows the real failure message.
std::panic::resume_unwind(err);
}
}
/// Connects to the given agent via in-process duplex streams, sends an
/// `InitializeRequest`, and returns the response.
#[allow(dead_code)]
pub async fn initialize_agent(agent: Arc<GooseAcpAgent>) -> sacp::schema::InitializeResponse {
let (transport, _handle) = serve_agent_in_process(agent).await;
sacp::ClientToAgent::builder()
.connect_to(transport)
.unwrap()
.run_until(|cx: sacp::JrConnectionCx<sacp::ClientToAgent>| async move {
let resp = cx
.send_request(sacp::schema::InitializeRequest::new(
sacp::schema::ProtocolVersion::LATEST,
))
.block_task()
.await
.unwrap();
Ok::<_, sacp::Error>(resp)
})
.await
.unwrap()
}
pub mod provider;
pub mod server;