diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 505374fde2..f8586073f6 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -108,7 +108,7 @@ jobs: node-version: '22' - name: Install agentic providers - run: npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli + run: npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli @zed-industries/claude-agent-acp @zed-industries/codex-acp - name: Run Smoke Tests with Provider Script env: diff --git a/Cargo.lock b/Cargo.lock index da6905c3a6..b0b248e73e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4270,6 +4270,7 @@ dependencies = [ name = "goose" version = "1.27.0" dependencies = [ + "agent-client-protocol-schema", "ahash", "anyhow", "async-stream", @@ -4329,6 +4330,7 @@ dependencies = [ "reqwest 0.13.2", "rmcp 1.1.0", "rubato", + "sacp", "schemars 1.2.1", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b4424fbcb6..39881bd4b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ string_slice = "warn" [workspace.dependencies] rmcp = { version = "1.1.0", features = ["schemars", "auth"] } +sacp = "10.1.0" anyhow = "1.0" async-stream = "0.3" async-trait = "0.1" diff --git a/crates/goose-acp/Cargo.toml b/crates/goose-acp/Cargo.toml index c3c388c056..d9b4b4b74a 100644 --- a/crates/goose-acp/Cargo.toml +++ b/crates/goose-acp/Cargo.toml @@ -26,8 +26,8 @@ workspace = true goose = { path = "../goose", default-features = false } goose-mcp = { path = "../goose-mcp" } rmcp = { workspace = true } -sacp = "10.1.0" -agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model", "unstable_session_list"] } +sacp = { workspace = true } +agent-client-protocol-schema = { version = "0.10", features = ["unstable"] } anyhow = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat", "rt"] } diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 9ebd0aba71..94efd2a264 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -1,6 +1,7 @@ use crate::custom_requests::*; use anyhow::Result; use fs_err as fs; +use goose::acp::PermissionDecision; use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS}; use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig}; use goose::builtin_extension::register_builtin_extensions; @@ -591,24 +592,9 @@ impl GooseAcpAgent { } fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation { - let permission = match outcome { - RequestPermissionOutcome::Cancelled => Permission::Cancel, - RequestPermissionOutcome::Selected(selected) => { - match serde_json::from_value::(serde_json::Value::String( - selected.option_id.0.to_string(), - )) { - Ok(PermissionOptionKind::AllowAlways) => Permission::AlwaysAllow, - Ok(PermissionOptionKind::AllowOnce) => Permission::AllowOnce, - Ok(PermissionOptionKind::RejectOnce) => Permission::DenyOnce, - Ok(PermissionOptionKind::RejectAlways) => Permission::AlwaysDeny, - _ => Permission::Cancel, - } - } - _ => Permission::Cancel, - }; PermissionConfirmation { principal_type: PrincipalType::Tool, - permission, + permission: Permission::from(PermissionDecision::from(outcome)), } } diff --git a/crates/goose-acp/tests/common_tests/mod.rs b/crates/goose-acp/tests/common_tests/mod.rs index 69d78ce66f..6eb4b6f00e 100644 --- a/crates/goose-acp/tests/common_tests/mod.rs +++ b/crates/goose-acp/tests/common_tests/mod.rs @@ -4,15 +4,12 @@ #[path = "../fixtures/mod.rs"] pub mod fixtures; -use fixtures::{ - initialize_agent, Connection, OpenAiFixture, PermissionDecision, Session, TestConnectionConfig, -}; +use fixtures::{Connection, OpenAiFixture, PermissionDecision, Session, TestConnectionConfig}; use fs_err as fs; use goose::config::base::CONFIG_YAML_NAME; use goose::config::GooseMode; use goose::providers::provider_registry::ProviderConstructor; -use goose_acp::server::GooseAcpAgent; -use goose_test_support::{ExpectedSessionId, McpFixture, FAKE_CODE, TEST_MODEL}; +use goose_test_support::{ExpectedSessionId, McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL}; use sacp::schema::{McpServer, McpServerHttp, ModelId, ToolCallStatus}; use std::sync::Arc; @@ -57,29 +54,20 @@ pub async fn run_config_mcp() { expected_session_id.assert_matches(&session.session_id().0); } -pub async fn run_initialize_without_provider() { - let temp_dir = tempfile::tempdir().unwrap(); - +pub async fn run_initialize_doesnt_hit_provider() { let provider_factory: ProviderConstructor = Arc::new(|_, _| Box::pin(async { Err(anyhow::anyhow!("no provider configured")) })); - let agent = Arc::new( - GooseAcpAgent::new( - provider_factory, - vec![], - temp_dir.path().to_path_buf(), - temp_dir.path().to_path_buf(), - GooseMode::Auto, - false, - ) - .await - .unwrap(), - ); + let openai = OpenAiFixture::new(vec![], ExpectedSessionId::default()).await; + let config = TestConnectionConfig { + provider_factory: Some(provider_factory), + ..Default::default() + }; - let resp = initialize_agent(agent).await; - assert!(!resp.auth_methods.is_empty()); - assert!(resp - .auth_methods + let conn = C::new(config, openai).await; + assert!(!conn.auth_methods().is_empty()); + assert!(conn + .auth_methods() .iter() .any(|m| &*m.id.0 == "goose-provider")); } @@ -334,6 +322,33 @@ pub async fn run_prompt_image() { expected_session_id.assert_matches(&session.session_id().0); } +pub async fn run_prompt_image_attachment() { + let expected_session_id = ExpectedSessionId::default(); + let openai = OpenAiFixture::new( + vec![( + r#""type":"image_url""#.into(), + include_str!("../test_data/openai_image_attachment.txt"), + )], + expected_session_id.clone(), + ) + .await; + + let mut conn = C::new(TestConnectionConfig::default(), openai).await; + let (mut session, _) = conn.new_session().await; + expected_session_id.set(session.session_id().0.to_string()); + + let output = session + .prompt_with_image( + "Describe what you see in this image", + TEST_IMAGE_B64, + "image/png", + PermissionDecision::Cancel, + ) + .await; + assert!(output.text.contains("Hello Goose!")); + expected_session_id.assert_matches(&session.session_id().0); +} + pub async fn run_prompt_mcp() { let expected_session_id = ExpectedSessionId::default(); let mcp = McpFixture::new(Some(expected_session_id.clone())).await; diff --git a/crates/goose-acp/tests/fixtures/mod.rs b/crates/goose-acp/tests/fixtures/mod.rs index ff9c298311..b5b4056cd1 100644 --- a/crates/goose-acp/tests/fixtures/mod.rs +++ b/crates/goose-acp/tests/fixtures/mod.rs @@ -3,19 +3,17 @@ 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}; +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::{ - McpServer, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest, - RequestPermissionResponse, SelectedPermissionOutcome, SessionModelState, ToolCallStatus, -}; +use sacp::schema::{AuthMethod, McpServer, SessionModelState, ToolCallStatus}; use std::collections::VecDeque; use std::future::Future; use std::path::Path; @@ -26,49 +24,6 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -#[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) -} - pub struct OpenAiFixture { _server: MockServer, base_url: String, @@ -211,7 +166,7 @@ pub async fn spawn_acp_server_in_process( let base_url = base_url.clone(); Box::pin(async move { let api_client = - ApiClient::new(base_url, AuthMethod::BearerToken("test-key".to_string())) + ApiClient::new(base_url, ApiAuthMethod::BearerToken("test-key".to_string())) .unwrap(); let provider: Arc = Arc::new(OpenAiProvider::new(api_client, model_config)); @@ -273,6 +228,7 @@ pub trait Connection: Sized { &mut self, session_id: &str, ) -> (Self::Session, Option); + fn auth_methods(&self) -> &[AuthMethod]; fn reset_openai(&self); fn reset_permissions(&self); } @@ -281,6 +237,13 @@ pub trait Connection: Sized { 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); } @@ -332,4 +295,5 @@ pub async fn initialize_agent(agent: Arc) -> sacp::schema::Initia .unwrap() } +pub mod provider; pub mod server; diff --git a/crates/goose-acp/tests/fixtures/provider.rs b/crates/goose-acp/tests/fixtures/provider.rs new file mode 100644 index 0000000000..232068a641 --- /dev/null +++ b/crates/goose-acp/tests/fixtures/provider.rs @@ -0,0 +1,232 @@ +use super::{ + spawn_acp_server_in_process, Connection, OpenAiFixture, PermissionDecision, Session, + TestConnectionConfig, TestOutput, +}; +use async_trait::async_trait; +use futures::StreamExt; +use goose::acp::{AcpProvider, AcpProviderConfig, PermissionMapping}; +use goose::config::PermissionManager; +use goose::conversation::message::{ActionRequiredData, Message, MessageContent}; +use goose::model::ModelConfig; +use goose::permission::permission_confirmation::PrincipalType; +use goose::permission::{Permission, PermissionConfirmation}; +use goose::providers::base::Provider; +use goose_test_support::TEST_MODEL; +use sacp::schema::{AuthMethod, SessionModelState, ToolCallStatus}; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[allow(dead_code)] +pub struct ClientToProviderConnection { + provider: Arc>, + permission_manager: Arc, + auth_methods: Vec, + session_counter: usize, + _openai: OpenAiFixture, + _temp_dir: Option, +} + +#[allow(dead_code)] +pub struct ClientToProviderSession { + provider: Arc>, + acp_session_id: sacp::schema::SessionId, + session_id: String, +} + +impl ClientToProviderSession { + #[allow(dead_code)] + async fn send_message(&mut self, message: Message, decision: PermissionDecision) -> TestOutput { + let session_id = self.session_id.clone(); + let provider = self.provider.lock().await; + let model_config = provider.get_model_config(); + let mut stream = provider + .stream(&model_config, &session_id, "", &[message], &[]) + .await + .unwrap(); + let mut text = String::new(); + let mut tool_error = false; + let mut saw_tool = false; + + while let Some(item) = stream.next().await { + let (msg, _) = item.unwrap(); + if let Some(msg) = msg { + for content in msg.content { + match content { + MessageContent::Text(t) => { + text.push_str(&t.text); + } + MessageContent::ToolResponse(resp) => { + saw_tool = true; + if let Ok(result) = resp.tool_result { + tool_error |= result.is_error.unwrap_or(false); + } + } + MessageContent::ActionRequired(action) => { + if let ActionRequiredData::ToolConfirmation { id, .. } = action.data { + saw_tool = true; + tool_error |= decision.should_record_rejection(); + + let confirmation = PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: Permission::from(decision), + }; + + let handled = provider + .handle_permission_confirmation(&id, &confirmation) + .await; + assert!(handled); + } + } + _ => {} + } + } + } + } + + let tool_status = if saw_tool { + Some(if tool_error { + ToolCallStatus::Failed + } else { + ToolCallStatus::Completed + }) + } else { + None + }; + + TestOutput { text, tool_status } + } +} + +#[async_trait] +impl Connection for ClientToProviderConnection { + type Session = ClientToProviderSession; + + async fn new(config: TestConnectionConfig, openai: 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 goose_mode = config.goose_mode; + let mcp_servers = config.mcp_servers; + + let (transport, _handle, permission_manager) = spawn_acp_server_in_process( + openai.uri(), + &config.builtins, + data_root.as_path(), + goose_mode, + config.provider_factory, + ) + .await; + + let provider_config = AcpProviderConfig { + command: "unused".into(), + args: vec![], + env: vec![], + env_remove: vec![], + work_dir: data_root, + mcp_servers, + session_mode_id: None, + permission_mapping: PermissionMapping::default(), + }; + + let provider = AcpProvider::connect_with_transport( + "acp-test".to_string(), + ModelConfig::new(TEST_MODEL).unwrap(), + goose_mode, + provider_config, + transport.incoming, + transport.outgoing, + ) + .await + .unwrap(); + + let auth_methods = provider.auth_methods().to_vec(); + + Self { + provider: Arc::new(Mutex::new(provider)), + permission_manager, + auth_methods, + session_counter: 0, + _openai: openai, + _temp_dir: temp_dir, + } + } + + async fn new_session(&mut self) -> (ClientToProviderSession, Option) { + // Tests like run_model_set call new_session() multiple times on the same + // connection, so each needs a distinct key to avoid returning a cached session. + self.session_counter += 1; + let goose_id = format!("test-session-{}", self.session_counter); + let response = self + .provider + .lock() + .await + .ensure_session(Some(&goose_id)) + .await + .unwrap(); + + let session = ClientToProviderSession { + provider: Arc::clone(&self.provider), + acp_session_id: response.session_id, + session_id: goose_id, + }; + (session, response.models) + } + + async fn load_session( + &mut self, + _session_id: &str, + ) -> (ClientToProviderSession, Option) { + unimplemented!("TODO: implement load_session in ACP provider") + } + + fn auth_methods(&self) -> &[AuthMethod] { + &self.auth_methods + } + + fn reset_openai(&self) { + self._openai.reset(); + } + + fn reset_permissions(&self) { + self.permission_manager.remove_extension(""); + } +} + +#[async_trait] +impl Session for ClientToProviderSession { + fn session_id(&self) -> &sacp::schema::SessionId { + &self.acp_session_id + } + + async fn prompt(&mut self, prompt: &str, decision: PermissionDecision) -> TestOutput { + self.send_message(Message::user().with_text(prompt), decision) + .await + } + + async fn prompt_with_image( + &mut self, + prompt: &str, + image_b64: &str, + mime_type: &str, + decision: PermissionDecision, + ) -> TestOutput { + let message = Message::user() + .with_image(image_b64, mime_type) + .with_text(prompt); + self.send_message(message, decision).await + } + + async fn set_model(&self, model_id: &str) { + self.provider + .lock() + .await + .set_model(&self.acp_session_id, model_id) + .await + .unwrap(); + } +} diff --git a/crates/goose-acp/tests/fixtures/server.rs b/crates/goose-acp/tests/fixtures/server.rs index 1d02eb6625..53000b2a38 100644 --- a/crates/goose-acp/tests/fixtures/server.rs +++ b/crates/goose-acp/tests/fixtures/server.rs @@ -5,8 +5,8 @@ use super::{ use async_trait::async_trait; use goose::config::PermissionManager; use sacp::schema::{ - ContentBlock, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, - PromptRequest, ProtocolVersion, RequestPermissionRequest, SessionModelState, + AuthMethod, ContentBlock, ImageContent, InitializeRequest, LoadSessionRequest, McpServer, + NewSessionRequest, PromptRequest, ProtocolVersion, RequestPermissionRequest, SessionModelState, SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallStatus, }; use sacp::{ClientToAgent, JrConnectionCx}; @@ -22,6 +22,7 @@ pub struct ClientToAgentConnection { permission: Arc>, notify: Arc, permission_manager: Arc, + auth_methods: Vec, _openai: super::OpenAiFixture, _temp_dir: Option, } @@ -34,6 +35,42 @@ pub struct ClientToAgentSession { notify: Arc, } +impl ClientToAgentSession { + async fn send_prompt( + &mut self, + content: Vec, + decision: PermissionDecision, + ) -> TestOutput { + *self.permission.lock().unwrap() = decision; + self.updates.lock().unwrap().clear(); + + let response = self + .cx + .send_request(PromptRequest::new(self.session_id.clone(), content)) + .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 } + } +} + impl ClientToAgentConnection { #[allow(dead_code)] pub fn cx(&self) -> &JrConnectionCx { @@ -67,7 +104,7 @@ impl Connection for ClientToAgentConnection { let notify = Arc::new(Notify::new()); let permission = Arc::new(Mutex::new(PermissionDecision::Cancel)); - let cx = { + let (cx, auth_methods) = { let updates_clone = updates.clone(); let notify_clone = notify.clone(); let permission_clone = permission.clone(); @@ -75,11 +112,13 @@ impl Connection for ClientToAgentConnection { let cx_holder: Arc>>> = Arc::new(Mutex::new(None)); let cx_holder_clone = cx_holder.clone(); + let auth_holder: Arc>> = Arc::new(Mutex::new(Vec::new())); + let auth_holder_clone = auth_holder.clone(); let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { - let permission_mapping = PermissionMapping; + let permission_mapping = PermissionMapping::default(); let result = ClientToAgent::builder() .on_receive_notification( @@ -112,12 +151,15 @@ impl Connection for ClientToAgentConnection { .unwrap() .run_until({ let cx_holder = cx_holder_clone; + let auth_holder = auth_holder_clone; move |cx: JrConnectionCx| async move { - cx.send_request(InitializeRequest::new(ProtocolVersion::LATEST)) + let resp = cx + .send_request(InitializeRequest::new(ProtocolVersion::LATEST)) .block_task() .await .unwrap(); + *auth_holder.lock().unwrap() = resp.auth_methods; *cx_holder.lock().unwrap() = Some(cx.clone()); let _ = ready_tx.send(()); @@ -133,7 +175,8 @@ impl Connection for ClientToAgentConnection { ready_rx.await.unwrap(); let cx = cx_holder.lock().unwrap().take().unwrap(); - cx + let auth = std::mem::take(&mut *auth_holder.lock().unwrap()); + (cx, auth) }; Self { @@ -143,6 +186,7 @@ impl Connection for ClientToAgentConnection { permission, notify, permission_manager, + auth_methods, _openai: openai, _temp_dir: temp_dir, } @@ -190,6 +234,10 @@ impl Connection for ClientToAgentConnection { (session, response.models) } + fn auth_methods(&self) -> &[AuthMethod] { + &self.auth_methods + } + fn reset_openai(&self) { self._openai.reset(); } @@ -206,36 +254,25 @@ impl Session for ClientToAgentSession { } 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.session_id.clone(), - vec![ContentBlock::Text(TextContent::new(text))], - )) - .block_task() + self.send_prompt(vec![ContentBlock::Text(TextContent::new(text))], decision) .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 } + async fn prompt_with_image( + &mut self, + text: &str, + image_b64: &str, + mime_type: &str, + decision: PermissionDecision, + ) -> TestOutput { + self.send_prompt( + vec![ + ContentBlock::Image(ImageContent::new(image_b64, mime_type)), + ContentBlock::Text(TextContent::new(text)), + ], + decision, + ) + .await } // HACK: sacp doesn't support session/set_model yet, so we send it as untyped JSON. diff --git a/crates/goose-acp/tests/provider_test.rs b/crates/goose-acp/tests/provider_test.rs new file mode 100644 index 0000000000..0ad2bcde86 --- /dev/null +++ b/crates/goose-acp/tests/provider_test.rs @@ -0,0 +1,66 @@ +#![recursion_limit = "256"] + +mod common_tests; +use common_tests::fixtures::provider::ClientToProviderConnection; +use common_tests::fixtures::run_test; +use common_tests::{ + run_config_mcp, run_initialize_doesnt_hit_provider, run_load_model, run_model_list, + run_model_set, run_permission_persistence, run_prompt_basic, run_prompt_codemode, + run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, +}; + +#[test] +fn test_provider_config_mcp() { + run_test(async { run_config_mcp::().await }); +} + +#[test] +fn test_provider_initialize_doesnt_hit_provider() { + run_test(async { run_initialize_doesnt_hit_provider::().await }); +} + +#[test] +#[ignore = "TODO: implement load_session in ACP provider"] +fn test_provider_load_model() { + run_test(async { run_load_model::().await }); +} + +#[test] +fn test_provider_model_list() { + run_test(async { run_model_list::().await }); +} + +#[test] +fn test_provider_model_set() { + run_test(async { run_model_set::().await }); +} + +#[test] +fn test_provider_permission_persistence() { + run_test(async { run_permission_persistence::().await }); +} + +#[test] +fn test_provider_prompt_basic() { + run_test(async { run_prompt_basic::().await }); +} + +#[test] +fn test_provider_prompt_codemode() { + run_test(async { run_prompt_codemode::().await }); +} + +#[test] +fn test_provider_prompt_image() { + run_test(async { run_prompt_image::().await }); +} + +#[test] +fn test_provider_prompt_image_attachment() { + run_test(async { run_prompt_image_attachment::().await }); +} + +#[test] +fn test_provider_prompt_mcp() { + run_test(async { run_prompt_mcp::().await }); +} diff --git a/crates/goose-acp/tests/server_test.rs b/crates/goose-acp/tests/server_test.rs index a143d15356..7e97e6ad47 100644 --- a/crates/goose-acp/tests/server_test.rs +++ b/crates/goose-acp/tests/server_test.rs @@ -2,9 +2,9 @@ mod common_tests; use common_tests::fixtures::run_test; use common_tests::fixtures::server::ClientToAgentConnection; use common_tests::{ - run_config_mcp, run_initialize_without_provider, run_load_model, run_model_list, run_model_set, - run_permission_persistence, run_prompt_basic, run_prompt_codemode, run_prompt_image, - run_prompt_mcp, + run_config_mcp, run_initialize_doesnt_hit_provider, run_load_model, run_model_list, + run_model_set, run_permission_persistence, run_prompt_basic, run_prompt_codemode, + run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, }; #[test] @@ -13,8 +13,8 @@ fn test_config_mcp() { } #[test] -fn test_initialize_without_provider() { - run_test(async { run_initialize_without_provider().await }); +fn test_initialize_doesnt_hit_provider() { + run_test(async { run_initialize_doesnt_hit_provider::().await }); } #[test] @@ -52,6 +52,11 @@ fn test_prompt_image() { run_test(async { run_prompt_image::().await }); } +#[test] +fn test_prompt_image_attachment() { + run_test(async { run_prompt_image_attachment::().await }); +} + #[test] fn test_prompt_mcp() { run_test(async { run_prompt_mcp::().await }); diff --git a/crates/goose-acp/tests/test_data/openai_image_attachment.txt b/crates/goose-acp/tests/test_data/openai_image_attachment.txt new file mode 100644 index 0000000000..add7a87aa3 --- /dev/null +++ b/crates/goose-acp/tests/test_data/openai_image_attachment.txt @@ -0,0 +1,238 @@ +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"7E7t8"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"Here"},"finish_reason":null}],"usage":null,"obfuscation":"C7q"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"’s"},"finish_reason":null}],"usage":null,"obfuscation":"2YLin"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" what"},"finish_reason":null}],"usage":null,"obfuscation":"X0"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" I"},"finish_reason":null}],"usage":null,"obfuscation":"cWHjE"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" can"},"finish_reason":null}],"usage":null,"obfuscation":"sSB"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" derive"},"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" from"},"finish_reason":null}],"usage":null,"obfuscation":"wh"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" the"},"finish_reason":null}],"usage":null,"obfuscation":"pdc"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" image"},"finish_reason":null}],"usage":null,"obfuscation":"6"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":\n\n"},"finish_reason":null}],"usage":null,"obfuscation":"sf"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"8yQy1S"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" OCR"},"finish_reason":null}],"usage":null,"obfuscation":"8A1"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"/text"},"finish_reason":null}],"usage":null,"obfuscation":"9R"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" transcription"},"finish_reason":null}],"usage":null,"obfuscation":"Ehc21BJqv"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":\n"},"finish_reason":null}],"usage":null,"obfuscation":"9ab4"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"u2EwQi"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Hello"},"finish_reason":null}],"usage":null,"obfuscation":"5"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Goose"},"finish_reason":null}],"usage":null,"obfuscation":"b"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"!\n"},"finish_reason":null}],"usage":null,"obfuscation":"EsVy"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"Va8f84"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" This"},"finish_reason":null}],"usage":null,"obfuscation":"IR"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" is"},"finish_reason":null}],"usage":null,"obfuscation":"eaRL"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" a"},"finish_reason":null}],"usage":null,"obfuscation":"cdTOW"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" test"},"finish_reason":null}],"usage":null,"obfuscation":"WN"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" image"},"finish_reason":null}],"usage":null,"obfuscation":"l"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".\n\n"},"finish_reason":null}],"usage":null,"obfuscation":"lx"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"ABF429"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Observ"},"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"ations"},"finish_reason":null}],"usage":null,"obfuscation":"J"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":\n"},"finish_reason":null}],"usage":null,"obfuscation":"iWhB"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"IFJGlV"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" -"},"finish_reason":null}],"usage":null,"obfuscation":"C8bxY"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" The"},"finish_reason":null}],"usage":null,"obfuscation":"UNZ"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" text"},"finish_reason":null}],"usage":null,"obfuscation":"aO"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" appears"},"finish_reason":null}],"usage":null,"obfuscation":"fMW5Ik2fIuCxYIi"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" in"},"finish_reason":null}],"usage":null,"obfuscation":"6Csr"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" two"},"finish_reason":null}],"usage":null,"obfuscation":"1OA"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" lines"},"finish_reason":null}],"usage":null,"obfuscation":"6"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" on"},"finish_reason":null}],"usage":null,"obfuscation":"MfK8"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" a"},"finish_reason":null}],"usage":null,"obfuscation":"Xn4NP"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" white"},"finish_reason":null}],"usage":null,"obfuscation":"Z"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" background"},"finish_reason":null}],"usage":null,"obfuscation":"xJbYCha0CxG5"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" with"},"finish_reason":null}],"usage":null,"obfuscation":"pN"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" black"},"finish_reason":null}],"usage":null,"obfuscation":"7"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" sans"},"finish_reason":null}],"usage":null,"obfuscation":"ni"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-serif"},"finish_reason":null}],"usage":null,"obfuscation":"X"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" style"},"finish_reason":null}],"usage":null,"obfuscation":"W"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".\n"},"finish_reason":null}],"usage":null,"obfuscation":"TPOZ"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"finish_reason":null}],"usage":null,"obfuscation":"SpMYza"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" -"},"finish_reason":null}],"usage":null,"obfuscation":"CSuHP"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Lik"},"finish_reason":null}],"usage":null,"obfuscation":"Wgi"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"ely"},"finish_reason":null}],"usage":null,"obfuscation":"dhDR"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" centered"},"finish_reason":null}],"usage":null,"obfuscation":"ai50dSkUIc0bS6"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" alignment"},"finish_reason":null}],"usage":null,"obfuscation":"8sTNdzGIIYmdW"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}],"usage":null,"obfuscation":"Fby3dv"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" two"},"finish_reason":null}],"usage":null,"obfuscation":"Lpl"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-line"},"finish_reason":null}],"usage":null,"obfuscation":"xf"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" layout"},"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".\n\n"},"finish_reason":null}],"usage":null,"obfuscation":"CM"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"What"},"finish_reason":null}],"usage":null,"obfuscation":"zy9"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" would"},"finish_reason":null}],"usage":null,"obfuscation":"d"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" you"},"finish_reason":null}],"usage":null,"obfuscation":"qyo"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" like"},"finish_reason":null}],"usage":null,"obfuscation":"tG"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" me"},"finish_reason":null}],"usage":null,"obfuscation":"KJ3J"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" to"},"finish_reason":null}],"usage":null,"obfuscation":"jTJN"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" do"},"finish_reason":null}],"usage":null,"obfuscation":"Kvuu"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" next"},"finish_reason":null}],"usage":null,"obfuscation":"Q4"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"?\n"},"finish_reason":null}],"usage":null,"obfuscation":"MKya"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"FGV2M3"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Translate"},"finish_reason":null}],"usage":null,"obfuscation":"ojudhthRN0wje"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" the"},"finish_reason":null}],"usage":null,"obfuscation":"j1d"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" text"},"finish_reason":null}],"usage":null,"obfuscation":"IN"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"\n"},"finish_reason":null}],"usage":null,"obfuscation":"5GPdt"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"GdygSH"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Extract"},"finish_reason":null}],"usage":null,"obfuscation":"CocHxzrTqsaRFbq"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" more"},"finish_reason":null}],"usage":null,"obfuscation":"LN"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" metadata"},"finish_reason":null}],"usage":null,"obfuscation":"52g08uQUy8fzv2"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" ("},"finish_reason":null}],"usage":null,"obfuscation":"AmD59"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"font"},"finish_reason":null}],"usage":null,"obfuscation":"Qvr"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}],"usage":null,"obfuscation":"DKTZR4"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" size"},"finish_reason":null}],"usage":null,"obfuscation":"sa"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":","},"finish_reason":null}],"usage":null,"obfuscation":"kQknkh"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" contrast"},"finish_reason":null}],"usage":null,"obfuscation":"CzLtt2a3ZKZRsk"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":")\n"},"finish_reason":null}],"usage":null,"obfuscation":"10ts"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"BkGfhE"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Generate"},"finish_reason":null}],"usage":null,"obfuscation":"kRp8kkudiLXyPr"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" alt"},"finish_reason":null}],"usage":null,"obfuscation":"naE"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" text"},"finish_reason":null}],"usage":null,"obfuscation":"WO"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" or"},"finish_reason":null}],"usage":null,"obfuscation":"wjCo"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" accessibility"},"finish_reason":null}],"usage":null,"obfuscation":"HgWk0MkQP"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" description"},"finish_reason":null}],"usage":null,"obfuscation":"2RkUmSr6mK5"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"\n"},"finish_reason":null}],"usage":null,"obfuscation":"qCmgA"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"dDYudm"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Create"},"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" a"},"finish_reason":null}],"usage":null,"obfuscation":"Uco4R"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" caption"},"finish_reason":null}],"usage":null,"obfuscation":"cAPhTd1DoHVetWW"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" or"},"finish_reason":null}],"usage":null,"obfuscation":"qK8N"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" use"},"finish_reason":null}],"usage":null,"obfuscation":"wGO"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" it"},"finish_reason":null}],"usage":null,"obfuscation":"DKP3"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" in"},"finish_reason":null}],"usage":null,"obfuscation":"0qux"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" code"},"finish_reason":null}],"usage":null,"obfuscation":"FI"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" ("},"finish_reason":null}],"usage":null,"obfuscation":"BXnPm"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"HTML"},"finish_reason":null}],"usage":null,"obfuscation":"lal"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"/C"},"finish_reason":null}],"usage":null,"obfuscation":"2bBrS"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"SS"},"finish_reason":null}],"usage":null,"obfuscation":"kM3Jx"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":")\n"},"finish_reason":null}],"usage":null,"obfuscation":"H7Et"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"EbOoyr"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Run"},"finish_reason":null}],"usage":null,"obfuscation":"h74"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" OCR"},"finish_reason":null}],"usage":null,"obfuscation":"3vs"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" on"},"finish_reason":null}],"usage":null,"obfuscation":"N3y0"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" similar"},"finish_reason":null}],"usage":null,"obfuscation":"mMnr4ffD7zygIYv"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" images"},"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" for"},"finish_reason":null}],"usage":null,"obfuscation":"TJp"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" batch"},"finish_reason":null}],"usage":null,"obfuscation":"6"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" processing"},"finish_reason":null}],"usage":null,"obfuscation":"ls6HOZgAV1ez"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null,"obfuscation":"j"} + +data: {"id":"chatcmpl-DHk8aNO2gKKqlrPxXg3cHSzIBitLf","object":"chat.completion.chunk","created":1773121300,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":2768,"completion_tokens":572,"total_tokens":3340,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":448,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"EvMBUZOQw2ps4DX"} + +data: [DONE] + diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 839c9b59cc..6b635af7d5 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -102,6 +102,8 @@ tempfile = { workspace = true } dashmap = "6.1" ahash = "0.8" tokio-util = { workspace = true, features = ["compat"] } +sacp = { workspace = true } +agent-client-protocol-schema = { version = "0.10", features = ["unstable"] } unicode-normalization = "0.1" # For local Whisper transcription diff --git a/crates/goose/src/acp/common.rs b/crates/goose/src/acp/common.rs new file mode 100644 index 0000000000..759687b23a --- /dev/null +++ b/crates/goose/src/acp/common.rs @@ -0,0 +1,326 @@ +use std::str::FromStr; + +use crate::permission::Permission; +use sacp::schema::{ + PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SelectedPermissionOutcome, ToolCallStatus, +}; +use strum::{Display, EnumString}; + +#[derive(Clone, Debug)] +pub struct PermissionMapping { + pub allow_option_id: Option, + pub reject_option_id: Option, + pub rejected_tool_status: ToolCallStatus, +} + +impl Default for PermissionMapping { + fn default() -> Self { + Self { + allow_option_id: None, + reject_option_id: None, + rejected_tool_status: ToolCallStatus::Failed, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Display, EnumString)] +#[strum(serialize_all = "snake_case")] +pub enum PermissionDecision { + AllowAlways, + AllowOnce, + RejectAlways, + RejectOnce, + Cancel, +} + +impl PermissionDecision { + pub fn should_record_rejection(self) -> bool { + matches!( + self, + PermissionDecision::RejectAlways + | PermissionDecision::RejectOnce + | PermissionDecision::Cancel + ) + } +} + +impl From for PermissionDecision { + fn from(p: Permission) -> Self { + match p { + Permission::AlwaysAllow => Self::AllowAlways, + Permission::AllowOnce => Self::AllowOnce, + Permission::DenyOnce => Self::RejectOnce, + Permission::AlwaysDeny => Self::RejectAlways, + Permission::Cancel => Self::Cancel, + } + } +} + +impl From for Permission { + fn from(d: PermissionDecision) -> Self { + match d { + PermissionDecision::AllowAlways => Self::AlwaysAllow, + PermissionDecision::AllowOnce => Self::AllowOnce, + PermissionDecision::RejectOnce => Self::DenyOnce, + PermissionDecision::RejectAlways => Self::AlwaysDeny, + PermissionDecision::Cancel => Self::Cancel, + } + } +} + +impl From<&RequestPermissionOutcome> for PermissionDecision { + fn from(outcome: &RequestPermissionOutcome) -> Self { + match outcome { + RequestPermissionOutcome::Cancelled => Self::Cancel, + RequestPermissionOutcome::Selected(selected) => { + Self::from_str(&selected.option_id.0).unwrap_or(Self::Cancel) + } + _ => Self::Cancel, + } + } +} + +pub fn map_permission_response( + mapping: &PermissionMapping, + request: &RequestPermissionRequest, + decision: PermissionDecision, +) -> RequestPermissionResponse { + let selected_id = match decision { + PermissionDecision::AllowAlways => select_option_id( + &request.options, + &mapping.allow_option_id, + PermissionOptionKind::AllowAlways, + ) + .or_else(|| { + select_option_id( + &request.options, + &mapping.allow_option_id, + PermissionOptionKind::AllowOnce, + ) + }), + PermissionDecision::AllowOnce => select_option_id( + &request.options, + &mapping.allow_option_id, + PermissionOptionKind::AllowOnce, + ) + .or_else(|| { + select_option_id( + &request.options, + &mapping.allow_option_id, + PermissionOptionKind::AllowAlways, + ) + }), + PermissionDecision::RejectAlways => select_option_id( + &request.options, + &mapping.reject_option_id, + PermissionOptionKind::RejectAlways, + ) + .or_else(|| { + select_option_id( + &request.options, + &mapping.reject_option_id, + PermissionOptionKind::RejectOnce, + ) + }), + PermissionDecision::RejectOnce => select_option_id( + &request.options, + &mapping.reject_option_id, + PermissionOptionKind::RejectOnce, + ) + .or_else(|| { + select_option_id( + &request.options, + &mapping.reject_option_id, + PermissionOptionKind::RejectAlways, + ) + }), + PermissionDecision::Cancel => None, + }; + + if let Some(option_id) = selected_id { + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )) + } else { + RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled) + } +} + +fn select_option_id( + options: &[PermissionOption], + preferred_id: &Option, + kind: PermissionOptionKind, +) -> Option { + if let Some(preferred_id) = preferred_id { + let preferred = sacp::schema::PermissionOptionId::new(preferred_id.clone()); + if options.iter().any(|opt| opt.option_id == preferred) { + return Some(preferred_id.clone()); + } + } + + options + .iter() + .find(|opt| opt.kind == kind) + .map(|opt| opt.option_id.0.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sacp::schema::{PermissionOptionId, ToolCallId, ToolCallUpdate, ToolCallUpdateFields}; + use test_case::test_case; + + fn make_request(options: Vec) -> RequestPermissionRequest { + let tool_call = + ToolCallUpdate::new(ToolCallId::new("tool-1"), ToolCallUpdateFields::default()); + RequestPermissionRequest::new("session-1", tool_call, options) + } + + fn option(id: &str, kind: PermissionOptionKind) -> PermissionOption { + PermissionOption::new( + PermissionOptionId::new(id.to_string()), + id.to_string(), + kind, + ) + } + + #[test_case( + Some("allow"), + None, + PermissionDecision::AllowOnce, + "allow", + true; + "allow_uses_preferred_id" + )] + #[test_case( + None, + None, + PermissionDecision::AllowAlways, + "allow_always", + false; + "allow_always_prefers_kind" + )] + #[test_case( + Some("missing"), + None, + PermissionDecision::AllowOnce, + "allow_once", + false; + "allow_falls_back_to_kind" + )] + #[test_case( + None, + Some("reject"), + PermissionDecision::RejectOnce, + "reject", + true; + "reject_uses_preferred_id" + )] + #[test_case( + None, + Some("missing"), + PermissionDecision::RejectOnce, + "reject_once", + false; + "reject_falls_back_to_kind" + )] + fn test_permission_mapping( + allow_option_id: Option<&str>, + reject_option_id: Option<&str>, + decision: PermissionDecision, + expected_id: &str, + include_preferred: bool, + ) { + let mut options = vec![ + option("allow_once", PermissionOptionKind::AllowOnce), + option("allow_always", PermissionOptionKind::AllowAlways), + option("reject_once", PermissionOptionKind::RejectOnce), + option("reject", PermissionOptionKind::RejectAlways), + ]; + + if include_preferred { + if let Some(preferred_allow) = allow_option_id { + if !options + .iter() + .any(|opt| opt.option_id.0.as_ref() == preferred_allow) + { + options.push(option(preferred_allow, PermissionOptionKind::AllowOnce)); + } + } + + if let Some(preferred_reject) = reject_option_id { + if !options + .iter() + .any(|opt| opt.option_id.0.as_ref() == preferred_reject) + { + options.push(option(preferred_reject, PermissionOptionKind::RejectOnce)); + } + } + } + + let request = make_request(options); + + let mapping = PermissionMapping { + allow_option_id: allow_option_id.map(|s| s.to_string()), + reject_option_id: reject_option_id.map(|s| s.to_string()), + rejected_tool_status: ToolCallStatus::Failed, + }; + + let response = map_permission_response(&mapping, &request, decision); + match response.outcome { + RequestPermissionOutcome::Selected(selected) => { + assert_eq!(selected.option_id.0.as_ref(), expected_id); + } + _ => panic!("expected selected outcome"), + } + } + + #[test_case(PermissionDecision::Cancel; "cancelled")] + fn test_permission_cancelled(decision: PermissionDecision) { + let request = make_request(vec![option("allow_once", PermissionOptionKind::AllowOnce)]); + let response = map_permission_response(&PermissionMapping::default(), &request, decision); + assert!(matches!( + response.outcome, + RequestPermissionOutcome::Cancelled + )); + } + + #[test_case(Permission::AlwaysAllow, PermissionDecision::AllowAlways; "always_allow")] + #[test_case(Permission::AllowOnce, PermissionDecision::AllowOnce; "allow_once")] + #[test_case(Permission::DenyOnce, PermissionDecision::RejectOnce; "deny_once")] + #[test_case(Permission::AlwaysDeny, PermissionDecision::RejectAlways; "always_deny")] + #[test_case(Permission::Cancel, PermissionDecision::Cancel; "cancel")] + fn test_permission_to_decision(input: Permission, expected: PermissionDecision) { + assert_eq!(PermissionDecision::from(input), expected); + } + + #[test_case(PermissionDecision::AllowAlways, Permission::AlwaysAllow; "allow_always")] + #[test_case(PermissionDecision::AllowOnce, Permission::AllowOnce; "allow_once")] + #[test_case(PermissionDecision::RejectOnce, Permission::DenyOnce; "reject_once")] + #[test_case(PermissionDecision::RejectAlways, Permission::AlwaysDeny; "reject_always")] + #[test_case(PermissionDecision::Cancel, Permission::Cancel; "cancel")] + fn test_decision_to_permission(input: PermissionDecision, expected: Permission) { + assert_eq!(Permission::from(input), expected); + } + + #[test_case("allow_once", PermissionDecision::AllowOnce; "allow_once")] + #[test_case("allow_always", PermissionDecision::AllowAlways; "allow_always")] + #[test_case("reject_once", PermissionDecision::RejectOnce; "reject_once")] + #[test_case("reject_always", PermissionDecision::RejectAlways; "reject_always")] + #[test_case("unknown", PermissionDecision::Cancel; "unknown_maps_to_cancel")] + fn test_outcome_to_decision(option_id: &str, expected: PermissionDecision) { + let outcome = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + PermissionOptionId::new(option_id.to_string()), + )); + assert_eq!(PermissionDecision::from(&outcome), expected); + } + + #[test] + fn test_cancelled_outcome_to_decision() { + assert_eq!( + PermissionDecision::from(&RequestPermissionOutcome::Cancelled), + PermissionDecision::Cancel + ); + } +} diff --git a/crates/goose/src/acp/mod.rs b/crates/goose/src/acp/mod.rs new file mode 100644 index 0000000000..a29d207dd4 --- /dev/null +++ b/crates/goose/src/acp/mod.rs @@ -0,0 +1,5 @@ +mod common; +mod provider; + +pub use common::{map_permission_response, PermissionDecision, PermissionMapping}; +pub use provider::{extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig}; diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs new file mode 100644 index 0000000000..461791afff --- /dev/null +++ b/crates/goose/src/acp/provider.rs @@ -0,0 +1,962 @@ +use anyhow::{Context, Result}; +use async_stream::try_stream; +use rmcp::model::{Role, Tool}; +use sacp::schema::{ + AuthMethod, ContentBlock, ContentChunk, EnvVariable, HttpHeader, ImageContent, + InitializeRequest, InitializeResponse, McpCapabilities, McpServer, McpServerHttp, + McpServerStdio, NewSessionRequest, NewSessionResponse, PromptRequest, ProtocolVersion, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionId, + SessionNotification, SessionUpdate, SetSessionModeRequest, StopReason, TextContent, + ToolCallContent, +}; +use sacp::{ClientToAgent, JrConnectionCx}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use tokio::process::{Child, Command}; +use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use crate::acp::{map_permission_response, PermissionDecision, PermissionMapping}; +use crate::config::{ExtensionConfig, GooseMode}; +use crate::conversation::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::permission::permission_confirmation::PrincipalType; +use crate::permission::{Permission, PermissionConfirmation}; +use crate::providers::base::{MessageStream, PermissionRouting, Provider}; +use crate::providers::errors::ProviderError; + +#[derive(Clone, Debug)] +pub struct AcpProviderConfig { + pub command: PathBuf, + pub args: Vec, + pub env: Vec<(String, String)>, + pub env_remove: Vec, + pub work_dir: PathBuf, + pub mcp_servers: Vec, + pub session_mode_id: Option, + pub permission_mapping: PermissionMapping, +} + +enum ClientRequest { + NewSession { + response_tx: oneshot::Sender>, + }, + SetModel { + session_id: SessionId, + model_id: String, + response_tx: oneshot::Sender>, + }, + Prompt { + session_id: SessionId, + content: Vec, + response_tx: mpsc::Sender, + }, + Shutdown, +} + +#[derive(Debug)] +enum AcpUpdate { + Text(String), + Thought(String), + ToolCallStart { + id: String, + }, + ToolCallComplete { + id: String, + }, + PermissionRequest { + request: Box, + response_tx: oneshot::Sender, + }, + Complete(StopReason), + Error(String), +} + +pub struct AcpProvider { + name: String, + model: ModelConfig, + goose_mode: GooseMode, + tx: mpsc::Sender, + permission_mapping: PermissionMapping, + rejected_tool_calls: Arc>>, + pending_confirmations: + Arc>>>, + goose_to_acp_id: Arc>>, + auth_methods: Vec, +} + +impl std::fmt::Debug for AcpProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AcpProvider") + .field("name", &self.name) + .field("model", &self.model) + .finish() + } +} + +impl AcpProvider { + pub async fn connect( + name: String, + model: ModelConfig, + goose_mode: GooseMode, + config: AcpProviderConfig, + ) -> Result { + let (tx, rx) = mpsc::channel(32); + let (init_tx, init_rx) = oneshot::channel(); + let permission_mapping = config.permission_mapping.clone(); + let rejected_tool_calls = Arc::new(TokioMutex::new(HashSet::new())); + + tokio::spawn(run_client_loop(config, rx, init_tx)); + + let init_response = init_rx + .await + .context("ACP client initialization cancelled")??; + + Ok(Self::new_with_runtime( + name, + model, + goose_mode, + tx, + permission_mapping, + rejected_tool_calls, + init_response.auth_methods, + )) + } + + pub async fn connect_with_transport( + name: String, + model: ModelConfig, + goose_mode: GooseMode, + config: AcpProviderConfig, + read: R, + write: W, + ) -> Result + where + R: futures::AsyncRead + Unpin + Send + 'static, + W: futures::AsyncWrite + Unpin + Send + 'static, + { + let (tx, mut rx) = mpsc::channel(32); + let (init_tx, init_rx) = oneshot::channel(); + let permission_mapping = config.permission_mapping.clone(); + let rejected_tool_calls = Arc::new(TokioMutex::new(HashSet::new())); + let transport = sacp::ByteStreams::new(write, read); + let init_tx = Arc::new(Mutex::new(Some(init_tx))); + tokio::spawn(async move { + if let Err(e) = + run_protocol_loop_with_transport(config, transport, &mut rx, init_tx.clone()).await + { + tracing::error!("ACP protocol error: {e}"); + } + }); + + let init_response = init_rx + .await + .context("ACP client initialization cancelled")??; + + Ok(Self::new_with_runtime( + name, + model, + goose_mode, + tx, + permission_mapping, + rejected_tool_calls, + init_response.auth_methods, + )) + } + + fn new_with_runtime( + name: String, + model: ModelConfig, + goose_mode: GooseMode, + tx: mpsc::Sender, + permission_mapping: PermissionMapping, + rejected_tool_calls: Arc>>, + auth_methods: Vec, + ) -> Self { + Self { + name, + model, + goose_mode, + tx, + permission_mapping, + rejected_tool_calls, + pending_confirmations: Arc::new(TokioMutex::new(HashMap::new())), + goose_to_acp_id: Arc::new(TokioMutex::new(HashMap::new())), + auth_methods, + } + } + + pub fn auth_methods(&self) -> &[AuthMethod] { + &self.auth_methods + } + + pub async fn new_session(&self) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.tx + .send(ClientRequest::NewSession { response_tx }) + .await + .context("ACP client is unavailable")?; + response_rx.await.context("ACP session/new cancelled")? + } + + pub async fn set_model(&self, session_id: &SessionId, model_id: &str) -> Result<()> { + let (response_tx, response_rx) = oneshot::channel(); + self.tx + .send(ClientRequest::SetModel { + session_id: session_id.clone(), + model_id: model_id.to_string(), + response_tx, + }) + .await + .context("ACP client is unavailable")?; + response_rx + .await + .context("ACP session/set_model cancelled")? + } + + pub async fn handle_permission_confirmation( + &self, + request_id: &str, + confirmation: &PermissionConfirmation, + ) -> bool { + let mut pending = self.pending_confirmations.lock().await; + if let Some(tx) = pending.remove(request_id) { + let _ = tx.send(confirmation.clone()); + return true; + } + false + } + + pub async fn ensure_session( + &self, + session_id: Option<&str>, + ) -> Result { + if let Some(session_id) = session_id { + if let Some(response) = self.goose_to_acp_id.lock().await.get(session_id) { + return Ok(response.clone()); + } + } + + let response = self.new_session().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to create ACP session: {e}")) + })?; + + if let Some(session_id) = session_id { + self.goose_to_acp_id + .lock() + .await + .insert(session_id.to_string(), response.clone()); + } + + Ok(response) + } + + async fn prompt( + &self, + session_id: SessionId, + content: Vec, + ) -> Result> { + let (response_tx, response_rx) = mpsc::channel(64); + self.tx + .send(ClientRequest::Prompt { + session_id, + content, + response_tx, + }) + .await + .context("ACP client is unavailable")?; + Ok(response_rx) + } +} + +#[async_trait::async_trait] +impl Provider for AcpProvider { + fn get_name(&self) -> &str { + &self.name + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + fn permission_routing(&self) -> PermissionRouting { + PermissionRouting::ActionRequired + } + + async fn handle_permission_confirmation( + &self, + request_id: &str, + confirmation: &PermissionConfirmation, + ) -> bool { + AcpProvider::handle_permission_confirmation(self, request_id, confirmation).await + } + + async fn stream( + &self, + _model_config: &ModelConfig, + session_id: &str, + _system: &str, + messages: &[Message], + _tools: &[Tool], + ) -> Result { + let response = self.ensure_session(Some(session_id)).await?; + let prompt_blocks = messages_to_prompt(messages); + let mut rx = self + .prompt(response.session_id, prompt_blocks) + .await + .map_err(|e| ProviderError::RequestFailed(format!("Failed to send ACP prompt: {e}")))?; + + let pending_confirmations = self.pending_confirmations.clone(); + let rejected_tool_calls = self.rejected_tool_calls.clone(); + let permission_mapping = self.permission_mapping.clone(); + let goose_mode = self.goose_mode; + + let reject_all_tools = goose_mode == GooseMode::Chat; + + Ok(Box::pin(try_stream! { + // ACP agents execute tools internally. Goose never dispatches tool calls; + // it only sees text, thoughts, and permission requests from the agent. + // + // In Chat mode (reject_all_tools), we suppress all text after a tool + // starts because the agent may send tool results as AcpUpdate::Text, + // bypassing the permission response. + let mut suppress_text = false; + + while let Some(update) = rx.recv().await { + match update { + AcpUpdate::Text(text) => { + if !suppress_text { + let message = Message::assistant().with_text(text); + yield (Some(message), None); + } + } + AcpUpdate::Thought(text) => { + let message = Message::assistant() + .with_thinking(text, "") + .with_visibility(true, false); + yield (Some(message), None); + } + AcpUpdate::ToolCallStart { id, .. } => { + if reject_all_tools { + suppress_text = true; + rejected_tool_calls.lock().await.insert(id); + } + } + AcpUpdate::ToolCallComplete { id, .. } => { + let is_error = rejected_tool_calls.lock().await.remove(&id); + if is_error { + let message = Message::assistant().with_text("Tool call was denied."); + yield (Some(message), None); + } + } + AcpUpdate::PermissionRequest { request, response_tx } => { + if let Some(decision) = permission_decision_from_mode(goose_mode) { + if decision.should_record_rejection() { + rejected_tool_calls.lock().await.insert(request.tool_call.tool_call_id.0.to_string()); + } + let response = map_permission_response(&permission_mapping, &request, decision); + let _ = response_tx.send(response); + continue; + } + + let request_id = request.tool_call.tool_call_id.0.to_string(); + let (tx, rx) = oneshot::channel(); + + pending_confirmations + .lock() + .await + .insert(request_id.clone(), tx); + + if let Some(action_required) = build_action_required_message(&request) { + yield (Some(action_required), None); + } + + let confirmation = rx.await.unwrap_or(PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission: Permission::Cancel, + }); + + pending_confirmations.lock().await.remove(&request_id); + + let decision = PermissionDecision::from(confirmation.permission); + if decision.should_record_rejection() { + rejected_tool_calls.lock().await.insert(request.tool_call.tool_call_id.0.to_string()); + } + let response = map_permission_response(&permission_mapping, &request, decision); + let _ = response_tx.send(response); + } + AcpUpdate::Complete(_reason) => { + break; + } + AcpUpdate::Error(e) => { + Err(ProviderError::RequestFailed(e))?; + } + } + } + })) + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + let response = self.ensure_session(None).await?; + Ok(response + .models + .map(|state| { + state + .available_models + .iter() + .map(|m| m.model_id.0.to_string()) + .collect() + }) + .unwrap_or_default()) + } +} + +impl Drop for AcpProvider { + fn drop(&mut self) { + let tx = self.tx.clone(); + tokio::spawn(async move { + let _ = tx.send(ClientRequest::Shutdown).await; + }); + } +} + +async fn run_client_loop( + config: AcpProviderConfig, + mut rx: mpsc::Receiver, + init_tx: oneshot::Sender>, +) { + let init_tx = Arc::new(Mutex::new(Some(init_tx))); + + let child = match spawn_acp_process(&config).await { + Ok(c) => c, + Err(e) => { + let message = e.to_string(); + send_init_result(&init_tx, Err(anyhow::anyhow!(message.clone()))); + tracing::error!("failed to spawn ACP process: {message}"); + return; + } + }; + + match run_protocol_loop_with_child(config, child, &mut rx, init_tx.clone()).await { + Ok(()) => tracing::debug!("ACP protocol loop exited cleanly"), + Err(e) => { + let message = e.to_string(); + tracing::error!(error = %e, "ACP protocol loop error"); + send_init_result(&init_tx, Err(anyhow::anyhow!(message))); + } + } +} + +async fn spawn_acp_process(config: &AcpProviderConfig) -> Result { + let mut cmd = Command::new(&config.command); + cmd.args(&config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + + for key in &config.env_remove { + cmd.env_remove(key); + } + + for (key, value) in &config.env { + cmd.env(key, value); + } + + cmd.spawn().context("failed to spawn ACP process") +} + +async fn run_protocol_loop_with_child( + config: AcpProviderConfig, + mut child: Child, + rx: &mut mpsc::Receiver, + init_tx: Arc>>>>, +) -> Result<()> { + let stdin = child.stdin.take().context("no stdin")?; + let stdout = child.stdout.take().context("no stdout")?; + let transport = sacp::ByteStreams::new(stdin.compat_write(), stdout.compat()); + run_protocol_loop_with_transport(config, transport, rx, init_tx).await +} + +async fn run_protocol_loop_with_transport( + config: AcpProviderConfig, + transport: sacp::ByteStreams, + rx: &mut mpsc::Receiver, + init_tx: Arc>>>>, +) -> Result<()> +where + R: futures::AsyncRead + Unpin + Send + 'static, + W: futures::AsyncWrite + Unpin + Send + 'static, +{ + let prompt_response_tx: Arc>>> = + Arc::new(Mutex::new(None)); + + ClientToAgent::builder() + .on_receive_notification( + { + let prompt_response_tx = prompt_response_tx.clone(); + async move |notification: SessionNotification, _cx| { + if let Some(tx) = prompt_response_tx.lock().unwrap().as_ref() { + match notification.update { + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) => { + let _ = tx.try_send(AcpUpdate::Text(text)); + } + SessionUpdate::AgentThoughtChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) => { + let _ = tx.try_send(AcpUpdate::Thought(text)); + } + SessionUpdate::ToolCall(tool_call) => { + let _ = tx.try_send(AcpUpdate::ToolCallStart { + id: tool_call.tool_call_id.0.to_string(), + }); + } + SessionUpdate::ToolCallUpdate(update) => { + if update.fields.status.is_some() { + let _ = tx.try_send(AcpUpdate::ToolCallComplete { + id: update.tool_call_id.0.to_string(), + }); + } + } + _ => {} + } + } + Ok(()) + } + }, + sacp::on_receive_notification!(), + ) + .on_receive_request( + { + let prompt_response_tx = prompt_response_tx.clone(); + async move |request: RequestPermissionRequest, request_cx, _connection_cx| { + let (response_tx, response_rx) = oneshot::channel(); + + let handler = prompt_response_tx.lock().unwrap().as_ref().cloned(); + let tx = handler.ok_or_else(sacp::Error::internal_error)?; + + if tx.is_closed() { + return Err(sacp::Error::internal_error()); + } + + tx.try_send(AcpUpdate::PermissionRequest { + request: Box::new(request), + response_tx, + }) + .map_err(|_| sacp::Error::internal_error())?; + + let response = response_rx.await.unwrap_or_else(|_| { + RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled) + }); + request_cx.respond(response) + } + }, + sacp::on_receive_request!(), + ) + .connect_to(transport)? + .run_until({ + let prompt_response_tx = prompt_response_tx.clone(); + move |cx: JrConnectionCx| { + handle_requests(config, cx, rx, prompt_response_tx, init_tx.clone()) + } + }) + .await?; + + Ok(()) +} + +async fn handle_requests( + config: AcpProviderConfig, + cx: JrConnectionCx, + rx: &mut mpsc::Receiver, + prompt_response_tx: Arc>>>, + init_tx: Arc>>>>, +) -> Result<(), sacp::Error> { + let init_response = cx + .send_request(InitializeRequest::new(ProtocolVersion::LATEST)) + .block_task() + .await + .map_err(|err| { + let message = format!("ACP initialize failed: {err}"); + send_init_result(&init_tx, Err(anyhow::anyhow!(message.clone()))); + sacp::Error::internal_error().data(message) + })?; + + let mcp_capabilities = init_response.agent_capabilities.mcp_capabilities.clone(); + send_init_result(&init_tx, Ok(init_response)); + + while let Some(request) = rx.recv().await { + match request { + ClientRequest::NewSession { response_tx } => { + handle_new_session_request(&config, &cx, &mcp_capabilities, response_tx).await; + } + ClientRequest::SetModel { + session_id, + model_id, + response_tx, + } => { + // sacp doesn't support session/set_model as a typed request yet + let msg = sacp::UntypedMessage::new( + "session/set_model", + serde_json::json!({ + "sessionId": session_id.0, + "modelId": model_id + }), + ) + .unwrap(); + let result = cx + .send_request(msg) + .block_task() + .await + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("ACP session/set_model failed: {e}")); + let _ = response_tx.send(result); + } + ClientRequest::Prompt { + session_id, + content, + response_tx, + } => { + *prompt_response_tx.lock().unwrap() = Some(response_tx.clone()); + + let response = cx + .send_request(PromptRequest::new(session_id, content)) + .block_task() + .await; + + match response { + Ok(r) => { + let _ = response_tx.try_send(AcpUpdate::Complete(r.stop_reason)); + } + Err(e) => { + let _ = response_tx.try_send(AcpUpdate::Error(e.to_string())); + } + } + + *prompt_response_tx.lock().unwrap() = None; + } + ClientRequest::Shutdown => break, + } + } + + Ok(()) +} + +async fn handle_new_session_request( + config: &AcpProviderConfig, + cx: &JrConnectionCx, + mcp_capabilities: &McpCapabilities, + response_tx: oneshot::Sender>, +) { + let mcp_servers = filter_supported_servers(&config.mcp_servers, mcp_capabilities); + let session = cx + .send_request(NewSessionRequest::new(config.work_dir.clone()).mcp_servers(mcp_servers)) + .block_task() + .await; + + let result = match session { + Ok(session) => apply_session_mode(config, cx, session).await, + Err(err) => Err(anyhow::anyhow!("ACP session/new failed: {err}")), + }; + + let _ = response_tx.send(result); +} + +async fn apply_session_mode( + config: &AcpProviderConfig, + cx: &JrConnectionCx, + session: NewSessionResponse, +) -> Result { + if let (Some(mode_id), Some(modes)) = (config.session_mode_id.clone(), session.modes.as_ref()) { + if modes.current_mode_id.0.as_ref() != mode_id.as_str() { + let available: Vec = modes + .available_modes + .iter() + .map(|mode| mode.id.0.to_string()) + .collect(); + + if !available.iter().any(|id| id == &mode_id) { + return Err(anyhow::anyhow!( + "Requested mode '{}' not offered by agent. Available modes: {}", + mode_id, + available.join(", ") + )); + } + cx.send_request(SetSessionModeRequest::new( + session.session_id.clone(), + mode_id, + )) + .block_task() + .await + .map_err(|err| anyhow::anyhow!("ACP agent rejected session/set_mode: {err}"))?; + } + } + + Ok(session) +} + +pub fn extension_configs_to_mcp_servers(configs: &[ExtensionConfig]) -> Vec { + let mut servers = Vec::new(); + + for config in configs { + match config { + ExtensionConfig::StreamableHttp { + name, uri, headers, .. + } => { + let http_headers = headers + .iter() + .map(|(key, value)| HttpHeader::new(key, value)) + .collect(); + servers.push(McpServer::Http( + McpServerHttp::new(name, uri).headers(http_headers), + )); + } + ExtensionConfig::Stdio { + name, + cmd, + args, + envs, + .. + } => { + let env_vars = envs + .get_env() + .into_iter() + .map(|(key, value)| EnvVariable::new(key, value)) + .collect(); + + servers.push(McpServer::Stdio( + McpServerStdio::new(name, cmd) + .args(args.clone()) + .env(env_vars), + )); + } + ExtensionConfig::Sse { name, .. } => { + tracing::debug!(name, "skipping SSE extension, migrate to streamable_http"); + } + _ => {} + } + } + + servers +} + +fn filter_supported_servers( + servers: &[McpServer], + capabilities: &McpCapabilities, +) -> Vec { + servers + .iter() + .filter(|server| match server { + McpServer::Http(http) => { + if !capabilities.http { + tracing::debug!( + name = http.name, + "skipping HTTP server, agent lacks capability" + ); + false + } else { + true + } + } + McpServer::Sse(sse) => { + tracing::debug!(name = sse.name, "skipping SSE server, unsupported"); + false + } + _ => true, + }) + .cloned() + .collect() +} + +fn send_init_result( + init_tx: &Arc>>>>, + result: Result, +) { + if let Some(tx) = init_tx.lock().unwrap().take() { + let _ = tx.send(result); + } +} + +fn messages_to_prompt(messages: &[Message]) -> Vec { + let mut content_blocks = Vec::new(); + + let last_user = messages + .iter() + .rev() + .find(|m| m.role == Role::User && m.is_agent_visible()); + + if let Some(message) = last_user { + for content in &message.content { + match content { + MessageContent::Text(text) => { + content_blocks.push(ContentBlock::Text(TextContent::new(text.text.clone()))); + } + MessageContent::Image(image) => { + content_blocks.push(ContentBlock::Image(ImageContent::new( + &image.data, + &image.mime_type, + ))); + } + _ => {} + } + } + } + + content_blocks +} + +fn build_action_required_message(request: &RequestPermissionRequest) -> Option { + let tool_title = request + .tool_call + .fields + .title + .clone() + .unwrap_or_else(|| "Tool".to_string()); + + let arguments = request + .tool_call + .fields + .raw_input + .as_ref() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + + let prompt = request + .tool_call + .fields + .content + .as_ref() + .and_then(|content| { + content.iter().find_map(|c| match c { + ToolCallContent::Content(val) => match &val.content { + ContentBlock::Text(text) => Some(text.text.clone()), + _ => None, + }, + _ => None, + }) + }); + + Some( + Message::assistant() + .with_action_required( + request.tool_call.tool_call_id.0.to_string(), + tool_title, + arguments, + prompt, + ) + .user_only(), + ) +} + +fn permission_decision_from_mode(goose_mode: GooseMode) -> Option { + match goose_mode { + GooseMode::Auto => Some(PermissionDecision::AllowOnce), + GooseMode::Chat => Some(PermissionDecision::RejectOnce), + GooseMode::Approve | GooseMode::SmartApprove => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::extension::Envs; + use test_case::test_case; + + #[test_case( + ExtensionConfig::Stdio { + name: "github".into(), + description: String::new(), + cmd: "/path/to/github-mcp-server".into(), + args: vec!["stdio".into()], + envs: Envs::new([("GITHUB_PERSONAL_ACCESS_TOKEN".into(), "ghp_xxxxxxxxxxxx".into())].into()), + env_keys: vec![], + timeout: None, + bundled: Some(false), + available_tools: vec![], + }, + vec![ + McpServer::Stdio( + McpServerStdio::new("github", "/path/to/github-mcp-server") + .args(vec!["stdio".into()]) + .env(vec![EnvVariable::new("GITHUB_PERSONAL_ACCESS_TOKEN", "ghp_xxxxxxxxxxxx")]) + ) + ] + ; "stdio_converts_to_mcpserver_stdio" + )] + #[test_case( + ExtensionConfig::StreamableHttp { + name: "github".into(), + description: String::new(), + uri: "https://api.githubcopilot.com/mcp/".into(), + envs: Envs::default(), + env_keys: vec![], + headers: HashMap::from([("Authorization".into(), "Bearer ghp_xxxxxxxxxxxx".into())]), + timeout: None, + bundled: Some(false), + available_tools: vec![], + }, + vec![ + McpServer::Http( + McpServerHttp::new("github", "https://api.githubcopilot.com/mcp/") + .headers(vec![HttpHeader::new("Authorization", "Bearer ghp_xxxxxxxxxxxx")]) + ) + ] + ; "streamable_http_converts_to_mcpserver_http_when_capable" + )] + fn test_extension_configs_to_mcp_servers(config: ExtensionConfig, expected: Vec) { + let result = extension_configs_to_mcp_servers(&[config]); + assert_eq!(result.len(), expected.len(), "server count mismatch"); + for (a, e) in result.iter().zip(expected.iter()) { + match (a, e) { + (McpServer::Stdio(actual), McpServer::Stdio(expected)) => { + assert_eq!(actual.name, expected.name); + assert_eq!(actual.command, expected.command); + assert_eq!(actual.args, expected.args); + assert_eq!(actual.env.len(), expected.env.len()); + } + (McpServer::Http(actual), McpServer::Http(expected)) => { + assert_eq!(actual.name, expected.name); + assert_eq!(actual.url, expected.url); + assert_eq!(actual.headers.len(), expected.headers.len()); + } + _ => panic!("server type mismatch"), + } + } + } + + #[test] + fn test_sse_skips() { + let config = ExtensionConfig::Sse { + name: "test-sse".into(), + description: String::new(), + uri: Some("https://example.com/sse".into()), + }; + let result = extension_configs_to_mcp_servers(&[config]); + assert!(result.is_empty()); + } + + #[test] + fn test_filter_supported_servers_skips_http_without_capability() { + let config = ExtensionConfig::StreamableHttp { + name: "github".into(), + description: String::new(), + uri: "https://api.githubcopilot.com/mcp/".into(), + envs: Envs::default(), + env_keys: vec![], + headers: HashMap::from([("Authorization".into(), "Bearer ghp_xxxxxxxxxxxx".into())]), + timeout: None, + bundled: Some(false), + available_tools: vec![], + }; + + let servers = extension_configs_to_mcp_servers(&[config]); + let filtered = filter_supported_servers(&servers, &McpCapabilities::default()); + assert!(filtered.is_empty()); + } +} diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 28c13c13f1..f73313e3bb 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -1,3 +1,4 @@ +pub mod acp; pub mod action_required_manager; pub mod agents; pub mod builtin_extension; diff --git a/crates/goose/src/providers/claude_acp.rs b/crates/goose/src/providers/claude_acp.rs new file mode 100644 index 0000000000..54ff9aabfb --- /dev/null +++ b/crates/goose/src/providers/claude_acp.rs @@ -0,0 +1,91 @@ +use anyhow::Result; +use futures::future::BoxFuture; +use std::path::PathBuf; + +use crate::acp::{ + extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping, +}; +use crate::config::search_path::SearchPaths; +use crate::config::{Config, GooseMode}; +use crate::model::ModelConfig; +use crate::providers::base::{ProviderDef, ProviderMetadata}; + +const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp"; +pub const CLAUDE_ACP_DEFAULT_MODEL: &str = "default"; +const CLAUDE_ACP_DOC_URL: &str = "https://github.com/zed-industries/claude-agent-acp"; +const CLAUDE_ACP_BINARY: &str = "claude-agent-acp"; + +pub struct ClaudeAcpProvider; + +impl ProviderDef for ClaudeAcpProvider { + type Provider = AcpProvider; + + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + CLAUDE_ACP_PROVIDER_NAME, + "Claude Code", + "ACP wrapper for Anthropic's Claude. Install: npm install -g @zed-industries/claude-agent-acp", + CLAUDE_ACP_DEFAULT_MODEL, + vec![], + CLAUDE_ACP_DOC_URL, + vec![], + ) + } + + fn from_env( + model: ModelConfig, + extensions: Vec, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + let config = Config::global(); + // with_npm() includes npm global bin dir (desktop app PATH may not) + let resolved_command = SearchPaths::builder() + .with_npm() + .resolve(CLAUDE_ACP_BINARY)?; + let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); + + // claude-agent-acp permission option_ids + let permission_mapping = PermissionMapping { + allow_option_id: Some("allow".to_string()), + reject_option_id: Some("reject".to_string()), + rejected_tool_status: sacp::schema::ToolCallStatus::Failed, + }; + + let provider_config = AcpProviderConfig { + command: resolved_command, + args: vec![], + env: vec![], + // Prevent nested-session detection in claude-agent-acp (wraps Claude Code) + env_remove: vec!["CLAUDECODE".to_string()], + work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + mcp_servers: extension_configs_to_mcp_servers(&extensions), + session_mode_id: Some(map_goose_mode(goose_mode)), + permission_mapping, + }; + + let metadata = Self::metadata(); + AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await + }) + } +} + +fn map_goose_mode(goose_mode: GooseMode) -> String { + match goose_mode { + GooseMode::Auto => { + // Closest to "autonomous": Claude Code's bypassPermissions skips confirmations. + "bypassPermissions".to_string() + } + GooseMode::Approve => { + // Claude Code's default matches "ask before risky actions". + "default".to_string() + } + GooseMode::SmartApprove => { + // Best-effort: acceptEdits auto-accepts file edits but still prompts for risky ops. + "acceptEdits".to_string() + } + GooseMode::Chat => { + // Plan mode disables tool execution, aligning with chat-only intent. + "plan".to_string() + } + } +} diff --git a/crates/goose/src/providers/codex_acp.rs b/crates/goose/src/providers/codex_acp.rs new file mode 100644 index 0000000000..f25457475c --- /dev/null +++ b/crates/goose/src/providers/codex_acp.rs @@ -0,0 +1,121 @@ +use anyhow::Result; +use futures::future::BoxFuture; +use std::path::PathBuf; + +use crate::acp::{ + extension_configs_to_mcp_servers, AcpProvider, AcpProviderConfig, PermissionMapping, +}; +use crate::config::search_path::SearchPaths; +use crate::config::{Config, GooseMode}; +use crate::model::ModelConfig; +use crate::providers::base::{ProviderDef, ProviderMetadata}; + +const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp"; +pub const CODEX_ACP_DEFAULT_MODEL: &str = "gpt-5.2-codex"; +const CODEX_ACP_DOC_URL: &str = "https://github.com/zed-industries/codex-acp"; + +pub struct CodexAcpProvider; + +impl ProviderDef for CodexAcpProvider { + type Provider = AcpProvider; + + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + CODEX_ACP_PROVIDER_NAME, + "Codex CLI", + "ACP adapter for OpenAI's coding assistant. Install: npm install -g @zed-industries/codex-acp", + CODEX_ACP_DEFAULT_MODEL, + vec![], + CODEX_ACP_DOC_URL, + vec![], + ) + } + + fn from_env( + model: ModelConfig, + extensions: Vec, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + let config = Config::global(); + // with_npm() includes npm global bin dir (desktop app PATH may not) + let resolved_command = SearchPaths::builder() + .with_npm() + .resolve(CODEX_ACP_PROVIDER_NAME)?; + let work_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let env = vec![]; + let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); + let mcp_servers = extension_configs_to_mcp_servers(&extensions); + + // fixed goose mode via -c overrides until session/set-mode works + let (approval_policy, sandbox_mode) = map_goose_mode(goose_mode); + let mut args = vec![ + "-c".to_string(), + format!("approval_policy={approval_policy}"), + "-c".to_string(), + format!("sandbox_mode={sandbox_mode}"), + ]; + + // Codex sandbox blocks network by default. Enable it when HTTP MCP + // servers are configured so codex-acp can connect to them. + let has_http_mcp = mcp_servers + .iter() + .any(|s| matches!(s, sacp::schema::McpServer::Http(_))); + if has_http_mcp { + args.extend([ + "-c".to_string(), + "sandbox_workspace_write.network_access=true".to_string(), + ]); + } + + // codex-acp permission option_ids + let permission_mapping = PermissionMapping { + allow_option_id: Some("approved".to_string()), + reject_option_id: Some("abort".to_string()), + rejected_tool_status: sacp::schema::ToolCallStatus::Failed, + }; + + let provider_config = AcpProviderConfig { + command: resolved_command, + args, + env, + env_remove: vec![], + work_dir, + mcp_servers, + // Disabled until https://github.com/zed-industries/codex-acp/issues/179 is fixed. + session_mode_id: None, + permission_mapping, + }; + + let metadata = Self::metadata(); + AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await + }) + } +} + +// Codex sandbox scope determines what needs approval: operations within the +// sandbox are auto-approved, operations outside it trigger on-request prompts. +// So Approve uses read-only sandbox to force write approvals through goose. +fn map_goose_mode(goose_mode: GooseMode) -> (&'static str, &'static str) { + match goose_mode { + GooseMode::Auto => ("never", "danger-full-access"), + GooseMode::SmartApprove => ("on-request", "workspace-write"), + GooseMode::Approve => ("on-request", "read-only"), + GooseMode::Chat => ("never", "read-only"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + #[test_case(GooseMode::Auto, "never", "danger-full-access")] + #[test_case(GooseMode::SmartApprove, "on-request", "workspace-write")] + #[test_case(GooseMode::Approve, "on-request", "read-only")] + #[test_case(GooseMode::Chat, "never", "read-only")] + fn test_map_goose_mode(mode: GooseMode, expected_approval: &str, expected_sandbox: &str) { + let (approval, sandbox) = map_goose_mode(mode); + assert_eq!(approval, expected_approval); + assert_eq!(sandbox, expected_sandbox); + } +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 0015ee9ee8..e9fd9b8879 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -7,8 +7,10 @@ use super::{ base::{Provider, ProviderMetadata}, bedrock::BedrockProvider, chatgpt_codex::ChatGptCodexProvider, + claude_acp::ClaudeAcpProvider, claude_code::ClaudeCodeProvider, codex::CodexProvider, + codex_acp::CodexAcpProvider, cursor_agent::CursorAgentProvider, databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, @@ -52,7 +54,9 @@ async fn init_registry() -> RwLock { registry.register::(false); registry.register::(false); registry.register::(true); + registry.register::(false); registry.register::(true); + registry.register::(false); registry.register::(true); registry.register::(false); registry.register::(true); diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index ad4f8c4ce6..c07bcb0d24 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -9,9 +9,11 @@ pub mod bedrock; pub mod canonical; pub mod catalog; pub mod chatgpt_codex; +pub mod claude_acp; pub mod claude_code; pub(crate) mod cli_common; pub mod codex; +pub mod codex_acp; pub mod cursor_agent; pub mod databricks; pub mod embedding; diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index 227310bf1e..c612bf06ab 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -10,8 +10,10 @@ use goose::providers::anthropic::ANTHROPIC_DEFAULT_MODEL; use goose::providers::azure::AZURE_DEFAULT_MODEL; use goose::providers::base::Provider; use goose::providers::bedrock::BEDROCK_DEFAULT_MODEL; +use goose::providers::claude_acp::CLAUDE_ACP_DEFAULT_MODEL; use goose::providers::claude_code::CLAUDE_CODE_DEFAULT_MODEL; use goose::providers::codex::CODEX_DEFAULT_MODEL; +use goose::providers::codex_acp::CODEX_ACP_DEFAULT_MODEL; use goose::providers::create_with_named_model; use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL; use goose::providers::errors::ProviderError; @@ -842,6 +844,27 @@ async fn test_codex_provider() -> Result<()> { .await } +// Requires: npm install -g @zed-industries/claude-agent-acp +#[tokio::test] +async fn test_claude_acp_provider() -> Result<()> { + ProviderTestConfig::with_agentic_provider( + "claude-acp", + CLAUDE_ACP_DEFAULT_MODEL, + "claude-agent-acp", + ) + .model_switch_name("sonnet") + .run() + .await +} + +// Requires: npm install -g @zed-industries/codex-acp +#[tokio::test] +async fn test_codex_acp_provider() -> Result<()> { + ProviderTestConfig::with_agentic_provider("codex-acp", CODEX_ACP_DEFAULT_MODEL, "codex-acp") + .run() + .await +} + #[ctor::dtor] fn print_test_report() { TEST_REPORT.print_summary();