diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 21f210142..214c95db3 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -22,6 +22,7 @@ serde_json = { workspace = true } bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] } bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] } bitfun-webdriver = { path = "../../crates/webdriver" } +bitfun-acp = { path = "../../crates/acp" } # Tauri tauri = { workspace = true } @@ -39,6 +40,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } log = { workspace = true } chrono = { workspace = true } +uuid = { workspace = true } regex = { workspace = true } dirs = { workspace = true } dark-light = { workspace = true } diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs new file mode 100644 index 000000000..5123ed9ff --- /dev/null +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -0,0 +1,524 @@ +//! ACP client API + +use crate::api::app_state::AppState; +use crate::api::session_storage_path::desktop_effective_session_storage_path; +use bitfun_acp::client::{ + AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, + AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, + SubmitAcpPermissionResponseRequest, +}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientIdRequest { + pub client_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAcpFlowSessionRequest { + pub client_id: String, + #[serde(default)] + pub session_name: Option, + pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +pub type CreateAcpFlowSessionResponse = CreateAcpFlowSessionRecordResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartAcpDialogTurnRequest { + pub session_id: String, + pub client_id: String, + pub user_input: String, + #[serde(default)] + pub original_user_input: Option, + pub turn_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelAcpDialogTurnRequest { + pub session_id: String, + pub client_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAcpSessionOptionsRequest { + pub session_id: String, + pub client_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[tauri::command] +pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.initialize_all().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.list_clients().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn probe_acp_client_requirements( + state: State<'_, AppState>, +) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .probe_client_requirements() + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn predownload_acp_client_adapter( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .predownload_client_adapter(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn install_acp_client_cli( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .install_client_cli(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn create_acp_flow_session( + state: State<'_, AppState>, + app_handle: AppHandle, + request: CreateAcpFlowSessionRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + + let session_storage_path = desktop_effective_session_storage_path( + &state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let response = service + .create_flow_session_record( + &session_storage_path, + &request.workspace_path, + &request.client_id, + request.session_name, + ) + .await + .map_err(|e| e.to_string())?; + if let Err(error) = service + .start_client_for_session(&request.client_id, &response.session_id) + .await + { + if let Err(cleanup_error) = service + .delete_flow_session_record(&session_storage_path, &response.session_id) + .await + { + log::warn!( + "Failed to delete ACP session record after client start failure: session_id={}, error={}", + response.session_id, + cleanup_error + ); + } + return Err(error.to_string()); + } + + let _ = app_handle.emit( + "agentic://session-created", + serde_json::json!({ + "sessionId": response.session_id.clone(), + "sessionName": response.session_name.clone(), + "agentType": response.agent_type.clone(), + "workspacePath": request.workspace_path, + "remoteConnectionId": request.remote_connection_id, + "remoteSshHost": request.remote_ssh_host, + }), + ); + + Ok(response) +} + +#[tauri::command] +pub async fn start_acp_dialog_turn( + state: State<'_, AppState>, + app_handle: AppHandle, + request: StartAcpDialogTurnRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())? + .clone(); + + let session_id = request.session_id.clone(); + let turn_id = request.turn_id.clone(); + let user_input = request.user_input.clone(); + let original_user_input = request + .original_user_input + .clone() + .unwrap_or_else(|| request.user_input.clone()); + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + + app_handle + .emit( + "agentic://dialog-turn-started", + serde_json::json!({ + "sessionId": session_id, + "turnId": turn_id, + "turnIndex": null, + "userInput": user_input, + "originalUserInput": original_user_input, + "userMessageMetadata": null, + "subagentParentInfo": null, + }), + ) + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + let mut current_round_id: Option = None; + let result = service + .prompt_agent_stream( + &request.client_id, + request.user_input, + request.workspace_path, + Some(request.session_id.clone()), + session_storage_path, + request.timeout_seconds, + |event| { + match event { + AcpClientStreamEvent::ModelRoundStarted { + round_id, + round_index, + disable_explore_grouping, + } => { + current_round_id = Some(round_id.clone()); + app_handle + .emit( + "agentic://model-round-started", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "roundIndex": round_index, + "renderHints": { + "disableExploreGrouping": disable_explore_grouping, + }, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::AgentText(text) => { + let round_id = current_round_id.clone().ok_or_else(|| { + bitfun_core::util::errors::BitFunError::service( + "ACP text arrived before model round start".to_string(), + ) + })?; + app_handle + .emit( + "agentic://text-chunk", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "text": text, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::AgentThought(text) => { + let round_id = current_round_id.clone().ok_or_else(|| { + bitfun_core::util::errors::BitFunError::service( + "ACP thought arrived before model round start".to_string(), + ) + })?; + app_handle + .emit( + "agentic://text-chunk", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "text": text, + "contentType": "thinking", + "isThinkingEnd": false, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::ToolEvent(tool_event) => { + app_handle + .emit( + "agentic://tool-event", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "toolEvent": tool_event, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::Completed => { + app_handle + .emit( + "agentic://dialog-turn-completed", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "subagentParentInfo": null, + "partialRecoveryReason": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::Cancelled => { + app_handle + .emit( + "agentic://dialog-turn-cancelled", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + } + Ok(()) + }, + ) + .await; + + if let Err(error) = result { + let _ = app_handle.emit( + "agentic://dialog-turn-failed", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "error": error.to_string(), + "errorCategory": null, + "errorDetail": null, + "subagentParentInfo": null, + }), + ); + } + }); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_acp_dialog_turn( + state: State<'_, AppState>, + request: CancelAcpDialogTurnRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .cancel_agent_session( + &request.client_id, + request.workspace_path, + Some(request.session_id), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_acp_session_options( + state: State<'_, AppState>, + request: GetAcpSessionOptionsRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .get_session_options( + &request.client_id, + request.workspace_path, + session_storage_path, + Some(request.session_id), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_acp_session_model( + state: State<'_, AppState>, + request: SetAcpSessionModelRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .set_session_model(request, session_storage_path) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn stop_acp_client( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .stop_client(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn load_acp_json_config(state: State<'_, AppState>) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.load_json_config().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn save_acp_json_config( + state: State<'_, AppState>, + json_config: String, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .save_json_config(&json_config) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn submit_acp_permission_response( + state: State<'_, AppState>, + request: SubmitAcpPermissionResponseRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .submit_permission_response(request) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 8ab2afcbf..87e1bca41 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -579,8 +579,28 @@ fn resolve_missing_image_payloads( #[tauri::command] pub async fn cancel_dialog_turn( coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, request: CancelDialogTurnRequest, ) -> Result<(), String> { + if let Some(acp_client_service) = app_state.acp_client_service.as_ref() { + match acp_client_service + .cancel_bitfun_session(&request.session_id) + .await + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) => { + log::error!( + "Failed to cancel ACP dialog turn: session_id={}, dialog_turn_id={}, error={}", + request.session_id, + request.dialog_turn_id, + error + ); + return Err(format!("Failed to cancel ACP dialog turn: {}", error)); + } + } + } + coordinator .cancel_dialog_turn(&request.session_id, &request.dialog_turn_id) .await @@ -696,6 +716,11 @@ pub async fn delete_session( request.remote_ssh_host.as_deref(), ) .await; + if let Some(acp_client_service) = app_state.acp_client_service.as_ref() { + acp_client_service + .release_bitfun_session(&request.session_id) + .await; + } coordinator .delete_session(&effective_path, &request.session_id) .await diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 90d05e995..1a3855a5c 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -73,6 +73,7 @@ pub struct AppState { pub ai_rules_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, + pub acp_client_service: Option>, pub token_usage_service: Arc, pub miniapp_manager: Arc, pub js_worker_pool: Option>, @@ -143,6 +144,12 @@ impl AppState { } }; let path_manager = workspace_service.path_manager().clone(); + let acp_client_service = Some( + bitfun_acp::AcpClientService::new(config_service.clone(), path_manager.clone()) + .map_err(|e| { + BitFunError::service(format!("Failed to initialize ACP client service: {}", e)) + })?, + ); let announcement_scheduler = Arc::new( announcement::AnnouncementScheduler::new(&path_manager) @@ -335,6 +342,7 @@ impl AppState { ai_rules_service, agent_registry, mcp_service, + acp_client_service, token_usage_service, miniapp_manager, js_worker_pool, diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index b7eb72de6..ffe5f97fe 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -1,5 +1,6 @@ //! API layer module +pub mod acp_client_api; pub mod agentic_api; pub mod ai_memory_api; pub mod ai_rules_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 4b05ff948..cb0cd81d1 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -28,6 +28,7 @@ use tauri::Manager; // Re-export API pub use api::*; +use api::acp_client_api::*; use api::ai_rules_api::*; use api::clipboard_file_api::*; use api::commands::*; @@ -314,6 +315,7 @@ pub async fn run() { } init_mcp_servers(app_handle.clone()); + init_acp_clients(app_handle.clone()); init_services(app_handle.clone(), startup_log_level); @@ -583,6 +585,20 @@ pub async fn run() { api::mcp_api::start_mcp_remote_oauth, api::mcp_api::get_mcp_remote_oauth_session, api::mcp_api::cancel_mcp_remote_oauth, + initialize_acp_clients, + get_acp_clients, + probe_acp_client_requirements, + predownload_acp_client_adapter, + install_acp_client_cli, + stop_acp_client, + load_acp_json_config, + save_acp_json_config, + submit_acp_permission_response, + create_acp_flow_session, + start_acp_dialog_turn, + cancel_acp_dialog_turn, + get_acp_session_options, + set_acp_session_model, lsp_initialize, lsp_start_server_for_file, lsp_stop_server, @@ -914,6 +930,17 @@ fn init_mcp_servers(app_handle: tauri::AppHandle) { }); } +fn init_acp_clients(app_handle: tauri::AppHandle) { + tokio::spawn(async move { + let state: tauri::State<'_, api::AppState> = app_handle.state(); + if let Some(service) = state.acp_client_service.as_ref() { + if let Err(error) = service.initialize_all().await { + log::warn!("Failed to initialize ACP clients: {}", error); + } + } + }); +} + fn setup_panic_hook() { std::panic::set_hook(Box::new(move |panic_info| { let location = panic_info diff --git a/src/crates/acp/Cargo.toml b/src/crates/acp/Cargo.toml index 1774a325b..9b33fb2c5 100644 --- a/src/crates/acp/Cargo.toml +++ b/src/crates/acp/Cargo.toml @@ -16,7 +16,9 @@ agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat"] } async-trait = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } dashmap = { workspace = true } log = { workspace = true } +uuid = { workspace = true } diff --git a/src/crates/acp/src/client/config.rs b/src/crates/acp/src/client/config.rs new file mode 100644 index 000000000..fbae5eb12 --- /dev/null +++ b/src/crates/acp/src/client/config.rs @@ -0,0 +1,96 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientConfigFile { + #[serde(default)] + pub acp_clients: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientConfig { + #[serde(default)] + pub name: Option, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub readonly: bool, + #[serde(default)] + pub permission_mode: AcpClientPermissionMode, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpClientPermissionMode { + Ask, + AllowOnce, + RejectOnce, +} + +impl Default for AcpClientPermissionMode { + fn default() -> Self { + Self::Ask + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientInfo { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub enabled: bool, + pub readonly: bool, + pub permission_mode: AcpClientPermissionMode, + pub status: AcpClientStatus, + pub tool_name: String, + pub session_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientRequirementProbe { + pub id: String, + pub tool: AcpRequirementProbeItem, + #[serde(default)] + pub adapter: Option, + pub runnable: bool, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpRequirementProbeItem { + pub name: String, + pub installed: bool, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpClientStatus { + Configured, + Starting, + Running, + Stopped, + Failed, +} + +fn default_true() -> bool { + true +} diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs new file mode 100644 index 000000000..c33f8ce1d --- /dev/null +++ b/src/crates/acp/src/client/manager.rs @@ -0,0 +1,1629 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use agent_client_protocol::schema::{ + AgentCapabilities, CancelNotification, ClientCapabilities, CloseSessionRequest, Implementation, + InitializeRequest, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, + NewSessionResponse, PermissionOption, PermissionOptionKind, ProtocolVersion, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + ResumeSessionRequest, ResumeSessionResponse, SelectedPermissionOutcome, SessionConfigOption, + SessionConfigOptionValue, SessionModelState, SetSessionConfigOptionRequest, + SetSessionModelRequest, StopReason, +}; +use agent_client_protocol::{ + ActiveSession, Agent, ByteStreams, Client, ConnectionTo, Error, SessionMessage, +}; +use bitfun_core::agentic::tools::registry::get_global_tool_registry; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::config::ConfigService; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use dashmap::DashMap; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::process::{Child, Command}; +use tokio::sync::{oneshot, Mutex, RwLock}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use super::config::{ + AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, + AcpClientRequirementProbe, AcpClientStatus, +}; +use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; +use super::requirements::{ + acp_requirement_spec, apply_command_environment, install_npm_cli_package, + predownload_npm_adapter, probe_executable, probe_npm_adapter, resolve_configured_command, +}; +use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; +use super::session_persistence::AcpSessionPersistence; +pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; +use super::stream::{acp_dispatch_to_stream_events, AcpClientStreamEvent, AcpStreamRoundTracker}; +use super::tool::AcpAgentTool; + +const CONFIG_PATH: &str = "acp_clients"; +const PERMISSION_TIMEOUT: Duration = Duration::from_secs(600); +const SESSION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); +const LOAD_REPLAY_DRAIN_QUIET_WINDOW: Duration = Duration::from_millis(250); +const LOAD_REPLAY_DRAIN_MAX_DURATION: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitAcpPermissionResponseRequest { + pub permission_id: String, + pub approve: bool, + #[serde(default)] + pub option_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientPermissionResponse { + pub permission_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAcpSessionModelRequest { + pub client_id: String, + pub session_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + pub model_id: String, +} + +pub struct AcpClientService { + config_service: Arc, + session_persistence: AcpSessionPersistence, + clients: DashMap>, + pending_permissions: DashMap, + session_permission_modes: DashMap, +} + +struct PendingPermission { + sender: oneshot::Sender, + options: Vec, +} + +struct AcpClientConnection { + id: String, + client_id: String, + config: AcpClientConfig, + status: RwLock, + connection: RwLock>>, + agent_capabilities: RwLock>, + sessions: DashMap>>, + cancel_handles: DashMap, + shutdown_tx: Mutex>>, + child: Mutex>, +} + +struct AcpRemoteSession { + active: Option>, + models: Option, + config_options: Vec, + discard_pending_updates_before_next_prompt: bool, +} + +#[derive(Clone)] +struct AcpCancelHandle { + session_id: String, + connection: ConnectionTo, +} + +impl AcpRemoteSession { + fn new() -> Self { + Self { + active: None, + models: None, + config_options: Vec::new(), + discard_pending_updates_before_next_prompt: false, + } + } +} + +impl AcpClientService { + pub fn new( + config_service: Arc, + path_manager: Arc, + ) -> BitFunResult> { + Ok(Arc::new(Self { + config_service, + session_persistence: AcpSessionPersistence::new(path_manager)?, + clients: DashMap::new(), + pending_permissions: DashMap::new(), + session_permission_modes: DashMap::new(), + })) + } + + pub async fn create_flow_session_record( + &self, + session_storage_path: &Path, + workspace_path: &str, + client_id: &str, + session_name: Option, + ) -> BitFunResult { + self.session_persistence + .create_flow_session_record( + session_storage_path, + workspace_path, + client_id, + session_name, + ) + .await + } + + pub async fn initialize_all(self: &Arc) -> BitFunResult<()> { + let configs = self.load_configs().await?; + self.register_configured_tools(&configs).await; + + let configured_ids = configs + .keys() + .cloned() + .collect::>(); + let running_connections = self + .clients + .iter() + .map(|entry| (entry.key().clone(), entry.value().client_id.clone())) + .collect::>(); + for (connection_id, client_id) in running_connections { + let should_stop = !configured_ids.contains(&client_id) + || configs + .get(&client_id) + .map(|config| !config.enabled) + .unwrap_or(true); + if should_stop { + let _ = self.stop_connection(&connection_id).await; + } + } + + Ok(()) + } + + pub async fn list_clients(self: &Arc) -> BitFunResult> { + let configs = self.load_configs().await?; + let mut infos = Vec::with_capacity(configs.len()); + for (id, config) in configs { + let clients = self + .clients + .iter() + .filter(|entry| entry.value().client_id == id) + .map(|entry| entry.value().clone()) + .collect::>(); + let mut statuses = Vec::with_capacity(clients.len()); + let mut session_count = 0usize; + for client in &clients { + statuses.push(*client.status.read().await); + session_count += client.sessions.len(); + } + let status = aggregate_client_status(&statuses); + infos.push(AcpClientInfo { + tool_name: AcpAgentTool::tool_name_for(&id), + name: config.name.clone().unwrap_or_else(|| id.clone()), + command: config.command.clone(), + args: config.args.clone(), + enabled: config.enabled, + readonly: config.readonly, + permission_mode: config.permission_mode, + id, + status, + session_count, + }); + } + infos.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(infos) + } + + pub async fn probe_client_requirements( + self: &Arc, + ) -> BitFunResult> { + let configs = self.load_configs().await?; + let mut ids = configs.keys().cloned().collect::>(); + for id in ["opencode", "claude-code", "codex"] { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.to_string()); + } + } + ids.sort(); + + let mut probes = Vec::with_capacity(ids.len()); + for id in ids { + let spec = acp_requirement_spec(&id, configs.get(&id)); + let tool = probe_executable(spec.tool_command).await; + let adapter = match spec.adapter { + Some(adapter) => Some(probe_npm_adapter(adapter.package, adapter.bin).await), + None => None, + }; + let runnable = tool.installed + && adapter + .as_ref() + .map(|adapter| adapter.installed) + .unwrap_or(true); + let mut notes = Vec::new(); + if !tool.installed { + notes.push(format!("{} is not available on PATH", spec.tool_command)); + } + if let Some(adapter) = adapter.as_ref() { + if !adapter.installed { + notes.push(format!( + "{} is not installed in npm global or offline cache", + adapter.name + )); + } + } + + debug!( + "ACP requirement probe: id={} tool_installed={} adapter_installed={} runnable={} notes={:?}", + id, + tool.installed, + adapter.as_ref().map(|adapter| adapter.installed).unwrap_or(true), + runnable, + notes + ); + + probes.push(AcpClientRequirementProbe { + id, + tool, + adapter, + runnable, + notes, + }); + } + + Ok(probes) + } + + pub async fn predownload_client_adapter(self: &Arc, client_id: &str) -> BitFunResult<()> { + let configs = self.load_configs().await?; + let spec = acp_requirement_spec(client_id, configs.get(client_id)); + let adapter = spec.adapter.ok_or_else(|| { + BitFunError::config(format!( + "ACP client '{}' does not use a downloadable adapter", + client_id + )) + })?; + + predownload_npm_adapter(adapter.package, adapter.bin).await + } + + pub async fn install_client_cli(self: &Arc, client_id: &str) -> BitFunResult<()> { + let configs = self.load_configs().await?; + let spec = acp_requirement_spec(client_id, configs.get(client_id)); + let package = spec.install_package.ok_or_else(|| { + BitFunError::config(format!( + "ACP client '{}' does not have a known CLI installer", + client_id + )) + })?; + + install_npm_cli_package(package).await + } + + pub async fn start_client_for_session( + self: &Arc, + client_id: &str, + bitfun_session_id: &str, + ) -> BitFunResult<()> { + let connection_id = session_client_connection_id(client_id, bitfun_session_id); + self.start_client_connection(&connection_id, client_id) + .await + } + + async fn start_client_connection( + self: &Arc, + connection_id: &str, + client_id: &str, + ) -> BitFunResult<()> { + if let Some(existing) = self.clients.get(connection_id) { + let status = *existing.status.read().await; + if matches!(status, AcpClientStatus::Running | AcpClientStatus::Starting) { + return Ok(()); + } + } + + let config = self + .load_configs() + .await? + .remove(client_id) + .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + + if !config.enabled { + return Err(BitFunError::config(format!( + "ACP client is disabled: {}", + client_id + ))); + } + + let connection = Arc::new(AcpClientConnection::new( + connection_id.to_string(), + client_id.to_string(), + config, + )); + self.clients + .insert(connection_id.to_string(), connection.clone()); + *connection.status.write().await = AcpClientStatus::Starting; + + let program = + resolve_configured_command(&connection.config.command, &connection.config.env); + let mut command = Command::new(&program); + command + .args(&connection.config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + apply_command_environment(&mut command, Some(&connection.config.env)); + configure_process_group(&mut command); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + self.clients.remove(connection_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "Failed to spawn ACP client '{}': {}", + client_id, error + ))); + } + }; + + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child_process_tree(connection_id, child).await; + self.clients.remove(connection_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "ACP client '{}' stdout is unavailable", + client_id + ))); + } + }; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + terminate_child_process_tree(connection_id, child).await; + self.clients.remove(connection_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "ACP client '{}' stdin is unavailable", + client_id + ))); + } + }; + + *connection.child.lock().await = Some(child); + + let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); + let service = self.clone(); + let connection_for_task = connection.clone(); + let (cx_tx, cx_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + *connection.shutdown_tx.lock().await = Some(shutdown_tx); + + tokio::spawn(async move { + let result = Client + .builder() + .name("bitfun-acp-client") + .on_receive_request( + { + let service = service.clone(); + async move |request: RequestPermissionRequest, responder, cx| { + let service = service.clone(); + cx.spawn(async move { + responder.respond_with_result( + service.handle_permission_request(request).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx| { + let init = InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(ClientCapabilities::new()) + .client_info(Implementation::new( + "bitfun-desktop", + env!("CARGO_PKG_VERSION"), + )); + let initialize_response = cx.send_request(init).block_task().await?; + let _ = cx_tx.send((cx, initialize_response.agent_capabilities)); + let _ = shutdown_rx.await; + Ok(()) + }) + .await; + + if let Err(error) = result { + warn!( + "ACP client connection ended with error: id={} error={:?}", + connection_for_task.id, error + ); + *connection_for_task.status.write().await = AcpClientStatus::Failed; + } else { + *connection_for_task.status.write().await = AcpClientStatus::Stopped; + } + *connection_for_task.connection.write().await = None; + *connection_for_task.agent_capabilities.write().await = None; + connection_for_task.sessions.clear(); + }); + + let (cx, agent_capabilities) = cx_rx.await.map_err(|_| { + BitFunError::service(format!( + "ACP client '{}' exited before initialization completed", + client_id + )) + })?; + *connection.connection.write().await = Some(cx); + *connection.agent_capabilities.write().await = Some(agent_capabilities); + *connection.status.write().await = AcpClientStatus::Running; + info!("ACP client started: id={}", client_id); + Ok(()) + } + + pub async fn stop_client(self: &Arc, client_id: &str) -> BitFunResult<()> { + let connection_ids = self + .clients + .iter() + .filter(|entry| entry.value().client_id == client_id) + .map(|entry| entry.key().clone()) + .collect::>(); + for connection_id in connection_ids { + self.stop_connection(&connection_id).await?; + } + Ok(()) + } + + async fn stop_connection(self: &Arc, connection_id: &str) -> BitFunResult<()> { + let Some(client) = self.clients.get(connection_id).map(|entry| entry.clone()) else { + return Ok(()); + }; + + if let Some(tx) = client.shutdown_tx.lock().await.take() { + let _ = tx.send(()); + } + if let Some(child) = client.child.lock().await.take() { + terminate_child_process_tree(connection_id, child).await; + } + *client.connection.write().await = None; + *client.agent_capabilities.write().await = None; + client.sessions.clear(); + client.cancel_handles.clear(); + *client.status.write().await = AcpClientStatus::Stopped; + self.clients.remove(connection_id); + info!( + "ACP client stopped: id={} client_id={}", + connection_id, client.client_id + ); + Ok(()) + } + + pub async fn release_bitfun_session(self: &Arc, bitfun_session_id: &str) -> bool { + let session_key_prefix = format!("{}:", bitfun_session_id); + let clients = self + .clients + .iter() + .map(|entry| entry.value().clone()) + .collect::>(); + let mut released = false; + let mut idle_client_ids = Vec::new(); + + for client in clients { + let session_keys = client + .sessions + .iter() + .filter(|entry| entry.key().starts_with(&session_key_prefix)) + .map(|entry| entry.key().clone()) + .collect::>(); + if session_keys.is_empty() { + continue; + } + + released = true; + let supports_close = client + .agent_capabilities + .read() + .await + .as_ref() + .and_then(|capabilities| capabilities.session_capabilities.close.as_ref()) + .is_some(); + + for session_key in session_keys { + let active_session_id = + if let Some((_, session)) = client.sessions.remove(&session_key) { + let mut session = session.lock().await; + let session_id = session + .active + .as_ref() + .map(|active| active.session_id().to_string()); + session.active = None; + session_id + } else { + None + }; + let cancel_handle = client + .cancel_handles + .remove(&session_key) + .map(|(_, handle)| handle); + let remote_session_id = cancel_handle + .as_ref() + .map(|handle| handle.session_id.clone()) + .or(active_session_id); + + let Some(remote_session_id) = remote_session_id else { + continue; + }; + + self.session_permission_modes.remove(&remote_session_id); + let connection = cancel_handle + .as_ref() + .map(|handle| handle.connection.clone()); + close_or_cancel_remote_session( + &client, + connection, + &remote_session_id, + supports_close, + ) + .await; + } + + if client.id != client.client_id + && client.sessions.is_empty() + && client.cancel_handles.is_empty() + { + idle_client_ids.push(client.id.clone()); + } + } + + for connection_id in idle_client_ids { + if let Err(error) = self.stop_connection(&connection_id).await { + warn!( + "Failed to stop idle ACP client after session release: id={} error={}", + connection_id, error + ); + } + } + + released + } + + pub async fn delete_flow_session_record( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + ) -> BitFunResult<()> { + self.session_persistence + .delete_flow_session_record(session_storage_path, bitfun_session_id) + .await + } + + pub async fn load_json_config(&self) -> BitFunResult { + let config = parse_config_value(self.load_config_value().await?)?; + serde_json::to_string_pretty(&config) + .map_err(|error| BitFunError::config(format!("Failed to render ACP config: {}", error))) + } + + pub async fn save_json_config(self: &Arc, json_config: &str) -> BitFunResult<()> { + let value: serde_json::Value = serde_json::from_str(json_config).map_err(|error| { + BitFunError::config(format!("Invalid ACP client JSON config: {}", error)) + })?; + let config = parse_config_value(value)?; + let canonical_value = serde_json::to_value(config).map_err(|error| { + BitFunError::config(format!("Failed to render ACP config: {}", error)) + })?; + self.config_service + .set_config(CONFIG_PATH, canonical_value) + .await?; + self.initialize_all().await + } + + pub async fn submit_permission_response( + &self, + request: SubmitAcpPermissionResponseRequest, + ) -> BitFunResult { + let Some((_, pending)) = self.pending_permissions.remove(&request.permission_id) else { + return Err(BitFunError::NotFound(format!( + "ACP permission request not found: {}", + request.permission_id + ))); + }; + + let option_id = request + .option_id + .unwrap_or_else(|| select_permission_option_id(&pending.options, request.approve)); + let response = RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )); + let _ = pending.sender.send(response); + Ok(AcpClientPermissionResponse { + permission_id: request.permission_id, + resolved: true, + }) + } + + pub async fn get_session_options( + self: &Arc, + client_id: &str, + workspace_path: Option, + session_storage_path: Option, + bitfun_session_id: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + bitfun_session_id.as_deref(), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )) + } + + pub async fn set_session_model( + self: &Arc, + request: SetAcpSessionModelRequest, + session_storage_path: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session( + &request.client_id, + request.workspace_path, + Some(&request.session_id), + ) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + Some(&request.session_id), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + let active = session + .active + .as_ref() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + let remote_session_id = active.session_id().to_string(); + let connection = active.connection(); + + let mut set_model_error = None; + if session.models.is_some() { + match connection + .send_request(SetSessionModelRequest::new( + remote_session_id.clone(), + request.model_id.clone(), + )) + .block_task() + .await + .map_err(protocol_error) + { + Ok(_) => { + if let Some(models) = session.models.as_mut() { + models.current_model_id = request.model_id.clone().into(); + } + if let Some(session_storage_path) = session_storage_path.as_deref() { + self.session_persistence + .update_model_id( + session_storage_path, + &request.session_id, + &request.model_id, + ) + .await?; + } + return Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )); + } + Err(error) => { + set_model_error = Some(error); + } + } + } + + if let Some(config_id) = model_config_id(&session.config_options) { + let response = connection + .send_request(SetSessionConfigOptionRequest::new( + remote_session_id, + config_id, + SessionConfigOptionValue::value_id(request.model_id.clone()), + )) + .block_task() + .await + .map_err(protocol_error)?; + session.config_options = response.config_options; + if let Some(session_storage_path) = session_storage_path.as_deref() { + self.session_persistence + .update_model_id(session_storage_path, &request.session_id, &request.model_id) + .await?; + } + return Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )); + } + + if let Some(error) = set_model_error { + return Err(error); + } + Err(BitFunError::NotFound( + "ACP session does not expose selectable models".to_string(), + )) + } + + pub async fn prompt_agent( + self: &Arc, + client_id: &str, + prompt: String, + workspace_path: Option, + bitfun_session_id: Option, + session_storage_path: Option, + timeout_seconds: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let run = async { + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + bitfun_session_id.as_deref(), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + + discard_pending_session_updates_if_needed(&mut session).await; + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + active.read_to_string().await.map_err(protocol_error) + }; + + if let Some(seconds) = timeout_seconds.filter(|seconds| *seconds > 0) { + tokio::time::timeout(Duration::from_secs(seconds), run) + .await + .map_err(|_| { + BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + })? + } else { + run.await + } + } + + pub async fn prompt_agent_stream( + self: &Arc, + client_id: &str, + prompt: String, + workspace_path: Option, + bitfun_session_id: Option, + session_storage_path: Option, + timeout_seconds: Option, + mut on_event: F, + ) -> BitFunResult<()> + where + F: FnMut(AcpClientStreamEvent) -> BitFunResult<()> + Send, + { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let run = async { + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + bitfun_session_id.as_deref(), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + + discard_pending_session_updates_if_needed(&mut session).await; + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + let mut round_tracker = AcpStreamRoundTracker::new(); + + loop { + match active.read_update().await.map_err(protocol_error)? { + SessionMessage::SessionMessage(dispatch) => { + for event in acp_dispatch_to_stream_events(dispatch).await? { + for event in round_tracker.apply(event) { + on_event(event)?; + } + } + } + SessionMessage::StopReason(stop_reason) => { + let event = if matches!(stop_reason, StopReason::Cancelled) { + AcpClientStreamEvent::Cancelled + } else { + AcpClientStreamEvent::Completed + }; + on_event(event)?; + break; + } + _ => {} + } + } + Ok(()) + }; + + if let Some(seconds) = timeout_seconds.filter(|seconds| *seconds > 0) { + tokio::time::timeout(Duration::from_secs(seconds), run) + .await + .map_err(|_| { + BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + })? + } else { + run.await + } + } + + pub async fn cancel_agent_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option, + ) -> BitFunResult<()> { + let connection_id = bitfun_session_id + .as_deref() + .map(|session_id| session_client_connection_id(client_id, session_id)) + .unwrap_or_else(|| client_id.to_string()); + let client = self + .clients + .get(&connection_id) + .map(|entry| entry.clone()) + .ok_or_else(|| { + BitFunError::service(format!("ACP client is not running: {}", client_id)) + })?; + + let cwd = workspace_path + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| BitFunError::validation("Workspace path is required".to_string()))?; + let session_key = build_session_key(bitfun_session_id.as_deref(), client_id, &cwd); + let handle = client.cancel_handles.get(&session_key).ok_or_else(|| { + BitFunError::NotFound(format!( + "ACP session is not active for client '{}' in workspace '{}'", + client_id, + cwd.display() + )) + })?; + + handle + .connection + .send_notification(CancelNotification::new(handle.session_id.clone())) + .map_err(protocol_error)?; + Ok(()) + } + + pub async fn cancel_bitfun_session( + self: &Arc, + bitfun_session_id: &str, + ) -> BitFunResult { + let session_key_prefix = format!("{}:", bitfun_session_id); + for client in self.clients.iter().map(|entry| entry.value().clone()) { + let handle = client + .cancel_handles + .iter() + .find(|entry| entry.key().starts_with(&session_key_prefix)) + .map(|entry| entry.value().clone()); + + if let Some(handle) = handle { + handle + .connection + .send_notification(CancelNotification::new(handle.session_id.clone())) + .map_err(protocol_error)?; + return Ok(true); + } + } + + Ok(false) + } + + async fn resolve_client_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option<&str>, + ) -> BitFunResult<(Arc, PathBuf, String)> { + let connection_id = bitfun_session_id + .map(|session_id| session_client_connection_id(client_id, session_id)) + .unwrap_or_else(|| client_id.to_string()); + self.start_client_connection(&connection_id, client_id) + .await?; + let client = self + .clients + .get(&connection_id) + .map(|entry| entry.clone()) + .ok_or_else(|| { + BitFunError::service(format!("ACP client is not running: {}", client_id)) + })?; + + let cwd = workspace_path + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| BitFunError::validation("Workspace path is required".to_string()))?; + let session_key = build_session_key(bitfun_session_id, client_id, &cwd); + Ok((client, cwd, session_key)) + } + + async fn ensure_remote_session( + &self, + client: &Arc, + session_key: &str, + cwd: &Path, + bitfun_session_id: Option<&str>, + session_storage_path: Option<&Path>, + session: &mut AcpRemoteSession, + ) -> BitFunResult<()> { + if session.active.is_some() { + return Ok(()); + } + + let cx = client.connection().await?; + let persisted_remote_session_id = + if let (Some(session_storage_path), Some(bitfun_session_id)) = + (session_storage_path, bitfun_session_id) + { + self.session_persistence + .load_remote_session_id(session_storage_path, bitfun_session_id) + .await? + } else { + None + }; + let capabilities = client.agent_capabilities.read().await.clone(); + let mut last_resume_error: Option = None; + + for strategy in preferred_resume_strategies( + capabilities.as_ref(), + persisted_remote_session_id.as_deref(), + ) { + let response = match strategy { + AcpRemoteSessionStrategy::Load => { + let Some(remote_session_id) = persisted_remote_session_id.as_deref() else { + continue; + }; + match cx + .send_request(LoadSessionRequest::new(remote_session_id.to_string(), cwd)) + .block_task() + .await + .map_err(protocol_error) + { + Ok(response) => new_session_response_from_load(remote_session_id, response), + Err(error) => { + warn!( + "Failed to load ACP remote session, falling back: client_id={}, remote_session_id={}, error={}", + client.id, remote_session_id, error + ); + last_resume_error = Some(error.to_string()); + continue; + } + } + } + AcpRemoteSessionStrategy::Resume => { + let Some(remote_session_id) = persisted_remote_session_id.as_deref() else { + continue; + }; + match cx + .send_request(ResumeSessionRequest::new( + remote_session_id.to_string(), + cwd, + )) + .block_task() + .await + .map_err(protocol_error) + { + Ok(response) => { + new_session_response_from_resume(remote_session_id, response) + } + Err(error) => { + warn!( + "Failed to resume ACP remote session, falling back: client_id={}, remote_session_id={}, error={}", + client.id, remote_session_id, error + ); + last_resume_error = Some(error.to_string()); + continue; + } + } + } + AcpRemoteSessionStrategy::New => cx + .send_request(NewSessionRequest::new(cwd)) + .block_task() + .await + .map_err(protocol_error)?, + }; + + self.attach_remote_session( + client, + session_key, + bitfun_session_id, + session_storage_path, + session, + response, + strategy, + last_resume_error.clone(), + ) + .await?; + return Ok(()); + } + + Err(BitFunError::service( + "Failed to initialize ACP remote session".to_string(), + )) + } + + async fn attach_remote_session( + &self, + client: &Arc, + session_key: &str, + bitfun_session_id: Option<&str>, + session_storage_path: Option<&Path>, + session: &mut AcpRemoteSession, + response: NewSessionResponse, + strategy: AcpRemoteSessionStrategy, + last_resume_error: Option, + ) -> BitFunResult<()> { + let cx = client.connection().await?; + let models = response.models.clone(); + let config_options = response.config_options.clone().unwrap_or_default(); + let active = cx + .attach_session(response, Vec::new()) + .map_err(protocol_error)?; + let remote_session_id = active.session_id().to_string(); + client.cancel_handles.insert( + session_key.to_string(), + AcpCancelHandle { + session_id: remote_session_id.clone(), + connection: active.connection(), + }, + ); + self.session_permission_modes + .insert(remote_session_id.clone(), client.config.permission_mode); + if let (Some(session_storage_path), Some(bitfun_session_id)) = + (session_storage_path, bitfun_session_id) + { + self.session_persistence + .update_remote_session_state( + session_storage_path, + bitfun_session_id, + &remote_session_id, + strategy.as_str(), + last_resume_error, + ) + .await?; + } + session.models = models; + session.config_options = config_options; + session.discard_pending_updates_before_next_prompt = + matches!(strategy, AcpRemoteSessionStrategy::Load); + session.active = Some(active); + Ok(()) + } + + async fn load_configs(&self) -> BitFunResult> { + Ok(parse_config_value(self.load_config_value().await?)?.acp_clients) + } + + async fn load_config_value(&self) -> BitFunResult { + Ok(self + .config_service + .get_config::(Some(CONFIG_PATH)) + .await + .unwrap_or_else(|_| json!({ "acpClients": {} }))) + } + + async fn register_configured_tools( + self: &Arc, + configs: &HashMap, + ) { + let registry = get_global_tool_registry(); + let mut registry = registry.write().await; + registry.unregister_tools_by_prefix("acp__"); + + let tools = configs + .iter() + .filter(|(_, config)| config.enabled) + .map(|(id, config)| { + Arc::new(AcpAgentTool::new(id.clone(), config.clone(), self.clone())) + as Arc + }) + .collect::>(); + + for tool in tools { + debug!("Registering ACP client tool: name={}", tool.name()); + registry.register_tool(tool); + } + } + + async fn handle_permission_request( + self: Arc, + request: RequestPermissionRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let permission_mode = self.permission_mode_for_session(&session_id); + match permission_mode { + AcpClientPermissionMode::AllowOnce => { + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::AllowOnce, + true, + )); + } + AcpClientPermissionMode::RejectOnce => { + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::RejectOnce, + false, + )); + } + AcpClientPermissionMode::Ask => {} + } + + let permission_id = format!("acp_permission_{}", uuid::Uuid::new_v4()); + let (tx, rx) = oneshot::channel(); + self.pending_permissions.insert( + permission_id.clone(), + PendingPermission { + sender: tx, + options: request.options.clone(), + }, + ); + + let payload = json!({ + "permissionId": permission_id, + "sessionId": session_id, + "toolCall": request.tool_call, + "options": request.options, + }); + + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: "backend-event-acppermissionrequest".to_string(), + payload, + }) + .await + { + warn!("Failed to emit ACP permission request: {}", error); + } + + match tokio::time::timeout(PERMISSION_TIMEOUT, rx).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )), + Err(_) => { + self.pending_permissions.remove(&permission_id); + Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )) + } + } + } + + fn permission_mode_for_session(&self, session_id: &str) -> AcpClientPermissionMode { + self.session_permission_modes + .get(session_id) + .map(|entry| *entry.value()) + .unwrap_or(AcpClientPermissionMode::Ask) + } +} + +impl AcpClientConnection { + fn new(id: String, client_id: String, config: AcpClientConfig) -> Self { + Self { + id, + client_id, + config, + status: RwLock::new(AcpClientStatus::Configured), + connection: RwLock::new(None), + agent_capabilities: RwLock::new(None), + sessions: DashMap::new(), + cancel_handles: DashMap::new(), + shutdown_tx: Mutex::new(None), + child: Mutex::new(None), + } + } + + async fn connection(&self) -> BitFunResult> { + self.connection.read().await.clone().ok_or_else(|| { + BitFunError::service(format!("ACP client is not connected: {}", self.id)) + }) + } +} + +fn parse_config_value(value: serde_json::Value) -> BitFunResult { + if value.get("acpClients").is_some() { + serde_json::from_value(value) + .map_err(|error| BitFunError::config(format!("Invalid ACP client config: {}", error))) + } else if value.is_object() { + serde_json::from_value(json!({ "acpClients": value })).map_err(|error| { + BitFunError::config(format!("Invalid ACP client config map: {}", error)) + }) + } else { + Err(BitFunError::config( + "ACP client config must be an object".to_string(), + )) + } +} + +fn build_session_key(bitfun_session_id: Option<&str>, client_id: &str, cwd: &Path) -> String { + format!( + "{}:{}:{}", + bitfun_session_id.unwrap_or("standalone"), + client_id, + cwd.to_string_lossy() + ) +} + +fn session_client_connection_id(client_id: &str, bitfun_session_id: &str) -> String { + format!("{}::session::{}", client_id, bitfun_session_id) +} + +fn aggregate_client_status(statuses: &[AcpClientStatus]) -> AcpClientStatus { + if statuses.is_empty() { + return AcpClientStatus::Configured; + } + if statuses + .iter() + .any(|status| matches!(status, AcpClientStatus::Running)) + { + return AcpClientStatus::Running; + } + if statuses + .iter() + .any(|status| matches!(status, AcpClientStatus::Starting)) + { + return AcpClientStatus::Starting; + } + if statuses + .iter() + .any(|status| matches!(status, AcpClientStatus::Failed)) + { + return AcpClientStatus::Failed; + } + AcpClientStatus::Stopped +} + +fn configure_process_group(command: &mut Command) { + #[cfg(unix)] + { + command.process_group(0); + } +} + +async fn terminate_child_process_tree(client_id: &str, mut child: Child) { + let pid = child.id(); + + #[cfg(unix)] + if let Some(pid) = pid { + let process_group = format!("-{}", pid); + match Command::new("kill") + .arg("-TERM") + .arg(&process_group) + .status() + .await + { + Ok(status) if status.success() => {} + Ok(status) => { + warn!( + "ACP client process group terminate exited unsuccessfully: id={} pid={} status={}", + client_id, pid, status + ); + } + Err(error) => { + warn!( + "Failed to terminate ACP client process group: id={} pid={} error={}", + client_id, pid, error + ); + } + } + + match tokio::time::timeout(Duration::from_millis(750), child.wait()).await { + Ok(Ok(_)) => return, + Ok(Err(error)) => { + warn!( + "Failed to wait for ACP client process after terminate: id={} pid={} error={}", + client_id, pid, error + ); + } + Err(_) => {} + } + + if let Err(error) = Command::new("kill") + .arg("-KILL") + .arg(&process_group) + .status() + .await + { + warn!( + "Failed to kill ACP client process group: id={} pid={} error={}", + client_id, pid, error + ); + } + let _ = child.wait().await; + return; + } + + #[cfg(windows)] + if let Some(pid) = pid { + match Command::new("taskkill") + .arg("/PID") + .arg(pid.to_string()) + .arg("/T") + .arg("/F") + .status() + .await + { + Ok(status) if status.success() => { + let _ = child.wait().await; + return; + } + Ok(status) => { + warn!( + "ACP client process tree kill exited unsuccessfully: id={} pid={} status={}", + client_id, pid, status + ); + } + Err(error) => { + warn!( + "Failed to kill ACP client process tree: id={} pid={} error={}", + client_id, pid, error + ); + } + } + } + + if let Err(error) = child.start_kill() { + warn!( + "Failed to kill ACP client process: id={} error={}", + client_id, error + ); + } + let _ = child.wait().await; +} + +async fn close_or_cancel_remote_session( + client: &AcpClientConnection, + connection: Option>, + remote_session_id: &str, + supports_close: bool, +) { + let connection = match connection { + Some(connection) => connection, + None => match client.connection().await { + Ok(connection) => connection, + Err(error) => { + warn!( + "Failed to release ACP session because client is disconnected: client_id={} remote_session_id={} error={}", + client.id, remote_session_id, error + ); + return; + } + }, + }; + + if supports_close { + let close = connection + .send_request(CloseSessionRequest::new(remote_session_id.to_string())) + .block_task(); + match tokio::time::timeout(SESSION_CLOSE_TIMEOUT, close).await { + Ok(Ok(_)) => { + debug!( + "ACP remote session closed: client_id={} remote_session_id={}", + client.id, remote_session_id + ); + } + Ok(Err(error)) => { + warn!( + "Failed to close ACP remote session: client_id={} remote_session_id={} error={}", + client.id, remote_session_id, error + ); + } + Err(_) => { + warn!( + "Timed out closing ACP remote session: client_id={} remote_session_id={} timeout_ms={}", + client.id, + remote_session_id, + SESSION_CLOSE_TIMEOUT.as_millis() + ); + } + } + } else if let Err(error) = connection + .send_notification(CancelNotification::new(remote_session_id.to_string())) + .map_err(protocol_error) + { + warn!( + "Failed to cancel ACP remote session during release: client_id={} remote_session_id={} error={}", + client.id, remote_session_id, error + ); + } +} + +fn new_session_response_from_load( + remote_session_id: &str, + response: LoadSessionResponse, +) -> NewSessionResponse { + NewSessionResponse::new(remote_session_id.to_string()) + .modes(response.modes) + .models(response.models) + .config_options(response.config_options) + .meta(response.meta) +} + +fn new_session_response_from_resume( + remote_session_id: &str, + response: ResumeSessionResponse, +) -> NewSessionResponse { + NewSessionResponse::new(remote_session_id.to_string()) + .modes(response.modes) + .models(response.models) + .config_options(response.config_options) + .meta(response.meta) +} + +async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSession) { + if !session.discard_pending_updates_before_next_prompt { + return; + } + + session.discard_pending_updates_before_next_prompt = false; + let Some(active) = session.active.as_mut() else { + return; + }; + + let started_at = Instant::now(); + let mut discarded_count = 0usize; + while started_at.elapsed() < LOAD_REPLAY_DRAIN_MAX_DURATION { + match tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await { + Ok(Ok(_)) => { + discarded_count += 1; + } + Ok(Err(error)) => { + warn!( + "Failed to discard ACP load replay update before prompt: error={}", + error + ); + break; + } + Err(_) => break, + } + } + + if discarded_count > 0 { + info!( + "Discarded ACP load replay updates before prompt: count={}", + discarded_count + ); + } +} + +fn protocol_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(format!("ACP protocol error: {}", error)) +} + +fn select_permission_by_kind( + request: &RequestPermissionRequest, + preferred: PermissionOptionKind, + approve: bool, +) -> RequestPermissionResponse { + let fallback_kind = if approve { + PermissionOptionKind::AllowAlways + } else { + PermissionOptionKind::RejectAlways + }; + let option_id = request + .options + .iter() + .find(|option| option.kind == preferred) + .or_else(|| { + request + .options + .iter() + .find(|option| option.kind == fallback_kind) + }) + .map(|option| option.option_id.to_string()) + .unwrap_or_else(|| select_permission_option_id(&request.options, approve)); + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )) +} + +fn select_permission_option_id(options: &[PermissionOption], approve: bool) -> String { + let preferred_kinds = if approve { + [ + PermissionOptionKind::AllowOnce, + PermissionOptionKind::AllowAlways, + ] + } else { + [ + PermissionOptionKind::RejectOnce, + PermissionOptionKind::RejectAlways, + ] + }; + + options + .iter() + .find(|option| preferred_kinds.contains(&option.kind)) + .or_else(|| options.first()) + .map(|option| option.option_id.to_string()) + .unwrap_or_else(|| { + if approve { + "allow_once".to_string() + } else { + "reject_once".to_string() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_actual_permission_option_id_for_approval() { + let options = vec![ + PermissionOption::new("deny", "Deny", PermissionOptionKind::RejectOnce), + PermissionOption::new("yes-once", "Allow", PermissionOptionKind::AllowOnce), + ]; + + assert_eq!(select_permission_option_id(&options, true), "yes-once"); + } + + #[test] + fn selects_actual_permission_option_id_for_rejection() { + let options = vec![ + PermissionOption::new("allow-always", "Allow", PermissionOptionKind::AllowAlways), + PermissionOption::new("no-once", "Reject", PermissionOptionKind::RejectOnce), + ]; + + assert_eq!(select_permission_option_id(&options, false), "no-once"); + } +} diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs new file mode 100644 index 000000000..7c61197d1 --- /dev/null +++ b/src/crates/acp/src/client/mod.rs @@ -0,0 +1,20 @@ +mod config; +mod manager; +mod remote_session; +mod requirements; +mod session_options; +mod session_persistence; +mod stream; +mod tool; +mod tool_card_bridge; + +pub use config::{ + AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, + AcpClientRequirementProbe, AcpClientStatus, AcpRequirementProbeItem, +}; +pub use manager::{ + AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, + SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, +}; +pub use session_options::{AcpSessionModelOption, AcpSessionOptions}; +pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/remote_session.rs b/src/crates/acp/src/client/remote_session.rs new file mode 100644 index 000000000..ba90f4e24 --- /dev/null +++ b/src/crates/acp/src/client/remote_session.rs @@ -0,0 +1,100 @@ +use agent_client_protocol::schema::AgentCapabilities; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AcpRemoteSessionStrategy { + New, + Load, + Resume, +} + +impl AcpRemoteSessionStrategy { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::New => "new", + Self::Load => "load", + Self::Resume => "resume", + } + } +} + +pub(super) fn preferred_resume_strategies( + capabilities: Option<&AgentCapabilities>, + remote_session_id: Option<&str>, +) -> Vec { + let mut strategies = Vec::new(); + let has_remote_session_id = remote_session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + + if has_remote_session_id { + // Prefer loading saved session state over resuming a live stream. Some + // ACP clients continue an unfinished prompt on resume, and ACP update + // notifications are only scoped to the remote session, not a BitFun turn. + if capabilities + .map(|capabilities| capabilities.load_session) + .unwrap_or(false) + { + strategies.push(AcpRemoteSessionStrategy::Load); + } + + if capabilities + .and_then(|capabilities| capabilities.session_capabilities.resume.as_ref()) + .is_some() + { + strategies.push(AcpRemoteSessionStrategy::Resume); + } + } + + strategies.push(AcpRemoteSessionStrategy::New); + strategies +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn falls_back_to_new_without_remote_session_id() { + assert_eq!( + preferred_resume_strategies(Some(&AgentCapabilities::new().load_session(true)), None), + vec![AcpRemoteSessionStrategy::New] + ); + } + + #[test] + fn prefers_load_when_resume_is_not_supported() { + assert_eq!( + preferred_resume_strategies( + Some(&AgentCapabilities::new().load_session(true)), + Some("s1") + ), + vec![ + AcpRemoteSessionStrategy::Load, + AcpRemoteSessionStrategy::New + ] + ); + } + + #[test] + fn prefers_load_before_resume_when_both_are_supported() { + assert_eq!( + preferred_resume_strategies( + Some( + &AgentCapabilities::new() + .load_session(true) + .session_capabilities( + agent_client_protocol::schema::SessionCapabilities::new().resume( + agent_client_protocol::schema::SessionResumeCapabilities::new(), + ), + ), + ), + Some("s1") + ), + vec![ + AcpRemoteSessionStrategy::Load, + AcpRemoteSessionStrategy::Resume, + AcpRemoteSessionStrategy::New + ] + ); + } +} diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs new file mode 100644 index 000000000..5ff54efb3 --- /dev/null +++ b/src/crates/acp/src/client/requirements.rs @@ -0,0 +1,479 @@ +use std::collections::{HashMap, HashSet}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use tokio::process::Command; + +use super::config::{AcpClientConfig, AcpRequirementProbeItem}; + +const REQUIREMENT_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const ADAPTER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); +const CLI_INSTALL_TIMEOUT: Duration = Duration::from_secs(600); + +pub(crate) struct AcpRequirementSpec<'a> { + pub(crate) tool_command: &'a str, + pub(crate) install_package: Option<&'a str>, + pub(crate) adapter: Option>, +} + +pub(crate) struct AcpAdapterSpec<'a> { + pub(crate) package: &'a str, + pub(crate) bin: &'a str, +} + +pub(crate) fn acp_requirement_spec<'a>( + client_id: &'a str, + config: Option<&'a AcpClientConfig>, +) -> AcpRequirementSpec<'a> { + match client_id { + "claude-code" => AcpRequirementSpec { + tool_command: "claude", + install_package: Some("@anthropic-ai/claude-code"), + adapter: Some(AcpAdapterSpec { + package: "@zed-industries/claude-code-acp", + bin: "claude-code-acp", + }), + }, + "codex" => AcpRequirementSpec { + tool_command: "codex", + install_package: Some("@openai/codex"), + adapter: Some(AcpAdapterSpec { + package: "@zed-industries/codex-acp", + bin: "codex-acp", + }), + }, + "opencode" => AcpRequirementSpec { + tool_command: "opencode", + install_package: Some("opencode-ai"), + adapter: None, + }, + _ => AcpRequirementSpec { + tool_command: config + .map(|config| config.command.as_str()) + .unwrap_or(client_id), + install_package: None, + adapter: None, + }, + } +} + +pub(crate) async fn probe_executable(command: &str) -> AcpRequirementProbeItem { + let path = find_executable(command); + let mut item = AcpRequirementProbeItem { + name: command.to_string(), + installed: path.is_some(), + version: None, + path: path.as_ref().map(|path| path.to_string_lossy().to_string()), + error: None, + }; + + if let Some(path) = path { + match run_command_with_timeout(path.as_os_str(), ["--version"], REQUIREMENT_PROBE_TIMEOUT) + .await + { + Ok(output) if output.status.success() => { + item.version = parse_version_text(&output.stdout) + .or_else(|| parse_version_text(&output.stderr)); + } + Ok(output) => { + item.error = Some(command_error_summary(&output.stderr, &output.stdout)); + } + Err(error) => { + item.error = Some(error); + } + } + } + + item +} + +pub(crate) async fn probe_npm_adapter(package: &str, bin: &str) -> AcpRequirementProbeItem { + let npm_path = find_executable("npm"); + let mut item = AcpRequirementProbeItem { + name: package.to_string(), + installed: false, + version: None, + path: None, + error: None, + }; + let Some(npm_path) = npm_path else { + item.error = Some("npm is not available on PATH".to_string()); + return item; + }; + + let global_args = ["ls", "-g", "--json", "--depth=0", package]; + match run_command_with_timeout(npm_path.as_os_str(), global_args, REQUIREMENT_PROBE_TIMEOUT) + .await + { + Ok(output) if output.status.success() => { + if let Some(version) = npm_ls_package_version(&output.stdout, package) { + item.installed = true; + item.version = Some(version); + item.path = Some("npm global".to_string()); + return item; + } + } + Ok(output) => { + item.error = Some(command_error_summary(&output.stderr, &output.stdout)); + } + Err(error) => { + item.error = Some(error); + } + } + + let offline_args = vec![ + "exec".to_string(), + "--offline".to_string(), + "--yes".to_string(), + format!("--package={package}"), + "--".to_string(), + bin.to_string(), + "--help".to_string(), + ]; + match run_command_with_timeout( + npm_path.as_os_str(), + offline_args.iter().map(String::as_str), + REQUIREMENT_PROBE_TIMEOUT, + ) + .await + { + Ok(output) if output.status.success() => { + item.installed = true; + item.path = Some("npm offline cache".to_string()); + item.error = None; + } + Ok(output) => { + item.error = Some(command_error_summary(&output.stderr, &output.stdout)); + } + Err(error) => { + item.error = Some(error); + } + } + + if find_executable("npx").is_some() { + item.installed = true; + item.path = Some("npx auto-install".to_string()); + item.error = None; + } + + item +} + +pub(crate) async fn predownload_npm_adapter(package: &str, bin: &str) -> BitFunResult<()> { + let npm_path = find_executable("npm") + .ok_or_else(|| BitFunError::service("npm is not available on PATH".to_string()))?; + let args = vec![ + "exec".to_string(), + "--yes".to_string(), + format!("--package={package}"), + "--".to_string(), + bin.to_string(), + "--help".to_string(), + ]; + + match run_command_with_timeout( + npm_path.as_os_str(), + args.iter().map(String::as_str), + ADAPTER_DOWNLOAD_TIMEOUT, + ) + .await + { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(BitFunError::service(format!( + "Failed to predownload ACP adapter '{}': {}", + package, + command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to predownload ACP adapter '{}': {}", + package, error + ))), + } +} + +pub(crate) async fn install_npm_cli_package(package: &str) -> BitFunResult<()> { + let npm_path = find_executable("npm") + .ok_or_else(|| BitFunError::service("npm is not available on PATH".to_string()))?; + let args = ["install", "-g", package]; + + match run_command_with_timeout(npm_path.as_os_str(), args, CLI_INSTALL_TIMEOUT).await { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(BitFunError::service(format!( + "Failed to install ACP agent CLI '{}': {}", + package, + command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to install ACP agent CLI '{}': {}", + package, error + ))), + } +} + +pub(crate) fn resolve_configured_command( + command: &str, + extra_env: &HashMap, +) -> PathBuf { + let configured_path = configured_path_value(extra_env); + find_executable_with_path(command, configured_path.as_deref()) + .unwrap_or_else(|| PathBuf::from(command)) +} + +pub(crate) fn apply_command_environment( + command: &mut Command, + extra_env: Option<&HashMap>, +) { + let configured_path = extra_env.and_then(configured_path_value); + let search_path = joined_command_search_path(configured_path.as_deref()); + if !search_path.is_empty() { + command.env("PATH", search_path); + } + + if let Some(extra_env) = extra_env { + for (key, value) in extra_env { + if !key.eq_ignore_ascii_case("PATH") { + command.env(key, value); + } + } + } +} + +async fn run_command_with_timeout( + program: &OsStr, + args: I, + timeout: Duration, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut command = Command::new(program); + command.args(args); + apply_command_environment(&mut command, None); + match tokio::time::timeout(timeout, command.output()).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("Timed out while checking command".to_string()), + } +} + +fn npm_ls_package_version(stdout: &[u8], package: &str) -> Option { + let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; + value + .get("dependencies")? + .get(package)? + .get("version")? + .as_str() + .map(ToString::to_string) +} + +fn parse_version_text(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(ToString::to_string) +} + +fn command_error_summary(stderr: &[u8], stdout: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr).trim().to_string(); + if !stderr.is_empty() { + return truncate_error(stderr); + } + let stdout = String::from_utf8_lossy(stdout).trim().to_string(); + if !stdout.is_empty() { + return truncate_error(stdout); + } + "Command exited unsuccessfully".to_string() +} + +fn truncate_error(value: String) -> String { + const MAX_LEN: usize = 240; + if value.chars().count() <= MAX_LEN { + return value; + } + format!("{}...", value.chars().take(MAX_LEN).collect::()) +} + +fn find_executable(command: &str) -> Option { + find_executable_with_path(command, None) +} + +fn find_executable_with_path(command: &str, configured_path: Option<&OsStr>) -> Option { + let command_path = PathBuf::from(command); + if command_path.components().count() > 1 { + return executable_file(&command_path).then_some(command_path); + } + + for directory in command_search_paths(configured_path) { + for candidate in executable_candidates(&directory, command) { + if executable_file(&candidate) { + return Some(candidate); + } + } + } + None +} + +fn configured_path_value(extra_env: &HashMap) -> Option { + extra_env + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .map(|(_, value)| OsString::from(value)) +} + +fn joined_command_search_path(configured_path: Option<&OsStr>) -> OsString { + let paths = command_search_paths(configured_path); + if paths.is_empty() { + return OsString::new(); + } + env::join_paths(paths).unwrap_or_else(|_| env::var_os("PATH").unwrap_or_default()) +} + +fn command_search_paths(configured_path: Option<&OsStr>) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + if let Some(configured_path) = configured_path { + push_split_paths(&mut paths, &mut seen, configured_path); + } + if let Some(env_path) = env::var_os("PATH") { + push_split_paths(&mut paths, &mut seen, &env_path); + } + + push_user_bin_paths(&mut paths, &mut seen); + push_system_bin_paths(&mut paths, &mut seen); + paths +} + +fn push_split_paths(paths: &mut Vec, seen: &mut HashSet, value: &OsStr) { + for directory in env::split_paths(value) { + push_search_path(paths, seen, directory); + } +} + +fn push_user_bin_paths(paths: &mut Vec, seen: &mut HashSet) { + let Some(home) = env::var_os("HOME") else { + return; + }; + let home = PathBuf::from(home); + push_existing_search_path(paths, seen, home.join(".local/bin")); + push_existing_search_path(paths, seen, home.join(".cargo/bin")); + push_existing_search_path(paths, seen, home.join(".npm-global/bin")); +} + +fn push_system_bin_paths(paths: &mut Vec, seen: &mut HashSet) { + #[cfg(target_os = "macos")] + { + for prefix in ["/opt/homebrew", "/usr/local"] { + push_existing_search_path(paths, seen, PathBuf::from(format!("{prefix}/bin"))); + push_existing_search_path(paths, seen, PathBuf::from(format!("{prefix}/sbin"))); + for node in ["node", "node@18", "node@20", "node@22", "node@24"] { + push_existing_search_path( + paths, + seen, + PathBuf::from(format!("{prefix}/opt/{node}/bin")), + ); + } + } + } +} + +fn push_existing_search_path( + paths: &mut Vec, + seen: &mut HashSet, + path: PathBuf, +) { + if path.is_dir() { + push_search_path(paths, seen, path); + } +} + +fn push_search_path(paths: &mut Vec, seen: &mut HashSet, path: PathBuf) { + if path.as_os_str().is_empty() { + return; + } + + let key = search_path_key(&path); + if seen.insert(key) { + paths.push(path); + } +} + +fn search_path_key(path: &Path) -> OsString { + #[cfg(windows)] + { + OsString::from(path.to_string_lossy().to_ascii_lowercase()) + } + #[cfg(not(windows))] + { + path.as_os_str().to_os_string() + } +} + +fn executable_candidates(directory: &Path, command: &str) -> Vec { + #[cfg(windows)] + { + let command_path = PathBuf::from(command); + if command_path.extension().is_some() { + return vec![directory.join(command)]; + } + let extensions = env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".EXE;.BAT;.CMD")); + extensions + .to_string_lossy() + .split(';') + .filter(|extension| !extension.is_empty()) + .map(|extension| directory.join(format!("{command}{extension}"))) + .collect() + } + + #[cfg(not(windows))] + { + vec![directory.join(command)] + } +} + +fn executable_file(path: &Path) -> bool { + path.is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_search_paths_keep_configured_path_first() { + let configured_paths = env::join_paths([ + PathBuf::from("/tmp/bitfun-acp-first"), + PathBuf::from("/tmp/bitfun-acp-second"), + ]) + .expect("test paths should be joinable"); + + let paths = command_search_paths(Some(&configured_paths)); + + assert_eq!(paths.first(), Some(&PathBuf::from("/tmp/bitfun-acp-first"))); + assert_eq!(paths.get(1), Some(&PathBuf::from("/tmp/bitfun-acp-second"))); + } + + #[test] + fn find_executable_uses_configured_path() { + let test_dir = env::temp_dir().join(format!("bitfun-acp-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&test_dir).expect("test dir should be created"); + + #[cfg(windows)] + let file_name = "bitfun-test-tool.EXE"; + #[cfg(not(windows))] + let file_name = "bitfun-test-tool"; + + let executable = test_dir.join(file_name); + std::fs::write(&executable, b"").expect("test executable should be written"); + + let found = find_executable_with_path("bitfun-test-tool", Some(test_dir.as_os_str())); + + let _ = std::fs::remove_dir_all(&test_dir); + assert_eq!(found, Some(executable)); + } +} diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs new file mode 100644 index 000000000..3f6ea88b5 --- /dev/null +++ b/src/crates/acp/src/client/session_options.rs @@ -0,0 +1,150 @@ +use agent_client_protocol::schema::{ + ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectOptions, SessionModelState, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionOptions { + #[serde(default)] + pub current_model_id: Option, + #[serde(default)] + pub available_models: Vec, + #[serde(default)] + pub model_config_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionModelOption { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, +} + +pub(super) fn session_options_from_state( + models: Option<&SessionModelState>, + config_options: &[SessionConfigOption], +) -> AcpSessionOptions { + if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { + return AcpSessionOptions { + current_model_id: Some(models.current_model_id.to_string()), + available_models: models + .available_models + .iter() + .map(model_option_from_model_info) + .collect(), + model_config_id: None, + }; + } + + model_config_option(config_options) + .map(|option| { + let (current_model_id, available_models) = select_model_values(option); + AcpSessionOptions { + current_model_id, + available_models, + model_config_id: Some(option.id.to_string()), + } + }) + .unwrap_or_default() +} + +pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { + model_config_option(config_options).map(|option| option.id.to_string()) +} + +fn model_option_from_model_info(model: &ModelInfo) -> AcpSessionModelOption { + AcpSessionModelOption { + id: model.model_id.to_string(), + name: model.name.clone(), + description: model.description.clone(), + } +} + +fn model_config_option(config_options: &[SessionConfigOption]) -> Option<&SessionConfigOption> { + config_options + .iter() + .find(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model))) + .or_else(|| { + config_options.iter().find(|option| { + let id = option.id.to_string().to_ascii_lowercase(); + let name = option.name.to_ascii_lowercase(); + id == "model" || id.ends_with("_model") || name.contains("model") + }) + }) + .filter(|option| matches!(option.kind, SessionConfigKind::Select(_))) +} + +fn select_model_values( + option: &SessionConfigOption, +) -> (Option, Vec) { + let SessionConfigKind::Select(select) = &option.kind else { + return (None, Vec::new()); + }; + + let models = match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(|option| AcpSessionModelOption { + id: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + }) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group.options.iter().map(|option| AcpSessionModelOption { + id: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + }) + }) + .collect(), + _ => Vec::new(), + }; + + (Some(select.current_value.to_string()), models) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::{ModelInfo, SessionConfigOption}; + + #[test] + fn converts_native_model_state() { + let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); + + let options = session_options_from_state(Some(&state), &[]); + + assert_eq!(options.current_model_id.as_deref(), Some("gpt-5.4")); + assert_eq!(options.available_models.len(), 1); + assert_eq!(options.available_models[0].name, "GPT 5.4"); + assert!(options.model_config_id.is_none()); + } + + #[test] + fn converts_model_config_option_fallback() { + let config = SessionConfigOption::select( + "model", + "Model", + "fast", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new("fast", "Fast"), + agent_client_protocol::schema::SessionConfigSelectOption::new("smart", "Smart"), + ], + ) + .category(SessionConfigOptionCategory::Model); + + let options = session_options_from_state(None, &[config]); + + assert_eq!(options.current_model_id.as_deref(), Some("fast")); + assert_eq!(options.model_config_id.as_deref(), Some("model")); + assert_eq!(options.available_models.len(), 2); + assert_eq!(options.available_models[1].id, "smart"); + } +} diff --git a/src/crates/acp/src/client/session_persistence.rs b/src/crates/acp/src/client/session_persistence.rs new file mode 100644 index 000000000..6b53e5fc4 --- /dev/null +++ b/src/crates/acp/src/client/session_persistence.rs @@ -0,0 +1,181 @@ +use std::path::Path; +use std::sync::Arc; + +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::session::SessionMetadata; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub(super) const CUSTOM_METADATA_PROVIDER_KEY: &str = "provider"; +pub(super) const CUSTOM_METADATA_PROVIDER_VALUE: &str = "acp"; +pub(super) const CUSTOM_METADATA_CLIENT_ID_KEY: &str = "acpClientId"; +pub(super) const CUSTOM_METADATA_REMOTE_SESSION_ID_KEY: &str = "acpRemoteSessionId"; +pub(super) const CUSTOM_METADATA_RESUME_STRATEGY_KEY: &str = "acpResumeStrategy"; +pub(super) const CUSTOM_METADATA_LAST_RESUME_ERROR_KEY: &str = "acpLastResumeError"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAcpFlowSessionRecordResponse { + pub session_id: String, + pub session_name: String, + pub agent_type: String, +} + +pub(super) struct AcpSessionPersistence { + manager: PersistenceManager, +} + +impl AcpSessionPersistence { + pub(super) fn new(path_manager: Arc) -> BitFunResult { + Ok(Self { + manager: PersistenceManager::new(path_manager)?, + }) + } + + pub(super) async fn create_flow_session_record( + &self, + session_storage_path: &Path, + workspace_path: &str, + client_id: &str, + session_name: Option, + ) -> BitFunResult { + let session_id = format!("acp_{}_{}", client_id, uuid::Uuid::new_v4()); + let agent_type = format!("acp:{}", client_id); + let session_name = session_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("{} ACP", client_id)); + + let mut metadata = SessionMetadata::new( + session_id.clone(), + session_name.clone(), + agent_type.clone(), + "auto".to_string(), + ); + metadata.workspace_path = Some(workspace_path.to_string()); + metadata.custom_metadata = Some(json!({ + "kind": "normal", + CUSTOM_METADATA_PROVIDER_KEY: CUSTOM_METADATA_PROVIDER_VALUE, + CUSTOM_METADATA_CLIENT_ID_KEY: client_id, + CUSTOM_METADATA_REMOTE_SESSION_ID_KEY: null, + CUSTOM_METADATA_RESUME_STRATEGY_KEY: null, + CUSTOM_METADATA_LAST_RESUME_ERROR_KEY: null, + })); + + self.manager + .save_session_metadata(session_storage_path, &metadata) + .await?; + + Ok(CreateAcpFlowSessionRecordResponse { + session_id, + session_name, + agent_type, + }) + } + + pub(super) async fn delete_flow_session_record( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + ) -> BitFunResult<()> { + self.manager + .delete_session(session_storage_path, bitfun_session_id) + .await + } + + pub(super) async fn load_remote_session_id( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + ) -> BitFunResult> { + let Some(metadata) = self + .manager + .load_session_metadata(session_storage_path, bitfun_session_id) + .await? + else { + return Ok(None); + }; + + Ok(metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(CUSTOM_METADATA_REMOTE_SESSION_ID_KEY)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string)) + } + + pub(super) async fn update_remote_session_state( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + remote_session_id: &str, + resume_strategy: &str, + last_resume_error: Option, + ) -> BitFunResult<()> { + self.update_metadata(session_storage_path, bitfun_session_id, |metadata| { + let mut custom = metadata.custom_metadata.take().unwrap_or_else(|| json!({})); + ensure_object(&mut custom)?; + custom[CUSTOM_METADATA_PROVIDER_KEY] = json!(CUSTOM_METADATA_PROVIDER_VALUE); + custom[CUSTOM_METADATA_REMOTE_SESSION_ID_KEY] = json!(remote_session_id); + custom[CUSTOM_METADATA_RESUME_STRATEGY_KEY] = json!(resume_strategy); + custom[CUSTOM_METADATA_LAST_RESUME_ERROR_KEY] = + last_resume_error.map(Value::String).unwrap_or(Value::Null); + metadata.custom_metadata = Some(custom); + metadata.touch(); + Ok(()) + }) + .await + } + + pub(super) async fn update_model_id( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + model_id: &str, + ) -> BitFunResult<()> { + self.update_metadata(session_storage_path, bitfun_session_id, |metadata| { + metadata.model_name = model_id.to_string(); + metadata.touch(); + Ok(()) + }) + .await + } + + async fn update_metadata( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + update: impl FnOnce(&mut SessionMetadata) -> BitFunResult<()>, + ) -> BitFunResult<()> { + let Some(mut metadata) = self + .manager + .load_session_metadata(session_storage_path, bitfun_session_id) + .await? + else { + return Ok(()); + }; + + update(&mut metadata)?; + self.manager + .save_session_metadata(session_storage_path, &metadata) + .await + } +} + +fn ensure_object(value: &mut Value) -> BitFunResult<()> { + if value.is_object() { + return Ok(()); + } + + *value = json!({}); + if value.is_object() { + Ok(()) + } else { + Err(BitFunError::service( + "Failed to initialize ACP session custom metadata".to_string(), + )) + } +} diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs new file mode 100644 index 000000000..d6a38261c --- /dev/null +++ b/src/crates/acp/src/client/stream.rs @@ -0,0 +1,360 @@ +use agent_client_protocol::schema::{ + ContentBlock, ContentChunk, SessionNotification, SessionUpdate, ToolCall, ToolCallContent, + ToolCallStatus, ToolCallUpdate, +}; +use agent_client_protocol::util::MatchDispatch; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use bitfun_events::ToolEventData; + +use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; + +#[derive(Debug, Clone)] +pub enum AcpClientStreamEvent { + ModelRoundStarted { + round_id: String, + round_index: usize, + disable_explore_grouping: bool, + }, + AgentText(String), + AgentThought(String), + ToolEvent(ToolEventData), + Completed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AcpStreamItemKind { + Text, + Tool, +} + +#[derive(Debug, Default)] +pub(super) struct AcpStreamRoundTracker { + next_round_index: usize, + last_item_kind: Option, +} + +impl AcpStreamRoundTracker { + pub(super) fn new() -> Self { + Self::default() + } + + pub(super) fn apply(&mut self, event: AcpClientStreamEvent) -> Vec { + match event { + AcpClientStreamEvent::AgentText(_) | AcpClientStreamEvent::AgentThought(_) => { + let mut events = Vec::new(); + if self.last_item_kind.is_none() + || self.last_item_kind == Some(AcpStreamItemKind::Tool) + { + events.push(self.next_round_started_event()); + } + self.last_item_kind = Some(AcpStreamItemKind::Text); + events.push(event); + events + } + AcpClientStreamEvent::ToolEvent(_) => { + let mut events = Vec::new(); + if self.last_item_kind.is_none() { + events.push(self.next_round_started_event()); + } + self.last_item_kind = Some(AcpStreamItemKind::Tool); + events.push(event); + events + } + AcpClientStreamEvent::ModelRoundStarted { .. } + | AcpClientStreamEvent::Completed + | AcpClientStreamEvent::Cancelled => vec![event], + } + } + + fn next_round_started_event(&mut self) -> AcpClientStreamEvent { + let round_index = self.next_round_index; + self.next_round_index += 1; + AcpClientStreamEvent::ModelRoundStarted { + round_id: format!( + "round_{}_{}", + chrono::Utc::now().timestamp_millis(), + uuid::Uuid::new_v4() + ), + round_index, + disable_explore_grouping: true, + } + } +} + +pub async fn acp_dispatch_to_stream_events( + dispatch: agent_client_protocol::Dispatch, +) -> BitFunResult> { + let mut events = Vec::new(); + MatchDispatch::new(dispatch) + .if_notification(async |notification: SessionNotification| { + match notification.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let Some(text) = content_chunk_text(chunk) { + events.push(AcpClientStreamEvent::AgentText(text)); + } + } + SessionUpdate::AgentThoughtChunk(chunk) => { + if let Some(text) = content_chunk_text(chunk) { + events.push(AcpClientStreamEvent::AgentThought(text)); + } + } + SessionUpdate::ToolCall(tool_call) => { + events.extend(acp_tool_call_events(tool_call)); + } + SessionUpdate::ToolCallUpdate(tool_call_update) => { + if let Some(event) = acp_tool_call_update_event(tool_call_update) { + events.push(event); + } + } + _ => {} + } + Ok(()) + }) + .await + .otherwise_ignore() + .map_err(protocol_error)?; + Ok(events) +} + +fn content_chunk_text(chunk: ContentChunk) -> Option { + match chunk.content { + ContentBlock::Text(text) => Some(text.text), + _ => None, + } +} + +fn acp_tool_call_events(tool_call: ToolCall) -> Vec { + let tool_id = tool_call.tool_call_id.to_string(); + let tool_name = acp_tool_name( + &tool_call.title, + tool_call.raw_input.as_ref(), + Some(&tool_call.kind), + ); + let params = normalize_tool_params( + &tool_name, + tool_call.raw_input.clone().unwrap_or_else(|| { + serde_json::json!({ + "title": tool_call.title, + "kind": format!("{:?}", tool_call.kind), + }) + }), + ); + + let mut events = vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params, + timeout_seconds: None, + })]; + + match tool_call.status { + ToolCallStatus::Completed => { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + tool_id, + tool_name, + result: acp_tool_result_value( + tool_call.raw_output, + Some(tool_call.content), + Some(tool_call.locations), + ), + result_for_assistant: None, + duration_ms: 0, + })); + } + ToolCallStatus::Failed => { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + tool_id, + tool_name, + error: acp_tool_error_text(tool_call.raw_output, tool_call.content), + })); + } + ToolCallStatus::Pending | ToolCallStatus::InProgress => {} + _ => {} + } + + events +} + +fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { + let tool_id = update.tool_call_id.to_string(); + let title = update.fields.title.unwrap_or_else(|| tool_id.clone()); + let tool_name = acp_tool_name( + &title, + update.fields.raw_input.as_ref(), + update.fields.kind.as_ref(), + ); + + match update.fields.status { + Some(ToolCallStatus::Completed) => { + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + tool_id, + tool_name, + result: acp_tool_result_value( + update.fields.raw_output, + update.fields.content, + update.fields.locations, + ), + result_for_assistant: None, + duration_ms: 0, + })) + } + Some(ToolCallStatus::Failed) => { + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + tool_id, + tool_name, + error: acp_tool_error_text( + update.fields.raw_output, + update.fields.content.unwrap_or_default(), + ), + })) + } + Some(ToolCallStatus::InProgress) | Some(ToolCallStatus::Pending) | Some(_) => { + let params = normalize_tool_params( + &tool_name, + update.fields.raw_input.unwrap_or_else(|| { + serde_json::json!({ + "title": title, + }) + }), + ); + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: None, + })) + } + None => update.fields.raw_input.map(|params| { + let params = normalize_tool_params(&tool_name, params); + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: None, + }) + }), + } +} + +fn acp_tool_result_value( + raw_output: Option, + content: Option>, + locations: Option>, +) -> serde_json::Value { + if let Some(raw_output) = raw_output { + return raw_output; + } + + let content = content.unwrap_or_default(); + let locations = locations.unwrap_or_default(); + if content.is_empty() && locations.is_empty() { + return serde_json::Value::Null; + } + + serde_json::json!({ + "content": content, + "locations": locations, + }) +} + +fn acp_tool_error_text( + raw_output: Option, + content: Vec, +) -> String { + if let Some(raw_output) = raw_output { + return value_to_display_text(&raw_output); + } + if !content.is_empty() { + return serde_json::to_string_pretty(&content).unwrap_or_else(|_| { + serde_json::to_string(&content).unwrap_or_else(|_| "ACP tool failed".to_string()) + }); + } + "ACP tool failed".to_string() +} + +fn value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn protocol_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(format!("ACP protocol error: {}", error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tool_event(id: &str) -> AcpClientStreamEvent { + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: id.to_string(), + tool_name: "Bash".to_string(), + params: json!({ "command": "echo ok" }), + timeout_seconds: None, + }) + } + + fn event_kinds(events: &[AcpClientStreamEvent]) -> Vec<&'static str> { + events + .iter() + .map(|event| match event { + AcpClientStreamEvent::ModelRoundStarted { .. } => "round", + AcpClientStreamEvent::AgentText(_) => "text", + AcpClientStreamEvent::AgentThought(_) => "thought", + AcpClientStreamEvent::ToolEvent(_) => "tool", + AcpClientStreamEvent::Completed => "completed", + AcpClientStreamEvent::Cancelled => "cancelled", + }) + .collect() + } + + #[test] + fn starts_new_round_for_text_after_tool() { + let mut tracker = AcpStreamRoundTracker::new(); + let mut events = Vec::new(); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("before".to_string()))); + events.extend(tracker.apply(tool_event("tool-1"))); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("after".to_string()))); + + assert_eq!( + event_kinds(&events), + vec!["round", "text", "tool", "round", "text"] + ); + assert!(matches!( + events[0], + AcpClientStreamEvent::ModelRoundStarted { round_index: 0, .. } + )); + assert!(matches!( + events[3], + AcpClientStreamEvent::ModelRoundStarted { round_index: 1, .. } + )); + } + + #[test] + fn keeps_consecutive_tools_in_one_round_before_text() { + let mut tracker = AcpStreamRoundTracker::new(); + let mut events = Vec::new(); + events.extend(tracker.apply(tool_event("tool-1"))); + events.extend(tracker.apply(tool_event("tool-2"))); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("done".to_string()))); + + assert_eq!( + event_kinds(&events), + vec!["round", "tool", "tool", "round", "text"] + ); + } + + #[test] + fn keeps_consecutive_text_in_one_round() { + let mut tracker = AcpStreamRoundTracker::new(); + let mut events = Vec::new(); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("a".to_string()))); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("b".to_string()))); + + assert_eq!(event_kinds(&events), vec!["round", "text", "text"]); + } +} diff --git a/src/crates/acp/src/client/tool.rs b/src/crates/acp/src/client/tool.rs new file mode 100644 index 000000000..cff7f94c8 --- /dev/null +++ b/src/crates/acp/src/client/tool.rs @@ -0,0 +1,226 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_core::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde_json::{json, Value}; + +use super::config::AcpClientConfig; +use super::manager::AcpClientService; + +pub struct AcpAgentTool { + client_id: String, + config: AcpClientConfig, + service: Arc, + full_name: String, +} + +impl AcpAgentTool { + pub fn new(client_id: String, config: AcpClientConfig, service: Arc) -> Self { + let full_name = Self::tool_name_for(&client_id); + Self { + client_id, + config, + service, + full_name, + } + } + + pub fn tool_name_for(client_id: &str) -> String { + format!("acp__{}__prompt", sanitize_tool_part(client_id)) + } + + fn display_name(&self) -> String { + self.config + .name + .clone() + .unwrap_or_else(|| self.client_id.clone()) + } +} + +#[async_trait] +impl Tool for AcpAgentTool { + fn name(&self) -> &str { + &self.full_name + } + + async fn description(&self) -> BitFunResult { + Ok(format!( + "Send a prompt to the external ACP agent '{}'. Use this when another local ACP-compatible agent is better suited for a delegated task.", + self.display_name() + )) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The task or question to send to the external ACP agent." + }, + "workspace_path": { + "type": "string", + "description": "Optional absolute workspace path. Defaults to the current BitFun workspace." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 0, + "description": "Optional timeout in seconds. Use 0 or omit it to wait without a fixed timeout." + } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn user_facing_name(&self) -> String { + format!("{} (ACP)", self.display_name()) + } + + fn is_readonly(&self) -> bool { + self.config.readonly + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + !self.config.readonly + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match input.get("prompt").and_then(|value| value.as_str()) { + Some(prompt) if !prompt.trim().is_empty() => ValidationResult::default(), + Some(_) => ValidationResult { + result: false, + message: Some("prompt cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }, + None => ValidationResult { + result: false, + message: Some("prompt is required".to_string()), + error_code: Some(400), + meta: None, + }, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let prompt_preview = input + .get("prompt") + .and_then(|value| value.as_str()) + .map(truncate_prompt) + .unwrap_or_else(|| "prompt".to_string()); + format!( + "Sending ACP prompt to '{}': {}", + self.display_name(), + prompt_preview + ) + } + + fn render_tool_use_rejected_message(&self) -> String { + format!("ACP prompt to '{}' was rejected", self.display_name()) + } + + fn render_tool_result_message(&self, output: &Value) -> String { + output + .get("response") + .and_then(|value| value.as_str()) + .map(|response| { + format!( + "ACP agent '{}' responded:\n{}", + self.display_name(), + response + ) + }) + .unwrap_or_else(|| format!("ACP agent '{}' completed", self.display_name())) + } + + fn render_result_for_assistant(&self, output: &Value) -> String { + output + .get("response") + .and_then(|value| value.as_str()) + .unwrap_or("ACP agent completed without text output") + .to_string() + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let prompt = input + .get("prompt") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| BitFunError::tool("prompt is required".to_string()))? + .to_string(); + + let workspace_path = input + .get("workspace_path") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }); + let timeout_seconds = input + .get("timeout_seconds") + .and_then(|value| value.as_u64()); + + let response = self + .service + .prompt_agent( + &self.client_id, + prompt, + workspace_path, + context.session_id.clone(), + None, + timeout_seconds, + ) + .await?; + + let data = json!({ + "client_id": self.client_id, + "response": response, + }); + Ok(vec![ToolResult::Result { + result_for_assistant: Some(self.render_result_for_assistant(&data)), + data, + image_attachments: None, + }]) + } +} + +fn sanitize_tool_part(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + sanitized.trim_matches('_').to_string() +} + +fn truncate_prompt(prompt: &str) -> String { + const LIMIT: usize = 160; + if prompt.chars().count() <= LIMIT { + prompt.to_string() + } else { + format!("{}...", prompt.chars().take(LIMIT).collect::()) + } +} diff --git a/src/crates/acp/src/client/tool_card_bridge.rs b/src/crates/acp/src/client/tool_card_bridge.rs new file mode 100644 index 000000000..b0d19e2a1 --- /dev/null +++ b/src/crates/acp/src/client/tool_card_bridge.rs @@ -0,0 +1,434 @@ +use agent_client_protocol::schema::ToolKind; + +pub(super) fn acp_tool_name( + title: &str, + raw_input: Option<&serde_json::Value>, + kind: Option<&ToolKind>, +) -> String { + if let Some(name) = raw_input.and_then(tool_name_from_raw_input) { + return normalize_tool_name(&name, title, raw_input, kind); + } + + normalize_tool_name("", title, raw_input, kind) +} + +pub(super) fn normalize_tool_params( + tool_name: &str, + params: serde_json::Value, +) -> serde_json::Value { + let Some(object) = params.as_object() else { + return params; + }; + + let mut normalized = object.clone(); + match tool_name { + "Bash" => { + if !normalized.contains_key("command") { + if let Some(value) = normalized.get("cmd").cloned() { + normalized.insert("command".to_string(), value); + } + } + if let Some(value) = normalized.get("command").cloned() { + normalized.insert( + "command".to_string(), + serde_json::Value::String(command_value_to_display_text(&value)), + ); + } + } + "Read" | "Write" | "Edit" | "Delete" => { + if !normalized.contains_key("file_path") { + if let Some(value) = normalized + .get("path") + .or_else(|| normalized.get("target_file")) + .or_else(|| normalized.get("targetFile")) + .or_else(|| normalized.get("filePath")) + .or_else(|| normalized.get("filename")) + .cloned() + { + normalized.insert("file_path".to_string(), value); + } + } + if tool_name == "Edit" { + if !normalized.contains_key("old_string") { + if let Some(value) = normalized.get("oldString").cloned() { + normalized.insert("old_string".to_string(), value); + } + } + if !normalized.contains_key("new_string") { + if let Some(value) = normalized.get("newString").cloned() { + normalized.insert("new_string".to_string(), value); + } + } + } + } + "LS" => { + if !normalized.contains_key("path") { + if let Some(value) = normalized + .get("directory") + .or_else(|| normalized.get("dir")) + .or_else(|| normalized.get("target_directory")) + .or_else(|| normalized.get("targetDirectory")) + .cloned() + { + normalized.insert("path".to_string(), value); + } + } + } + "Grep" => { + if !normalized.contains_key("pattern") { + if let Some(value) = normalized + .get("query") + .or_else(|| normalized.get("text")) + .or_else(|| normalized.get("search_pattern")) + .or_else(|| normalized.get("searchPattern")) + .cloned() + { + normalized.insert("pattern".to_string(), value); + } + } + } + "Glob" => { + if !normalized.contains_key("pattern") { + if let Some(value) = normalized + .get("glob") + .or_else(|| normalized.get("glob_pattern")) + .or_else(|| normalized.get("globPattern")) + .or_else(|| normalized.get("file_pattern")) + .or_else(|| normalized.get("filePattern")) + .cloned() + { + normalized.insert("pattern".to_string(), value); + } + } + } + _ => {} + } + + serde_json::Value::Object(normalized) +} + +fn tool_name_from_raw_input(raw_input: &serde_json::Value) -> Option { + let object = raw_input.as_object()?; + for key in [ + "tool", + "toolName", + "tool_name", + "name", + "function", + "action", + ] { + let Some(value) = object.get(key).and_then(|value| value.as_str()) else { + continue; + }; + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + None +} + +fn normalize_tool_name( + candidate: &str, + title: &str, + raw_input: Option<&serde_json::Value>, + kind: Option<&ToolKind>, +) -> String { + let candidate = candidate.trim(); + let normalized_candidate = normalize_known_tool_alias(candidate); + if normalized_candidate != candidate || is_native_tool_name(&normalized_candidate) { + return normalized_candidate; + } + + let title_lower = title.trim().to_ascii_lowercase(); + let candidate_lower = candidate.to_ascii_lowercase(); + let haystack = format!("{} {}", candidate_lower, title_lower); + let input = raw_input.and_then(|value| value.as_object()); + if let Some(input) = input { + if has_any_key(input, &["command", "cmd"]) { + return "Bash".to_string(); + } + if has_any_key( + input, + &[ + "glob", + "glob_pattern", + "globPattern", + "file_pattern", + "filePattern", + ], + ) { + return "Glob".to_string(); + } + if has_any_key( + input, + &["pattern", "search_pattern", "searchPattern", "query"], + ) { + if contains_any(&haystack, &["web search", "search web"]) { + return "WebSearch".to_string(); + } + return "Grep".to_string(); + } + if has_any_key( + input, + &["directory", "dir", "target_directory", "targetDirectory"], + ) { + return "LS".to_string(); + } + + let has_file_path = has_any_key( + input, + &[ + "file_path", + "filePath", + "target_file", + "targetFile", + "filename", + "path", + ], + ); + if has_file_path { + if has_any_key(input, &["content", "contents"]) { + return "Write".to_string(); + } + if has_any_key( + input, + &["old_string", "oldString", "new_string", "newString"], + ) { + return "Edit".to_string(); + } + match kind { + Some(ToolKind::Delete) => return "Delete".to_string(), + Some(ToolKind::Edit) | Some(ToolKind::Move) => return "Edit".to_string(), + Some(ToolKind::Read) => return "Read".to_string(), + _ => {} + } + } + } + + if contains_any( + &haystack, + &[ + "bash", + "shell", + "terminal", + "command", + "execute", + "exec", + "run command", + ], + ) { + return "Bash".to_string(); + } + if contains_any(&haystack, &["list", "directory", "folder", "ls"]) { + return "LS".to_string(); + } + if contains_any( + &haystack, + &["glob", "find file", "file search", "search files"], + ) { + return "Glob".to_string(); + } + if contains_any(&haystack, &["grep", "search", "ripgrep", "rg"]) { + return "Grep".to_string(); + } + if contains_any(&haystack, &["write", "create file", "new file"]) { + return "Write".to_string(); + } + if contains_any(&haystack, &["edit", "patch", "replace", "modify"]) { + return "Edit".to_string(); + } + if contains_any(&haystack, &["delete", "remove", "unlink"]) { + return "Delete".to_string(); + } + if contains_any(&haystack, &["read", "open file", "view file"]) { + return "Read".to_string(); + } + if contains_any(&haystack, &["web search", "search web"]) { + return "WebSearch".to_string(); + } + + match kind { + Some(ToolKind::Read) => "Read".to_string(), + Some(ToolKind::Edit) => "Edit".to_string(), + Some(ToolKind::Delete) => "Delete".to_string(), + Some(ToolKind::Move) => "Edit".to_string(), + Some(ToolKind::Search) => "Grep".to_string(), + Some(ToolKind::Execute) => "Bash".to_string(), + Some(ToolKind::Fetch) => "WebSearch".to_string(), + Some(ToolKind::Think) | Some(ToolKind::SwitchMode) | Some(ToolKind::Other) | Some(_) => { + fallback_tool_name(candidate, title) + } + None => fallback_tool_name(candidate, title), + } +} + +fn fallback_tool_name(candidate: &str, title: &str) -> String { + if !candidate.is_empty() { + candidate.to_string() + } else { + let title = title.trim(); + if title.is_empty() { + "ACP Tool".to_string() + } else { + title.to_string() + } + } +} + +fn normalize_known_tool_alias(name: &str) -> String { + match name.trim().to_ascii_lowercase().as_str() { + "read" | "read_file" | "readfile" | "view" | "open" => "Read".to_string(), + "ls" | "list" | "list_dir" | "list_directory" | "readdir" => "LS".to_string(), + "grep" | "rg" | "search" | "text_search" => "Grep".to_string(), + "glob" | "find" | "file_search" => "Glob".to_string(), + "bash" | "sh" | "shell" | "terminal" | "command" | "cmd" | "execute" => "Bash".to_string(), + "write" | "write_file" | "create" => "Write".to_string(), + "edit" | "patch" | "replace" | "update" => "Edit".to_string(), + "delete" | "remove" | "rm" => "Delete".to_string(), + "todowrite" | "todo_write" | "todo" => "TodoWrite".to_string(), + "websearch" | "web_search" | "search_web" => "WebSearch".to_string(), + _ => name.to_string(), + } +} + +fn is_native_tool_name(name: &str) -> bool { + matches!( + name, + "Read" + | "Write" + | "Edit" + | "Delete" + | "LS" + | "Grep" + | "Glob" + | "Bash" + | "TodoWrite" + | "WebSearch" + ) +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| value.contains(needle)) +} + +fn has_any_key(object: &serde_json::Map, keys: &[&str]) -> bool { + keys.iter().any(|key| object.contains_key(*key)) +} + +fn command_value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(items) => items + .iter() + .map(command_value_to_display_text) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "), + serde_json::Value::Number(number) => number.to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalizes_execute_tools_to_bash_card() { + let input = json!({ "command": "pnpm test" }); + assert_eq!( + acp_tool_name("Run shell command", Some(&input), Some(&ToolKind::Execute)), + "Bash" + ); + + let params = normalize_tool_params("Bash", json!({ "cmd": "ls -la" })); + assert_eq!(params["command"], "ls -la"); + } + + #[test] + fn normalizes_bash_command_arrays_to_display_string() { + let params = normalize_tool_params( + "Bash", + json!({ + "command": ["/bin/zsh", "-lc", "sed -n '1,120p' src/lib.rs"], + "cwd": "/tmp/project" + }), + ); + + assert_eq!(params["command"], "/bin/zsh -lc sed -n '1,120p' src/lib.rs"); + assert_eq!(params["cwd"], "/tmp/project"); + } + + #[test] + fn normalizes_file_tools_to_native_cards() { + let read_input = json!({ "path": "src/main.rs" }); + assert_eq!( + acp_tool_name("Read file", Some(&read_input), Some(&ToolKind::Read)), + "Read" + ); + assert_eq!( + normalize_tool_params("Read", read_input)["file_path"], + "src/main.rs" + ); + + let write_input = json!({ "path": "README.md", "content": "hello" }); + assert_eq!( + acp_tool_name("Create file", Some(&write_input), Some(&ToolKind::Edit)), + "Write" + ); + } + + #[test] + fn normalizes_search_tools_to_grep_or_glob_cards() { + let grep_input = json!({ "query": "AcpClientService" }); + assert_eq!( + acp_tool_name("Search text", Some(&grep_input), Some(&ToolKind::Search)), + "Grep" + ); + assert_eq!( + normalize_tool_params("Grep", grep_input)["pattern"], + "AcpClientService" + ); + + let glob_input = json!({ "glob_pattern": "**/*.rs" }); + assert_eq!( + acp_tool_name("Find files", Some(&glob_input), Some(&ToolKind::Search)), + "Glob" + ); + assert_eq!( + normalize_tool_params("Glob", glob_input)["pattern"], + "**/*.rs" + ); + } + + #[test] + fn search_with_path_stays_search_card() { + let input = json!({ "pattern": "ToolEventData", "path": "src" }); + assert_eq!( + acp_tool_name("Search text", Some(&input), Some(&ToolKind::Search)), + "Grep" + ); + } + + #[test] + fn normalizes_camel_case_file_params() { + let input = json!({ + "filePath": "src/lib.rs", + "oldString": "before", + "newString": "after" + }); + assert_eq!( + acp_tool_name("Edit file", Some(&input), Some(&ToolKind::Edit)), + "Edit" + ); + + let params = normalize_tool_params("Edit", input); + assert_eq!(params["file_path"], "src/lib.rs"); + assert_eq!(params["old_string"], "before"); + assert_eq!(params["new_string"], "after"); + } +} diff --git a/src/crates/acp/src/lib.rs b/src/crates/acp/src/lib.rs index a7e9ab7f2..5930b0a52 100644 --- a/src/crates/acp/src/lib.rs +++ b/src/crates/acp/src/lib.rs @@ -3,9 +3,11 @@ //! This crate owns the external ACP server surface and maps it onto BitFun's //! core agentic runtime. CLI and other hosts should only start this crate. +pub mod client; mod runtime; mod server; pub use agent_client_protocol as protocol; +pub use client::AcpClientService; pub use runtime::BitfunAcpRuntime; pub use server::AcpServer; diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 525e7ef16..fbf856d03 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -71,17 +71,24 @@ impl ToolRegistry { /// Remove all tools from the MCP server pub fn unregister_mcp_server_tools(&mut self, server_id: &str) { let prefix = format!("mcp__{}__", server_id); + self.unregister_tools_by_prefix(&prefix); + } + + /// Remove all tools whose registry name starts with the given prefix. + pub fn unregister_tools_by_prefix(&mut self, prefix: &str) -> usize { let to_remove: Vec = self .tools .keys() - .filter(|k| k.starts_with(&prefix)) + .filter(|k| k.starts_with(prefix)) .cloned() .collect(); + let count = to_remove.len(); for key in to_remove { - info!("Unregistering MCP tool: tool_name={}", key); + info!("Unregistering dynamic tool: tool_name={}", key); self.tools.shift_remove(&key); } + count } /// Register all tools diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 8c7bb1b02..185c50841 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -44,6 +44,9 @@ pub struct GlobalConfig { /// MCP server configuration (stored uniformly; supports both JSON and structured formats). #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option, + /// ACP client configuration (stored as `{ "acpClients": { ... } }`). + #[serde(skip_serializing_if = "Option::is_none")] + pub acp_clients: Option, /// Theme system configuration. #[serde(skip_serializing_if = "Option::is_none")] pub themes: Option, @@ -1175,6 +1178,7 @@ impl Default for GlobalConfig { workspace: WorkspaceConfig::default(), ai: AIConfig::default(), mcp_servers: None, + acp_clients: None, themes: Some(ThemesConfig::default()), font: None, version: "1.0.0".to_string(), diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index acfd3a893..42c9c5820 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -141,8 +141,21 @@ function App() { } }; + const initACPClients = async () => { + try { + const { ACPClientAPI } = await import('../infrastructure/api/service-api/ACPClientAPI'); + await ACPClientAPI.initializeClients(); + log.debug('ACP clients initialized'); + const requirementProbes = await ACPClientAPI.probeClientRequirements({ force: true }); + log.debug('ACP client requirements probed', { count: requirementProbes.length }); + } catch (error) { + log.error('Failed to initialize ACP clients', error); + } + }; + initIdeControl(); initMCPServers(); + initACPClients(); }, []); diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 94abf92ea..aa4e03007 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -531,6 +531,8 @@ $_section-header-height: 24px; &__sections { flex: 1 1 auto; + min-width: 0; + max-width: 100%; overflow-y: auto; overflow-x: hidden; // No padding-top: separation from top-actions is only .bitfun-nav-panel__top-actions padding-bottom. diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index be691aeae..4fe2070c1 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -12,6 +12,8 @@ &__inline-list { display: flex; flex-direction: column; + min-width: 0; + max-width: 100%; padding: 2px $size-gap-1 2px; gap: 0; // No vertical margin: spacing between assistant blocks comes from 2px top/bottom padding only @@ -59,6 +61,8 @@ display: flex; align-items: center; gap: 5px; + min-width: 0; + max-width: 100%; height: 26px; padding: 0 $size-gap-1; border: none; @@ -69,6 +73,7 @@ font-weight: 400; cursor: pointer; width: 100%; + overflow: hidden; text-align: left; overflow: hidden; position: relative; @@ -184,6 +189,8 @@ &__inline-item-main { flex: 1 1 0; min-width: 0; + max-width: 100%; + overflow: hidden; display: flex; align-items: center; gap: 4px; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index f692cea6f..164f9bdd9 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; @@ -19,6 +19,11 @@ import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace'; +import { + ACPClientAPI, + type AcpClientInfo, + type AcpClientRequirementProbe, +} from '@/infrastructure/api/service-api/ACPClientAPI'; import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; import SessionsSection from '../sessions/SessionsSection'; import { @@ -82,7 +87,6 @@ function useStickyObserver(ref: React.RefObject) { return isStuck; } - interface WorkspaceItemProps { workspace: WorkspaceInfo; isActive: boolean; @@ -131,6 +135,7 @@ const WorkspaceItem: React.FC = ({ const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); + const [acpClients, setAcpClients] = useState([]); const menuRef = useRef(null); const menuAnchorRef = useRef(null); const menuPopoverRef = useRef(null); @@ -339,6 +344,36 @@ const WorkspaceItem: React.FC = ({ }; }, [menuOpen, updateMenuPosition]); + useEffect(() => { + let cancelled = false; + + const loadAcpClients = async () => { + try { + const [clients, requirementProbes] = await Promise.all([ + ACPClientAPI.getClients(), + ACPClientAPI.probeClientRequirements(), + ]); + const probesById = new Map( + requirementProbes.map(probe => [probe.id, probe]) + ); + if (!cancelled) { + setAcpClients(clients.filter(client => client.enabled && probesById.get(client.id)?.runnable === true)); + } + } catch (_error) { + setAcpClients([]); + } + }; + + void loadAcpClients(); + window.addEventListener('bitfun:acp-clients-changed', loadAcpClients); + window.addEventListener('bitfun:acp-requirements-changed', loadAcpClients); + return () => { + cancelled = true; + window.removeEventListener('bitfun:acp-clients-changed', loadAcpClients); + window.removeEventListener('bitfun:acp-requirements-changed', loadAcpClients); + }; + }, []); + const handleActivate = useCallback(async () => { if (!isActive) { await setActiveWorkspace(workspace.id); @@ -499,6 +534,33 @@ const WorkspaceItem: React.FC = ({ void handleCreateSession('Cowork'); }, [handleCreateSession]); + const handleCreateAcpSession = useCallback(async (client: AcpClientInfo) => { + setMenuOpen(false); + try { + const sessionId = await flowChatManager.createAcpChatSession( + client.id, + { + workspacePath: workspace.rootPath, + ...(isRemoteWorkspace(workspace) && workspace.connectionId + ? { remoteConnectionId: workspace.connectionId } + : {}), + ...(isRemoteWorkspace(workspace) && workspace.sshHost + ? { remoteSshHost: workspace.sshHost } + : {}), + }, + ); + await openMainSession(sessionId, { + workspaceId: workspace.id, + activateWorkspace: setActiveWorkspace, + }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), + { duration: 4000 } + ); + } + }, [setActiveWorkspace, t, workspace]); + const handleCreateInitSession = useCallback(async () => { setMenuOpen(false); @@ -999,6 +1061,22 @@ const WorkspaceItem: React.FC = ({ {t('nav.sessions.newCoworkSessionShort')} + {acpClients.map(client => { + const label = client.name || client.id; + return ( + + ); + })} + + + {dropdownOpen && ( +
+
+ ACP model + + {acpClientId} + +
+ +
+ {acpAvailableModels.map(model => { + const isSelected = currentAcpModelId === model.id; + + return ( + +
handleSelectModel(model.id)} + > +
+ + {model.modelName} + +
+ {isSelected && ( + + )} +
+
+ ); + })} +
+
+ )} + + ); + } + if (availableModels.length === 0) { return null; } diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index f504fe862..b7a19c8ed 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -21,6 +21,7 @@ export interface FlowChatContextValue { // Session info sessionId?: string; activeSessionOverride?: Session | null; + allowUserMessageRollback?: boolean; // Config config?: FlowChatConfig; @@ -67,4 +68,3 @@ export const useFlowChatContext = () => { return useContext(FlowChatContext); }; - diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 565f6c463..8f981c933 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -145,7 +145,9 @@ export const ModelRoundItem = React.memo( // 1) group subagent items // 2) group normal items into explore/critical via anchor tool const groupedItems = useMemo(() => { - const deferExploreGrouping = round.isStreaming && hasActiveStreamingNarrative(sortedItems); + const deferExploreGrouping = + round.renderHints?.disableExploreGrouping === true || + (round.isStreaming && hasActiveStreamingNarrative(sortedItems)); const intermediateGroups: Array<{ type: 'normal', item: FlowItem } | { type: 'subagent', parentTaskToolId: string, items: FlowItem[] }> = []; let currentSubagentGroup: { parentTaskToolId: string, items: FlowItem[] } | null = null; @@ -259,7 +261,7 @@ export const ModelRoundItem = React.memo( flushPendingAsCritical(); return finalGroups; - }, [round.isStreaming, sortedItems]); + }, [round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems]); const extractDialogTurnContent = useCallback(() => { const flowChatStore = FlowChatStore.getInstance(); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 16c6a2186..c589fb545 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -22,6 +22,7 @@ import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurn import type { FlowChatConfig } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { isAcpFlowSession } from '../../utils/acpSession'; import './ModernFlowChatContainer.scss'; interface ModernFlowChatContainerProps { @@ -52,6 +53,7 @@ export const ModernFlowChatContainer: React.FC = ( const virtualListRef = useRef(null); const chatScopeRef = useRef(null); const { workspacePath } = useWorkspaceContext(); + const allowUserMessageRollback = !isAcpFlowSession(activeSession); const { exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, @@ -94,6 +96,7 @@ export const ModernFlowChatContainer: React.FC = ( onToolReject: handleToolReject, sessionId: activeSession?.sessionId, activeSessionOverride: activeSession, + allowUserMessageRollback, config: { enableMarkdown: true, autoScroll: true, @@ -119,6 +122,7 @@ export const ModernFlowChatContainer: React.FC = ( handleToolConfirm, handleToolReject, activeSession, + allowUserMessageRollback, config, exploreGroupStates, handleExploreGroupToggle, diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index f9aa71e80..189c4ea25 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -27,7 +27,12 @@ interface UserMessageItemProps { export const UserMessageItem = React.memo( ({ message, turnId }) => { const { t } = useTranslation('flow-chat'); - const { config, sessionId, activeSessionOverride } = useFlowChatContext(); + const { + config, + sessionId, + activeSessionOverride, + allowUserMessageRollback = true, + } = useFlowChatContext(); const activeSessionFromStore = useActiveSession(); const activeSession = activeSessionOverride ?? activeSessionFromStore; const [copied, setCopied] = useState(false); @@ -43,7 +48,7 @@ export const UserMessageItem = React.memo( const turnIndex = activeSession?.dialogTurns.findIndex(t => t.id === turnId) ?? -1; const dialogTurn = turnIndex >= 0 ? activeSession?.dialogTurns[turnIndex] : null; const isFailed = dialogTurn?.status === 'error'; - const canRollback = !!sessionId && turnIndex >= 0 && !isRollingBack; + const canRollback = allowUserMessageRollback && !!sessionId && turnIndex >= 0 && !isRollingBack; const { displayText, reproductionSteps } = useMemo(() => { const reproductionRegex = /([\s\S]*?)<\/reproduction_steps\s*>?/g; @@ -225,7 +230,7 @@ export const UserMessageItem = React.memo( - ) : ( + ) : allowUserMessageRollback ? ( - )} + ) : null} @@ -276,4 +281,3 @@ export const UserMessageItem = React.memo( ); UserMessageItem.displayName = 'UserMessageItem'; - diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts index a5d386162..27761308e 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts @@ -5,46 +5,51 @@ import { useCallback } from 'react'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { + ACPClientAPI, +} from '@/infrastructure/api/service-api/ACPClientAPI'; import { flowChatStore } from '../../store/FlowChatStore'; import type { DialogTurn, FlowItem, FlowToolItem, ModelRound } from '../../types/flow-chat'; const log = createLogger('useFlowChatToolActions'); interface ResolvedToolContext { - activeSessionId: string | null; + sessionId: string | null; toolItem: FlowToolItem | null; turnId: string | null; } function resolveToolContext(toolId: string): ResolvedToolContext { const latestState = flowChatStore.getState(); - const dialogTurns = Array.from(latestState.sessions.values()).flatMap(session => - session.dialogTurns as DialogTurn[], - ); - + let sessionId: string | null = null; let toolItem: FlowToolItem | null = null; let turnId: string | null = null; - for (const turn of dialogTurns) { - for (const modelRound of turn.modelRounds as ModelRound[]) { - const item = modelRound.items.find((candidate: FlowItem) => ( - candidate.type === 'tool' && candidate.id === toolId - )) as FlowToolItem | undefined; + for (const [candidateSessionId, session] of latestState.sessions) { + for (const turn of session.dialogTurns as DialogTurn[]) { + for (const modelRound of turn.modelRounds as ModelRound[]) { + const item = modelRound.items.find((candidate: FlowItem) => ( + candidate.type === 'tool' && candidate.id === toolId + )) as FlowToolItem | undefined; + + if (item) { + sessionId = candidateSessionId; + toolItem = item; + turnId = turn.id; + break; + } + } - if (item) { - toolItem = item; - turnId = turn.id; + if (toolItem) { break; } } - if (toolItem) { - break; - } + if (toolItem) break; } return { - activeSessionId: latestState.activeSessionId, + sessionId, toolItem, turnId, }; @@ -53,33 +58,36 @@ function resolveToolContext(toolId: string): ResolvedToolContext { export function useFlowChatToolActions() { const handleToolConfirm = useCallback(async (toolId: string, updatedInput?: any) => { try { - const { activeSessionId, toolItem, turnId } = resolveToolContext(toolId); + const { sessionId, toolItem, turnId } = resolveToolContext(toolId); - if (!toolItem || !turnId) { + if (!sessionId || !toolItem || !turnId) { notificationService.error(`Tool confirmation failed: tool item ${toolId} not found in current session`); return; } const finalInput = updatedInput || toolItem.toolCall?.input; - if (activeSessionId) { - flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { - userConfirmed: true, - status: 'confirmed', - toolCall: { - ...toolItem.toolCall, - input: finalInput, - }, - } as any); - } - - if (!activeSessionId) { - throw new Error('No active session ID'); + flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { + userConfirmed: true, + status: 'confirmed', + toolCall: { + ...toolItem.toolCall, + input: finalInput, + }, + } as any); + + const acpPermission = toolItem.acpPermission; + if (acpPermission?.permissionId) { + await ACPClientAPI.submitPermissionResponse({ + permissionId: acpPermission.permissionId, + approve: true, + }); + return; } const { agentService } = await import('../../../shared/services/agent-service'); await agentService.confirmToolExecution( - activeSessionId, + sessionId, toolId, 'confirm', finalInput, @@ -92,27 +100,30 @@ export function useFlowChatToolActions() { const handleToolReject = useCallback(async (toolId: string) => { try { - const { activeSessionId, toolItem, turnId } = resolveToolContext(toolId); + const { sessionId, toolItem, turnId } = resolveToolContext(toolId); - if (!toolItem || !turnId) { + if (!sessionId || !toolItem || !turnId) { log.warn('Tool rejection failed: tool item not found', { toolId }); return; } - if (activeSessionId) { - flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { - userConfirmed: false, - status: 'rejected', - } as any); - } - - if (!activeSessionId) { - throw new Error('No active session ID'); + flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { + userConfirmed: false, + status: 'cancelled', + } as any); + + const acpPermission = toolItem.acpPermission; + if (acpPermission?.permissionId) { + await ACPClientAPI.submitPermissionResponse({ + permissionId: acpPermission.permissionId, + approve: false, + }); + return; } const { agentService } = await import('../../../shared/services/agent-service'); await agentService.confirmToolExecution( - activeSessionId, + sessionId, toolId, 'reject', ); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index f1a2ac76b..f4434580b 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -10,6 +10,7 @@ import { processingStatusManager } from './ProcessingStatusManager'; import { FlowChatStore } from '../store/FlowChatStore'; import { AgentService } from '../../shared/services/agent-service'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; import { createLogger } from '@/shared/utils/logger'; @@ -198,6 +199,51 @@ export class FlowChatManager { return createChatSessionModule(this.context, config, mode); } + async createAcpChatSession(clientId: string, config: SessionConfig = {}): Promise { + const workspacePath = + config.workspacePath?.trim() || + this.context.currentWorkspacePath?.trim(); + if (!workspacePath) { + throw new Error('Workspace path is required to create an ACP session'); + } + + window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { + detail: { phase: 'start', clientId }, + })); + + try { + const response = await ACPClientAPI.createFlowSession({ + clientId, + workspacePath, + remoteConnectionId: config.remoteConnectionId, + remoteSshHost: config.remoteSshHost, + sessionName: `${clientId} ACP`, + }); + + this.context.flowChatStore.createSession( + response.sessionId, + { + ...config, + workspacePath, + agentType: response.agentType, + }, + undefined, + response.sessionName, + 128128, + response.agentType, + workspacePath, + config.remoteConnectionId, + config.remoteSshHost, + ); + + return response.sessionId; + } finally { + window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { + detail: { phase: 'finish', clientId }, + })); + } + } + async switchChatSession(sessionId: string): Promise { return switchChatSessionModule(this.context, sessionId); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts new file mode 100644 index 000000000..b4f658d20 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts @@ -0,0 +1,100 @@ +/** + * Bridges ACP permission requests into FlowChat tool cards. + */ + +import { FlowChatStore } from '../../store/FlowChatStore'; +import type { FlowToolItem } from '../../types/flow-chat'; +import type { AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; + +const pendingAcpPermissionRequests = new Map(); + +function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null { + const toolCallId = event.toolCall?.toolCallId; + return typeof toolCallId === 'string' && toolCallId.trim().length > 0 + ? toolCallId + : null; +} + +function findToolContextById( + store: FlowChatStore, + toolId: string +): { sessionId: string; turnId: string; itemId: string } | null { + const state = store.getState(); + for (const [sessionId, session] of state.sessions) { + for (const turn of session.dialogTurns) { + for (const round of turn.modelRounds) { + const item = round.items.find(candidate => ( + candidate.type === 'tool' && + (candidate.id === toolId || (candidate as FlowToolItem).toolCall?.id === toolId) + )) as FlowToolItem | undefined; + + if (item) { + return { sessionId, turnId: turn.id, itemId: item.id }; + } + } + } + } + return null; +} + +function applyAcpPermissionRequest( + store: FlowChatStore, + toolId: string, + event: AcpPermissionRequestEvent +): boolean { + const toolContext = findToolContextById(store, toolId); + if (!toolContext) { + return false; + } + + store.updateModelRoundItem(toolContext.sessionId, toolContext.turnId, toolContext.itemId, { + requiresConfirmation: true, + userConfirmed: false, + status: 'pending_confirmation', + acpPermission: { + permissionId: event.permissionId, + sessionId: event.sessionId, + toolCallId: toolId, + requestedAt: Date.now(), + options: event.options, + toolCall: event.toolCall, + }, + } as any); + + const activeSessionId = store.getState().activeSessionId; + if (toolContext.sessionId !== activeSessionId) { + store.setSessionNeedsAttention(toolContext.sessionId, 'tool_confirm'); + } + + return true; +} + +export function handleAcpPermissionRequestForToolCard(event: AcpPermissionRequestEvent): boolean { + const toolId = acpPermissionToolId(event); + if (!toolId) { + return false; + } + + const store = FlowChatStore.getInstance(); + if (!applyAcpPermissionRequest(store, toolId, event)) { + pendingAcpPermissionRequests.set(toolId, event); + return true; + } + + pendingAcpPermissionRequests.delete(toolId); + return true; +} + +export function applyPendingAcpPermissionForTool( + store: FlowChatStore, + toolId: string +): void { + const event = pendingAcpPermissionRequests.get(toolId); + if (!event) { + return; + } + + if (applyAcpPermissionRequest(store, toolId, event)) { + pendingAcpPermissionRequests.delete(toolId); + } +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 5a7ffcf69..cb7125f2c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -26,6 +26,7 @@ import type { } from '@/infrastructure/api/service-api/AgentAPI'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import { MCPAPI } from '@/infrastructure/api/service-api/MCPAPI'; +import { ACPClientAPI, type AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; import { globalEventBus } from '@/infrastructure/event-bus'; import type { FlowChatContext, DialogTurn, ModelRound, FlowToolItem } from './types'; import { @@ -63,6 +64,7 @@ import { handleToolExecutionProgress, handleToolTerminalReady, } from './ToolEventModule'; +import { handleAcpPermissionRequestForToolCard } from './AcpPermissionToolCardModule'; import { routeModelRoundStartedToToolCardInternal, routeTextChunkToToolCardInternal, @@ -359,6 +361,9 @@ export async function initializeEventListeners( const unlistenMcpInteractionRequest = await listen('backend-event-mcpinteractionrequest', (event: any) => { void handleMcpInteractionRequest((event.payload as any)?.value || event.payload); }); + const unlistenAcpPermissionRequest = await listen('backend-event-acppermissionrequest', (event: any) => { + void handleAcpPermissionRequest((event.payload as any)?.value || event.payload); + }); const callbacks: AgenticEventCallbacks = { onSessionCreated: (event) => { @@ -423,6 +428,7 @@ export async function initializeEventListeners( unlistenProgress(); unlistenTerminalReady(); unlistenMcpInteractionRequest(); + unlistenAcpPermissionRequest(); agenticEventListener.stopListening(); }; } @@ -462,6 +468,28 @@ async function handleMcpInteractionRequest(rawEvent: unknown): Promise { } } +async function handleAcpPermissionRequest(rawEvent: unknown): Promise { + const event = rawEvent as AcpPermissionRequestEvent | undefined; + const permissionId = event?.permissionId; + if (!permissionId) { + log.warn('Received invalid ACP permission request event', { rawEvent }); + return; + } + + if (handleAcpPermissionRequestForToolCard(event)) return; + + log.warn('ACP permission request cannot be matched to a tool card, rejecting request', { permissionId }); + try { + await ACPClientAPI.submitPermissionResponse({ + permissionId, + approve: false, + }); + } catch (error) { + log.error('Failed to submit ACP permission auto-rejection', { permissionId, error }); + notificationService.error('Failed to respond to ACP permission request'); + } +} + /** * Handle session created event (e.g. remote mobile created a session) */ @@ -1342,6 +1370,11 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { completeActiveTextItems(context, sessionId, turnId); + const disableExploreGrouping = + event.renderHints?.disableExploreGrouping === true || + event.metadata?.disableExploreGrouping === true || + event.disableExploreGrouping === true; + const modelRound: ModelRound = { id: roundId, index: roundIndex || 0, @@ -1349,7 +1382,10 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { isStreaming: true, isComplete: false, status: 'streaming', - startTime: Date.now() + startTime: Date.now(), + ...(disableExploreGrouping + ? { renderHints: { disableExploreGrouping: true } } + : {}), }; context.flowChatStore.addModelRound(sessionId, turnId, modelRound); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 261737534..94d1cf563 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -4,6 +4,7 @@ */ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { notificationService } from '../../../shared/notification-system'; @@ -29,6 +30,13 @@ const log = createLogger('MessageModule'); const ONE_SHOT_AGENT_TYPES_FOR_SESSION = new Set(['Init']); +function acpClientIdFromMode(mode: string | undefined): string | null { + const value = mode?.trim(); + if (!value?.startsWith('acp:')) return null; + const clientId = value.slice('acp:'.length).trim(); + return clientId || null; +} + function normalizeModelSelection( modelId: string | undefined, models: AIModelConfig[], @@ -127,8 +135,10 @@ export async function sendMessage( try { const refreshedSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; const currentAgentType = (agentType?.trim() || refreshedSession.mode || 'agentic').trim(); + const acpClientId = acpClientIdFromMode(currentAgentType); if ( + !acpClientId && agentType?.trim() && !ONE_SHOT_AGENT_TYPES_FOR_SESSION.has(currentAgentType) && refreshedSession.mode !== currentAgentType @@ -160,7 +170,9 @@ export async function sendMessage( return; } - await ensureBackendSession(context, sessionId); + if (!acpClientId) { + await ensureBackendSession(context, sessionId); + } const readySession = context.flowChatStore.getState().sessions.get(sessionId); if (!readySession) { @@ -227,7 +239,9 @@ export async function sendMessage( metadata: { sessionId: sessionId, dialogTurnId } }); - await syncSessionModelSelection(context, sessionId, currentAgentType); + if (!acpClientId) { + await syncSessionModelSelection(context, sessionId, currentAgentType); + } const updatedSession = context.flowChatStore.getState().sessions.get(sessionId); if (!updatedSession) { @@ -239,25 +253,19 @@ export async function sendMessage( const workspacePath = updatedSession.workspacePath; - try { - await agentAPI.startDialogTurn({ - sessionId: sessionId, + if (acpClientId) { + await ACPClientAPI.startDialogTurn({ + sessionId, + clientId: acpClientId, userInput: message, originalUserInput: displayMessage || message, turnId: dialogTurnId, - agentType: currentAgentType, workspacePath, - imageContexts: options?.imageContexts, + remoteConnectionId: updatedSession.remoteConnectionId, + remoteSshHost: updatedSession.remoteSshHost, }); - } catch (error: any) { - if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { - log.warn('Backend session still not found, retrying creation', { - sessionId: sessionId, - dialogTurnsCount: updatedSession.dialogTurns.length - }); - - await retryCreateBackendSession(context, sessionId); - + } else { + try { await agentAPI.startDialogTurn({ sessionId: sessionId, userInput: message, @@ -267,8 +275,27 @@ export async function sendMessage( workspacePath, imageContexts: options?.imageContexts, }); - } else { - throw error; + } catch (error: any) { + if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { + log.warn('Backend session still not found, retrying creation', { + sessionId: sessionId, + dialogTurnsCount: updatedSession.dialogTurns.length + }); + + await retryCreateBackendSession(context, sessionId); + + await agentAPI.startDialogTurn({ + sessionId: sessionId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + agentType: currentAgentType, + workspacePath, + imageContexts: options?.imageContexts, + }); + } else { + throw error; + } } } @@ -325,7 +352,7 @@ export async function cancelCurrentTask(context: FlowChatContext): Promise ({ item, index })) .filter(({ item }) => item.type === 'text' && !isRuntimeStatusItem(item)) diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts new file mode 100644 index 000000000..d43f37548 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import type { AnyFlowItem, DialogTurn, FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; +import { processNormalTextChunkInternal } from './TextChunkModule'; + +function makeContext(session: Session): any { + return { + flowChatStore: { + getState: () => ({ + sessions: new Map([[session.sessionId, session]]), + }), + addModelRoundItemSilent: ( + _sessionId: string, + _turnId: string, + item: AnyFlowItem, + roundId: string, + ) => { + const round = session.dialogTurns[0].modelRounds.find(candidate => candidate.id === roundId); + round?.items.push(item); + }, + updateModelRoundItemSilent: ( + _sessionId: string, + _turnId: string, + itemId: string, + updates: Partial, + ) => { + for (const round of session.dialogTurns[0].modelRounds) { + const item = round.items.find(candidate => candidate.id === itemId); + if (item) { + Object.assign(item, updates); + return; + } + } + }, + batchUpdateModelRoundItems: () => {}, + }, + contentBuffers: new Map(), + activeTextItems: new Map(), + eventBatcher: { getBufferSize: () => 0, clear: () => {} }, + pendingTurnCompletions: new Map(), + saveDebouncers: new Map(), + lastSaveTimestamps: new Map(), + lastSaveHashes: new Map(), + turnSaveInFlight: new Map(), + turnSavePending: new Set(), + }; +} + +function makeSession(): Session { + const round: ModelRound = { + id: 'round-1', + index: 0, + items: [], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1000, + }; + const turn: DialogTurn = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [round], + status: 'processing', + startTime: 900, + }; + return { + sessionId: 'session-1', + dialogTurns: [turn], + status: 'active', + config: {}, + createdAt: 800, + lastActiveAt: 1000, + error: null, + sessionKind: 'normal', + }; +} + +function insertTool(session: Session): void { + const tool: FlowToolItem = { + id: 'tool-1', + type: 'tool', + toolName: 'Read', + timestamp: 1001, + status: 'completed', + toolCall: { + id: 'tool-1', + input: { file_path: 'src/main.rs' }, + }, + toolResult: { + result: 'contents', + success: true, + }, + }; + session.dialogTurns[0].modelRounds[0].items.push(tool); +} + +describe('processNormalTextChunkInternal', () => { + it('keeps using the existing active text item after tools in the same round', () => { + const session = makeSession(); + const context = makeContext(session); + + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', 'Before tools.'); + insertTool(session); + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', ' After tools.'); + + const items = session.dialogTurns[0].modelRounds[0].items; + const textItems = items.filter(item => item.type === 'text'); + expect(textItems).toHaveLength(1); + expect((textItems[0] as any).content).toBe('Before tools. After tools.'); + }); + + it('uses a separate text item when later text arrives in a separate round', () => { + const session = makeSession(); + session.dialogTurns[0].modelRounds.push({ + id: 'round-2', + index: 1, + items: [], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1002, + }); + const context = makeContext(session); + + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', 'Before tools.'); + insertTool(session); + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-2', 'After tools.'); + + const [firstRound, secondRound] = session.dialogTurns[0].modelRounds; + expect(firstRound.items.map(item => item.type)).toEqual(['text', 'tool']); + expect(secondRound.items.map(item => item.type)).toEqual(['text']); + expect((firstRound.items[0] as any).content).toBe('Before tools.'); + expect((secondRound.items[0] as any).content).toBe('After tools.'); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index 240ecd375..8817a8daa 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -4,6 +4,46 @@ import type { FlowChatContext, FlowTextItem } from './types'; import { clearRuntimeStatus } from './RuntimeStatusModule'; +import { isAcpFlowSession } from '../../utils/acpSession'; + +function activeTextHasLaterRoundItem( + context: FlowChatContext, + sessionId: string, + turnId: string, + roundId: string, + textItemId: string +): boolean { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session || !isAcpFlowSession(session)) { + return false; + } + + const turn = session?.dialogTurns.find(candidate => candidate.id === turnId); + const round = turn?.modelRounds.find(candidate => candidate.id === roundId); + if (!round) return false; + + const textItemIndex = round.items.findIndex(item => item.id === textItemId); + if (textItemIndex === -1) return false; + + return round.items.slice(textItemIndex + 1).some(item => item.type !== 'text'); +} + +function closeActiveTextSegment( + context: FlowChatContext, + sessionId: string, + turnId: string, + roundId: string, + textItemId: string +): void { + context.flowChatStore.updateModelRoundItemSilent(sessionId, turnId, textItemId, { + isStreaming: false, + status: 'completed', + } as any); + + context.contentBuffers.get(sessionId)?.delete(roundId); + context.activeTextItems.get(sessionId)?.delete(roundId); +} + /** * Process a normal text chunk without notifying the store. */ @@ -26,6 +66,14 @@ export function processNormalTextChunkInternal( const sessionContentBuffer = context.contentBuffers.get(sessionId)!; const sessionActiveTextItems = context.activeTextItems.get(sessionId)!; + const activeTextItemId = sessionActiveTextItems.get(roundId); + if ( + activeTextItemId && + activeTextHasLaterRoundItem(context, sessionId, turnId, roundId, activeTextItemId) + ) { + closeActiveTextSegment(context, sessionId, turnId, roundId, activeTextItemId); + } + // Coalesce excessive newlines while appending. const currentContent = sessionContentBuffer.get(roundId) || ''; const cleanedContent = (currentContent + text).replace(/\n{3,}/g, '\n\n'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index b2a326dde..55a988967 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -8,6 +8,7 @@ import { parsePartialJson } from '../../../shared/utils/partialJsonParser'; import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext, FlowToolItem, ToolEventOptions, DialogTurn } from './types'; import { immediateSaveDialogTurn } from './PersistenceModule'; +import { applyPendingAcpPermissionForTool } from './AcpPermissionToolCardModule'; import type { CancelledToolEvent, CompletedToolEvent, @@ -204,6 +205,7 @@ function applyParamsPartial( _contentSize: hasContentField ? ((parsedParams.content || parsedParams.contents || '').length) : undefined }, silent); applyPendingTerminalSessionId(store, sessionId, turnId, toolEvent.tool_id, silent); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } } @@ -279,6 +281,7 @@ function handleEarlyDetected( if (options?.isSubagent && options.parentToolId && !shouldDisplayInMainFlow) { store.insertModelRoundItemAfterTool(sessionId, turnId, options.parentToolId, preparingToolItem); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { let lastModelRound = dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1]; if (!lastModelRound) { @@ -296,6 +299,7 @@ function handleEarlyDetected( } store.addModelRoundItem(sessionId, turnId, preparingToolItem, lastModelRound.id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } } @@ -340,6 +344,7 @@ function handleStarted( partialParams: undefined } as any); applyPendingTerminalSessionId(store, sessionId, turnId, toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { const toolItem: FlowToolItem = { id: toolEvent.tool_id, @@ -361,11 +366,13 @@ function handleStarted( if (options?.isSubagent && options.parentToolId) { store.insertModelRoundItemAfterTool(sessionId, turnId, options.parentToolId, toolItem); pendingTerminalSessionIds.delete(toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { const lastModelRound = dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1]; if (lastModelRound) { store.addModelRoundItem(sessionId, turnId, toolItem, lastModelRound.id); pendingTerminalSessionIds.delete(toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { log.error('Tool Started event without ModelRound (backend bug)', { sessionId, @@ -402,6 +409,8 @@ function handleCompleted( duration_ms: toolEvent.duration_ms }, status: 'completed' as const, + requiresConfirmation: false, + acpPermission: undefined, isParamsStreaming: false, endTime: Date.now() }; @@ -430,6 +439,8 @@ function handleFailed( error: toolEvent.error }, status: 'error', + requiresConfirmation: false, + acpPermission: undefined, endTime: Date.now() } as any); @@ -459,6 +470,8 @@ function handleCancelled( error: toolEvent.reason || 'User cancelled operation' }, status: finalStatus, + requiresConfirmation: false, + acpPermission: undefined, endTime: Date.now() } as any); diff --git a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts index 1cb64c2dd..5cbb04e63 100644 --- a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts +++ b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts @@ -318,4 +318,3 @@ export class SessionStateMachineImpl { this.notifyListeners(); } } - diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 656f48a89..5af10c993 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -43,6 +43,11 @@ import { sessionBelongsToWorkspaceNavRow } from '../utils/sessionOrdering'; import { sessionMatchesWorkspace } from '../utils/workspaceScope'; const log = createLogger('FlowChatStore'); +const VALID_AGENT_TYPES = new Set(['agentic', 'debug', 'Plan', 'Cowork', 'Claw', 'Team', 'DeepResearch']); + +function isValidPersistedAgentType(agentType: string): boolean { + return VALID_AGENT_TYPES.has(agentType) || agentType.startsWith('acp:'); +} export class FlowChatStore { private static instance: FlowChatStore; @@ -1549,6 +1554,7 @@ export class FlowChatStore { turnId, roundIndex, timestamp: round.startTime, + renderHints: round.renderHints, textItems, toolItems, thinkingItems, @@ -1639,9 +1645,8 @@ export class FlowChatStore { return prev; } - const VALID_AGENT_TYPES = ['agentic', 'debug', 'Plan', 'Cowork', 'Claw', 'Team', 'DeepResearch']; const rawAgentType = metadata.agentType || 'agentic'; - const validatedAgentType = VALID_AGENT_TYPES.includes(rawAgentType) ? rawAgentType : 'agentic'; + const validatedAgentType = isValidPersistedAgentType(rawAgentType) ? rawAgentType : 'agentic'; if (rawAgentType !== validatedAgentType) { log.warn('Invalid agentType, falling back to agentic', { sessionId: metadata.sessionId, rawAgentType, validatedAgentType }); @@ -1711,11 +1716,16 @@ export class FlowChatStore { const { stateMachineManager } = await import('../state-machine'); stateMachineManager.getOrCreate(sessionId); - try { - const { agentAPI } = await import('@/infrastructure/api'); - await agentAPI.restoreSession(sessionId, workspacePath, remoteConnectionId, remoteSshHost); - } catch (error) { - log.warn('Backend session restore failed (may be new session)', { sessionId, error }); + const existingSession = this.state.sessions.get(sessionId); + const isAcpSession = existingSession?.mode?.startsWith('acp:') || + existingSession?.config.agentType?.startsWith('acp:'); + if (!isAcpSession) { + try { + const { agentAPI } = await import('@/infrastructure/api'); + await agentAPI.restoreSession(sessionId, workspacePath, remoteConnectionId, remoteSshHost); + } catch (error) { + log.warn('Backend session restore failed (may be new session)', { sessionId, error }); + } } const { sessionAPI } = await import('@/infrastructure/api'); @@ -1814,6 +1824,7 @@ export class FlowChatStore { id: round.id, turnId: round.turnId, index: round.roundIndex ?? 0, + renderHints: round.renderHints, items: [ ...round.textItems.map((text: any) => ({ id: text.id, diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts new file mode 100644 index 000000000..51770fcaf --- /dev/null +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FlowTextItem, FlowToolItem, ModelRound, Session } from '../types/flow-chat'; + +vi.mock('./FlowChatStore', () => ({ + flowChatStore: { + getState: () => ({ + activeSessionId: null, + sessions: new Map(), + }), + }, +})); + +vi.mock('../tool-cards', () => ({ + isCollapsibleTool: (toolName: string) => ['Read', 'LS', 'Grep', 'Glob', 'WebSearch', 'Bash'].includes(toolName), + READ_TOOL_NAMES: new Set(['Read']), + SEARCH_TOOL_NAMES: new Set(['Grep', 'Glob', 'WebSearch']), + COMMAND_TOOL_NAMES: new Set(['Bash']), +})); + +import { sessionToVirtualItems } from './modernFlowChatStore'; + +function makeTextItem(id: string, content: string): FlowTextItem { + return { + id, + type: 'text', + content, + isStreaming: false, + isMarkdown: true, + timestamp: 1000, + status: 'completed', + }; +} + +function makeReadTool(id: string): FlowToolItem { + return { + id, + type: 'tool', + toolName: 'Read', + timestamp: 1001, + status: 'completed', + toolCall: { + id, + input: { file_path: 'src/main.rs' }, + }, + toolResult: { + result: 'file contents', + success: true, + }, + }; +} + +function makeRound(overrides: Partial = {}): ModelRound { + return { + id: overrides.id ?? 'round-1', + index: 0, + items: overrides.items ?? [ + makeTextItem('text-1', 'I will inspect the file.'), + makeReadTool('tool-1'), + ], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1000, + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): Session { + return { + sessionId: overrides.sessionId ?? 'session-1', + dialogTurns: overrides.dialogTurns ?? [{ + id: 'turn-1', + sessionId: overrides.sessionId ?? 'session-1', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [makeRound()], + status: 'completed', + startTime: 900, + }], + status: 'idle', + config: overrides.config ?? {}, + createdAt: 800, + lastActiveAt: 1000, + error: null, + ...overrides, + }; +} + +describe('sessionToVirtualItems explore grouping', () => { + it('groups normal rounds containing only collapsible tools and narrative', () => { + const session = makeSession({ sessionId: 'normal-session' }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); + }); + + it('does not special-case ACP rounds without explicit render hints', () => { + const session = makeSession({ + sessionId: 'acp-session', + config: { agentType: 'acp:opencode' }, + }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); + }); + + it('honors explicit round render hints for non-ACP sessions', () => { + const round = makeRound({ + id: 'round-with-hint', + renderHints: { disableExploreGrouping: true }, + }); + const session = makeSession({ + sessionId: 'hint-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'hint-session', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [round], + status: 'completed', + startTime: 900, + }], + }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'model-round']); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 430c6f746..dbf09967a 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -89,6 +89,10 @@ function hasActiveStreamingNarrative(round: ModelRound): boolean { function isExploreOnlyRound(round: ModelRound): boolean { if (!round.items || round.items.length === 0) return false; + if (round.renderHints?.disableExploreGrouping === true) { + return false; + } + if (round.isStreaming && hasActiveStreamingNarrative(round)) { return false; } @@ -164,7 +168,6 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { cachedSession = session; cachedDialogTurnsRef = session.dialogTurns; - if (!session) return []; const items: VirtualItem[] = []; @@ -182,7 +185,8 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { return; } - const nonEmptyRounds = turn.modelRounds.filter(round => round.items && round.items.length > 0); + const nonEmptyRounds = turn.modelRounds + .filter(round => round.items && round.items.length > 0); interface TempExploreGroup { rounds: ModelRound[]; diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 48adf94b3..4f5c5bf72 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -64,6 +64,23 @@ export interface FlowToolItem extends FlowItem { }; requiresConfirmation?: boolean; userConfirmed?: boolean; + acpPermission?: { + permissionId: string; + sessionId?: string; + toolCallId?: string; + requestedAt: number; + options?: Array<{ + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + }>; + toolCall?: { + toolCallId?: string; + title?: string; + rawInput?: unknown; + content?: unknown; + }; + }; aiIntent?: string; // AI rationale for calling the tool. startTime?: number; // Tool start time. endTime?: number; // Tool end time. @@ -96,6 +113,14 @@ export interface ImageAnalysisResult { analysis_time_ms: number; // Analysis duration. } +export interface ModelRoundRenderHints { + /** + * Keep all round items in the normal transcript instead of merging + * collapsible tools and adjacent narrative into an explore group. + */ + disableExploreGrouping?: boolean; +} + // Model round: output from a single model call. export interface ModelRound { id: string; @@ -107,6 +132,7 @@ export interface ModelRound { startTime: number; endTime?: number; error?: string; + renderHints?: ModelRoundRenderHints; } // Token usage stats. diff --git a/src/web-ui/src/flow_chat/utils/acpSession.ts b/src/web-ui/src/flow_chat/utils/acpSession.ts new file mode 100644 index 000000000..27fef1e00 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/acpSession.ts @@ -0,0 +1,24 @@ +import type { Session } from '../types/flow-chat'; + +const ACP_AGENT_TYPE_PREFIX = 'acp:'; + +export function acpClientIdFromAgentType(agentType: string | null | undefined): string | null { + const value = agentType?.trim(); + if (!value?.startsWith(ACP_AGENT_TYPE_PREFIX)) return null; + + const clientId = value.slice(ACP_AGENT_TYPE_PREFIX.length).trim(); + return clientId || null; +} + +export function isAcpAgentType(agentType: string | null | undefined): boolean { + return acpClientIdFromAgentType(agentType) !== null; +} + +export function isAcpFlowSession( + session: Pick | null | undefined, +): boolean { + return Boolean( + isAcpAgentType(session?.config?.agentType) || + isAcpAgentType(session?.mode), + ); +} diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts new file mode 100644 index 000000000..9db847132 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -0,0 +1,230 @@ +import { api } from './ApiClient'; + +export type AcpClientPermissionMode = 'ask' | 'allow_once' | 'reject_once'; +export type AcpClientStatus = 'configured' | 'starting' | 'running' | 'stopped' | 'failed'; + +export interface AcpClientInfo { + id: string; + name: string; + command: string; + args: string[]; + enabled: boolean; + readonly: boolean; + permissionMode: AcpClientPermissionMode; + status: AcpClientStatus; + toolName: string; + sessionCount: number; +} + +export interface AcpRequirementProbeItem { + name: string; + installed: boolean; + version?: string; + path?: string; + error?: string; +} + +export interface AcpClientRequirementProbe { + id: string; + tool: AcpRequirementProbeItem; + adapter?: AcpRequirementProbeItem; + runnable: boolean; + notes: string[]; +} + +export interface AcpClientIdRequest { + clientId: string; +} + +export interface CreateAcpFlowSessionRequest { + clientId: string; + sessionName?: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +export interface CreateAcpFlowSessionResponse { + sessionId: string; + sessionName: string; + agentType: string; +} + +export interface StartAcpDialogTurnRequest { + sessionId: string; + clientId: string; + userInput: string; + originalUserInput?: string; + turnId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + timeoutSeconds?: number; +} + +export interface CancelAcpDialogTurnRequest { + sessionId: string; + clientId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +export interface GetAcpSessionOptionsRequest { + sessionId: string; + clientId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +export interface SetAcpSessionModelRequest { + sessionId: string; + clientId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + modelId: string; +} + +export interface AcpSessionModelOption { + id: string; + name: string; + description?: string; +} + +export interface AcpSessionOptions { + currentModelId?: string; + availableModels: AcpSessionModelOption[]; + modelConfigId?: string; +} + +export interface SubmitAcpPermissionResponseRequest { + permissionId: string; + approve: boolean; + optionId?: string; +} + +export interface AcpPermissionOption { + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; +} + +export interface AcpPermissionRequestEvent { + permissionId: string; + sessionId: string; + toolCall?: { + toolCallId?: string; + title?: string; + rawInput?: unknown; + content?: unknown; + }; + options?: AcpPermissionOption[]; +} + +let requirementProbeCache: AcpClientRequirementProbe[] | null = null; +let requirementProbeInFlight: Promise | null = null; + +export class ACPClientAPI { + private static invalidateRequirementProbeCache(): void { + requirementProbeCache = null; + requirementProbeInFlight = null; + } + + static async initializeClients(): Promise { + await api.invoke('initialize_acp_clients'); + ACPClientAPI.invalidateRequirementProbeCache(); + window.dispatchEvent(new Event('bitfun:acp-clients-changed')); + } + + static async getClients(): Promise { + return api.invoke('get_acp_clients'); + } + + static async probeClientRequirements( + options: { force?: boolean } = {} + ): Promise { + if (!options.force && requirementProbeCache) { + return requirementProbeCache; + } + if (!options.force && requirementProbeInFlight) { + return requirementProbeInFlight; + } + + requirementProbeInFlight = api.invoke('probe_acp_client_requirements') + .then((probes) => { + requirementProbeCache = probes; + window.dispatchEvent(new Event('bitfun:acp-requirements-changed')); + return probes; + }) + .finally(() => { + requirementProbeInFlight = null; + }); + + return requirementProbeInFlight; + } + + static async predownloadClientAdapter(request: AcpClientIdRequest): Promise { + await api.invoke('predownload_acp_client_adapter', { request }); + ACPClientAPI.invalidateRequirementProbeCache(); + window.dispatchEvent(new Event('bitfun:acp-requirements-changed')); + } + + static async installClientCli(request: AcpClientIdRequest): Promise { + await api.invoke('install_acp_client_cli', { request }); + ACPClientAPI.invalidateRequirementProbeCache(); + window.dispatchEvent(new Event('bitfun:acp-requirements-changed')); + } + + static async stopClient(request: AcpClientIdRequest): Promise { + await api.invoke('stop_acp_client', { request }); + window.dispatchEvent(new Event('bitfun:acp-clients-changed')); + } + + static async loadJsonConfig(): Promise { + return api.invoke('load_acp_json_config'); + } + + static async saveJsonConfig(jsonConfig: string): Promise { + await api.invoke('save_acp_json_config', { jsonConfig }); + ACPClientAPI.invalidateRequirementProbeCache(); + window.dispatchEvent(new Event('bitfun:acp-clients-changed')); + } + + static async submitPermissionResponse( + request: SubmitAcpPermissionResponseRequest + ): Promise { + return api.invoke('submit_acp_permission_response', { request }); + } + + static async createFlowSession( + request: CreateAcpFlowSessionRequest + ): Promise { + const response = await api.invoke('create_acp_flow_session', { request }); + window.dispatchEvent(new Event('bitfun:acp-clients-changed')); + return response; + } + + static async startDialogTurn(request: StartAcpDialogTurnRequest): Promise { + return api.invoke('start_acp_dialog_turn', { request }); + } + + static async cancelDialogTurn(request: CancelAcpDialogTurnRequest): Promise { + return api.invoke('cancel_acp_dialog_turn', { request }); + } + + static async getSessionOptions( + request: GetAcpSessionOptionsRequest + ): Promise { + return api.invoke('get_acp_session_options', { request }); + } + + static async setSessionModel( + request: SetAcpSessionModelRequest + ): Promise { + return api.invoke('set_acp_session_model', { request }); + } +} + +export default ACPClientAPI; diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss new file mode 100644 index 000000000..e1b7e9058 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -0,0 +1,274 @@ +@use '../../../component-library/styles/tokens' as *; + +.bitfun-acp-agents { + &__manager { + display: flex; + flex-direction: column; + gap: $size-gap-4; + } + + &__toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-3; + align-items: center; + } + + &__toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + flex-wrap: wrap; + } + + &__search { + min-width: 0; + } + + &__filter-select { + width: 132px; + + .select__trigger { + min-height: 32px; + } + } + + &__json-textarea { + font-family: $font-family-mono; + font-size: var(--font-size-xs); + line-height: 1.6; + } + + &__json-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $size-gap-2; + margin-top: $size-gap-3; + } + + &__registry-list { + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: inherit; + } + + &__registry-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 92px 82px 120px; + gap: $size-gap-2; + align-items: center; + min-height: 56px; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--border-subtle); + background: transparent; + + &:last-child { + border-bottom: 0; + } + + &:hover { + background: var(--element-bg-subtle); + } + } + + &__registry-main { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: $size-gap-2; + align-items: center; + min-width: 0; + } + + &__registry-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: var(--element-bg-medium); + color: var(--color-text-secondary); + } + + &__registry-copy { + min-width: 0; + } + + &__registry-name { + display: block; + min-width: 0; + overflow: hidden; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: $font-weight-semibold; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__registry-description { + margin: 2px 0 0; + overflow: hidden; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__registry-command { + font-family: $font-family-mono; + } + + &__capabilities { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + justify-content: flex-start; + } + + &__capability { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding: 0 6px; + border: 1px solid var(--border-subtle); + border-radius: 999px; + background: var(--element-bg-subtle); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + line-height: 1; + white-space: nowrap; + + &.is-ok { + border-color: rgba($color-success, 0.24); + background: rgba($color-success, 0.08); + color: var(--color-success); + } + + &.is-error { + border-color: rgba($color-error, 0.24); + background: rgba($color-error, 0.08); + color: var(--color-error); + } + + &.is-muted { + color: var(--color-text-muted); + } + } + + &__status-cell, + &__confirmation-cell { + display: flex; + align-items: center; + justify-content: flex-end; + min-width: 0; + } + + &__status { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 24px; + min-width: 68px; + padding: 0 $size-gap-2; + border: 1px solid transparent; + border-radius: 999px; + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + line-height: 1; + white-space: nowrap; + + &.is-enabled { + border-color: rgba($color-success, 0.24); + background: rgba($color-success, 0.08); + color: var(--color-success); + } + + &.is-ready { + border-color: rgba($color-success, 0.24); + background: rgba($color-success, 0.08); + color: var(--color-success); + } + + &.is-not_installed { + border-color: var(--border-subtle); + background: var(--element-bg-subtle); + color: var(--color-text-muted); + } + + &.is-invalid { + border-color: rgba($color-error, 0.24); + background: rgba($color-error, 0.08); + color: var(--color-error); + } + + &.is-checking { + border-color: var(--border-subtle); + background: var(--element-bg-subtle); + color: var(--color-text-secondary); + + svg { + animation: bitfun-acp-spin 0.9s linear infinite; + } + } + } + + &__confirmation-select { + width: 118px; + + .select__trigger { + min-height: 30px; + padding: 3px $size-gap-2; + background: var(--element-bg); + } + + .select__value { + font-size: var(--font-size-xs); + } + } + + &__add-button { + min-width: 74px; + } + + &__empty { + padding: $size-gap-4; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + text-align: center; + } + + @media (max-width: 860px) { + &__toolbar, + &__registry-row { + grid-template-columns: 1fr; + } + + &__toolbar-actions, + &__capabilities, + &__status-cell, + &__confirmation-cell { + justify-content: flex-start; + } + + &__filter-select, + &__confirmation-select { + width: 150px; + } + } +} + +@keyframes bitfun-acp-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx new file mode 100644 index 000000000..2bebd607d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -0,0 +1,836 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Bot, + Download, + ExternalLink, + FileJson, + LoaderCircle, + Plus, + Save, + Search, + Terminal, +} from 'lucide-react'; +import { Button, Input, Select, Textarea } from '@/component-library'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageSection, +} from './common'; +import { + ACPClientAPI, + type AcpClientInfo, + type AcpClientPermissionMode, + type AcpClientRequirementProbe, + type AcpRequirementProbeItem, +} from '../../api/service-api/ACPClientAPI'; +import { systemAPI } from '../../api/service-api/SystemAPI'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import './AcpAgentsConfig.scss'; + +const log = createLogger('AcpAgentsConfig'); + +interface AcpClientConfig { + name?: string; + command: string; + args: string[]; + env: Record; + enabled: boolean; + readonly: boolean; + permissionMode: AcpClientPermissionMode; +} + +interface AcpClientConfigFile { + acpClients: Record; +} + +interface AcpClientPreset { + id: string; + name: string; + description: string; + version?: string; + command: string; + args: string[]; +} + +const PRESETS: AcpClientPreset[] = [ + { + id: 'opencode', + name: 'opencode', + description: 'AI coding agent with native ACP support.', + command: 'opencode', + args: ['acp'], + }, + { + id: 'claude-code', + name: 'Claude Code', + description: 'Claude Code connected through the Zed ACP adapter.', + command: 'npx', + args: ['--yes', '@zed-industries/claude-code-acp@latest'], + }, + { + id: 'codex', + name: 'Codex', + description: 'OpenAI Codex CLI connected through the Zed ACP adapter.', + command: 'npx', + args: ['--yes', '@zed-industries/codex-acp@latest'], + }, +]; + +const PRESET_BY_ID = new Map(PRESETS.map(preset => [preset.id, preset])); +let requirementProbeCache: AcpClientRequirementProbe[] | null = null; +let requirementProbeInFlight: Promise | null = null; + +function loadRequirementProbes(options: { force?: boolean } = {}): Promise { + if (!options.force && requirementProbeCache) { + return Promise.resolve(requirementProbeCache); + } + + if (!options.force && requirementProbeInFlight) { + return requirementProbeInFlight; + } + + requirementProbeInFlight = ACPClientAPI.probeClientRequirements({ force: options.force }) + .then((probes) => { + requirementProbeCache = probes; + return probes; + }) + .finally(() => { + requirementProbeInFlight = null; + }); + + return requirementProbeInFlight; +} + +function defaultConfigForPreset(preset: AcpClientPreset): AcpClientConfig { + return { + name: preset.name, + command: preset.command, + args: preset.args, + env: {}, + enabled: true, + readonly: false, + permissionMode: 'ask', + }; +} + +function normalizeConfigValue(value: unknown): AcpClientConfigFile { + const candidate = value && typeof value === 'object' ? value as Record : {}; + const rawClients = ( + candidate.acpClients && typeof candidate.acpClients === 'object' && !Array.isArray(candidate.acpClients) + ) + ? candidate.acpClients as Record + : candidate; + + const acpClients: Record = {}; + for (const [id, rawConfig] of Object.entries(rawClients)) { + if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + continue; + } + + const item = rawConfig as Record; + const command = typeof item.command === 'string' ? item.command.trim() : ''; + if (!command) { + continue; + } + + acpClients[id] = { + name: typeof item.name === 'string' ? item.name : undefined, + command, + args: Array.isArray(item.args) ? item.args.map(String) : [], + env: normalizeEnvObject(item.env), + enabled: item.enabled !== false, + readonly: item.readonly === true, + permissionMode: normalizePermissionMode(item.permissionMode), + }; + } + + return { acpClients }; +} + +function normalizeEnvObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as Record).map(([key, envValue]) => [key, String(envValue)]) + ); +} + +function normalizePermissionMode(value: unknown): AcpClientPermissionMode { + return value === 'allow_once' || value === 'reject_once' ? value : 'ask'; +} + +function formatConfig(config: AcpClientConfigFile): string { + return JSON.stringify(config, null, 2); +} + +function parseEnvText(value: string): Record { + const env: Record = {}; + for (const rawLine of value.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + const separator = line.indexOf('='); + if (separator <= 0) { + throw new Error(`Invalid env line: ${line}`); + } + env[line.slice(0, separator).trim()] = line.slice(separator + 1); + } + return env; +} + +function formatEnv(env: Record): string { + return Object.entries(env).map(([key, value]) => `${key}=${value}`).join('\n'); +} + +function requirementTone(item?: AcpRequirementProbeItem): 'ok' | 'error' | 'muted' { + if (!item) return 'muted'; + return item.installed ? 'ok' : 'error'; +} + +type RegistryFilter = 'all' | 'installed' | 'not_installed' | 'invalid'; +type AgentRowStatus = 'enabled' | 'ready' | 'not_installed' | 'invalid' | 'checking'; + +function getAgentRowStatus({ + configured, + enabled, + runnable, + probePending, +}: { + configured: boolean; + enabled: boolean; + runnable: boolean; + probePending: boolean; +}): AgentRowStatus { + if (probePending) return 'checking'; + if (!configured) return runnable ? 'ready' : 'not_installed'; + return enabled && runnable ? 'enabled' : 'invalid'; +} + +function CapabilityBadge({ + icon, + item, + label, + checking, + installedText, + missingText, + checkingText, +}: { + icon: React.ReactNode; + item?: AcpRequirementProbeItem; + label: string; + checking?: boolean; + installedText: string; + missingText: string; + checkingText: string; +}) { + const tone = item ? requirementTone(item) : 'muted'; + const title = item + ? [label, item.installed ? installedText : missingText, item.path, item.version, item.error] + .filter(Boolean) + .join('\n') + : checking ? `${label}\n${checkingText}` : label; + + return ( + + {icon} + {label} + + ); +} + +function AgentStatusBadge({ + status, + label, +}: { + status: AgentRowStatus; + label: string; +}) { + return ( + + {status === 'checking' && } + {label} + + ); +} + +const AcpAgentsConfig: React.FC = () => { + const { t } = useTranslation('settings/acp-agents'); + const { error: notifyError, success: notifySuccess } = useNotification(); + const jsonEditorRef = useRef(null); + + const [config, setConfig] = useState({ acpClients: {} }); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [showJsonEditor, setShowJsonEditor] = useState(false); + const [jsonConfig, setJsonConfig] = useState(''); + const [envDrafts, setEnvDrafts] = useState>({}); + const [requirementProbes, setRequirementProbes] = useState( + requirementProbeCache ?? [] + ); + const [probingRequirements, setProbingRequirements] = useState(false); + const [registrySearch, setRegistrySearch] = useState(''); + const [registryFilter, setRegistryFilter] = useState('all'); + const [installingClientIds, setInstallingClientIds] = useState>(() => new Set()); + const requirementProbeRequestIdRef = useRef(0); + + const clientsById = useMemo(() => new Map(clients.map(client => [client.id, client])), [clients]); + const probesById = useMemo( + () => new Map(requirementProbes.map(probe => [probe.id, probe])), + [requirementProbes] + ); + const customClientRows = useMemo(() => { + const ids = new Set([ + ...Object.keys(config.acpClients), + ...clients.map(client => client.id), + ]); + + return Array.from(ids) + .filter(id => !PRESET_BY_ID.has(id)) + .sort((a, b) => a.localeCompare(b)); + }, [clients, config.acpClients]); + + const registryPresets = useMemo(() => { + const search = registrySearch.trim().toLowerCase(); + return PRESETS.filter(preset => { + const probe = probesById.get(preset.id); + const probePending = probingRequirements && !probe; + const configured = Boolean(config.acpClients[preset.id]); + const enabled = config.acpClients[preset.id]?.enabled ?? clientsById.get(preset.id)?.enabled ?? false; + const status = getAgentRowStatus({ + configured, + enabled, + runnable: probe?.runnable === true, + probePending, + }); + if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; + if (registryFilter === 'not_installed' && status !== 'not_installed') return false; + if (registryFilter === 'invalid' && status !== 'invalid') return false; + if (!search) return true; + return [ + preset.name, + preset.id, + preset.description, + preset.command, + ...preset.args, + ].join(' ').toLowerCase().includes(search); + }); + }, [clientsById, config.acpClients, probesById, probingRequirements, registryFilter, registrySearch]); + + const visibleCustomClientRows = useMemo(() => { + const search = registrySearch.trim().toLowerCase(); + return customClientRows.filter(clientId => { + const clientConfig = config.acpClients[clientId]; + const clientInfo = clientsById.get(clientId); + const requirementProbe = probesById.get(clientId); + const probePending = probingRequirements && !requirementProbe; + const configured = Boolean(clientConfig || clientInfo); + const enabled = clientConfig?.enabled ?? clientInfo?.enabled ?? false; + const status = getAgentRowStatus({ + configured, + enabled, + runnable: requirementProbe?.runnable === true, + probePending, + }); + if (registryFilter === 'installed' && status !== 'enabled' && status !== 'ready') return false; + if (registryFilter === 'not_installed' && status !== 'not_installed') return false; + if (registryFilter === 'invalid' && status !== 'invalid') return false; + if (!search) return true; + return [ + clientId, + clientConfig?.name, + clientInfo?.name, + clientConfig?.command, + ...(clientConfig?.args ?? []), + ].filter(Boolean).join(' ').toLowerCase().includes(search); + }); + }, [clientsById, config.acpClients, customClientRows, probesById, probingRequirements, registryFilter, registrySearch]); + + const refreshRequirementProbes = useCallback(async ( + options: { force?: boolean; notifyOnError?: boolean } = {} + ) => { + if (!options.force && requirementProbeCache) { + setRequirementProbes(requirementProbeCache); + setProbingRequirements(false); + return; + } + + const requestId = ++requirementProbeRequestIdRef.current; + setProbingRequirements(true); + try { + const nextRequirementProbes = await loadRequirementProbes({ force: options.force }); + if (requirementProbeRequestIdRef.current === requestId) { + setRequirementProbes(nextRequirementProbes); + } + } catch (error) { + log.error('Failed to probe ACP agent requirements', error); + if (options.notifyOnError ?? true) { + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.probeFailed'), + }); + } + } finally { + if (requirementProbeRequestIdRef.current === requestId) { + setProbingRequirements(false); + } + } + }, [notifyError, t]); + + const loadConfig = useCallback(async ( + options: { showLoading?: boolean; refreshRequirements?: boolean } = {} + ) => { + const showLoading = options.showLoading ?? true; + const refreshRequirements = options.refreshRequirements ?? true; + try { + if (showLoading) { + setLoading(true); + } + const [rawConfig, nextClients] = await Promise.all([ + ACPClientAPI.loadJsonConfig(), + ACPClientAPI.getClients(), + ]); + const parsed = normalizeConfigValue(JSON.parse(rawConfig || '{}')); + setConfig(parsed); + setJsonConfig(formatConfig(parsed)); + setEnvDrafts( + Object.fromEntries( + Object.entries(parsed.acpClients).map(([clientId, clientConfig]) => [ + clientId, + formatEnv(clientConfig.env), + ]) + ) + ); + setClients(nextClients); + setDirty(false); + if (refreshRequirements) { + void refreshRequirementProbes({ notifyOnError: false }); + } + } catch (error) { + log.error('Failed to load ACP agent config', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.loadFailed'), + }); + } finally { + if (showLoading) { + setLoading(false); + } + } + }, [notifyError, refreshRequirementProbes, t]); + + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + + useEffect(() => { + const handleAcpClientsChanged = () => { + void loadConfig({ showLoading: false }); + }; + window.addEventListener('bitfun:acp-clients-changed', handleAcpClientsChanged); + return () => { + window.removeEventListener('bitfun:acp-clients-changed', handleAcpClientsChanged); + }; + }, [loadConfig]); + + const patchClientConfig = (clientId: string, patch: Partial) => { + setConfig(prev => { + const preset = PRESET_BY_ID.get(clientId); + const current = prev.acpClients[clientId] ?? + (preset ? defaultConfigForPreset(preset) : undefined); + if (!current) return prev; + + const next = { + acpClients: { + ...prev.acpClients, + [clientId]: { + ...current, + ...patch, + }, + }, + }; + setJsonConfig(formatConfig(next)); + return next; + }); + setDirty(true); + }; + + const installPresetClient = async (preset: AcpClientPreset) => { + setInstallingClientIds(prev => new Set(prev).add(preset.id)); + try { + await ACPClientAPI.installClientCli({ clientId: preset.id }); + requirementProbeCache = null; + await refreshRequirementProbes({ force: true, notifyOnError: false }); + notifySuccess(t('notifications.downloadSuccess')); + } catch (error) { + log.error('Failed to download ACP agent CLI', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.downloadFailed'), + }); + } finally { + setInstallingClientIds(prev => { + const next = new Set(prev); + next.delete(preset.id); + return next; + }); + } + }; + + const mergeEnvDrafts = (baseConfig: AcpClientConfigFile): AcpClientConfigFile => ({ + acpClients: Object.fromEntries( + Object.entries(baseConfig.acpClients).map(([clientId, clientConfig]) => [ + clientId, + { + ...clientConfig, + env: envDrafts[clientId] !== undefined + ? parseEnvText(envDrafts[clientId]) + : clientConfig.env, + }, + ]) + ), + }); + + const saveConfig = async (nextConfig = config, options: { mergeEnvDrafts?: boolean } = {}) => { + try { + setSaving(true); + const configToSave = options.mergeEnvDrafts === false + ? nextConfig + : mergeEnvDrafts(nextConfig); + await ACPClientAPI.saveJsonConfig(formatConfig(configToSave)); + const nextClients = await ACPClientAPI.getClients(); + setClients(nextClients); + setConfig(configToSave); + setJsonConfig(formatConfig(configToSave)); + setDirty(false); + void refreshRequirementProbes({ force: true, notifyOnError: false }); + notifySuccess(t('notifications.saveSuccess')); + } catch (error) { + log.error('Failed to save ACP agent config', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.saveFailed'), + }); + } finally { + setSaving(false); + } + }; + + const addPresetClient = async (preset: AcpClientPreset) => { + const nextClient = defaultConfigForPreset(preset); + const next = { + acpClients: { + ...config.acpClients, + [preset.id]: nextClient, + }, + }; + setConfig(next); + setJsonConfig(formatConfig(next)); + setEnvDrafts(prev => ({ + ...prev, + [preset.id]: formatEnv(nextClient.env), + })); + setDirty(true); + await saveConfig(next, { mergeEnvDrafts: false }); + }; + + const saveJsonConfig = async () => { + try { + const parsed = normalizeConfigValue(JSON.parse(jsonConfig)); + await saveConfig(parsed, { mergeEnvDrafts: false }); + setConfig(parsed); + setEnvDrafts( + Object.fromEntries( + Object.entries(parsed.acpClients).map(([clientId, clientConfig]) => [ + clientId, + formatEnv(clientConfig.env), + ]) + ) + ); + setShowJsonEditor(false); + } catch (error) { + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.invalidJson'), + }); + } + }; + + const permissionOptions = useMemo(() => [ + { value: 'ask', label: t('permissionMode.ask') }, + { value: 'allow_once', label: t('permissionMode.allowOnce') }, + { value: 'reject_once', label: t('permissionMode.rejectOnce') }, + ], [t]); + + const registryFilterOptions = useMemo(() => [ + { value: 'all', label: t('registry.filters.all') }, + { value: 'installed', label: t('registry.filters.enabled') }, + { value: 'not_installed', label: t('registry.filters.notInstalled') }, + { value: 'invalid', label: t('registry.filters.configInvalid') }, + ], [t]); + + const getStatusLabel = useCallback((status: AgentRowStatus) => { + if (status === 'enabled') return t('registry.enabled'); + if (status === 'ready') return t('registry.ready'); + if (status === 'not_installed') return t('registry.notInstalled'); + if (status === 'checking') return t('registry.checking'); + return t('registry.configInvalid'); + }, [t]); + + const openLearnMore = useCallback(() => { + void systemAPI.openExternal('https://agentclientprotocol.com/get-started/introduction').catch((error) => { + log.error('Failed to open ACP documentation', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.openLinkFailed'), + }); + }); + }, [notifyError, t]); + + return ( + + + + +
+
+ setRegistrySearch(event.target.value)} + placeholder={t('registry.searchPlaceholder')} + prefix={} + size="medium" + variant="outlined" + /> +
+