645 lines
24 KiB
Rust
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;
|