Files
2026-06-04 03:12:04 +00:00

645 lines
24 KiB
Rust

#![recursion_limit = "256"]
#![allow(unused_attributes)]
use agent_client_protocol::schema::{
CreateTerminalResponse, KillTerminalResponse, ListSessionsResponse, McpServer,
ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalResponse, SessionModeState,
SessionModelState, SessionUpdate, TerminalExitStatus, TerminalId, TerminalOutputResponse,
ToolCallContent, ToolCallStatus, ToolKind, WaitForTerminalExitResponse, WriteTextFileRequest,
WriteTextFileResponse,
};
use async_trait::async_trait;
use fs_err as fs;
use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions};
pub use goose::acp::{map_permission_response, PermissionDecision};
use goose::agents::GoosePlatform;
use goose::builtin_extension::register_builtin_extensions;
use goose::config::paths::Paths;
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::session_context::SESSION_ID_HEADER;
use goose_test_support::{ExpectedSessionId, TEST_MODEL};
use std::collections::VecDeque;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use tokio::task::JoinHandle;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
static ACP_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
static ACP_CONFIG_ROOT: LazyLock<tempfile::TempDir> =
LazyLock::new(|| tempfile::tempdir().unwrap());
fn write_global_test_config(config_path: &Path, openai_base_url: &str) {
let contents = fs::read_to_string(config_path).unwrap();
let mut config: serde_yaml::Mapping = serde_yaml::from_str(&contents).unwrap();
config.insert(
serde_yaml::Value::String("OPENAI_HOST".to_string()),
serde_yaml::Value::String(openai_base_url.to_string()),
);
let global_config_dir = Paths::config_dir();
fs::create_dir_all(&global_config_dir).unwrap();
let global_config_path = global_config_dir.join(goose::config::base::CONFIG_YAML_NAME);
fs::write(&global_config_path, serde_yaml::to_string(&config).unwrap()).unwrap();
}
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: Arc<dyn 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!("../acp_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 = agent_client_protocol::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 agent_client_protocol 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 =
agent_client_protocol::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<AcpProviderFactory>,
current_model: &str,
disable_session_naming: bool,
) -> (DuplexTransport, JoinHandle<()>, Arc<PermissionManager>) {
fs::create_dir_all(data_root).unwrap();
// TODO: Paths::in_state_dir is global, ignoring per-test data_root
fs::create_dir_all(Paths::in_state_dir("logs")).unwrap();
let config_path = data_root.join(goose::config::base::CONFIG_YAML_NAME);
if !config_path.exists() {
fs::write(
&config_path,
format!(
"GOOSE_MODEL: {current_model}\nGOOSE_PROVIDER: openai\nGOOSE_MODE: {}\n",
goose_mode
),
)
.unwrap();
}
write_global_test_config(&config_path, openai_base_url);
let provider_factory = provider_factory.unwrap_or_else(|| {
let base_url = openai_base_url.to_string();
Arc::new(
move |_provider_name, model_config, _extensions, _working_dir| {
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 = GooseAcpAgent::new(GooseAcpAgentOptions {
provider_factory,
builtins: builtins.to_vec(),
data_dir: data_root.to_path_buf(),
config_dir: data_root.to_path_buf(),
disable_session_naming,
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
})
.await
.unwrap();
let agent = Arc::new(agent);
let permission_manager = agent.permission_manager();
let (transport, handle) = serve_agent_in_process(agent).await;
(transport, handle, permission_manager)
}
#[derive(Debug)]
pub struct TestOutput {
pub text: String,
pub tool_status: Option<ToolCallStatus>,
}
#[derive(Debug, PartialEq)]
pub enum Notification {
UserMessage,
AgentMessage,
AgentThought,
ToolCall,
ToolCallKind(ToolKind),
ToolCallContent(String),
ToolCallStatus(ToolCallStatus),
Plan,
AvailableCommands,
CurrentMode,
ConfigOption,
SessionInfoUpdate {
title: Option<String>,
updated_at: Option<String>,
message_count: Option<u64>,
user_set_name: Option<bool>,
},
}
pub fn to_notifications(updates: &[SessionUpdate]) -> Vec<Notification> {
let mut out = Vec::new();
for u in updates {
match u {
SessionUpdate::UserMessageChunk(_) => {
if out.last() != Some(&Notification::UserMessage) {
out.push(Notification::UserMessage);
}
}
SessionUpdate::AgentMessageChunk(_) => {
if out.last() != Some(&Notification::AgentMessage) {
out.push(Notification::AgentMessage);
}
}
SessionUpdate::AgentThoughtChunk(_) => {
if out.last() != Some(&Notification::AgentThought) {
out.push(Notification::AgentThought);
}
}
SessionUpdate::ToolCall(_) => out.push(Notification::ToolCall),
SessionUpdate::ToolCallUpdate(upd) => {
if let Some(kind) = upd.fields.kind {
out.push(Notification::ToolCallKind(kind));
}
if let Some(ref content) = upd.fields.content {
for c in content {
let tag = match c {
ToolCallContent::Content(_) => "content",
ToolCallContent::Diff(_) => "diff",
ToolCallContent::Terminal(_) => "terminal",
_ => "unknown",
};
out.push(Notification::ToolCallContent(tag.into()));
}
}
if let Some(status) = upd.fields.status {
out.push(Notification::ToolCallStatus(status));
}
}
SessionUpdate::Plan(_) => out.push(Notification::Plan),
SessionUpdate::AvailableCommandsUpdate(_) => out.push(Notification::AvailableCommands),
SessionUpdate::CurrentModeUpdate(_) => out.push(Notification::CurrentMode),
SessionUpdate::ConfigOptionUpdate(_) => out.push(Notification::ConfigOption),
SessionUpdate::SessionInfoUpdate(update) => {
let meta = update.meta.as_ref();
out.push(Notification::SessionInfoUpdate {
title: update.title.value().cloned(),
updated_at: update.updated_at.value().cloned(),
message_count: meta
.and_then(|m| m.get("messageCount"))
.and_then(|v| v.as_u64()),
user_set_name: meta
.and_then(|m| m.get("userSetName"))
.and_then(|v| v.as_bool()),
});
}
_ => {}
}
}
out
}
pub fn assert_notifications(actual: &[Notification], expected: &[Notification]) {
assert_eq!(actual, expected);
}
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:?}");
}
}
/// Expected terminal calls. Each variant carries (expected_input, return_value) data,
/// like OpenAiFixture's (pattern, response) pairs.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TerminalCall {
Create(String, String), // (command, terminal_id)
WaitForExit(String, u32), // (terminal_id, exit_code)
Output(String, String, u32), // (terminal_id, text, exit_code)
Release(String), // terminal_id
Kill(String), // terminal_id
}
impl TerminalCall {
fn name(&self) -> &'static str {
match self {
Self::Create(..) => "create",
Self::WaitForExit(..) => "wait_for_exit",
Self::Output(..) => "output",
Self::Release(_) => "release",
Self::Kill(_) => "kill",
}
}
}
pub struct TerminalFixture {
queue: Arc<Mutex<VecDeque<TerminalCall>>>,
errors: Arc<Mutex<Vec<String>>>,
}
impl TerminalFixture {
pub fn new(calls: Vec<TerminalCall>) -> Arc<Self> {
Arc::new(Self {
queue: Arc::new(Mutex::new(VecDeque::from(calls))),
errors: Arc::new(Mutex::new(Vec::new())),
})
}
fn pop(&self, expected: &str) -> Option<TerminalCall> {
let Some(call) = self.queue.lock().unwrap().pop_front() else {
self.record_error(format!("unexpected {expected}: queue empty"));
return None;
};
if call.name() != expected {
self.record_error(format!("expected {expected}, got {}", call.name()));
return None;
}
Some(call)
}
fn record_error(&self, msg: String) {
self.errors.lock().unwrap().push(msg);
}
fn validate_terminal_id(&self, method: &str, expected: &str, actual: &TerminalId) {
if expected != actual.0.as_ref() {
self.record_error(format!(
"{method}: expected terminal_id {expected}, got {actual}"
));
}
}
pub fn on_create(&self, command: &str) -> CreateTerminalResponse {
if let Some(TerminalCall::Create(expect_command, terminal_id)) = self.pop("create") {
if command != expect_command {
self.record_error(format!(
"create: expected command {expect_command}, got {command}"
));
}
CreateTerminalResponse::new(TerminalId::new(terminal_id))
} else {
CreateTerminalResponse::new(TerminalId::new("error"))
}
}
pub fn on_wait_for_exit(&self, terminal_id: &TerminalId) -> WaitForTerminalExitResponse {
if let Some(TerminalCall::WaitForExit(expected_id, exit_code)) = self.pop("wait_for_exit") {
self.validate_terminal_id("wait_for_exit", &expected_id, terminal_id);
WaitForTerminalExitResponse::new(TerminalExitStatus::new().exit_code(exit_code))
} else {
WaitForTerminalExitResponse::new(TerminalExitStatus::new().exit_code(1))
}
}
pub fn on_output(&self, terminal_id: &TerminalId) -> TerminalOutputResponse {
if let Some(TerminalCall::Output(expected_id, text, exit_code)) = self.pop("output") {
self.validate_terminal_id("output", &expected_id, terminal_id);
TerminalOutputResponse::new(text, false)
.exit_status(TerminalExitStatus::new().exit_code(exit_code))
} else {
TerminalOutputResponse::new("", false)
}
}
pub fn on_release(&self, terminal_id: &TerminalId) -> ReleaseTerminalResponse {
if let Some(TerminalCall::Release(expected_id)) = self.pop("release") {
self.validate_terminal_id("release", &expected_id, terminal_id);
}
ReleaseTerminalResponse::new()
}
pub fn on_kill(&self, terminal_id: &TerminalId) -> KillTerminalResponse {
if let Some(TerminalCall::Kill(expected_id)) = self.pop("kill") {
self.validate_terminal_id("kill", &expected_id, terminal_id);
}
KillTerminalResponse::new()
}
pub fn assert_called(&self) {
let errors = self.errors.lock().unwrap();
assert!(errors.is_empty(), "terminal fixture errors: {errors:?}");
let queue = self.queue.lock().unwrap();
assert!(
queue.is_empty(),
"terminal fixture has unconsumed calls: {queue:?}"
);
}
}
#[derive(Debug)]
pub struct SessionData<S> {
pub session: S,
pub models: Option<SessionModelState>,
pub modes: Option<SessionModeState>,
}
pub struct TestConnectionConfig {
pub mcp_servers: Vec<McpServer>,
pub builtins: Vec<String>,
pub goose_mode: GooseMode,
pub cwd: Option<tempfile::TempDir>,
pub data_root: PathBuf,
pub provider_factory: Option<AcpProviderFactory>,
pub read_text_file: Option<ReadTextFileHandler>,
pub write_text_file: Option<WriteTextFileHandler>,
pub terminal: Option<Arc<TerminalFixture>>,
// When true, strips config_options from responses to test the legacy set_mode/set_model path.
#[allow(dead_code)]
pub strip_config_options: bool,
// The model the server-side provider starts with. Defaults to TEST_MODEL.
pub current_model: String,
pub disable_session_naming: bool,
}
impl Default for TestConnectionConfig {
fn default() -> Self {
Self {
mcp_servers: Vec::new(),
builtins: Vec::new(),
goose_mode: GooseMode::default(),
cwd: None,
data_root: PathBuf::new(),
provider_factory: None,
read_text_file: None,
write_text_file: None,
terminal: None,
strip_config_options: false,
current_model: TEST_MODEL.to_string(),
disable_session_naming: true,
}
}
}
#[async_trait]
pub trait Connection: Sized {
type Session: Session;
fn expected_session_id() -> Arc<dyn ExpectedSessionId>;
async fn new(config: TestConnectionConfig, openai: OpenAiFixture) -> Self;
async fn new_session(&mut self) -> anyhow::Result<SessionData<Self::Session>>;
async fn load_session(
&mut self,
session_id: &str,
mcp_servers: Vec<McpServer>,
) -> anyhow::Result<SessionData<Self::Session>>;
async fn list_sessions(&self) -> anyhow::Result<ListSessionsResponse>;
async fn close_session(&self, session_id: &str) -> anyhow::Result<()>;
async fn delete_session(&self, session_id: &str) -> anyhow::Result<()>;
async fn set_mode(&self, session_id: &str, mode_id: &str) -> anyhow::Result<()>;
async fn set_model(&self, session_id: &str, model_id: &str) -> anyhow::Result<()>;
async fn set_config_option(
&self,
session_id: &str,
config_id: &str,
value: &str,
) -> anyhow::Result<()>;
fn data_root(&self) -> std::path::PathBuf;
fn reset_openai(&self);
fn reset_permissions(&self);
}
#[async_trait]
pub trait Session: std::fmt::Debug {
fn session_id(&self) -> &agent_client_protocol::schema::SessionId;
fn work_dir(&self) -> std::path::PathBuf;
/// Drains and returns raw session updates collected by the fixture.
fn session_updates(&self) -> Vec<SessionUpdate>;
/// Drains and returns simplified notifications collected by the fixture.
fn notifications(&self) -> Vec<Notification>;
async fn prompt(
&mut self,
text: &str,
decision: PermissionDecision,
) -> anyhow::Result<TestOutput>;
async fn prompt_with_image(
&mut self,
text: &str,
image_b64: &str,
mime_type: &str,
decision: PermissionDecision,
) -> anyhow::Result<TestOutput>;
}
#[allow(dead_code)]
pub fn run_test<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
let _guard = ACP_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
if std::env::var_os("GOOSE_PATH_ROOT").is_none() {
std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path());
}
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);
}
}
pub async fn send_custom(
cx: &agent_client_protocol::ConnectionTo<agent_client_protocol::Agent>,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, agent_client_protocol::Error> {
let msg = agent_client_protocol::UntypedMessage::new(method, params).unwrap();
cx.send_request(msg).block_task().await
}
pub mod provider;
pub mod server;