From a07b17ac32f8451bcc245546d5708d53d6c330b2 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 12 May 2026 15:33:26 +0800 Subject: [PATCH 1/5] feat(acp): support remote built-in clients --- src/apps/desktop/src/api/acp_client_api.rs | 44 +- src/crates/acp/Cargo.toml | 1 + src/crates/acp/src/client/builtin_clients.rs | 160 +++++ src/crates/acp/src/client/config.rs | 9 + src/crates/acp/src/client/manager.rs | 629 +++++++++++++----- src/crates/acp/src/client/mod.rs | 3 + .../acp/src/client/remote_capability_store.rs | 101 +++ src/crates/acp/src/client/requirements.rs | 173 ++++- src/crates/acp/src/client/tool.rs | 6 +- src/crates/acp/src/client/tool_card_bridge.rs | 434 ------------ .../acp/src/client/tool_card_bridge/mod.rs | 119 ++++ .../src/client/tool_card_bridge/tool_name.rs | 222 +++++++ .../client/tool_card_bridge/tool_params.rs | 110 +++ .../sections/workspaces/WorkspaceItem.tsx | 8 +- .../workspaceAcpMenuClients.test.ts | 40 ++ .../workspaces/workspaceAcpMenuClients.ts | 60 +- .../features/ssh-remote/SSHRemoteProvider.tsx | 81 ++- .../api/service-api/ACPClientAPI.ts | 33 +- 18 files changed, 1588 insertions(+), 645 deletions(-) create mode 100644 src/crates/acp/src/client/builtin_clients.rs create mode 100644 src/crates/acp/src/client/remote_capability_store.rs delete mode 100644 src/crates/acp/src/client/tool_card_bridge.rs create mode 100644 src/crates/acp/src/client/tool_card_bridge/mod.rs create mode 100644 src/crates/acp/src/client/tool_card_bridge/tool_name.rs create mode 100644 src/crates/acp/src/client/tool_card_bridge/tool_params.rs diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 5123ed9ff..bf12f4dac 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -76,6 +76,15 @@ pub struct GetAcpSessionOptionsRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProbeAcpClientRequirementsRequest { + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub force_refresh: bool, +} + #[tauri::command] pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { let service = state @@ -97,13 +106,17 @@ pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, + request: ProbeAcpClientRequirementsRequest, ) -> Result, String> { let service = state .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; service - .probe_client_requirements() + .probe_client_requirements( + request.remote_connection_id.as_deref(), + request.force_refresh, + ) .await .map_err(|e| e.to_string()) } @@ -166,7 +179,12 @@ pub async fn create_acp_flow_session( .await .map_err(|e| e.to_string())?; if let Err(error) = service - .start_client_for_session(&request.client_id, &response.session_id) + .start_client_for_session( + &request.client_id, + &response.session_id, + Some(&request.workspace_path), + request.remote_connection_id.as_deref(), + ) .await { if let Err(cleanup_error) = service @@ -246,14 +264,15 @@ pub async fn start_acp_dialog_turn( 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| { + .prompt_agent_stream( + &request.client_id, + request.user_input, + request.workspace_path, + request.remote_connection_id, + request.session_id.clone(), + session_storage_path, + request.timeout_seconds, + |event| { match event { AcpClientStreamEvent::ModelRoundStarted { round_id, @@ -404,7 +423,7 @@ pub async fn cancel_acp_dialog_turn( .cancel_agent_session( &request.client_id, request.workspace_path, - Some(request.session_id), + request.session_id, ) .await .map_err(|e| e.to_string()) @@ -435,8 +454,9 @@ pub async fn get_acp_session_options( .get_session_options( &request.client_id, request.workspace_path, + request.remote_connection_id, session_storage_path, - Some(request.session_id), + request.session_id, ) .await .map_err(|e| e.to_string()) diff --git a/src/crates/acp/Cargo.toml b/src/crates/acp/Cargo.toml index c469cd732..69ad62600 100644 --- a/src/crates/acp/Cargo.toml +++ b/src/crates/acp/Cargo.toml @@ -15,6 +15,7 @@ bitfun-events = { path = "../events" } agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat"] } +futures = { workspace = true } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/acp/src/client/builtin_clients.rs b/src/crates/acp/src/client/builtin_clients.rs new file mode 100644 index 000000000..4a4cea539 --- /dev/null +++ b/src/crates/acp/src/client/builtin_clients.rs @@ -0,0 +1,160 @@ +use std::collections::HashMap; + +use super::config::{AcpClientConfig, AcpClientPermissionMode}; + +pub(crate) struct BuiltinAcpClientPreset { + pub(crate) id: &'static str, + pub(crate) command: &'static str, + pub(crate) args: &'static [&'static str], + pub(crate) tool_command: &'static str, + pub(crate) install_package: &'static str, + pub(crate) adapter_package: Option<&'static str>, + pub(crate) adapter_bin: Option<&'static str>, +} + +const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ + BuiltinAcpClientPreset { + id: "opencode", + command: "opencode", + args: &["acp"], + tool_command: "opencode", + install_package: "opencode-ai", + adapter_package: None, + adapter_bin: None, + }, + BuiltinAcpClientPreset { + id: "claude-code", + command: "npx", + args: &["--yes", "@zed-industries/claude-code-acp@latest"], + tool_command: "claude", + install_package: "@anthropic-ai/claude-code", + adapter_package: Some("@zed-industries/claude-code-acp"), + adapter_bin: Some("claude-code-acp"), + }, + BuiltinAcpClientPreset { + id: "codex", + command: "npx", + args: &["--yes", "@zed-industries/codex-acp@latest"], + tool_command: "codex", + install_package: "@openai/codex", + adapter_package: Some("@zed-industries/codex-acp"), + adapter_bin: Some("codex-acp"), + }, +]; + +pub(crate) fn builtin_client_ids() -> impl Iterator { + BUILTIN_ACP_CLIENT_PRESETS.iter().map(|preset| preset.id) +} + +pub(crate) fn builtin_acp_client_preset( + client_id: &str, +) -> Option<&'static BuiltinAcpClientPreset> { + BUILTIN_ACP_CLIENT_PRESETS + .iter() + .find(|preset| preset.id == client_id) +} + +pub(crate) fn supported_remote_acp_clients() -> String { + builtin_client_ids().collect::>().join(", ") +} + +pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option { + let preset = builtin_acp_client_preset(client_id)?; + Some(AcpClientConfig { + name: None, + command: preset.command.to_string(), + args: preset + .args + .iter() + .map(|value| (*value).to_string()) + .collect(), + env: HashMap::new(), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }) +} + +pub(crate) fn remote_command_for_builtin_client(client_id: &str) -> Option { + let preset = builtin_acp_client_preset(client_id)?; + Some(render_shell_command(preset.command, preset.args)) +} + +pub(crate) fn remote_command_for_builtin_client_in_workspace( + client_id: &str, + workspace_path: &str, +) -> Option { + let command = remote_command_for_builtin_client(client_id)?; + let workspace_path = workspace_path.trim(); + if workspace_path.is_empty() { + return Some(command); + } + Some(format!( + "cd {} && {}", + shell_escape(workspace_path), + command + )) +} + +fn render_shell_command(command: &str, args: &[&str]) -> String { + std::iter::once(command) + .chain(args.iter().copied()) + .map(shell_escape) + .collect::>() + .join(" ") +} + +fn shell_escape(value: &str) -> String { + if value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') + }) { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_remote_builtin_command_for_opencode() { + assert_eq!( + remote_command_for_builtin_client("opencode").as_deref(), + Some("opencode acp") + ); + } + + #[test] + fn builds_remote_builtin_command_for_npx_adapter() { + assert_eq!( + remote_command_for_builtin_client("codex").as_deref(), + Some("npx --yes @zed-industries/codex-acp@latest") + ); + } + + #[test] + fn returns_default_config_for_builtin_client() { + let config = default_config_for_builtin_client("claude-code").expect("builtin config"); + assert!(config.enabled); + assert_eq!(config.command, "npx"); + assert_eq!( + config.args, + vec!["--yes", "@zed-industries/claude-code-acp@latest"] + ); + } + + #[test] + fn shell_escape_quotes_spaces() { + assert_eq!(shell_escape("hello world"), "'hello world'"); + } + + #[test] + fn builds_remote_builtin_command_in_workspace() { + assert_eq!( + remote_command_for_builtin_client_in_workspace("opencode", "/tmp/my repo").as_deref(), + Some("cd '/tmp/my repo' && opencode acp") + ); + } +} diff --git a/src/crates/acp/src/client/config.rs b/src/crates/acp/src/client/config.rs index fbae5eb12..fc9ffdd22 100644 --- a/src/crates/acp/src/client/config.rs +++ b/src/crates/acp/src/client/config.rs @@ -68,6 +68,15 @@ pub struct AcpClientRequirementProbe { pub notes: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAcpClientRequirementSnapshot { + pub connection_id: String, + pub last_probed_at: u64, + #[serde(default)] + pub probes: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpRequirementProbeItem { diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 3f1254ad5..3080e76e9 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::process::Stdio; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -20,8 +21,10 @@ 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::service::remote_ssh::workspace_state::get_remote_workspace_manager; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use dashmap::DashMap; +use futures::io::{AsyncRead as FuturesAsyncRead, AsyncWrite as FuturesAsyncWrite}; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -29,14 +32,21 @@ use tokio::process::{Child, Command}; use tokio::sync::{oneshot, Mutex, RwLock}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; +use super::builtin_clients::{ + builtin_acp_client_preset, builtin_client_ids, default_config_for_builtin_client, + remote_command_for_builtin_client, remote_command_for_builtin_client_in_workspace, + supported_remote_acp_clients, +}; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, - AcpClientRequirementProbe, AcpClientStatus, + AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; +use super::remote_capability_store::RemoteAcpCapabilityStore; 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, + predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, + probe_remote_npx_adapter, resolve_configured_command, }; use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; use super::session_persistence::AcpSessionPersistence; @@ -50,6 +60,9 @@ 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); +type AcpOutgoingStream = Pin>; +type AcpIncomingStream = Pin>; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SubmitAcpPermissionResponseRequest { @@ -83,6 +96,7 @@ pub struct SetAcpSessionModelRequest { pub struct AcpClientService { config_service: Arc, session_persistence: AcpSessionPersistence, + remote_capability_store: RemoteAcpCapabilityStore, clients: DashMap>, pending_permissions: DashMap, session_permission_modes: DashMap, @@ -113,6 +127,18 @@ struct AcpRemoteSession { discard_pending_updates_before_next_prompt: bool, } +struct ResolvedClientSession { + client: Arc, + cwd: PathBuf, + session_key: String, + session: Arc>, +} + +struct StartClientConfig { + remote_connection_id: Option, + config: AcpClientConfig, +} + #[derive(Clone)] struct AcpCancelHandle { session_id: String, @@ -137,7 +163,12 @@ impl AcpClientService { ) -> BitFunResult> { Ok(Arc::new(Self { config_service, - session_persistence: AcpSessionPersistence::new(path_manager)?, + session_persistence: AcpSessionPersistence::new(path_manager.clone())?, + remote_capability_store: RemoteAcpCapabilityStore::new( + path_manager + .user_data_dir() + .join("ssh_acp_capabilities.json"), + ), clients: DashMap::new(), pending_permissions: DashMap::new(), session_permission_modes: DashMap::new(), @@ -224,10 +255,29 @@ impl AcpClientService { pub async fn probe_client_requirements( self: &Arc, + remote_connection_id: Option<&str>, + force_refresh: bool, ) -> BitFunResult> { + if let Some(remote_connection_id) = remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return if force_refresh { + self.refresh_remote_client_requirements(remote_connection_id) + .await + } else { + Ok(self + .remote_capability_store + .get(remote_connection_id) + .await + .map(|snapshot| snapshot.probes) + .unwrap_or_default()) + }; + } + let configs = self.load_configs().await?; let mut ids = configs.keys().cloned().collect::>(); - for id in ["opencode", "claude-code", "codex"] { + for id in builtin_client_ids() { if !ids.iter().any(|candidate| candidate == id) { ids.push(id.to_string()); } @@ -281,6 +331,91 @@ impl AcpClientService { Ok(probes) } + pub async fn refresh_remote_client_requirements( + &self, + remote_connection_id: &str, + ) -> BitFunResult> { + let probes = self + .probe_remote_client_requirements(remote_connection_id) + .await?; + self.remote_capability_store + .set(RemoteAcpClientRequirementSnapshot { + connection_id: remote_connection_id.to_string(), + last_probed_at: current_unix_timestamp_ms(), + probes: probes.clone(), + }) + .await?; + Ok(probes) + } + + async fn probe_remote_client_requirements( + &self, + remote_connection_id: &str, + ) -> BitFunResult> { + let remote_manager = get_remote_workspace_manager().ok_or_else(|| { + BitFunError::service("Remote workspace manager is not initialized".to_string()) + })?; + let ssh_manager = remote_manager.get_ssh_manager().await.ok_or_else(|| { + BitFunError::service("SSH manager is not available for remote ACP".to_string()) + })?; + + let mut ids = builtin_client_ids() + .map(ToString::to_string) + .collect::>(); + ids.sort(); + + let mut probes = Vec::with_capacity(ids.len()); + for id in ids { + let spec = acp_requirement_spec(&id, None); + let tool = + probe_remote_executable(&ssh_manager, remote_connection_id, spec.tool_command) + .await; + let adapter = match spec.adapter { + Some(adapter) => Some( + probe_remote_npx_adapter(&ssh_manager, remote_connection_id, adapter.package) + .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 remote PATH", + spec.tool_command + )); + } + if let Some(adapter) = adapter.as_ref() { + if !adapter.installed { + notes.push("npx is not available on remote PATH".to_string()); + } + } + + debug!( + "Remote 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)); @@ -311,16 +446,25 @@ impl AcpClientService { self: &Arc, client_id: &str, bitfun_session_id: &str, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, ) -> BitFunResult<()> { let connection_id = session_client_connection_id(client_id, bitfun_session_id); - self.start_client_connection(&connection_id, client_id) - .await + self.start_client_connection( + &connection_id, + client_id, + workspace_path, + remote_connection_id, + ) + .await } async fn start_client_connection( self: &Arc, connection_id: &str, client_id: &str, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, ) -> BitFunResult<()> { if let Some(existing) = self.clients.get(connection_id) { let status = *existing.status.read().await; @@ -329,18 +473,12 @@ impl AcpClientService { } } - 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 StartClientConfig { + remote_connection_id, + config, + } = self + .resolve_start_client_config(client_id, workspace_path, remote_connection_id) + .await?; let connection = Arc::new(AcpClientConnection::new( connection_id.to_string(), @@ -351,57 +489,33 @@ impl AcpClientService { .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 = bitfun_core::util::process_manager::create_tokio_command(&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 (transport, child) = match remote_connection_id { + Some(ref remote_connection_id) => { + self.open_transport_for_connection( + client_id, + connection_id, + &connection.config, + workspace_path, + Some(remote_connection_id.as_str()), + ) + .await } - }; - - 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 - ))); + self.open_transport_for_connection( + client_id, + connection_id, + &connection.config, + workspace_path, + None, + ) + .await } - }; - - *connection.child.lock().await = Some(child); - - let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); + } + .map_err(|error| { + self.clients.remove(connection_id); + error + })?; + *connection.child.lock().await = child; let service = self.clone(); let connection_for_task = connection.clone(); let (cx_tx, cx_rx) = oneshot::channel(); @@ -464,7 +578,11 @@ impl AcpClientService { *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); + info!( + "ACP client started: id={} remote_connection_id={}", + client_id, + remote_connection_id.as_deref().unwrap_or("") + ); Ok(()) } @@ -652,24 +770,25 @@ impl AcpClientService { self: &Arc, client_id: &str, workspace_path: Option, + remote_connection_id: Option, session_storage_path: Option, - bitfun_session_id: Option, + bitfun_session_id: String, ) -> BitFunResult { - let (client, cwd, session_key) = self - .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + let resolved = self + .resolve_or_create_client_session( + client_id, + workspace_path, + remote_connection_id.as_deref(), + &bitfun_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; + let mut session = resolved.session.lock().await; self.ensure_remote_session( - &client, - &session_key, - &cwd, - bitfun_session_id.as_deref(), + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &bitfun_session_id, session_storage_path.as_deref(), &mut session, ) @@ -685,25 +804,21 @@ impl AcpClientService { request: SetAcpSessionModelRequest, session_storage_path: Option, ) -> BitFunResult { - let (client, cwd, session_key) = self - .resolve_client_session( + let resolved = self + .resolve_or_create_client_session( &request.client_id, request.workspace_path, - Some(&request.session_id), + request.remote_connection_id.as_deref(), + &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; + let mut session = resolved.session.lock().await; self.ensure_remote_session( - &client, - &session_key, - &cwd, - Some(&request.session_id), + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &request.session_id, session_storage_path.as_deref(), &mut session, ) @@ -785,26 +900,27 @@ impl AcpClientService { client_id: &str, prompt: String, workspace_path: Option, - bitfun_session_id: Option, + remote_connection_id: Option, + bitfun_session_id: String, 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()) + let resolved = self + .resolve_or_create_client_session( + client_id, + workspace_path, + remote_connection_id.as_deref(), + &bitfun_session_id, + ) .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; + let mut session = resolved.session.lock().await; self.ensure_remote_session( - &client, - &session_key, - &cwd, - bitfun_session_id.as_deref(), + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &bitfun_session_id, session_storage_path.as_deref(), &mut session, ) @@ -835,7 +951,8 @@ impl AcpClientService { client_id: &str, prompt: String, workspace_path: Option, - bitfun_session_id: Option, + remote_connection_id: Option, + bitfun_session_id: String, session_storage_path: Option, timeout_seconds: Option, mut on_event: F, @@ -843,22 +960,22 @@ impl AcpClientService { where F: FnMut(AcpClientStreamEvent) -> BitFunResult<()> + Send, { - let (client, cwd, session_key) = self - .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + let resolved = self + .resolve_or_create_client_session( + client_id, + workspace_path, + remote_connection_id.as_deref(), + &bitfun_session_id, + ) .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; + let mut session = resolved.session.lock().await; self.ensure_remote_session( - &client, - &session_key, - &cwd, - bitfun_session_id.as_deref(), + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &bitfun_session_id, session_storage_path.as_deref(), &mut session, ) @@ -911,12 +1028,9 @@ impl AcpClientService { self: &Arc, client_id: &str, workspace_path: Option, - bitfun_session_id: Option, + bitfun_session_id: String, ) -> 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 connection_id = session_client_connection_id(client_id, &bitfun_session_id); let client = self .clients .get(&connection_id) @@ -929,7 +1043,7 @@ impl AcpClientService { .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 session_key = build_session_key(&bitfun_session_id, 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 '{}'", @@ -973,13 +1087,17 @@ impl AcpClientService { self: &Arc, client_id: &str, workspace_path: Option, - bitfun_session_id: Option<&str>, + remote_connection_id: Option<&str>, + bitfun_session_id: &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 connection_id = session_client_connection_id(client_id, bitfun_session_id); + self.start_client_connection( + &connection_id, + client_id, + workspace_path.as_deref(), + remote_connection_id, + ) + .await?; let client = self .clients .get(&connection_id) @@ -996,12 +1114,40 @@ impl AcpClientService { Ok((client, cwd, session_key)) } + async fn resolve_or_create_client_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + remote_connection_id: Option<&str>, + bitfun_session_id: &str, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session( + client_id, + workspace_path, + remote_connection_id, + bitfun_session_id, + ) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + Ok(ResolvedClientSession { + client, + cwd, + session_key, + session, + }) + } + async fn ensure_remote_session( &self, client: &Arc, session_key: &str, cwd: &Path, - bitfun_session_id: Option<&str>, + bitfun_session_id: &str, session_storage_path: Option<&Path>, session: &mut AcpRemoteSession, ) -> BitFunResult<()> { @@ -1010,16 +1156,13 @@ impl AcpClientService { } 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 persisted_remote_session_id = if let Some(session_storage_path) = session_storage_path { + 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; @@ -1105,7 +1248,7 @@ impl AcpClientService { &self, client: &Arc, session_key: &str, - bitfun_session_id: Option<&str>, + bitfun_session_id: &str, session_storage_path: Option<&Path>, session: &mut AcpRemoteSession, response: NewSessionResponse, @@ -1128,9 +1271,7 @@ impl AcpClientService { ); 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) - { + if let Some(session_storage_path) = session_storage_path { self.session_persistence .update_remote_session_state( session_storage_path, @@ -1254,6 +1395,198 @@ impl AcpClientService { .map(|entry| *entry.value()) .unwrap_or(AcpClientPermissionMode::Ask) } + + async fn start_local_transport( + &self, + client_id: &str, + connection_id: &str, + config: &AcpClientConfig, + ) -> BitFunResult<(ByteStreams, Child)> { + let program = resolve_configured_command(&config.command, &config.env); + let mut command = bitfun_core::util::process_manager::create_tokio_command(&program); + command + .args(&config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + apply_command_environment(&mut command, Some(&config.env)); + configure_process_group(&mut command); + + let mut child = command.spawn().map_err(|error| { + 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; + 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; + return Err(BitFunError::service(format!( + "ACP client '{}' stdin is unavailable", + client_id + ))); + } + }; + + Ok(( + ByteStreams::new(Box::pin(stdin.compat_write()), Box::pin(stdout.compat())), + child, + )) + } + + async fn open_transport_for_connection( + &self, + client_id: &str, + connection_id: &str, + config: &AcpClientConfig, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + ) -> BitFunResult<( + ByteStreams, + Option, + )> { + match remote_connection_id { + Some(remote_connection_id) => self + .start_remote_transport(client_id, workspace_path, remote_connection_id) + .await + .map(|transport| (transport, None)), + None => self + .start_local_transport(client_id, connection_id, config) + .await + .map(|(transport, child)| (transport, Some(child))), + } + } + + async fn start_remote_transport( + &self, + client_id: &str, + workspace_path: Option<&str>, + remote_connection_id: &str, + ) -> BitFunResult> { + let command = workspace_path + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|workspace_path| { + remote_command_for_builtin_client_in_workspace(client_id, workspace_path) + }) + .or_else(|| remote_command_for_builtin_client(client_id)) + .ok_or_else(|| { + BitFunError::config(format!( + "Remote ACP currently supports only built-in clients: {}", + supported_remote_acp_clients() + )) + })?; + let remote_manager = get_remote_workspace_manager().ok_or_else(|| { + BitFunError::service("Remote workspace manager is not initialized".to_string()) + })?; + let ssh_manager = remote_manager.get_ssh_manager().await.ok_or_else(|| { + BitFunError::service("SSH manager is not available for remote ACP".to_string()) + })?; + let channel = ssh_manager + .open_exec_channel(remote_connection_id, &command) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to start remote ACP client '{}': {}", + client_id, error + )) + })?; + let stream = channel.into_stream(); + let (reader, writer) = tokio::io::split(stream); + Ok(ByteStreams::new( + Box::pin(writer.compat_write()), + Box::pin(reader.compat()), + )) + } + + async fn resolve_start_client_config( + &self, + client_id: &str, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + ) -> BitFunResult { + let remote_connection_id = remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let remote_builtin = remote_connection_id + .as_deref() + .and_then(|_| builtin_acp_client_preset(client_id)) + .is_some(); + let mut config = self + .load_configs() + .await? + .remove(client_id) + .or_else(|| { + if remote_connection_id.is_some() { + default_config_for_builtin_client(client_id) + } else { + None + } + }) + .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + + if remote_builtin { + config.enabled = true; + } + if !config.enabled { + return Err(BitFunError::config(format!( + "ACP client is disabled: {}", + client_id + ))); + } + + if remote_connection_id.is_some() { + ensure_remote_client_supported(client_id, workspace_path)?; + } + + Ok(StartClientConfig { + remote_connection_id, + config, + }) + } +} + +fn ensure_remote_client_supported( + client_id: &str, + workspace_path: Option<&str>, +) -> BitFunResult<()> { + if workspace_path + .map(str::trim) + .is_none_or(|workspace_path| workspace_path.is_empty()) + { + return Err(BitFunError::validation( + "Workspace path is required for remote ACP sessions".to_string(), + )); + } + + if builtin_acp_client_preset(client_id).is_none() { + return Err(BitFunError::config(format!( + "Remote ACP currently supports only built-in clients: {}", + supported_remote_acp_clients() + ))); + } + + Ok(()) +} + +fn current_unix_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) } impl AcpClientConnection { @@ -1294,10 +1627,10 @@ fn parse_config_value(value: serde_json::Value) -> BitFunResult, client_id: &str, cwd: &Path) -> String { +fn build_session_key(bitfun_session_id: &str, client_id: &str, cwd: &Path) -> String { format!( "{}:{}:{}", - bitfun_session_id.unwrap_or("standalone"), + bitfun_session_id, client_id, cwd.to_string_lossy() ) diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index 7c61197d1..af6a6891f 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -1,5 +1,7 @@ +mod builtin_clients; mod config; mod manager; +mod remote_capability_store; mod remote_session; mod requirements; mod session_options; @@ -11,6 +13,7 @@ mod tool_card_bridge; pub use config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, AcpRequirementProbeItem, + RemoteAcpClientRequirementSnapshot, }; pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, diff --git a/src/crates/acp/src/client/remote_capability_store.rs b/src/crates/acp/src/client/remote_capability_store.rs new file mode 100644 index 000000000..759b71c41 --- /dev/null +++ b/src/crates/acp/src/client/remote_capability_store.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use log::warn; +use tokio::sync::RwLock; + +use super::config::RemoteAcpClientRequirementSnapshot; + +#[derive(Clone)] +pub(crate) struct RemoteAcpCapabilityStore { + path: PathBuf, + snapshots: Arc>>, +} + +impl RemoteAcpCapabilityStore { + pub(crate) fn new(path: PathBuf) -> Self { + let snapshots = match std::fs::read_to_string(&path) { + Ok(content) => { + match serde_json::from_str::>(&content) { + Ok(entries) => entries + .into_iter() + .map(|entry| (entry.connection_id.clone(), entry)) + .collect(), + Err(error) => { + warn!( + "Failed to parse remote ACP capability snapshots: path={} error={}", + path.display(), + error + ); + HashMap::new() + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => HashMap::new(), + Err(error) => { + warn!( + "Failed to read remote ACP capability snapshots: path={} error={}", + path.display(), + error + ); + HashMap::new() + } + }; + + Self { + path, + snapshots: Arc::new(RwLock::new(snapshots)), + } + } + + pub(crate) async fn get( + &self, + connection_id: &str, + ) -> Option { + self.snapshots.read().await.get(connection_id).cloned() + } + + pub(crate) async fn set( + &self, + snapshot: RemoteAcpClientRequirementSnapshot, + ) -> BitFunResult<()> { + let entries = { + let mut guard = self.snapshots.write().await; + guard.insert(snapshot.connection_id.clone(), snapshot); + guard.values().cloned().collect::>() + }; + self.persist(entries).await + } + + async fn persist( + &self, + snapshots: Vec, + ) -> BitFunResult<()> { + if let Some(parent) = self.path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|error| { + BitFunError::io(format!( + "Failed to create remote ACP capability snapshot directory: {}", + error + )) + })?; + } + + let content = serde_json::to_string_pretty(&snapshots).map_err(|error| { + BitFunError::serialization(format!( + "Failed to serialize remote ACP capability snapshots: {}", + error + )) + })?; + tokio::fs::write(&self.path, content) + .await + .map_err(|error| { + BitFunError::io(format!( + "Failed to write remote ACP capability snapshots: {}", + error + )) + })?; + Ok(()) + } +} diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs index 0e0d11ac8..ca87ad556 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -4,9 +4,11 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::time::Duration; +use bitfun_core::service::remote_ssh::SSHConnectionManager; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use tokio::process::Command; +use super::builtin_clients::builtin_acp_client_preset; use super::config::{AcpClientConfig, AcpRequirementProbeItem}; const REQUIREMENT_PROBE_TIMEOUT: Duration = Duration::from_secs(3); @@ -28,35 +30,23 @@ 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, - }, + if let Some(preset) = builtin_acp_client_preset(client_id) { + return AcpRequirementSpec { + tool_command: preset.tool_command, + install_package: Some(preset.install_package), + adapter: match (preset.adapter_package, preset.adapter_bin) { + (Some(package), Some(bin)) => Some(AcpAdapterSpec { package, bin }), + _ => None, + }, + }; + } + + AcpRequirementSpec { + tool_command: config + .map(|config| config.command.as_str()) + .unwrap_or(client_id), + install_package: None, + adapter: None, } } @@ -162,6 +152,109 @@ pub(crate) async fn probe_npm_adapter(package: &str, bin: &str) -> AcpRequiremen item } +pub(crate) async fn probe_remote_executable( + ssh_manager: &SSHConnectionManager, + connection_id: &str, + command: &str, +) -> AcpRequirementProbeItem { + let mut item = AcpRequirementProbeItem { + name: command.to_string(), + installed: false, + version: None, + path: None, + error: None, + }; + + let resolve_command = format!("command -v {}", shell_escape(command)); + match ssh_manager + .execute_command(connection_id, &resolve_command) + .await + { + Ok((stdout, _stderr, exit_code)) if exit_code == 0 => { + let resolved_path = stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(ToString::to_string); + item.installed = resolved_path.is_some(); + item.path = resolved_path; + } + Ok((stdout, stderr, _)) => { + let summary = remote_command_error_summary(&stderr, &stdout); + if !summary.is_empty() { + item.error = Some(summary); + } + } + Err(error) => { + item.error = Some(error.to_string()); + } + } + + if item.installed { + let version_command = format!("{} --version", shell_escape(command)); + match ssh_manager + .execute_command(connection_id, &version_command) + .await + { + Ok((stdout, stderr, exit_code)) if exit_code == 0 => { + item.version = parse_version_text(stdout.as_bytes()) + .or_else(|| parse_version_text(stderr.as_bytes())); + } + Ok((stdout, stderr, _)) => { + item.error = Some(remote_command_error_summary(&stderr, &stdout)); + } + Err(error) => { + item.error = Some(error.to_string()); + } + } + } + + item +} + +pub(crate) async fn probe_remote_npx_adapter( + ssh_manager: &SSHConnectionManager, + connection_id: &str, + package: &str, +) -> AcpRequirementProbeItem { + let mut item = AcpRequirementProbeItem { + name: package.to_string(), + installed: false, + version: None, + path: None, + error: None, + }; + + let resolve_command = "command -v npx"; + match ssh_manager + .execute_command(connection_id, resolve_command) + .await + { + Ok((stdout, _stderr, exit_code)) if exit_code == 0 => { + item.installed = true; + item.path = stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(ToString::to_string) + .or_else(|| Some("remote npx auto-install".to_string())); + } + Ok((stdout, stderr, _)) => { + let summary = remote_command_error_summary(&stderr, &stdout); + item.error = Some(if summary.is_empty() { + "npx is not available on remote PATH".to_string() + } else { + summary + }); + } + Err(error) => { + item.error = Some(error.to_string()); + } + } + + 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()))?; @@ -290,6 +383,18 @@ fn command_error_summary(stderr: &[u8], stdout: &[u8]) -> String { "Command exited unsuccessfully".to_string() } +fn remote_command_error_summary(stderr: &str, stdout: &str) -> String { + let stderr = stderr.trim().to_string(); + if !stderr.is_empty() { + return truncate_error(stderr); + } + let stdout = stdout.trim().to_string(); + if !stdout.is_empty() { + return truncate_error(stdout); + } + String::new() +} + fn truncate_error(value: String) -> String { const MAX_LEN: usize = 240; if value.chars().count() <= MAX_LEN { @@ -298,6 +403,16 @@ fn truncate_error(value: String) -> String { format!("{}...", value.chars().take(MAX_LEN).collect::()) } +fn shell_escape(value: &str) -> String { + if value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') + }) { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + fn find_executable(command: &str) -> Option { find_executable_with_path(command, None) } diff --git a/src/crates/acp/src/client/tool.rs b/src/crates/acp/src/client/tool.rs index cff7f94c8..42037a274 100644 --- a/src/crates/acp/src/client/tool.rs +++ b/src/crates/acp/src/client/tool.rs @@ -158,6 +158,9 @@ impl Tool for AcpAgentTool { input: &Value, context: &ToolUseContext, ) -> BitFunResult> { + let bitfun_session_id = context.session_id.clone().ok_or_else(|| { + BitFunError::tool("ACP tool requires an active BitFun session".to_string()) + })?; let prompt = input .get("prompt") .and_then(|value| value.as_str()) @@ -184,7 +187,8 @@ impl Tool for AcpAgentTool { &self.client_id, prompt, workspace_path, - context.session_id.clone(), + None, + bitfun_session_id, None, timeout_seconds, ) diff --git a/src/crates/acp/src/client/tool_card_bridge.rs b/src/crates/acp/src/client/tool_card_bridge.rs deleted file mode 100644 index b0d19e2a1..000000000 --- a/src/crates/acp/src/client/tool_card_bridge.rs +++ /dev/null @@ -1,434 +0,0 @@ -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/client/tool_card_bridge/mod.rs b/src/crates/acp/src/client/tool_card_bridge/mod.rs new file mode 100644 index 000000000..e57901e27 --- /dev/null +++ b/src/crates/acp/src/client/tool_card_bridge/mod.rs @@ -0,0 +1,119 @@ +mod tool_name; +mod tool_params; + +pub(super) fn acp_tool_name( + title: &str, + raw_input: Option<&serde_json::Value>, + kind: Option<&agent_client_protocol::schema::ToolKind>, +) -> String { + tool_name::acp_tool_name(title, raw_input, kind) +} + +pub(super) fn normalize_tool_params( + tool_name: &str, + params: serde_json::Value, +) -> serde_json::Value { + tool_params::normalize_tool_params(tool_name, params) +} + +#[cfg(test)] +mod tests { + use super::{acp_tool_name, normalize_tool_params}; + use agent_client_protocol::schema::ToolKind; + 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/client/tool_card_bridge/tool_name.rs b/src/crates/acp/src/client/tool_card_bridge/tool_name.rs new file mode 100644 index 000000000..954932951 --- /dev/null +++ b/src/crates/acp/src/client/tool_card_bridge/tool_name.rs @@ -0,0 +1,222 @@ +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) +} + +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)) +} diff --git a/src/crates/acp/src/client/tool_card_bridge/tool_params.rs b/src/crates/acp/src/client/tool_card_bridge/tool_params.rs new file mode 100644 index 000000000..46a39d9a9 --- /dev/null +++ b/src/crates/acp/src/client/tool_card_bridge/tool_params.rs @@ -0,0 +1,110 @@ +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 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(), + } +} 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 bb09f6667..35646b696 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 @@ -312,10 +312,14 @@ const WorkspaceItem: React.FC = ({ useEffect(() => { let cancelled = false; + const remoteWorkspace = isRemoteWorkspace(workspace); const loadAcpClients = async () => { try { - const clients = await loadWorkspaceAcpMenuClients(); + const clients = await loadWorkspaceAcpMenuClients({ + remoteWorkspace, + remoteConnectionId: remoteWorkspace ? workspace.connectionId : undefined, + }); if (!cancelled) { setAcpClients(clients); } @@ -332,7 +336,7 @@ const WorkspaceItem: React.FC = ({ window.removeEventListener('bitfun:acp-clients-changed', loadAcpClients); window.removeEventListener('bitfun:acp-requirements-changed', loadAcpClients); }; - }, []); + }, [workspace]); const handleActivate = useCallback(async () => { if (!isActive) { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts index ebf4218ad..2f9e79e2f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts @@ -41,4 +41,44 @@ describe('loadWorkspaceAcpMenuClients', () => { expect(ACPClientAPI.probeClientRequirements).not.toHaveBeenCalled(); expect(clients.map(item => item.id)).toEqual(['opencode']); }); + + it('uses built-in ACP presets for remote workspaces without requiring local config', async () => { + vi.mocked(ACPClientAPI.getClients).mockResolvedValue([ + client('claude-code', false), + client('custom-remote-only', true), + ]); + vi.mocked(ACPClientAPI.probeClientRequirements).mockResolvedValue([ + { + id: 'opencode', + tool: { name: 'opencode', installed: false }, + runnable: false, + notes: ['opencode is not available on remote PATH'], + }, + { + id: 'claude-code', + tool: { name: 'claude', installed: false }, + adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + runnable: false, + notes: ['claude is not available on remote PATH'], + }, + { + id: 'codex', + tool: { name: 'codex', installed: true }, + adapter: { name: '@zed-industries/codex-acp', installed: true }, + runnable: true, + notes: [], + }, + ]); + + const clients = await loadWorkspaceAcpMenuClients({ + remoteWorkspace: true, + remoteConnectionId: 'ssh-1', + }); + + expect(ACPClientAPI.probeClientRequirements).toHaveBeenCalledWith({ + remoteConnectionId: 'ssh-1', + }); + expect(clients.map(item => item.id)).toEqual(['codex']); + expect(clients[0]?.enabled).toBe(true); + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts index ece2f4e6b..19d9f6eb7 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.ts @@ -3,7 +3,63 @@ import { type AcpClientInfo, } from '@/infrastructure/api/service-api/ACPClientAPI'; -export async function loadWorkspaceAcpMenuClients(): Promise { +const REMOTE_ACP_PRESETS = [ + { id: 'opencode', name: 'opencode' }, + { id: 'claude-code', name: 'Claude Code' }, + { id: 'codex', name: 'Codex' }, +] as const; + +interface LoadWorkspaceAcpMenuClientsOptions { + remoteWorkspace?: boolean; + remoteConnectionId?: string; +} + +function virtualRemoteClient(id: string, name: string): AcpClientInfo { + return { + id, + name, + command: '', + args: [], + enabled: true, + readonly: false, + permissionMode: 'ask', + status: 'configured', + toolName: `acp__${id}__prompt`, + sessionCount: 0, + }; +} + +export async function loadWorkspaceAcpMenuClients( + options: LoadWorkspaceAcpMenuClientsOptions = {} +): Promise { const clients = await ACPClientAPI.getClients(); - return clients.filter(client => client.enabled); + + if (!options.remoteWorkspace) { + return clients.filter(client => client.enabled); + } + + if (!options.remoteConnectionId) { + return []; + } + + const probes = await ACPClientAPI.probeClientRequirements({ + remoteConnectionId: options.remoteConnectionId, + }); + const runnableRemoteIds = new Set( + probes.filter(probe => probe.runnable).map(probe => probe.id) + ); + const clientsById = new Map(clients.map(client => [client.id, client])); + return REMOTE_ACP_PRESETS + .filter(({ id }) => runnableRemoteIds.has(id)) + .map(({ id, name }) => { + const configured = clientsById.get(id); + if (!configured) { + return virtualRemoteClient(id, name); + } + return { + ...configured, + name: configured.name || name, + enabled: true, + }; + }); } diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 49b7d9252..280acd15a 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -8,6 +8,7 @@ import { WorkspaceKind } from '@/shared/types/global-state'; import type { SSHConnectionConfig, RemoteWorkspace } from './types'; import { sshApi } from './sshApi'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { SSHContext, @@ -16,6 +17,53 @@ import { } from './SSHRemoteContext'; const log = createLogger('SSHRemoteProvider'); +const pendingAcpCapabilityRefreshes = new Set(); + +function refreshRemoteAcpCapabilities(connectionId: string): void { + const normalized = connectionId.trim(); + if (!normalized || pendingAcpCapabilityRefreshes.has(normalized)) { + return; + } + + pendingAcpCapabilityRefreshes.add(normalized); + void ACPClientAPI.probeClientRequirements({ + force: true, + remoteConnectionId: normalized, + }) + .catch(error => { + log.warn('Failed to refresh remote ACP capabilities', { connectionId: normalized, error }); + }) + .finally(() => { + pendingAcpCapabilityRefreshes.delete(normalized); + }); +} + +function getActiveRemoteWorkspaceForConnection(connectionId: string): RemoteWorkspace | null { + const normalizedConnectionId = connectionId.trim(); + if (!normalizedConnectionId) { + return null; + } + + const state = workspaceManager.getState(); + const activeWorkspace = state.activeWorkspaceId + ? state.openedWorkspaces.get(state.activeWorkspaceId) + : null; + + if ( + !activeWorkspace || + activeWorkspace.workspaceKind !== WorkspaceKind.Remote || + (activeWorkspace.connectionId ?? '').trim() !== normalizedConnectionId + ) { + return null; + } + + return { + connectionId: normalizedConnectionId, + connectionName: activeWorkspace.connectionName?.trim() || 'Remote', + remotePath: normalizeRemoteWorkspacePath(activeWorkspace.rootPath), + sshHost: activeWorkspace.sshHost?.trim() || undefined, + }; +} /** Match opened `WorkspaceInfo` so list_sessions maps to ~/.bitfun/remote_ssh/... */ function sshHostForRemoteWorkspace(connectionId: string, remotePath: string): string | undefined { @@ -340,6 +388,7 @@ export const SSHRemoteProvider: React.FC = ({ children } log.info('Remote workspace already connected', { connectionId: workspace.connectionId }); await sshApi.openWorkspace(workspace.connectionId, workspace.remotePath).catch(() => {}); setWorkspaceStatus(workspace.connectionId, 'connected'); + refreshRemoteAcpCapabilities(workspace.connectionId); if (!isAlreadyOpened) { await workspaceManager.openRemoteWorkspace(workspace).catch(() => {}); @@ -382,6 +431,7 @@ export const SSHRemoteProvider: React.FC = ({ children } if (result !== false) { log.info('Reconnection successful', { newConnectionId: result.connectionId }); setWorkspaceStatus(result.workspace.connectionId, 'connected'); + refreshRemoteAcpCapabilities(result.connectionId); if (!isAlreadyOpened) { await workspaceManager.openRemoteWorkspace(result.workspace).catch(() => {}); @@ -462,6 +512,7 @@ export const SSHRemoteProvider: React.FC = ({ children } if (result.success && result.connectionId) { log.info('SSH connection successful', { connectionId: result.connectionId }); + refreshRemoteAcpCapabilities(result.connectionId); let home = result.serverInfo?.homeDir?.trim(); if (!home && result.connectionId) { try { @@ -471,15 +522,37 @@ export const SSHRemoteProvider: React.FC = ({ children } /* non-desktop or probe skipped */ } } - setRemoteFileBrowserInitialPath( - home && home.length > 0 ? normalizeRemoteWorkspacePath(home) : '/tmp' - ); + const activeRemoteWorkspace = getActiveRemoteWorkspaceForConnection(result.connectionId); + const homePath = + home && home.length > 0 ? normalizeRemoteWorkspacePath(home) : '/tmp'; + + if (activeRemoteWorkspace) { + try { + await sshApi.openWorkspace(result.connectionId, activeRemoteWorkspace.remotePath); + setRemoteWorkspace(activeRemoteWorkspace); + setRemoteFileBrowserInitialPath(activeRemoteWorkspace.remotePath); + setWorkspaceStatus(result.connectionId, 'connected'); + setShowFileBrowser(false); + } catch (error) { + log.warn('Failed to reactivate active remote workspace after connect', { + connectionId: result.connectionId, + remotePath: activeRemoteWorkspace.remotePath, + error, + }); + setRemoteWorkspace(null); + setRemoteFileBrowserInitialPath(homePath); + setShowFileBrowser(true); + } + } else { + setRemoteWorkspace(null); + setRemoteFileBrowserInitialPath(homePath); + setShowFileBrowser(true); + } setStatus('connected'); setIsConnected(true); setConnectionId(result.connectionId); setConnectionConfig(config); setShowConnectionDialog(false); - setShowFileBrowser(true); startHeartbeat(result.connectionId); } else { log.warn('SSH connection failed', { error: result.error }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index 05a9d1d89..0b4bb3725 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -126,13 +126,14 @@ export interface AcpPermissionRequestEvent { options?: AcpPermissionOption[]; } -let requirementProbeCache: AcpClientRequirementProbe[] | null = null; -let requirementProbeInFlight: Promise | null = null; +const LOCAL_REQUIREMENT_CACHE_KEY = '__local__'; +const requirementProbeCache = new Map(); +const requirementProbeInFlight = new Map>(); export class ACPClientAPI { private static invalidateRequirementProbeCache(): void { - requirementProbeCache = null; - requirementProbeInFlight = null; + requirementProbeCache.clear(); + requirementProbeInFlight.clear(); } static async initializeClients(): Promise { @@ -146,26 +147,32 @@ export class ACPClientAPI { } static async probeClientRequirements( - options: { force?: boolean } = {} + options: { force?: boolean; remoteConnectionId?: string } = {} ): Promise { - if (!options.force && requirementProbeCache) { - return requirementProbeCache; + const cacheKey = options.remoteConnectionId || LOCAL_REQUIREMENT_CACHE_KEY; + if (!options.force && requirementProbeCache.has(cacheKey)) { + return requirementProbeCache.get(cacheKey) ?? []; } - if (!options.force && requirementProbeInFlight) { - return requirementProbeInFlight; + if (!options.force && requirementProbeInFlight.has(cacheKey)) { + return requirementProbeInFlight.get(cacheKey)!; } - requirementProbeInFlight = api.invoke('probe_acp_client_requirements') + const request = options.remoteConnectionId + ? { remoteConnectionId: options.remoteConnectionId, forceRefresh: options.force === true } + : {}; + + const inFlight = api.invoke('probe_acp_client_requirements', { request }) .then((probes) => { - requirementProbeCache = probes; + requirementProbeCache.set(cacheKey, probes); window.dispatchEvent(new Event('bitfun:acp-requirements-changed')); return probes; }) .finally(() => { - requirementProbeInFlight = null; + requirementProbeInFlight.delete(cacheKey); }); - return requirementProbeInFlight; + requirementProbeInFlight.set(cacheKey, inFlight); + return inFlight; } static async predownloadClientAdapter(request: AcpClientIdRequest): Promise { From ee5be4343a9e81aca63fd83383407d38f24310bb Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 12 May 2026 15:33:43 +0800 Subject: [PATCH 2/5] fix(flow-chat): restore ACP session state and permissions --- src/web-ui/src/app/layout/AppLayout.tsx | 21 ++- .../flow_chat/components/ModelSelector.tsx | 26 ++++ .../src/flow_chat/services/FlowChatManager.ts | 4 +- .../PersistenceModule.test.ts | 64 ++++++++ .../flow-chat-manager/PersistenceModule.ts | 3 + .../src/flow_chat/store/FlowChatStore.ts | 3 + .../tool-cards/ReadFileDisplay.test.tsx | 141 ++++++++++++++++++ .../flow_chat/tool-cards/ReadFileDisplay.tsx | 91 ++++++++++- src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 14 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index e6fba751f..ac25bcf9d 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -51,6 +51,7 @@ interface AppLayoutProps { interface AcpSessionCreationEventDetail { phase?: 'start' | 'finish'; clientId?: string; + action?: 'create' | 'restore'; } const AppLayout: React.FC = ({ className = '' }) => { @@ -126,7 +127,10 @@ const AppLayout: React.FC = ({ className = '' }) => { const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); const [showAboutDialog, setShowAboutDialog] = useState(false); const [showWorkspaceStatus, setShowWorkspaceStatus] = useState(false); - const [pendingAcpSessionClients, setPendingAcpSessionClients] = useState([]); + const [pendingAcpSessionClients, setPendingAcpSessionClients] = useState>([]); const handleOpenProject = useCallback(async () => { try { const selected = await open({ @@ -506,11 +510,12 @@ const AppLayout: React.FC = ({ className = '' }) => { const handler = (event: Event) => { const detail = (event as CustomEvent).detail; const clientId = detail?.clientId?.trim() || 'ACP'; + const action = detail?.action === 'restore' ? 'restore' : 'create'; if (detail?.phase === 'start') { - setPendingAcpSessionClients(prev => [...prev, clientId]); + setPendingAcpSessionClients(prev => [...prev, { clientId, action }]); } else if (detail?.phase === 'finish') { setPendingAcpSessionClients(prev => { - const index = prev.indexOf(clientId); + const index = prev.findIndex(item => item.clientId === clientId && item.action === action); if (index === -1) return prev; return prev.filter((_, currentIndex) => currentIndex !== index); }); @@ -583,9 +588,13 @@ const AppLayout: React.FC = ({ className = '' }) => {
- {tCommon('nav.workspaces.creatingAcpSession', { - agentName: pendingAcpSessionClients[pendingAcpSessionClients.length - 1], - })} + {pendingAcpSessionClients[pendingAcpSessionClients.length - 1].action === 'restore' + ? tCommon('nav.workspaces.restoringAcpSession', { + agentName: pendingAcpSessionClients[pendingAcpSessionClients.length - 1].clientId, + }) + : tCommon('nav.workspaces.creatingAcpSession', { + agentName: pendingAcpSessionClients[pendingAcpSessionClients.length - 1].clientId, + })}
)} diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 1c320fdbc..91867c117 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -130,6 +130,8 @@ export const ModelSelector: React.FC = ({ const [acpOptions, setAcpOptions] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const [loading, setLoading] = useState(false); + const acpRestoreToastShownRef = useRef(null); + const acpOptionsRef = useRef(null); const dropdownRef = useRef(null); const activeSession = sessionId ? FlowChatStore.getInstance().getState().sessions.get(sessionId) : undefined; @@ -188,6 +190,14 @@ export const ModelSelector: React.FC = ({ return; } + const shouldShowRestoreToast = !acpOptionsRef.current && acpRestoreToastShownRef.current !== sessionId; + if (shouldShowRestoreToast) { + acpRestoreToastShownRef.current = sessionId; + window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { + detail: { phase: 'start', clientId: acpClientId, action: 'restore' }, + })); + } + try { const options = await ACPClientAPI.getSessionOptions({ sessionId, @@ -200,6 +210,12 @@ export const ModelSelector: React.FC = ({ } catch (error) { log.warn('Failed to load ACP session model options', { sessionId, acpClientId, error }); setAcpOptions(null); + } finally { + if (shouldShowRestoreToast) { + window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { + detail: { phase: 'finish', clientId: acpClientId, action: 'restore' }, + })); + } } }, [ activeSession?.config.workspacePath, @@ -211,6 +227,16 @@ export const ModelSelector: React.FC = ({ sessionId, ]); + useEffect(() => { + acpOptionsRef.current = null; + acpRestoreToastShownRef.current = null; + setAcpOptions(null); + }, [sessionId]); + + useEffect(() => { + acpOptionsRef.current = acpOptions; + }, [acpOptions]); + useEffect(() => { loadAcpOptions(); }, [loadAcpOptions]); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index a7c050cb4..8b72929d9 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -217,7 +217,7 @@ export class FlowChatManager { } window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { - detail: { phase: 'start', clientId }, + detail: { phase: 'start', clientId, action: 'create' }, })); try { @@ -248,7 +248,7 @@ export class FlowChatManager { return response.sessionId; } finally { window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { - detail: { phase: 'finish', clientId }, + detail: { phase: 'finish', clientId, action: 'create' }, })); } } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index 52a509443..e671bc277 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -124,6 +124,70 @@ describe('PersistenceModule', () => { expect(persisted.modelRounds[0].textItems.map((item: any) => item.id)).toEqual(['real-text']); }); + it('persists ACP permission metadata for pending confirmation tools', () => { + const turn = createDialogTurn('processing'); + turn.modelRounds[0].items = [ + { + id: 'tool-1', + type: 'tool', + toolName: 'Read', + toolCall: { + id: 'tool-1', + input: { + filePath: '/', + }, + }, + status: 'pending_confirmation', + timestamp: 1001, + startTime: 1001, + requiresConfirmation: true, + userConfirmed: false, + acpPermission: { + permissionId: 'acp_permission_1', + sessionId: 'remote-session-1', + toolCallId: 'tool-1', + requestedAt: 1002, + options: [ + { + optionId: 'once', + name: 'Allow once', + kind: 'allow_once', + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once', + }, + ], + }, + } as any, + ]; + + const persisted = convertDialogTurnToBackendFormat(turn, 0); + const [toolItem] = persisted.modelRounds[0].toolItems; + + expect(toolItem.requiresConfirmation).toBe(true); + expect(toolItem.userConfirmed).toBe(false); + expect(toolItem.acpPermission).toEqual({ + permissionId: 'acp_permission_1', + sessionId: 'remote-session-1', + toolCallId: 'tool-1', + requestedAt: 1002, + options: [ + { + optionId: 'once', + name: 'Allow once', + kind: 'allow_once', + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once', + }, + ], + }); + }); + it('coalesces non-terminal immediate saves into a short latest-state window', async () => { const turn = createDialogTurn('processing'); const context = createContext(turn); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index a45d8cd0a..6979e6221 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -428,6 +428,9 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd toolCall: toolItem.toolCall || { input: {}, id: item.id }, toolResult: toolItem.toolResult, aiIntent: toolItem.aiIntent, + requiresConfirmation: toolItem.requiresConfirmation, + userConfirmed: toolItem.userConfirmed, + acpPermission: toolItem.acpPermission, startTime: toolItem.startTime || item.timestamp, endTime: toolItem.endTime, status: item.status || 'completed', diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 3231018e8..dee77f0de 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1956,6 +1956,9 @@ export class FlowChatStore { toolCall: tool.toolCall, toolResult: tool.toolResult, aiIntent: tool.aiIntent, + requiresConfirmation: tool.requiresConfirmation, + userConfirmed: tool.userConfirmed, + acpPermission: tool.acpPermission, startTime: tool.startTime, endTime: tool.endTime, durationMs: tool.durationMs, diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx new file mode 100644 index 000000000..643a585fd --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import { ReadFileDisplay } from './ReadFileDisplay'; +import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, + }), + }; +}); + +vi.mock('../../component-library', () => ({ + ToolProcessingDots: () => , + IconButton: ({ + children, + tooltip, + ...props + }: React.ButtonHTMLAttributes & { tooltip?: React.ReactNode }) => ( + + ), +})); + +vi.mock('./ToolCardHeaderActions', () => ({ + ToolCardHeaderActions: ({ children }: { children: React.ReactNode }) => {children}, +})); + +describe('ReadFileDisplay', () => { + let dom: JSDOM; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dom = new JSDOM('
', { + pretendToBeVisual: true, + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('CustomEvent', dom.window.CustomEvent); + + container = dom.window.document.getElementById('root') as HTMLDivElement; + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + vi.unstubAllGlobals(); + }); + + it('renders ACP permission actions for pending read confirmation', () => { + const onConfirm = vi.fn(); + const onReject = vi.fn(); + + const toolItem: FlowToolItem = { + id: 'tool-read-1', + type: 'tool', + toolName: 'Read', + status: 'pending_confirmation', + timestamp: Date.now(), + requiresConfirmation: true, + userConfirmed: false, + toolCall: { + id: 'call-read-1', + input: { + file_path: '/', + }, + }, + acpPermission: { + permissionId: 'perm-1', + requestedAt: Date.now(), + options: [ + { + optionId: 'once', + name: 'Allow once', + kind: 'allow_once', + }, + { + optionId: 'reject', + name: 'Reject', + kind: 'reject_once', + }, + ], + }, + }; + + const config: ToolCardConfig = { + toolName: 'Read', + displayName: 'Read File', + icon: 'R', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: 'Read file contents', + displayMode: 'compact', + }; + + act(() => { + root.render( + + ); + }); + + expect(container.textContent).toContain('Requesting read permission:'); + expect(container.textContent).toContain('/'); + + const actionButtons = container.querySelectorAll('button'); + expect(actionButtons).toHaveLength(2); + + act(() => { + actionButtons[0]?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + expect(onConfirm).toHaveBeenCalledWith(toolItem.toolCall.input, 'once', true); + + act(() => { + actionButtons[1]?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + expect(onReject).toHaveBeenCalledWith('reject'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index e15e6c5d0..d1ee33108 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -3,18 +3,24 @@ */ import React, { useMemo } from 'react'; -import { FileText } from 'lucide-react'; +import { Check, FileText, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { IconButton } from '../../component-library'; import type { ToolCardProps } from '../types/flow-chat'; +import { AcpPermissionActions } from './AcpPermissionActions'; +import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardHeaderActions } from './ToolCardHeaderActions'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; export const ReadFileDisplay: React.FC = React.memo(({ toolItem, + onConfirm, + onReject, onOpenInEditor }) => { const { t } = useTranslation('flow-chat'); - const { toolCall, toolResult, status } = toolItem; + const { toolCall, toolResult, status, requiresConfirmation, userConfirmed } = toolItem; const filePath = useMemo(() => { const path = toolCall?.input?.file_path || toolCall?.input?.target_file || toolCall?.input?.path; @@ -46,6 +52,24 @@ export const ReadFileDisplay: React.FC = React.memo(({ return filePath.split('/').pop() || filePath.split('\\').pop() || filePath; }, [filePath, t]); + const permissionTargetPath = useMemo(() => { + const rawInput = toolItem.acpPermission?.toolCall?.rawInput as Record | undefined; + const acpFilePath = + typeof rawInput?.filepath === 'string' && rawInput.filepath.trim().length > 0 + ? rawInput.filepath + : typeof rawInput?.filePath === 'string' && rawInput.filePath.trim().length > 0 + ? rawInput.filePath + : typeof rawInput?.parentDir === 'string' && rawInput.parentDir.trim().length > 0 + ? rawInput.parentDir + : null; + + if (acpFilePath) { + return acpFilePath; + } + + return filePath; + }, [filePath, toolItem.acpPermission?.toolCall?.rawInput]); + const lineRange = useMemo(() => { const start_line = toolCall?.input?.start_line; const limit = toolCall?.input?.limit; @@ -78,6 +102,13 @@ export const ReadFileDisplay: React.FC = React.memo(({ }, [toolResult?.result]); const canOpenFile = status === 'completed' && filePath !== t('toolCards.readFile.noFileSpecified') && filePath !== t('toolCards.readFile.parsingParams'); + const showConfirmationActions = Boolean( + requiresConfirmation && + !userConfirmed && + status !== 'completed' && + status !== 'cancelled' && + status !== 'error' + ); if (status === 'error') { return null; @@ -102,6 +133,14 @@ export const ReadFileDisplay: React.FC = React.memo(({ ); } + if (showConfirmationActions || status === 'pending_confirmation') { + return ( + <> + {t('toolCards.readFile.permissionRequest', { defaultValue: 'Requesting read permission:' })} {permissionTargetPath} + {lineRange && {lineRange}} + + ); + } if (status === 'pending') { return ( <> @@ -113,6 +152,53 @@ export const ReadFileDisplay: React.FC = React.memo(({ return null; }; + const renderActions = () => { + if (!showConfirmationActions) { + return undefined; + } + + return ( + + {hasAcpPermissionOptions(toolItem) ? ( + + ) : ( + <> + { + event.stopPropagation(); + onConfirm?.(toolCall?.input); + }} + tooltip={t('toolCards.default.waitingConfirm')} + > + + + { + event.stopPropagation(); + onReject?.(); + }} + tooltip={t('toolCards.acpPermission.reject')} + > + + + + )} + + ); + }; + return ( = React.memo(({ } />} content={renderContent()} + extra={renderActions()} /> } /> diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 280aedd7e..b3cd0447a 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -297,6 +297,7 @@ "copyPathFailed": "Failed to copy path", "createSessionFailed": "Failed to create session", "creatingAcpSession": "Starting {{agentName}} session...", + "restoringAcpSession": "Restoring {{agentName}} session...", "initSessionFailed": "Failed to start AGENTS.md initialization session", "worktreeCreated": "Worktree created", "worktreeCreatedAndOpened": "Worktree created and opened", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index a41850bb0..5dc9a5f74 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1321,6 +1321,7 @@ "parsingParams": "Parsing parameters...", "noFileSpecified": "No file specified", "readFile": "Read file", + "permissionRequest": "Requesting read permission:", "readingFile": "Reading", "preparingRead": "Preparing to read" }, diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 39375f45a..2a40a0c21 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -297,6 +297,7 @@ "copyPathFailed": "复制路径失败", "createSessionFailed": "新建会话失败", "creatingAcpSession": "正在启动 {{agentName}} 会话...", + "restoringAcpSession": "正在恢复 {{agentName}} 会话...", "initSessionFailed": "初始化 AGENTS.md 会话失败", "worktreeCreated": "已创建 worktree", "worktreeCreatedAndOpened": "已创建并打开 worktree", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 3a8573a5e..acf79c39a 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1321,6 +1321,7 @@ "parsingParams": "解析参数中...", "noFileSpecified": "未指定文件", "readFile": "读取文件", + "permissionRequest": "请求读取权限:", "readingFile": "正在读取", "preparingRead": "准备读取" }, diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 256771f84..bde132adf 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -297,6 +297,7 @@ "copyPathFailed": "複製路徑失敗", "createSessionFailed": "新建會話失敗", "creatingAcpSession": "正在啟動 {{agentName}} 會話...", + "restoringAcpSession": "正在恢復 {{agentName}} 會話...", "initSessionFailed": "初始化 AGENTS.md 會話失敗", "worktreeCreated": "已創建 worktree", "worktreeCreatedAndOpened": "已創建並打開 worktree", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index af0ace0f2..43f8ac804 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1321,6 +1321,7 @@ "parsingParams": "解析參數中...", "noFileSpecified": "未指定文件", "readFile": "讀取文件", + "permissionRequest": "請求讀取權限:", "readingFile": "正在讀取", "preparingRead": "準備讀取" }, From 00ee9eecf8e889a91d941173c2f6531e7809af6b Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 12 May 2026 15:33:54 +0800 Subject: [PATCH 3/5] fix(flow-chat): disable ACP queue steering --- .../components/PendingQueuePanel.tsx | 66 +++++++++++++------ 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx index 1a4d73097..71ec2267b 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx @@ -2,7 +2,7 @@ * Pending queue panel * * Renders the per-session list of "queued" user messages above the chat input. - * Each card supports inline edit, "send now" (mid-turn steering), and delete. + * Each card supports inline edit, optional "send now" (mid-turn steering), and delete. * * UX notes: * - Click anywhere on the preview text to start editing. @@ -26,12 +26,14 @@ import { import { Tooltip, IconButton } from '@/component-library'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { stateMachineManager } from '../state-machine'; +import { FlowChatStore } from '../store/FlowChatStore'; import { pendingQueueManager } from '../services/flow-chat-manager/PendingQueueModule'; import { FlowChatManager } from '../services/FlowChatManager'; import { insertSteeringItemIfAbsent } from '../services/flow-chat-manager/EventHandlerModule'; import { notificationService } from '../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import type { QueuedMessage } from '../types/flow-chat'; +import { isAcpFlowSession } from '../utils/acpSession'; import './PendingQueuePanel.scss'; const log = createLogger('PendingQueuePanel'); @@ -46,6 +48,10 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro const [items, setItems] = useState(() => sessionId ? pendingQueueManager.list(sessionId) : [], ); + const [isAcpSession, setIsAcpSession] = useState(() => { + if (!sessionId) return false; + return isAcpFlowSession(FlowChatStore.getInstance().getState().sessions.get(sessionId)); + }); const [editingId, setEditingId] = useState(null); const [editingDraft, setEditingDraft] = useState(''); @@ -61,6 +67,22 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro return unsubscribe; }, [sessionId]); + useEffect(() => { + if (!sessionId) { + setIsAcpSession(false); + return; + } + + const store = FlowChatStore.getInstance(); + const sync = () => { + setIsAcpSession(isAcpFlowSession(store.getState().sessions.get(sessionId))); + }; + + sync(); + const unsubscribe = store.subscribe(sync); + return unsubscribe; + }, [sessionId]); + const handleEditStart = useCallback((item: QueuedMessage) => { setEditingId(item.id); setEditingDraft(item.displayMessage ?? item.content); @@ -100,6 +122,10 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro const handleSendNow = useCallback( async (item: QueuedMessage) => { if (!sessionId) return; + if (isAcpSession) { + log.warn('Steering is disabled for ACP sessions', { sessionId, itemId: item.id }); + return; + } const machine = stateMachineManager.get(sessionId); const ctx = machine?.getContext(); const dialogTurnId = ctx?.currentDialogTurnId ?? null; @@ -175,7 +201,7 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro notificationService.error(t('pendingQueue.errors.sendNowFailed'), { duration: 4000 }); } }, - [sessionId, t], + [isAcpSession, sessionId, t], ); const visibleItems = useMemo(() => items, [items]); @@ -325,23 +351,25 @@ export function PendingQueuePanel({ sessionId, className }: PendingQueuePanelPro - - { - void handleSendNow(item); - }} - aria-label={t('pendingQueue.actions.sendNow')} - > - {isSendingNow ? ( - - ) : ( - - )} - - + {!isAcpSession && ( + + { + void handleSendNow(item); + }} + aria-label={t('pendingQueue.actions.sendNow')} + > + {isSendingNow ? ( + + ) : ( + + )} + + + )} Date: Tue, 12 May 2026 16:47:25 +0800 Subject: [PATCH 4/5] fix: timeout stuck ACP client startup --- src/crates/acp/src/client/manager.rs | 145 +++++++++++++++++--- src/crates/acp/src/client/remote_session.rs | 7 + 2 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 3080e76e9..0820367a5 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::Stdio; @@ -55,6 +56,7 @@ use super::stream::{acp_dispatch_to_stream_events, AcpClientStreamEvent, AcpStre use super::tool::AcpAgentTool; const CONFIG_PATH: &str = "acp_clients"; +const CLIENT_STARTUP_TIMEOUT: Duration = Duration::from_secs(20); 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); @@ -522,7 +524,7 @@ impl AcpClientService { let (shutdown_tx, shutdown_rx) = oneshot::channel(); *connection.shutdown_tx.lock().await = Some(shutdown_tx); - tokio::spawn(async move { + let connect_task = tokio::spawn(async move { let result = Client .builder() .name("bitfun-acp-client") @@ -569,12 +571,29 @@ impl AcpClientService { 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 - )) - })?; + let (cx, agent_capabilities) = + match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, cx_rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => { + connect_task.abort(); + self.cleanup_failed_startup(connection_id).await; + return Err(BitFunError::service(format!( + "ACP client '{}' exited before initialization completed", + client_id + ))); + } + Err(_) => { + warn!( + "ACP client startup timed out during initialize: id={} connection_id={} timeout_secs={}", + client_id, + connection_id, + CLIENT_STARTUP_TIMEOUT.as_secs() + ); + connect_task.abort(); + self.cleanup_failed_startup(connection_id).await; + return Err(startup_timeout_error(client_id, "initialize")); + } + }; *connection.connection.write().await = Some(cx); *connection.agent_capabilities.write().await = Some(agent_capabilities); *connection.status.write().await = AcpClientStatus::Running; @@ -586,6 +605,15 @@ impl AcpClientService { Ok(()) } + async fn cleanup_failed_startup(self: &Arc, connection_id: &str) { + if let Err(error) = self.stop_connection(connection_id).await { + warn!( + "Failed to clean up ACP client after startup failure: connection_id={} error={}", + connection_id, error + ); + } + } + pub async fn stop_client(self: &Arc, client_id: &str) -> BitFunResult<()> { let connection_ids = self .clients @@ -1143,7 +1171,7 @@ impl AcpClientService { } async fn ensure_remote_session( - &self, + self: &Arc, client: &Arc, session_key: &str, cwd: &Path, @@ -1175,14 +1203,24 @@ impl AcpClientService { 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() + match self + .run_startup_step( + client, + strategy.startup_phase_name(), + 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) => { + if is_startup_timeout_error(&error) { + return Err(error); + } warn!( "Failed to load ACP remote session, falling back: client_id={}, remote_session_id={}, error={}", client.id, remote_session_id, error @@ -1196,12 +1234,16 @@ impl AcpClientService { 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() + match self + .run_startup_step( + client, + strategy.startup_phase_name(), + cx.send_request(ResumeSessionRequest::new( + remote_session_id.to_string(), + cwd, + )) + .block_task(), + ) .await .map_err(protocol_error) { @@ -1209,6 +1251,9 @@ impl AcpClientService { new_session_response_from_resume(remote_session_id, response) } Err(error) => { + if is_startup_timeout_error(&error) { + return Err(error); + } warn!( "Failed to resume ACP remote session, falling back: client_id={}, remote_session_id={}, error={}", client.id, remote_session_id, error @@ -1218,9 +1263,12 @@ impl AcpClientService { } } } - AcpRemoteSessionStrategy::New => cx - .send_request(NewSessionRequest::new(cwd)) - .block_task() + AcpRemoteSessionStrategy::New => self + .run_startup_step( + client, + strategy.startup_phase_name(), + cx.send_request(NewSessionRequest::new(cwd)).block_task(), + ) .await .map_err(protocol_error)?, }; @@ -1244,6 +1292,33 @@ impl AcpClientService { )) } + async fn run_startup_step( + self: &Arc, + client: &Arc, + phase: &'static str, + future: F, + ) -> Result + where + F: Future>, + { + match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, future).await { + Ok(result) => result, + Err(_) => { + warn!( + "ACP client startup timed out: id={} connection_id={} phase={} timeout_secs={}", + client.client_id, + client.id, + phase, + CLIENT_STARTUP_TIMEOUT.as_secs() + ); + self.cleanup_failed_startup(&client.id).await; + Err(agent_client_protocol::util::internal_error( + startup_timeout_error_message(&client.client_id, phase), + )) + } + } + } + async fn attach_remote_session( &self, client: &Arc, @@ -1886,6 +1961,28 @@ fn protocol_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(format!("ACP protocol error: {}", error)) } +const STARTUP_TIMEOUT_ERROR_PREFIX: &str = "ACP startup timed out:"; + +fn startup_timeout_error(client_id: &str, phase: &str) -> BitFunError { + BitFunError::service(startup_timeout_error_message(client_id, phase)) +} + +fn startup_timeout_error_message(client_id: &str, phase: &str) -> String { + format!( + "{} client '{}' exceeded {}s during {} and was terminated. Please try again after the client is ready.", + STARTUP_TIMEOUT_ERROR_PREFIX, + client_id, + CLIENT_STARTUP_TIMEOUT.as_secs(), + phase + ) +} + +fn is_startup_timeout_error(error: &BitFunError) -> bool { + error + .to_string() + .contains(STARTUP_TIMEOUT_ERROR_PREFIX) +} + fn select_permission_by_kind( request: &RequestPermissionRequest, preferred: PermissionOptionKind, @@ -1963,4 +2060,12 @@ mod tests { assert_eq!(select_permission_option_id(&options, false), "no-once"); } + + #[test] + fn formats_startup_timeout_error_message() { + assert_eq!( + startup_timeout_error_message("codex", "initialize"), + "ACP startup timed out: client 'codex' exceeded 20s during initialize and was terminated. Please try again after the client is ready." + ); + } } diff --git a/src/crates/acp/src/client/remote_session.rs b/src/crates/acp/src/client/remote_session.rs index ba90f4e24..992724050 100644 --- a/src/crates/acp/src/client/remote_session.rs +++ b/src/crates/acp/src/client/remote_session.rs @@ -15,6 +15,13 @@ impl AcpRemoteSessionStrategy { Self::Resume => "resume", } } + + pub(super) fn startup_phase_name(self) -> &'static str { + match self { + Self::New => "session creation", + Self::Load | Self::Resume => "session restore", + } + } } pub(super) fn preferred_resume_strategies( From faa32c1ccfd5321897ba3ee177168b1af19695f9 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Tue, 12 May 2026 16:57:52 +0800 Subject: [PATCH 5/5] fix(web): satisfy ssh remote hook deps --- src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 280acd15a..42ee046e4 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -575,7 +575,7 @@ export const SSHRemoteProvider: React.FC = ({ children } } finally { setIsConnecting(false); } - }, [startHeartbeat]); + }, [setWorkspaceStatus, startHeartbeat]); const disconnect = useCallback(async () => { const currentRemoteWorkspace = remoteWorkspace;