acp: load configured extensions and refactor tests (#6803)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-01-30 08:39:03 +09:00
committed by GitHub
parent 70f7bd7cc1
commit b0c6373cf8
11 changed files with 732 additions and 432 deletions
Generated
+1
View File
@@ -2986,6 +2986,7 @@ version = "1.21.0"
dependencies = [
"anyhow",
"assert-json-diff",
"async-trait",
"axum 0.8.8",
"fs-err",
"futures",
+1
View File
@@ -23,3 +23,4 @@ crates/goose/src/providers/formats/google.rs::format_messages
crates/goose/src/providers/formats/openai.rs::format_messages
crates/goose/src/providers/formats/openai.rs::response_to_streaming_message
crates/goose/src/providers/snowflake.rs::post
crates/goose/src/security/mod.rs::analyze_tool_requests
+18
View File
@@ -0,0 +1,18 @@
# This file is created automatically if no .gooseignore exists.
# Customize or uncomment the patterns below instead of deleting the file.
# Removing it will simply cause goose to recreate it on the next start.
#
# Suggested patterns you can uncomment if desired:
# **/.ssh/** # block SSH keys and configs
# **/*.key # block loose private keys
# **/*.pem # block certificates/private keys
# **/.git/** # block git metadata entirely
# **/target/** # block Rust build artifacts
# **/node_modules/** # block JS/TS dependencies
# **/*.db # block local database files
# **/*.sqlite # block SQLite databases
#
**/.env
**/.env.*
**/secrets.*
+1
View File
@@ -26,6 +26,7 @@ url = { workspace = true }
[dev-dependencies]
assert-json-diff = "2.0.2"
async-trait = "0.1.89"
wiremock = { workspace = true }
tempfile = "3"
test-case = { workspace = true }
+24 -3
View File
@@ -2,6 +2,8 @@ use anyhow::Result;
use fs_err as fs;
use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS};
use goose::agents::{Agent, AgentConfig, ExtensionConfig, SessionConfig};
use goose::config::base::CONFIG_YAML_NAME;
use goose::config::extensions::get_enabled_extensions_with_config;
use goose::config::paths::Paths;
use goose::config::permission::PermissionManager;
use goose::config::Config;
@@ -46,7 +48,7 @@ pub struct GooseAcpAgent {
provider: Arc<dyn goose::providers::base::Provider>,
}
pub struct GooseAcpConfig {
pub struct AcpServerConfig {
pub provider: Arc<dyn goose::providers::base::Provider>,
pub builtins: Vec<String>,
pub data_dir: std::path::PathBuf,
@@ -277,8 +279,21 @@ async fn add_builtins(agent: &Agent, builtins: Vec<String>) {
}
}
}
async fn add_extensions(agent: &Agent, extensions: Vec<ExtensionConfig>) {
for extension in extensions {
let name = extension.name().to_string();
match agent.add_extension(extension).await {
Ok(_) => info!(extension = %name, "extension loaded"),
Err(e) => warn!(extension = %name, error = %e, "extension load failed"),
}
}
}
impl GooseAcpAgent {
pub fn permission_manager(&self) -> Arc<PermissionManager> {
Arc::clone(&self.agent.config.permission_manager)
}
pub async fn new(builtins: Vec<String>) -> Result<Self> {
let config = Config::global();
@@ -305,7 +320,7 @@ impl GooseAcpAgent {
.get_goose_mode()
.unwrap_or(goose::config::GooseMode::Auto);
Self::with_config(GooseAcpConfig {
Self::with_config(AcpServerConfig {
provider,
builtins,
data_dir: Paths::data_dir(),
@@ -315,8 +330,9 @@ impl GooseAcpAgent {
.await
}
pub async fn with_config(config: GooseAcpConfig) -> Result<Self> {
pub async fn with_config(config: AcpServerConfig) -> Result<Self> {
let session_manager = Arc::new(SessionManager::new(config.data_dir));
let config_dir = config.config_dir.clone();
let permission_manager = Arc::new(PermissionManager::new(config.config_dir));
let agent = Agent::with_config(AgentConfig::new(
@@ -328,7 +344,12 @@ impl GooseAcpAgent {
let agent_ptr = Arc::new(agent);
let config_path = config_dir.join(CONFIG_YAML_NAME);
let config_file = Config::new(&config_path, "goose")?;
let extensions = get_enabled_extensions_with_config(&config_file);
add_builtins(&agent_ptr, config.builtins).await;
add_extensions(&agent_ptr, extensions).await;
Ok(Self {
provider: config.provider.clone(),
+224
View File
@@ -0,0 +1,224 @@
// Required when compiled as standalone test "common"; harmless warning when included as module.
#![recursion_limit = "256"]
#![allow(unused_attributes)]
#[path = "../fixtures/mod.rs"]
pub mod fixtures;
use fixtures::{
ExpectedSessionId, McpFixture, OpenAiFixture, PermissionDecision, Session, TestSessionConfig,
FAKE_CODE,
};
use fs_err as fs;
use goose::config::base::CONFIG_YAML_NAME;
use goose::config::GooseMode;
use sacp::schema::{McpServer, McpServerHttp, ToolCallStatus};
pub async fn run_basic_completion<S: Session>() {
let expected_session_id = ExpectedSessionId::default();
let openai = OpenAiFixture::new(
vec![(
r#"</info-msg>\nwhat is 1+1""#.into(),
include_str!("../test_data/openai_basic_response.txt"),
)],
expected_session_id.clone(),
)
.await;
let mut session = S::new(TestSessionConfig::default(), openai).await;
expected_session_id.set(session.id());
let output = session
.prompt("what is 1+1", PermissionDecision::Cancel)
.await;
assert!(output.text.contains("2"));
expected_session_id.assert_matches(&session.id().0);
}
pub async fn run_mcp_http_server<S: Session>() {
let expected_session_id = ExpectedSessionId::default();
let mcp = McpFixture::new(expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
r#"</info-msg>\nUse the get_code tool and output only its result.""#.into(),
include_str!("../test_data/openai_tool_call_response.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result_response.txt"),
),
],
expected_session_id.clone(),
)
.await;
let config = TestSessionConfig {
mcp_servers: vec![McpServer::Http(McpServerHttp::new("lookup", &mcp.url))],
..Default::default()
};
let mut session = S::new(config, openai).await;
expected_session_id.set(session.id());
let output = session
.prompt(
"Use the get_code tool and output only its result.",
PermissionDecision::Cancel,
)
.await;
assert!(output.text.contains(FAKE_CODE));
expected_session_id.assert_matches(&session.id().0);
}
pub async fn run_builtin_and_mcp<S: Session>() {
let expected_session_id = ExpectedSessionId::default();
let prompt =
"Search for get_code and text_editor tools. Use them to save the code to /tmp/result.txt.";
let mcp = McpFixture::new(expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
format!(r#"</info-msg>\n{prompt}""#),
include_str!("../test_data/openai_builtin_search.txt"),
),
(
r#"lookup/get_code: Get the code"#.into(),
include_str!("../test_data/openai_builtin_read_modules.txt"),
),
(
r#"lookup[\"get_code\"]({}): string - Get the code"#.into(),
include_str!("../test_data/openai_builtin_execute.txt"),
),
(
r#"Successfully wrote to /tmp/result.txt"#.into(),
include_str!("../test_data/openai_builtin_final.txt"),
),
],
expected_session_id.clone(),
)
.await;
let config = TestSessionConfig {
builtins: vec!["code_execution".to_string(), "developer".to_string()],
mcp_servers: vec![McpServer::Http(McpServerHttp::new("lookup", &mcp.url))],
..Default::default()
};
let _ = fs::remove_file("/tmp/result.txt");
let mut session = S::new(config, openai).await;
expected_session_id.set(session.id());
let _ = session.prompt(prompt, PermissionDecision::Cancel).await;
let result = fs::read_to_string("/tmp/result.txt").unwrap_or_default();
assert!(result.contains(FAKE_CODE));
expected_session_id.assert_matches(&session.id().0);
}
pub async fn run_permission_persistence<S: Session>() {
let cases = vec![
(
PermissionDecision::AllowAlways,
ToolCallStatus::Completed,
"user:\n always_allow:\n - lookup__get_code\n ask_before: []\n never_allow: []\n",
),
(PermissionDecision::AllowOnce, ToolCallStatus::Completed, ""),
(
PermissionDecision::RejectAlways,
ToolCallStatus::Failed,
"user:\n always_allow: []\n ask_before: []\n never_allow:\n - lookup__get_code\n",
),
(PermissionDecision::RejectOnce, ToolCallStatus::Failed, ""),
(PermissionDecision::Cancel, ToolCallStatus::Failed, ""),
];
let temp_dir = tempfile::tempdir().unwrap();
let prompt = "Use the get_code tool and output only its result.";
let expected_session_id = ExpectedSessionId::default();
let mcp = McpFixture::new(expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
prompt.to_string(),
include_str!("../test_data/openai_tool_call_response.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result_response.txt"),
),
],
expected_session_id.clone(),
)
.await;
let config = TestSessionConfig {
mcp_servers: vec![McpServer::Http(McpServerHttp::new("lookup", &mcp.url))],
goose_mode: GooseMode::Approve,
data_root: temp_dir.path().to_path_buf(),
..Default::default()
};
let mut session = S::new(config, openai).await;
expected_session_id.set(session.id());
for (decision, expected_status, expected_yaml) in cases {
session.reset_openai();
session.reset_permissions();
let _ = fs::remove_file(temp_dir.path().join("permission.yaml"));
let output = session.prompt(prompt, decision).await;
assert_eq!(
output.tool_status.unwrap(),
expected_status,
"permission decision {:?}",
decision
);
assert_eq!(
fs::read_to_string(temp_dir.path().join("permission.yaml")).unwrap_or_default(),
expected_yaml,
"permission decision {:?}",
decision
);
}
expected_session_id.assert_matches(&session.id().0);
}
pub async fn run_configured_extension<S: Session>() {
let temp_dir = tempfile::tempdir().unwrap();
let expected_session_id = ExpectedSessionId::default();
let prompt = "Use the get_code tool and output only its result.";
let mcp = McpFixture::new(expected_session_id.clone()).await;
let config_yaml = format!(
"extensions:\n lookup:\n enabled: true\n type: streamable_http\n name: lookup\n description: Lookup server\n uri: \"{}\"\n",
mcp.url
);
fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap();
let openai = OpenAiFixture::new(
vec![
(
prompt.to_string(),
include_str!("../test_data/openai_tool_call_response.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("../test_data/openai_tool_result_response.txt"),
),
],
expected_session_id.clone(),
)
.await;
let config = TestSessionConfig {
data_root: temp_dir.path().to_path_buf(),
..Default::default()
};
let mut session = S::new(config, openai).await;
expected_session_id.set(session.id());
let output = session.prompt(prompt, PermissionDecision::Cancel).await;
assert!(output.text.contains(FAKE_CODE));
expected_session_id.assert_matches(&session.id().0);
}
@@ -1,5 +1,12 @@
use assert_json_diff::{assert_json_matches_no_panic, CompareMode, Config};
use async_trait::async_trait;
use fs_err as fs;
use goose::config::{GooseMode, PermissionManager};
use goose::model::ModelConfig;
use goose::providers::api_client::{ApiClient, AuthMethod};
use goose::providers::openai::OpenAiProvider;
use goose::session_context::SESSION_ID_HEADER;
use goose_acp::server::{serve, AcpServerConfig, GooseAcpAgent};
use rmcp::model::{ClientNotification, ClientRequest, Meta, ServerResult};
use rmcp::service::{NotificationContext, RequestContext, ServiceRole};
use rmcp::transport::streamable_http_server::{
@@ -9,9 +16,17 @@ use rmcp::{
handler::server::router::tool::ToolRouter, model::*, tool, tool_handler, tool_router,
ErrorData as McpError, RoleServer, ServerHandler, Service,
};
use sacp::schema::{
McpServer, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SelectedPermissionOutcome, ToolCallStatus,
};
use std::collections::VecDeque;
use std::future::Future;
use std::path::Path;
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};
@@ -19,6 +34,49 @@ pub const FAKE_CODE: &str = "test-uuid-12345-67890";
const NOT_YET_SET: &str = "session-id-not-yet-set";
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum PermissionDecision {
AllowAlways,
AllowOnce,
RejectOnce,
RejectAlways,
Cancel,
}
#[derive(Default)]
pub struct PermissionMapping;
pub fn map_permission_response(
_mapping: &PermissionMapping,
req: &RequestPermissionRequest,
decision: PermissionDecision,
) -> RequestPermissionResponse {
let outcome = match decision {
PermissionDecision::Cancel => RequestPermissionOutcome::Cancelled,
PermissionDecision::AllowAlways => select_option(req, PermissionOptionKind::AllowAlways),
PermissionDecision::AllowOnce => select_option(req, PermissionOptionKind::AllowOnce),
PermissionDecision::RejectOnce => select_option(req, PermissionOptionKind::RejectOnce),
PermissionDecision::RejectAlways => select_option(req, PermissionOptionKind::RejectAlways),
};
RequestPermissionResponse::new(outcome)
}
fn select_option(
req: &RequestPermissionRequest,
kind: PermissionOptionKind,
) -> RequestPermissionOutcome {
req.options
.iter()
.find(|opt| opt.kind == kind)
.map(|opt| {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
opt.option_id.clone(),
))
})
.unwrap_or(RequestPermissionOutcome::Cancelled)
}
#[derive(Clone)]
pub struct ExpectedSessionId {
value: Arc<Mutex<String>>,
@@ -60,14 +118,19 @@ impl ExpectedSessionId {
/// Calling this ensures incidental requests that might error asynchronously, such as
/// session rename have coherent session IDs.
pub fn assert_no_errors(&self) {
pub fn assert_matches(&self, actual: &str) {
let result = self.validate(Some(actual));
assert!(result.is_ok(), "{}", result.unwrap_err());
let e = self.errors.lock().unwrap();
assert!(e.is_empty(), "Session ID validation errors: {:?}", *e);
}
}
pub struct OpenAiFixture {
pub server: MockServer,
_server: MockServer,
base_url: String,
exchanges: Vec<(String, &'static str)>,
queue: Arc<Mutex<VecDeque<(String, &'static str)>>>,
}
impl OpenAiFixture {
@@ -78,8 +141,7 @@ impl OpenAiFixture {
expected_session_id: ExpectedSessionId,
) -> Self {
let mock_server = MockServer::start().await;
let queue: VecDeque<(String, &'static str)> = exchanges.into_iter().collect();
let queue = Arc::new(Mutex::new(queue));
let queue = Arc::new(Mutex::new(VecDeque::from(exchanges.clone())));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
@@ -104,7 +166,7 @@ impl OpenAiFixture {
return ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_string(include_str!(
"./test_data/openai_session_description.json"
"../test_data/openai_session_description.json"
));
}
@@ -135,14 +197,27 @@ impl OpenAiFixture {
.mount(&mock_server)
.await;
let base_url = mock_server.uri();
Self {
server: mock_server,
_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());
}
}
#[derive(Clone)]
pub struct Lookup {
struct Lookup {
tool_router: ToolRouter<Lookup>,
}
@@ -198,13 +273,13 @@ impl<R: ServiceRole> HasMeta for NotificationContext<R> {
}
}
pub struct ValidatingService<S> {
struct ValidatingService<S> {
inner: S,
expected_session_id: ExpectedSessionId,
}
impl<S> ValidatingService<S> {
pub fn new(inner: S, expected_session_id: ExpectedSessionId) -> Self {
fn new(inner: S, expected_session_id: ExpectedSessionId) -> Self {
Self {
inner,
expected_session_id,
@@ -287,3 +362,103 @@ impl McpFixture {
}
}
}
#[allow(dead_code)]
pub async fn spawn_acp_server_in_process(
openai_base_url: &str,
builtins: &[String],
data_root: &Path,
goose_mode: GooseMode,
) -> (
tokio::io::DuplexStream,
tokio::io::DuplexStream,
JoinHandle<()>,
Arc<PermissionManager>,
) {
fs::create_dir_all(data_root).unwrap();
let api_client = ApiClient::new(
openai_base_url.to_string(),
AuthMethod::BearerToken("test-key".to_string()),
)
.unwrap();
let model_config = ModelConfig::new("gpt-5-nano").unwrap();
let provider = OpenAiProvider::new(api_client, model_config);
let config = AcpServerConfig {
provider: Arc::new(provider),
builtins: builtins.to_vec(),
data_dir: data_root.to_path_buf(),
config_dir: data_root.to_path_buf(),
goose_mode,
};
let (client_read, server_write) = tokio::io::duplex(64 * 1024);
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
let agent = Arc::new(GooseAcpAgent::with_config(config).await.unwrap());
let permission_manager = agent.permission_manager();
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}");
}
});
(client_read, client_write, handle, permission_manager)
}
pub struct TestOutput {
pub text: String,
pub tool_status: Option<ToolCallStatus>,
}
pub struct TestSessionConfig {
pub mcp_servers: Vec<McpServer>,
pub builtins: Vec<String>,
pub goose_mode: GooseMode,
pub data_root: PathBuf,
}
impl Default for TestSessionConfig {
fn default() -> Self {
Self {
mcp_servers: Vec::new(),
builtins: Vec::new(),
goose_mode: GooseMode::Auto,
data_root: PathBuf::new(),
}
}
}
#[async_trait]
pub trait Session {
async fn new(config: TestSessionConfig, openai: OpenAiFixture) -> Self
where
Self: Sized;
fn id(&self) -> &sacp::schema::SessionId;
fn reset_openai(&self);
fn reset_permissions(&self);
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput;
}
#[allow(dead_code)]
pub fn run_test<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
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();
handle.join().unwrap();
}
pub mod server;
+226
View File
@@ -0,0 +1,226 @@
use super::{
map_permission_response, spawn_acp_server_in_process, PermissionDecision, PermissionMapping,
Session, TestOutput, TestSessionConfig,
};
use async_trait::async_trait;
use goose::config::PermissionManager;
use sacp::schema::{
ContentBlock, InitializeRequest, NewSessionRequest, PromptRequest, ProtocolVersion,
RequestPermissionRequest, SessionNotification, SessionUpdate, StopReason, TextContent,
ToolCallStatus,
};
use sacp::{ClientToAgent, JrConnectionCx};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Notify;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub struct ClientToAgentSession {
cx: JrConnectionCx<ClientToAgent>,
session_id: sacp::schema::SessionId,
updates: Arc<Mutex<Vec<SessionNotification>>>,
permission: Arc<Mutex<PermissionDecision>>,
notify: Arc<Notify>,
permission_manager: Arc<PermissionManager>,
// Keep the OpenAI mock server alive for the lifetime of the session.
_openai: super::OpenAiFixture,
// Keep the temp dir alive so test data/permissions persist during the session.
_temp_dir: Option<tempfile::TempDir>,
}
#[async_trait]
impl Session for ClientToAgentSession {
async fn new(config: TestSessionConfig, openai: super::OpenAiFixture) -> Self {
let (data_root, temp_dir) = match config.data_root.as_os_str().is_empty() {
true => {
let temp_dir = tempfile::tempdir().unwrap();
(temp_dir.path().to_path_buf(), Some(temp_dir))
}
false => (config.data_root.clone(), None),
};
let (client_read, client_write, _handle, permission_manager) = spawn_acp_server_in_process(
openai.uri(),
&config.builtins,
data_root.as_path(),
config.goose_mode,
)
.await;
let updates = Arc::new(Mutex::new(Vec::new()));
let notify = Arc::new(Notify::new());
let permission = Arc::new(Mutex::new(PermissionDecision::Cancel));
let transport = sacp::ByteStreams::new(client_write.compat_write(), client_read.compat());
let (cx, session_id) = {
let updates_clone = updates.clone();
let notify_clone = notify.clone();
let permission_clone = permission.clone();
let mcp_servers_clone = config.mcp_servers.clone();
let cx_holder: Arc<Mutex<Option<JrConnectionCx<ClientToAgent>>>> =
Arc::new(Mutex::new(None));
let session_id_holder: Arc<Mutex<Option<sacp::schema::SessionId>>> =
Arc::new(Mutex::new(None));
let cx_holder_clone = cx_holder.clone();
let session_id_holder_clone = session_id_holder.clone();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let permission_mapping = PermissionMapping;
let result = ClientToAgent::builder()
.on_receive_notification(
{
let updates = updates_clone.clone();
let notify = notify_clone.clone();
async move |notification: SessionNotification, _cx| {
updates.lock().unwrap().push(notification);
notify.notify_waiters();
Ok(())
}
},
sacp::on_receive_notification!(),
)
.on_receive_request(
{
let permission = permission_clone.clone();
async move |req: RequestPermissionRequest,
request_cx,
_connection_cx| {
let decision = *permission.lock().unwrap();
let response =
map_permission_response(&permission_mapping, &req, decision);
request_cx.respond(response)
}
},
sacp::on_receive_request!(),
)
.connect_to(transport)
.unwrap()
.run_until({
let mcp_servers = mcp_servers_clone;
let cx_holder = cx_holder_clone;
let session_id_holder = session_id_holder_clone;
move |cx: JrConnectionCx<ClientToAgent>| async move {
cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
.block_task()
.await
.unwrap();
let work_dir = tempfile::tempdir().unwrap();
let session = cx
.send_request(
NewSessionRequest::new(work_dir.path())
.mcp_servers(mcp_servers),
)
.block_task()
.await
.unwrap();
*cx_holder.lock().unwrap() = Some(cx.clone());
*session_id_holder.lock().unwrap() = Some(session.session_id);
let _ = ready_tx.send(());
std::future::pending::<Result<(), sacp::Error>>().await
}
})
.await;
if let Err(e) = result {
tracing::error!("SACP client error: {e}");
}
});
ready_rx.await.unwrap();
let cx = cx_holder.lock().unwrap().take().unwrap();
let session_id = session_id_holder.lock().unwrap().take().unwrap();
(cx, session_id)
};
Self {
cx,
session_id,
updates,
permission,
notify,
permission_manager,
_openai: openai,
_temp_dir: temp_dir,
}
}
fn id(&self) -> &sacp::schema::SessionId {
&self.session_id
}
fn reset_openai(&self) {
self._openai.reset();
}
fn reset_permissions(&self) {
self.permission_manager.remove_extension("");
}
async fn prompt(&mut self, text: &str, decision: PermissionDecision) -> TestOutput {
*self.permission.lock().unwrap() = decision;
self.updates.lock().unwrap().clear();
let response = self
.cx
.send_request(PromptRequest::new(
self.id().clone(),
vec![ContentBlock::Text(TextContent::new(text))],
))
.block_task()
.await
.unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
let mut updates_len = self.updates.lock().unwrap().len();
while updates_len == 0 {
self.notify.notified().await;
updates_len = self.updates.lock().unwrap().len();
}
let text = collect_agent_text(&self.updates);
let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
let mut tool_status = extract_tool_status(&self.updates);
while tool_status.is_none() && tokio::time::Instant::now() < deadline {
tokio::task::yield_now().await;
tool_status = extract_tool_status(&self.updates);
}
TestOutput { text, tool_status }
}
}
fn collect_agent_text(updates: &Arc<Mutex<Vec<SessionNotification>>>) -> String {
let guard = updates.lock().unwrap();
let mut text = String::new();
for notification in guard.iter() {
if let SessionUpdate::AgentMessageChunk(chunk) = &notification.update {
if let ContentBlock::Text(t) = &chunk.content {
text.push_str(&t.text);
}
}
}
text
}
fn extract_tool_status(updates: &Arc<Mutex<Vec<SessionNotification>>>) -> Option<ToolCallStatus> {
let guard = updates.lock().unwrap();
guard.iter().find_map(|notification| {
if let SessionUpdate::ToolCallUpdate(update) = &notification.update {
return update.fields.status;
}
None
})
}
+21 -418
View File
@@ -1,429 +1,32 @@
mod common;
use common::{ExpectedSessionId, McpFixture, OpenAiFixture, FAKE_CODE};
use fs_err as fs;
use goose::config::GooseMode;
use goose::model::ModelConfig;
use goose::providers::api_client::{ApiClient, AuthMethod};
use goose::providers::openai::OpenAiProvider;
use goose_acp::server::{serve, GooseAcpAgent, GooseAcpConfig};
use sacp::schema::{
ContentBlock, ContentChunk, InitializeRequest, McpServer, McpServerHttp, NewSessionRequest,
PermissionOptionKind, PromptRequest, ProtocolVersion, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallId, ToolCallStatus,
ToolCallUpdate, ToolCallUpdateFields,
mod common_tests;
use common_tests::fixtures::run_test;
use common_tests::fixtures::server::ClientToAgentSession;
use common_tests::{
run_basic_completion, run_builtin_and_mcp, run_configured_extension, run_mcp_http_server,
run_permission_persistence,
};
use sacp::{ClientToAgent, JrConnectionCx};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use test_case::test_case;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use wiremock::MockServer;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_acp_basic_completion() {
let temp_dir = tempfile::tempdir().unwrap();
let prompt = "what is 1+1";
let expected_session_id = ExpectedSessionId::default();
let openai = OpenAiFixture::new(
vec![(
format!(r#"</info-msg>\n{prompt}""#),
include_str!("./test_data/openai_basic_response.txt"),
)],
expected_session_id.clone(),
)
.await;
run_acp_session(
&openai.server,
vec![],
&[],
temp_dir.path(),
GooseMode::Auto,
None,
expected_session_id.clone(),
|cx, session_id, updates| async move {
let response = cx
.send_request(PromptRequest::new(
session_id,
vec![ContentBlock::Text(TextContent::new(prompt))],
))
.block_task()
.await
.unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
wait_for(
&updates,
&SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new("2"),
))),
)
.await;
},
)
.await;
expected_session_id.assert_no_errors();
#[test]
fn test_acp_basic_completion() {
run_test(async { run_basic_completion::<ClientToAgentSession>().await });
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_acp_with_mcp_http_server() {
let temp_dir = tempfile::tempdir().unwrap();
let prompt = "Use the get_code tool and output only its result.";
let expected_session_id = ExpectedSessionId::default();
let mcp = McpFixture::new(expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
format!(r#"</info-msg>\n{prompt}""#),
include_str!("./test_data/openai_tool_call_response.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("./test_data/openai_tool_result_response.txt"),
),
],
expected_session_id.clone(),
)
.await;
run_acp_session(
&openai.server,
vec![McpServer::Http(McpServerHttp::new("lookup", &mcp.url))],
&[],
temp_dir.path(),
GooseMode::Auto,
None,
expected_session_id.clone(),
|cx, session_id, updates| async move {
let response = cx
.send_request(PromptRequest::new(
session_id,
vec![ContentBlock::Text(TextContent::new(prompt))],
))
.block_task()
.await
.unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
wait_for(
&updates,
&SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new(FAKE_CODE),
))),
)
.await;
},
)
.await;
expected_session_id.assert_no_errors();
#[test]
fn test_acp_with_mcp_http_server() {
run_test(async { run_mcp_http_server::<ClientToAgentSession>().await });
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_acp_with_builtin_and_mcp() {
let temp_dir = tempfile::tempdir().unwrap();
let prompt =
"Search for get_code and text_editor tools. Use them to save the code to /tmp/result.txt.";
let expected_session_id = ExpectedSessionId::default();
let mcp = McpFixture::new(expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
format!(r#"</info-msg>\n{prompt}""#),
include_str!("./test_data/openai_builtin_search.txt"),
),
(
r#"lookup/get_code: Get the code"#.into(),
include_str!("./test_data/openai_builtin_read_modules.txt"),
),
(
r#"lookup[\"get_code\"]({}): string - Get the code"#.into(),
include_str!("./test_data/openai_builtin_execute.txt"),
),
(
r#"Successfully wrote to /tmp/result.txt"#.into(),
include_str!("./test_data/openai_builtin_final.txt"),
),
],
expected_session_id.clone(),
)
.await;
run_acp_session(
&openai.server,
vec![McpServer::Http(McpServerHttp::new("lookup", &mcp.url))],
&["code_execution", "developer"],
temp_dir.path(),
GooseMode::Auto,
None,
expected_session_id.clone(),
|cx, session_id, updates| async move {
let response = cx
.send_request(PromptRequest::new(
session_id,
vec![ContentBlock::Text(TextContent::new(prompt))],
))
.block_task()
.await
.unwrap();
assert_eq!(response.stop_reason, StopReason::EndTurn);
wait_for(
&updates,
&SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new(FAKE_CODE),
))),
)
.await;
},
)
.await;
expected_session_id.assert_no_errors();
#[test]
fn test_acp_with_builtin_and_mcp() {
run_test(async { run_builtin_and_mcp::<ClientToAgentSession>().await });
}
async fn wait_for(updates: &Arc<Mutex<Vec<SessionNotification>>>, expected: &SessionUpdate) {
let deadline = tokio::time::Instant::now() + Duration::from_millis(500);
let mut context = String::new();
loop {
let matched = {
let guard = updates.lock().unwrap();
context.clear();
match expected {
SessionUpdate::AgentMessageChunk(chunk) => {
let expected_text = match &chunk.content {
ContentBlock::Text(t) => &t.text,
other => panic!("wait_for: unhandled content {:?}", other),
};
for n in guard.iter() {
if let SessionUpdate::AgentMessageChunk(c) = &n.update {
if let ContentBlock::Text(t) = &c.content {
if t.text.is_empty() {
context.clear();
} else {
context.push_str(&t.text);
}
}
}
}
context.contains(expected_text)
}
SessionUpdate::ToolCallUpdate(expected_update) => {
for n in guard.iter() {
if let SessionUpdate::ToolCallUpdate(u) = &n.update {
context.push_str(&format!("{:?}\n", u));
if u.fields.status == expected_update.fields.status {
return;
}
}
}
false
}
other => panic!("wait_for: unhandled update {:?}", other),
}
};
if matched {
return;
}
if tokio::time::Instant::now() > deadline {
panic!("Timeout waiting for {:?}\n\n{}", expected, context);
}
tokio::task::yield_now().await;
}
#[test]
fn test_permission_persistence() {
run_test(async { run_permission_persistence::<ClientToAgentSession>().await });
}
async fn spawn_server_in_process(
mock_server: &MockServer,
builtins: &[&str],
data_root: &Path,
goose_mode: GooseMode,
) -> (
tokio::io::DuplexStream,
tokio::io::DuplexStream,
tokio::task::JoinHandle<()>,
) {
let api_client = ApiClient::new(
mock_server.uri(),
AuthMethod::BearerToken("test-key".to_string()),
)
.unwrap();
let model_config = ModelConfig::new("gpt-5-nano").unwrap();
let provider = OpenAiProvider::new(api_client, model_config);
let config = GooseAcpConfig {
provider: Arc::new(provider),
builtins: builtins.iter().map(|s| s.to_string()).collect(),
data_dir: data_root.to_path_buf(),
config_dir: data_root.to_path_buf(),
goose_mode,
};
let (client_read, server_write) = tokio::io::duplex(64 * 1024);
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
let agent = Arc::new(GooseAcpAgent::with_config(config).await.unwrap());
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}");
}
});
(client_read, client_write, handle)
}
#[allow(clippy::too_many_arguments)]
async fn run_acp_session<F, Fut>(
mock_server: &MockServer,
mcp_servers: Vec<McpServer>,
builtins: &[&str],
data_root: &Path,
mode: GooseMode,
select: Option<PermissionOptionKind>,
expected_session_id: ExpectedSessionId,
test_fn: F,
) where
F: FnOnce(
JrConnectionCx<ClientToAgent>,
sacp::schema::SessionId,
Arc<Mutex<Vec<SessionNotification>>>,
) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let (client_read, client_write, _handle) =
spawn_server_in_process(mock_server, builtins, data_root, mode).await;
let work_dir = tempfile::tempdir().unwrap();
let updates = Arc::new(Mutex::new(Vec::new()));
let transport = sacp::ByteStreams::new(client_write.compat_write(), client_read.compat());
ClientToAgent::builder()
.on_receive_notification(
{
let updates = updates.clone();
async move |notification: SessionNotification, _cx| {
updates.lock().unwrap().push(notification);
Ok(())
}
},
sacp::on_receive_notification!(),
)
.on_receive_request(
async move |req: RequestPermissionRequest, request_cx, _connection_cx| {
let response = match select {
Some(kind) => {
let id = req
.options
.iter()
.find(|o| o.kind == kind)
.unwrap()
.option_id
.clone();
RequestPermissionResponse::new(RequestPermissionOutcome::Selected(
SelectedPermissionOutcome::new(id),
))
}
None => RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled),
};
request_cx.respond(response)
},
sacp::on_receive_request!(),
)
.connect_to(transport)
.unwrap()
.run_until({
let updates = updates.clone();
let expected_session_id = expected_session_id.clone();
move |cx: JrConnectionCx<ClientToAgent>| async move {
cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST))
.block_task()
.await
.unwrap();
let session = cx
.send_request(NewSessionRequest::new(work_dir.path()).mcp_servers(mcp_servers))
.block_task()
.await
.unwrap();
expected_session_id.set(&session.session_id);
test_fn(cx.clone(), session.session_id, updates).await;
Ok(())
}
})
.await
.unwrap();
}
#[test_case(Some(PermissionOptionKind::AllowAlways), ToolCallStatus::Completed, "user:\n always_allow:\n - lookup__get_code\n ask_before: []\n never_allow: []\n"; "allow_always")]
#[test_case(Some(PermissionOptionKind::AllowOnce), ToolCallStatus::Completed, ""; "allow_once")]
#[test_case(Some(PermissionOptionKind::RejectAlways), ToolCallStatus::Failed, "user:\n always_allow: []\n ask_before: []\n never_allow:\n - lookup__get_code\n"; "reject_always")]
#[test_case(Some(PermissionOptionKind::RejectOnce), ToolCallStatus::Failed, ""; "reject_once")]
#[test_case(None, ToolCallStatus::Failed, ""; "cancelled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_permission_persistence(
kind: Option<PermissionOptionKind>,
expected_status: ToolCallStatus,
expected_yaml: &str,
) {
let temp_dir = tempfile::tempdir().unwrap();
let prompt = "Use the get_code tool and output only its result.";
let expected_session_id = ExpectedSessionId::default();
let mcp = McpFixture::new(expected_session_id.clone()).await;
let openai = OpenAiFixture::new(
vec![
(
format!(r#"</info-msg>\n{prompt}""#),
include_str!("./test_data/openai_tool_call_response.txt"),
),
(
format!(r#""content":"{FAKE_CODE}""#),
include_str!("./test_data/openai_tool_result_response.txt"),
),
],
expected_session_id.clone(),
)
.await;
run_acp_session(
&openai.server,
vec![McpServer::Http(McpServerHttp::new("lookup", &mcp.url))],
&[],
temp_dir.path(),
GooseMode::Approve,
kind,
expected_session_id.clone(),
|cx, session_id, updates| async move {
cx.send_request(PromptRequest::new(
session_id,
vec![ContentBlock::Text(TextContent::new(prompt))],
))
.block_task()
.await
.unwrap();
wait_for(
&updates,
&SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(""),
ToolCallUpdateFields::new().status(Some(expected_status)),
)),
)
.await;
},
)
.await;
expected_session_id.assert_no_errors();
assert_eq!(
fs::read_to_string(temp_dir.path().join("permission.yaml")).unwrap_or_default(),
expected_yaml
);
#[test]
fn test_configured_extension() {
run_test(async { run_configured_extension::<ClientToAgentSession>().await });
}
+18
View File
@@ -0,0 +1,18 @@
# This file is created automatically if no .gooseignore exists.
# Customize or uncomment the patterns below instead of deleting the file.
# Removing it will simply cause goose to recreate it on the next start.
#
# Suggested patterns you can uncomment if desired:
# **/.ssh/** # block SSH keys and configs
# **/*.key # block loose private keys
# **/*.pem # block certificates/private keys
# **/.git/** # block git metadata entirely
# **/target/** # block Rust build artifacts
# **/node_modules/** # block JS/TS dependencies
# **/*.db # block local database files
# **/*.sqlite # block SQLite databases
#
**/.env
**/.env.*
**/secrets.*
+14 -2
View File
@@ -26,8 +26,8 @@ pub fn name_to_key(name: &str) -> String {
.to_lowercase()
}
fn get_extensions_map() -> IndexMap<String, ExtensionEntry> {
let raw: Mapping = Config::global()
fn get_extensions_map_with_config(config: &Config) -> IndexMap<String, ExtensionEntry> {
let raw: Mapping = config
.get_param(EXTENSIONS_CONFIG_KEY)
.unwrap_or_else(|err| {
warn!(
@@ -56,6 +56,10 @@ fn get_extensions_map() -> IndexMap<String, ExtensionEntry> {
extensions_map
}
fn get_extensions_map() -> IndexMap<String, ExtensionEntry> {
get_extensions_map_with_config(Config::global())
}
fn save_extensions_map(extensions: IndexMap<String, ExtensionEntry>) {
let config = Config::global();
if let Err(e) = config.set_param(EXTENSIONS_CONFIG_KEY, &extensions) {
@@ -116,6 +120,14 @@ pub fn get_enabled_extensions() -> Vec<ExtensionConfig> {
.collect()
}
pub fn get_enabled_extensions_with_config(config: &Config) -> Vec<ExtensionConfig> {
get_extensions_map_with_config(config)
.into_values()
.filter(|ext| ext.enabled)
.map(|ext| ext.config)
.collect()
}
pub fn get_warnings() -> Vec<String> {
let raw: Mapping = Config::global()
.get_param(EXTENSIONS_CONFIG_KEY)