use agent_client_protocol_schema::AGENT_METHOD_NAMES; use anyhow::{Context, Result}; use async_stream::try_stream; use futures::future::BoxFuture; use rmcp::model::{Role, Tool}; use sacp::schema::{ AuthMethod, CloseSessionRequest, ContentBlock, ContentChunk, EnvVariable, HttpHeader, ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, McpCapabilities, McpServer, McpServerHttp, McpServerStdio, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, SessionConfigKind, SessionConfigOptionCategory, SessionConfigSelectOptions, SessionId, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, StopReason, TextContent, ToolCallContent, }; use sacp::{Agent, Client, ConnectionTo}; use std::collections::{HashMap, HashSet}; use std::future::Future; use std::path::PathBuf; use std::process::Stdio; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use tokio::process::{Child, Command}; use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex, OnceCell}; 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; /// Sentinel: resolved to SessionModelState.current_model_id at connect time. pub const ACP_CURRENT_MODEL: &str = "current"; 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 mode_mapping: HashMap, pub permission_mapping: PermissionMapping, pub notification_callback: Option>, } enum ClientRequest { NewSession { response_tx: oneshot::Sender>, }, ListSessions { response_tx: oneshot::Sender>, }, SetMode { session_id: SessionId, mode_id: String, response_tx: oneshot::Sender>, }, SetModel { session_id: SessionId, model_id: String, response_tx: oneshot::Sender>, }, SetConfigOption { session_id: SessionId, config_id: String, value: String, response_tx: oneshot::Sender>, }, Prompt { session_id: SessionId, content: Vec, response_tx: mpsc::Sender, }, CloseSession { session_id: SessionId, response_tx: oneshot::Sender>, }, // For ACP methods not yet in agent-client-protocol-schema (e.g. session/delete) Untyped { method: String, params: serde_json::Value, response_tx: oneshot::Sender>, }, } // tokio I/O handles can't move between runtimes, so the child process must be // spawned inside the OS thread. This closure lets start() share all other logic. type ClientLoopFn = Box< dyn FnOnce( AcpClientLoop, mpsc::Receiver, oneshot::Sender>, ) -> BoxFuture<'static, ()> + Send, >; #[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: Arc>, tx: Option>, loop_thread: Option>, mode_mapping: HashMap, permission_mapping: PermissionMapping, rejected_tool_calls: Arc>>, pending_confirmations: Arc>>>, goose_to_acp_id: Arc>>, acp_to_goose_id: Arc>>, /// Per-session model tracking for detecting model changes in stream(). session_model: Arc>>, auth_methods: Vec, supports_close: bool, init_session: OnceCell, } 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() } } // Dedicated runtime on an OS thread so session/close completes even during // main runtime shutdown. See reqwest InnerClientHandle. fn spawn_client_loop(fut: impl Future + Send + 'static) -> JoinHandle<()> { std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to build ACP client runtime"); rt.block_on(fut) }) } impl AcpProvider { pub async fn connect( name: String, model: ModelConfig, goose_mode: GooseMode, config: AcpProviderConfig, ) -> Result { Self::start( name, model, goose_mode, config, Box::new(|cl, rx, init_tx| Box::pin(cl.spawn(rx, init_tx))), ) .await } #[doc(hidden)] pub async fn connect_with_transport( name: String, model: ModelConfig, goose_mode: GooseMode, config: AcpProviderConfig, transport: impl sacp::ConnectTo + 'static, ) -> Result { Self::start( name, model, goose_mode, config, Box::new(move |cl, mut rx, init_tx| { Box::pin(async move { if let Err(e) = cl.run(transport, &mut rx, init_tx).await { tracing::error!("ACP protocol error: {e}"); } }) }), ) .await } async fn start( name: String, model: ModelConfig, goose_mode: GooseMode, config: AcpProviderConfig, run: ClientLoopFn, ) -> Result { let (tx, rx) = mpsc::channel(32); let (init_tx, init_rx) = oneshot::channel(); let mode_mapping = config.mode_mapping.clone(); let permission_mapping = config.permission_mapping.clone(); let rejected_tool_calls = Arc::new(TokioMutex::new(HashSet::new())); let goose_mode = Arc::new(Mutex::new(goose_mode)); let client_loop = AcpClientLoop::new(config, goose_mode.clone()); let loop_thread = spawn_client_loop(run(client_loop, rx, init_tx)); let init_response = init_rx .await .context("ACP client initialization cancelled")??; let supports_close = init_response .agent_capabilities .session_capabilities .close .is_some(); let mut provider = Self::new_with_runtime( name, model, goose_mode, tx, loop_thread, mode_mapping, permission_mapping, rejected_tool_calls, init_response.auth_methods, supports_close, ); if provider.model.model_name == ACP_CURRENT_MODEL { let response = provider.get_init_session().await?; let (current_model, _) = resolve_model_info(&provider.name, response)?; tracing::info!(from = ACP_CURRENT_MODEL, to = %current_model, "resolved ACP model"); provider.model.model_name = current_model; } Ok(provider) } #[allow(clippy::too_many_arguments)] fn new_with_runtime( name: String, model: ModelConfig, goose_mode: Arc>, tx: mpsc::Sender, loop_thread: JoinHandle<()>, mode_mapping: HashMap, permission_mapping: PermissionMapping, rejected_tool_calls: Arc>>, auth_methods: Vec, supports_close: bool, ) -> Self { Self { name, model, goose_mode, tx: Some(tx), loop_thread: Some(loop_thread), mode_mapping, permission_mapping, rejected_tool_calls, pending_confirmations: Arc::new(TokioMutex::new(HashMap::new())), goose_to_acp_id: Arc::new(TokioMutex::new(HashMap::new())), acp_to_goose_id: Arc::new(TokioMutex::new(HashMap::new())), session_model: Arc::new(TokioMutex::new(HashMap::new())), auth_methods, supports_close, init_session: OnceCell::new(), } } 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 .as_ref() .unwrap() .send(ClientRequest::NewSession { response_tx }) .await .context("ACP client is unavailable")?; response_rx .await .context(format!("ACP {} cancelled", AGENT_METHOD_NAMES.session_new))? } pub async fn list_sessions(&self) -> Result { let (response_tx, response_rx) = oneshot::channel(); self.tx .as_ref() .unwrap() .send(ClientRequest::ListSessions { response_tx }) .await .context("ACP client is unavailable")?; let raw = response_rx.await.context("ACP request cancelled")??; let acp_to_goose = self.acp_to_goose_id.lock().await; Ok(map_sessions_to_goose_ids(raw, &acp_to_goose)) } async fn resolve_acp_session_id(&self, goose_id: &str) -> Result { let map = self.goose_to_acp_id.lock().await; map.get(goose_id) .map(|r| r.session_id.clone()) .ok_or_else(|| { sacp::Error::resource_not_found(Some(goose_id.to_string())) .data(format!("Session not found: {goose_id}")) .into() }) } pub(crate) async fn send_set_mode(&self, goose_id: &str, mode_id: String) -> Result<()> { let session_id = self.resolve_acp_session_id(goose_id).await?; let (response_tx, response_rx) = oneshot::channel(); self.tx .as_ref() .unwrap() .send(ClientRequest::SetMode { session_id, mode_id, response_tx, }) .await .context("ACP client is unavailable")?; response_rx.await.context("ACP request cancelled")? } pub(crate) async fn send_set_model(&self, goose_id: &str, model_id: String) -> Result<()> { let session_id = self.resolve_acp_session_id(goose_id).await?; let (response_tx, response_rx) = oneshot::channel(); self.tx .as_ref() .unwrap() .send(ClientRequest::SetModel { session_id, model_id, response_tx, }) .await .context("ACP client is unavailable")?; response_rx.await.context("ACP request cancelled")? } pub(crate) async fn send_set_config_option( &self, goose_id: &str, config_id: String, value: String, ) -> Result<()> { let session_id = self.resolve_acp_session_id(goose_id).await?; let (response_tx, response_rx) = oneshot::channel(); self.tx .as_ref() .unwrap() .send(ClientRequest::SetConfigOption { session_id, config_id, value, response_tx, }) .await .context("ACP client is unavailable")?; response_rx.await.context("ACP request cancelled")? } // Only used by tests; session/delete has no typed request in agent-client-protocol-schema yet. #[doc(hidden)] pub async fn delete_session(&self, goose_id: &str) -> Result<()> { let session_id = self.resolve_acp_session_id(goose_id).await?; self.send_untyped( "session/delete", serde_json::json!({ "sessionId": session_id.0 }), ) .await?; // Clean up cached mappings so ensure_session doesn't return a stale entry. self.goose_to_acp_id.lock().await.remove(goose_id); self.acp_to_goose_id .lock() .await .remove(session_id.0.as_ref()); self.session_model.lock().await.remove(goose_id); Ok(()) } pub async fn send_untyped( &self, method: &str, params: serde_json::Value, ) -> Result { let (response_tx, response_rx) = oneshot::channel(); self.tx .as_ref() .unwrap() .send(ClientRequest::Untyped { method: method.to_string(), params, response_tx, }) .await .context("ACP client is unavailable")?; response_rx.await.context("ACP request cancelled")? } pub async fn has_session(&self, goose_id: &str) -> bool { self.goose_to_acp_id.lock().await.contains_key(goose_id) } // If false, callers fall back to legacy set_mode/set_model. async fn session_has_config_option( &self, goose_id: &str, category: SessionConfigOptionCategory, ) -> bool { let map = self.goose_to_acp_id.lock().await; map.get(goose_id) .and_then(|r| r.config_options.as_ref()) .is_some_and(|opts| opts.iter().any(|o| o.category.as_ref() == Some(&category))) } 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()); self.acp_to_goose_id .lock() .await .insert(response.session_id.0.to_string(), session_id.to_string()); // Initialize model tracking so stream() can detect changes. let (current_model, _) = resolve_model_info(&self.name, &response)?; self.session_model .lock() .await .entry(session_id.to_string()) .or_insert(current_model); } Ok(response) } async fn prompt( &self, session_id: SessionId, content: Vec, ) -> Result> { let (response_tx, response_rx) = mpsc::channel(64); self.tx .as_ref() .unwrap() .send(ClientRequest::Prompt { session_id, content, response_tx, }) .await .context("ACP client is unavailable")?; Ok(response_rx) } async fn get_init_session(&self) -> Result<&NewSessionResponse> { self.init_session .get_or_try_init(|| async { let response = self.new_session().await?; if self.supports_close { self.close_session_by_acp_id(response.session_id.clone()) .await?; } Ok(response) }) .await } async fn close_session_by_acp_id(&self, session_id: SessionId) -> Result<()> { let (response_tx, response_rx) = oneshot::channel(); self.tx .as_ref() .unwrap() .send(ClientRequest::CloseSession { session_id, response_tx, }) .await .context("ACP client is unavailable")?; response_rx.await.context("ACP request cancelled")? } } #[async_trait::async_trait] impl Provider for AcpProvider { fn get_name(&self) -> &str { &self.name } fn get_model_config(&self) -> ModelConfig { self.model.clone() } async fn update_mode(&self, session_id: &str, mode: GooseMode) -> Result<(), ProviderError> { let map = self.goose_to_acp_id.lock().await; if map.is_empty() { // Pre-initialization: no ACP session yet, just store the mode. // The shared Arc> is read at session creation time. drop(map); } else { drop(map); let mode_str = self.mode_mapping[&mode].clone(); if self .session_has_config_option(session_id, SessionConfigOptionCategory::Mode) .await { self.send_set_config_option(session_id, "mode".into(), mode_str) .await .map_err(|e| { ProviderError::RequestFailed(format!("Failed to set mode: {e}")) })?; } else { self.send_set_mode(session_id, mode_str) .await .map_err(|e| { ProviderError::RequestFailed(format!("Failed to set mode: {e}")) })?; } } let mut current = self .goose_mode .lock() .map_err(|_| ProviderError::RequestFailed("Failed to update mode".into()))?; *current = mode; Ok(()) } 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?; // Provider trait has no update_model — stream() is the only place to forward model changes. { let new_model = &model_config.model_name; let tracked = self.session_model.lock().await.get(session_id).cloned(); if tracked.as_deref() != Some(new_model) { if self .session_has_config_option(session_id, SessionConfigOptionCategory::Model) .await { self.send_set_config_option(session_id, "model".into(), new_model.clone()) .await .map_err(|e| { ProviderError::RequestFailed(format!("Failed to set model: {e}")) })?; } else { self.send_set_model(session_id, new_model.clone()) .await .map_err(|e| { ProviderError::RequestFailed(format!("Failed to set model: {e}")) })?; } self.session_model .lock() .await .insert(session_id.to_string(), new_model.clone()); } } 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 .lock() .map_err(|_| ProviderError::RequestFailed("goose_mode lock poisoned".into()))?; 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.get_init_session().await.map_err(|e| { ProviderError::RequestFailed(format!("Failed to create ACP session: {e}")) })?; let (_, available) = resolve_model_info(&self.name, response)?; Ok(available) } } impl Drop for AcpProvider { fn drop(&mut self) { // Join OS thread so session/close completes before runtime exits (reqwest InnerClientHandle pattern). self.tx.take(); if let Some(h) = self.loop_thread.take() { if let Err(e) = h.join() { tracing::debug!("AcpClientLoop thread panicked: {e:?}"); } } } } struct AcpClientLoop { config: AcpProviderConfig, goose_mode: Arc>, prompt_response_tx: Arc>>>, } impl AcpClientLoop { fn new(config: AcpProviderConfig, goose_mode: Arc>) -> Self { Self { config, goose_mode, prompt_response_tx: Arc::new(Mutex::new(None)), } } async fn spawn( self, mut rx: mpsc::Receiver, init_tx: oneshot::Sender>, ) { let child = match spawn_acp_process(&self.config).await { Ok(c) => c, Err(e) => { let _ = init_tx.send(Err(anyhow::anyhow!("{e}"))); tracing::error!("failed to spawn ACP process: {e}"); return; } }; match self.run_with_child(child, &mut rx, init_tx).await { Ok(()) => tracing::debug!("ACP protocol loop exited cleanly"), Err(e) => tracing::error!(error = %e, "ACP protocol loop error"), } } async fn run_with_child( self, mut child: Child, rx: &mut mpsc::Receiver, init_tx: oneshot::Sender>, ) -> 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()); self.run(transport, rx, init_tx).await } async fn run( self, transport: impl sacp::ConnectTo + 'static, rx: &mut mpsc::Receiver, init_tx: oneshot::Sender>, ) -> Result<()> { let AcpClientLoop { config, goose_mode, prompt_response_tx, } = self; let notification_callback = config.notification_callback.clone(); let reverse_modes = reverse_mode_mapping(&config.mode_mapping); Client .builder() .on_receive_notification( { let prompt_response_tx = prompt_response_tx.clone(); let reverse_modes = reverse_modes.clone(); async move |notification: SessionNotification, _cx| { if let Some(ref cb) = notification_callback { cb(notification.clone()); } // stream() reads goose_mode at call time, so it must // reflect any prior set_mode before the next prompt. match ¬ification.update { SessionUpdate::CurrentModeUpdate(update) => { if let Some(mode) = resolve_mode( &reverse_modes, update.current_mode_id.0.as_ref(), &goose_mode, ) { if let Ok(mut guard) = goose_mode.lock() { *guard = mode; } } } SessionUpdate::ConfigOptionUpdate(update) => { for opt in &update.config_options { if opt.category == Some(SessionConfigOptionCategory::Mode) { if let SessionConfigKind::Select(sel) = &opt.kind { if let Some(mode) = resolve_mode( &reverse_modes, sel.current_value.0.as_ref(), &goose_mode, ) { if let Ok(mut guard) = goose_mode.lock() { *guard = mode; } } } } } } _ => {} } if let Some(tx) = prompt_response_tx .lock() .ok() .as_ref() .and_then(|g| g.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, responder, _connection_cx| { let (response_tx, response_rx) = oneshot::channel(); let handler = prompt_response_tx .lock() .ok() .as_ref() .and_then(|g| g.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) }); responder.respond(response) } }, sacp::on_receive_request!(), ) .connect_with(transport, async move |cx: ConnectionTo| { handle_requests(config, cx, rx, prompt_response_tx, init_tx).await }) .await?; Ok(()) } } 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") } // sacp panics on Err from connect_with handlers, so log send failures instead of ?. fn log_undelivered(result: Result<(), E>, method: &str) { if let Err(e) = result { tracing::debug!(method, error = ?e, "response not delivered"); } } async fn handle_requests( config: AcpProviderConfig, cx: ConnectionTo, rx: &mut mpsc::Receiver, prompt_response_tx: Arc>>>, init_tx: oneshot::Sender>, ) -> Result<(), sacp::Error> { let mut init_tx = Some(init_tx); let init_response: InitializeResponse = cx .send_request(InitializeRequest::new(ProtocolVersion::LATEST)) .block_task() .await .map_err(|err| { let message = format!("ACP {} failed: {err}", AGENT_METHOD_NAMES.initialize); // Attempt to send a specific error to the ctor waiting on init_rx; if let Some(tx) = init_tx.take() { let _ = tx.send(Err(anyhow::anyhow!(message.clone()))); } sacp::Error::internal_error().data(message) })?; let supports_close = init_response .agent_capabilities .session_capabilities .close .is_some(); let mcp_capabilities = init_response.agent_capabilities.mcp_capabilities.clone(); if let Some(tx) = init_tx.take() { log_undelivered(tx.send(Ok(init_response)), AGENT_METHOD_NAMES.initialize); } let mut session_ids: Vec = Vec::new(); while let Some(request) = rx.recv().await { match request { ClientRequest::NewSession { response_tx } => { 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) => { session_ids.push(session.session_id.clone()); apply_session_mode(&config, &cx, session).await } Err(err) => Err(anyhow::anyhow!( "ACP {} failed: {err}", AGENT_METHOD_NAMES.session_new )), }; log_undelivered(response_tx.send(result), AGENT_METHOD_NAMES.session_new); } ClientRequest::ListSessions { response_tx } => { let result: Result = cx .send_request(ListSessionsRequest::new()) .block_task() .await .map_err(anyhow::Error::from); log_undelivered(response_tx.send(result), AGENT_METHOD_NAMES.session_list); } ClientRequest::SetMode { session_id, mode_id, response_tx, } => { let result: Result<()> = cx .send_request(SetSessionModeRequest::new(session_id, mode_id)) .block_task() .await .map(|_| ()) .map_err(anyhow::Error::from); log_undelivered( response_tx.send(result), AGENT_METHOD_NAMES.session_set_mode, ); } ClientRequest::SetModel { session_id, model_id, response_tx, } => { let result: Result<()> = cx .send_request(SetSessionModelRequest::new(session_id, model_id)) .block_task() .await .map(|_| ()) .map_err(anyhow::Error::from); log_undelivered( response_tx.send(result), AGENT_METHOD_NAMES.session_set_model, ); } ClientRequest::SetConfigOption { session_id, config_id, value, response_tx, } => { let value_id = sacp::schema::SessionConfigValueId::new(value); let req = SetSessionConfigOptionRequest::new(session_id, config_id, value_id); let result: Result<()> = cx .send_request(req) .block_task() .await .map(|_| ()) .map_err(anyhow::Error::from); log_undelivered( response_tx.send(result), AGENT_METHOD_NAMES.session_set_config_option, ); } ClientRequest::CloseSession { session_id, response_tx, } => { let result: Result<()> = cx .send_request(CloseSessionRequest::new(session_id.clone())) .block_task() .await .map(|_| ()) .map_err(anyhow::Error::from); session_ids.retain(|s| s != &session_id); log_undelivered(response_tx.send(result), AGENT_METHOD_NAMES.session_close); } ClientRequest::Untyped { method, params, response_tx, } => { let result: Result = match sacp::UntypedMessage::new(&method, params) { Ok(msg) => cx .send_request(msg) .block_task() .await .map_err(anyhow::Error::from), Err(e) => Err(anyhow::Error::from(e)), }; log_undelivered(response_tx.send(result), &method); } ClientRequest::Prompt { session_id, content, response_tx, } => { *prompt_response_tx.lock().unwrap() = Some(response_tx.clone()); let response: Result = cx .send_request(PromptRequest::new(session_id, content)) .block_task() .await; match response { Ok(r) => { log_undelivered( response_tx.try_send(AcpUpdate::Complete(r.stop_reason)), AGENT_METHOD_NAMES.session_prompt, ); } Err(e) => { log_undelivered( response_tx.try_send(AcpUpdate::Error(e.to_string())), AGENT_METHOD_NAMES.session_prompt, ); } } *prompt_response_tx.lock().unwrap() = None; } } } // After loop exits (channel closed by Drop): if supports_close { for session_id in session_ids { if let Err(e) = cx .send_request(CloseSessionRequest::new(session_id.clone())) .block_task() .await { tracing::debug!(method = AGENT_METHOD_NAMES.session_close, session_id = %session_id, error = %e, "failed on shutdown"); } } } Ok(()) } async fn apply_session_mode( config: &AcpProviderConfig, cx: &ConnectionTo, 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(", ") )); } let _: SetSessionModeResponse = cx .send_request(SetSessionModeRequest::new( session.session_id.clone(), mode_id, )) .block_task() .await .map_err(|err| { anyhow::anyhow!( "ACP agent rejected {}: {err}", AGENT_METHOD_NAMES.session_set_mode ) })?; } } 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 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(), ) } // config_options supersedes models; fall back to models. fn resolve_model_info( provider_name: &str, response: &NewSessionResponse, ) -> Result<(String, Vec), ProviderError> { if let Some(opts) = &response.config_options { if let Some(sel) = opts.iter().find_map(|opt| { if opt.category.as_ref() != Some(&SessionConfigOptionCategory::Model) { return None; } match &opt.kind { SessionConfigKind::Select(s) => Some(s), _ => None, } }) { let current = sel.current_value.0.to_string(); let available = match &sel.options { SessionConfigSelectOptions::Ungrouped(opts) => { opts.iter().map(|o| o.value.0.to_string()).collect() } SessionConfigSelectOptions::Grouped(groups) => groups .iter() .flat_map(|g| g.options.iter().map(|o| o.value.0.to_string())) .collect(), _ => vec![], }; return Ok((current, available)); } } let models = response.models.as_ref().ok_or_else(|| { ProviderError::RequestFailed(format!( "{provider_name}: agent returned neither config_options nor models" )) })?; let current = models.current_model_id.0.to_string(); let available = models .available_models .iter() .map(|am| am.model_id.0.to_string()) .collect(); Ok((current, available)) } fn reverse_mode_mapping( mode_mapping: &HashMap, ) -> HashMap> { let mut reverse: HashMap> = HashMap::new(); for (mode, id) in mode_mapping { reverse.entry(id.clone()).or_default().push(*mode); } reverse } // When multiple GooseModes map to the same provider ID (e.g. codex "read-only"), // prefer the current mode if it's among candidates. fn resolve_mode( reverse_modes: &HashMap>, mode_id: &str, current: &Arc>, ) -> Option { let candidates = reverse_modes.get(mode_id)?; if candidates.len() == 1 { return Some(candidates[0]); } let current = current.lock().ok()?; if candidates.contains(&*current) { Some(*current) } else { Some(candidates[0]) } } 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, } } // TODO: ID mapping is in-memory only — sessions from prior runs or other clients are dropped. // Persisting requires a schema change to map goose↔ACP IDs in the session DB. fn map_sessions_to_goose_ids( response: ListSessionsResponse, acp_to_goose: &HashMap, ) -> ListSessionsResponse { let sessions = response .sessions .into_iter() .filter_map(|mut info| { let goose_id = acp_to_goose.get(info.session_id.0.as_ref())?; info.session_id = SessionId::new(goose_id.clone()); Some(info) }) .collect(); ListSessionsResponse::new(sessions) } #[cfg(test)] mod tests { use super::*; use crate::agents::extension::Envs; use sacp::schema::{SessionConfigOption, SessionConfigSelectOption, SessionInfo}; 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()); } #[test_case( ListSessionsResponse::new(vec![ SessionInfo::new(SessionId::new("20260318_1"), "/Users/codefromthecrypt/oss/goose-2") .title("Fix login bug".to_string()) .updated_at("2026-03-18T07:02:42.549655Z".to_string()), SessionInfo::new(SessionId::new("20260318_2"), "/tmp/test-acpx") .title("Add caching layer".to_string()) .updated_at("2026-03-18T07:05:01.123Z".to_string()), ]), HashMap::from([ ("20260318_1".to_string(), "goose-session-1".to_string()), ("20260318_2".to_string(), "goose-session-2".to_string()), ]), ListSessionsResponse::new(vec![ SessionInfo::new(SessionId::new("goose-session-1"), "/Users/codefromthecrypt/oss/goose-2") .title("Fix login bug".to_string()) .updated_at("2026-03-18T07:02:42.549655Z".to_string()), SessionInfo::new(SessionId::new("goose-session-2"), "/tmp/test-acpx") .title("Add caching layer".to_string()) .updated_at("2026-03-18T07:05:01.123Z".to_string()), ]) ; "all sessions mapped with all fields preserved" )] #[test_case( ListSessionsResponse::new(vec![ SessionInfo::new(SessionId::new("20260318_1"), "/Users/codefromthecrypt/oss/goose-2") .title("Fix login bug".to_string()), SessionInfo::new(SessionId::new("other-agent-session"), "/tmp/other") .title("Not our session".to_string()), ]), HashMap::from([ ("20260318_1".to_string(), "goose-session-1".to_string()), ]), ListSessionsResponse::new(vec![ SessionInfo::new(SessionId::new("goose-session-1"), "/Users/codefromthecrypt/oss/goose-2") .title("Fix login bug".to_string()), ]) ; "unmapped sessions filtered out" )] #[test_case( ListSessionsResponse::new(vec![ SessionInfo::new(SessionId::new("20260318_1"), "/Users/codefromthecrypt/oss/goose-2") .title("ACP Session".to_string()) .updated_at("2026-03-18T01:29:02.141700Z".to_string()), ]), HashMap::new(), ListSessionsResponse::new(vec![]) ; "empty map returns empty list" )] fn test_map_sessions_to_goose_ids( response: ListSessionsResponse, acp_to_goose: HashMap, expected: ListSessionsResponse, ) { let result = map_sessions_to_goose_ids(response, &acp_to_goose); assert_eq!(result, expected); } #[test_case(GooseMode::Auto => Some(PermissionDecision::AllowOnce) ; "auto allows")] #[test_case(GooseMode::Chat => Some(PermissionDecision::RejectOnce) ; "chat rejects")] #[test_case(GooseMode::Approve => None ; "approve defers")] #[test_case(GooseMode::SmartApprove => None ; "smart_approve defers")] fn test_permission_decision_from_mode(mode: GooseMode) -> Option { permission_decision_from_mode(mode) } #[test_case( HashMap::from([ (GooseMode::Auto, "yolo".to_string()), (GooseMode::Approve, "default".to_string()), (GooseMode::SmartApprove, "auto_edit".to_string()), (GooseMode::Chat, "plan".to_string()), ]), HashMap::from([ ("yolo".to_string(), vec![GooseMode::Auto]), ("default".to_string(), vec![GooseMode::Approve]), ("auto_edit".to_string(), vec![GooseMode::SmartApprove]), ("plan".to_string(), vec![GooseMode::Chat]), ]) ; "gemini provider mapping" )] #[test_case( HashMap::from([ (GooseMode::Auto, "bypassPermissions".to_string()), (GooseMode::Approve, "default".to_string()), (GooseMode::SmartApprove, "acceptEdits".to_string()), (GooseMode::Chat, "plan".to_string()), ]), HashMap::from([ ("bypassPermissions".to_string(), vec![GooseMode::Auto]), ("default".to_string(), vec![GooseMode::Approve]), ("acceptEdits".to_string(), vec![GooseMode::SmartApprove]), ("plan".to_string(), vec![GooseMode::Chat]), ]) ; "claude provider mapping" )] #[test_case( HashMap::from([ (GooseMode::Auto, "full-access".to_string()), (GooseMode::Approve, "read-only".to_string()), (GooseMode::SmartApprove, "auto".to_string()), (GooseMode::Chat, "read-only".to_string()), ]), HashMap::from([ ("full-access".to_string(), vec![GooseMode::Auto]), ("read-only".to_string(), vec![GooseMode::Approve, GooseMode::Chat]), ("auto".to_string(), vec![GooseMode::SmartApprove]), ]) ; "codex duplicate read-only" )] fn test_reverse_mode_mapping( forward: HashMap, expected: HashMap>, ) { let result = reverse_mode_mapping(&forward); assert_eq!(result.len(), expected.len()); for (key, expected_modes) in &expected { let actual = result.get(key).expect("missing key"); assert_eq!( actual.len(), expected_modes.len(), "length mismatch for key {key}" ); for mode in expected_modes { assert!(actual.contains(mode), "missing {mode:?} for key {key}"); } } } #[test_case( NewSessionResponse::new("s1") .models(sacp::schema::SessionModelState::new( "default", vec![ sacp::schema::ModelInfo::new("default", "Default (recommended)"), sacp::schema::ModelInfo::new("sonnet", "Sonnet"), sacp::schema::ModelInfo::new("haiku", "Haiku"), ], )) .config_options(vec![ SessionConfigOption::select("model", "Model", "default", vec![ SessionConfigSelectOption::new("default", "Default (recommended)"), SessionConfigSelectOption::new("sonnet", "Sonnet"), SessionConfigSelectOption::new("haiku", "Haiku"), ]) .category(SessionConfigOptionCategory::Model), ]) => Ok(("default".to_string(), vec!["default".to_string(), "sonnet".to_string(), "haiku".to_string()])) ; "claude-agent-acp config_options supersedes models" )] #[test_case( NewSessionResponse::new("s1") .models(sacp::schema::SessionModelState::new( "auto-gemini-3", vec![ sacp::schema::ModelInfo::new("auto-gemini-3", "Auto (Gemini 3)"), sacp::schema::ModelInfo::new("auto-gemini-2.5", "Auto (Gemini 2.5)"), sacp::schema::ModelInfo::new("gemini-2.5-pro", "gemini-2.5-pro"), ], )) => Ok(("auto-gemini-3".to_string(), vec!["auto-gemini-3".to_string(), "auto-gemini-2.5".to_string(), "gemini-2.5-pro".to_string()])) ; "gemini-acp falls back to models" )] #[test_case( NewSessionResponse::new("s1") => Err(ProviderError::RequestFailed( "test: agent returned neither config_options nor models".to_string() )) ; "neither config_options nor models is an error" )] fn test_resolve_model_info( response: NewSessionResponse, ) -> Result<(String, Vec), ProviderError> { resolve_model_info("test", &response) } // Codex mapping: read-only maps to both Approve and Chat. fn codex_reverse_modes() -> HashMap> { HashMap::from([ ("full-access".to_string(), vec![GooseMode::Auto]), ( "read-only".to_string(), vec![GooseMode::Approve, GooseMode::Chat], ), ("auto".to_string(), vec![GooseMode::SmartApprove]), ]) } #[test_case( "full-access", GooseMode::Auto, Some(GooseMode::Auto) ; "unique mapping returns the only candidate" )] #[test_case( "read-only", GooseMode::Approve, Some(GooseMode::Approve) ; "duplicate prefers current when current is Approve" )] #[test_case( "read-only", GooseMode::Chat, Some(GooseMode::Chat) ; "duplicate prefers current when current is Chat" )] #[test_case( "read-only", GooseMode::Auto, Some(GooseMode::Approve) ; "duplicate falls back to first when current not in candidates" )] #[test_case( "unknown-id", GooseMode::Auto, None ; "unknown mode id returns None" )] fn test_resolve_mode(mode_id: &str, current: GooseMode, expected: Option) { let reverse_modes = codex_reverse_modes(); let current = Arc::new(Mutex::new(current)); let result = resolve_mode(&reverse_modes, mode_id, ¤t); // For the fallback case, just check we got *some* candidate (order is nondeterministic). if mode_id == "read-only" && expected == Some(GooseMode::Approve) { // Current (Auto) not in candidates — any candidate is valid. assert!(result == Some(GooseMode::Approve) || result == Some(GooseMode::Chat)); } else { assert_eq!(result, expected); } } }