From 59f7151aec35b7d18d2f15bc56ca4b020d2aa18b Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 27 Apr 2026 16:33:09 +0800 Subject: [PATCH 1/9] feat: add ACP client sessions --- docs/acp-design.md | 183 ++++ src/apps/desktop/Cargo.toml | 2 + src/apps/desktop/src/api/acp_client_api.rs | 426 +++++++++ src/apps/desktop/src/api/app_state.rs | 3 + src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/lib.rs | 26 + src/crates/acp/Cargo.toml | 2 + src/crates/acp/src/client/config.rs | 74 ++ src/crates/acp/src/client/manager.rs | 895 ++++++++++++++++++ src/crates/acp/src/client/mod.rs | 16 + src/crates/acp/src/client/session_options.rs | 150 +++ src/crates/acp/src/client/stream.rs | 217 +++++ src/crates/acp/src/client/tool.rs | 225 +++++ src/crates/acp/src/client/tool_card_bridge.rs | 398 ++++++++ src/crates/acp/src/lib.rs | 2 + src/crates/core/src/agentic/tools/registry.rs | 11 +- src/crates/core/src/service/config/types.rs | 4 + src/web-ui/src/app/App.tsx | 11 + .../sections/workspaces/WorkspaceItem.tsx | 69 +- src/web-ui/src/app/layout/AppLayout.tsx | 12 + .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsConfig.ts | 15 + .../settings/settingsTabSearchContent.ts | 10 + .../src/flow_chat/components/ChatInput.tsx | 19 +- .../flow_chat/components/ModelSelector.tsx | 153 ++- .../components/modern/ModelRoundItem.tsx | 6 +- .../modern/useFlowChatToolActions.ts | 122 ++- .../src/flow_chat/services/FlowChatManager.ts | 34 + .../AcpPermissionToolCardModule.ts | 100 ++ .../flow-chat-manager/EventHandlerModule.ts | 58 +- .../flow-chat-manager/MessageModule.ts | 76 +- .../flow-chat-manager/PersistenceModule.ts | 1 + .../flow-chat-manager/TextChunkModule.test.ts | 129 +++ .../flow-chat-manager/TextChunkModule.ts | 48 + .../flow-chat-manager/ToolEventModule.ts | 13 + .../state-machine/SessionStateMachine.ts | 10 +- .../src/flow_chat/store/FlowChatStore.ts | 2 + .../store/modernFlowChatStore.test.ts | 138 +++ .../flow_chat/store/modernFlowChatStore.ts | 35 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 26 + src/web-ui/src/flow_chat/utils/acpSession.ts | 24 + .../api/service-api/ACPClientAPI.ts | 165 ++++ .../config/components/AcpAgentsConfig.scss | 148 +++ .../config/components/AcpAgentsConfig.tsx | 634 +++++++++++++ .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/en-US/settings.json | 2 + .../locales/en-US/settings/acp-agents.json | 59 ++ src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/locales/zh-CN/settings.json | 2 + .../locales/zh-CN/settings/acp-agents.json | 59 ++ src/web-ui/src/locales/zh-TW/common.json | 1 + src/web-ui/src/locales/zh-TW/settings.json | 2 + .../locales/zh-TW/settings/acp-agents.json | 59 ++ .../src/shared/types/session-history.ts | 5 + 55 files changed, 4807 insertions(+), 80 deletions(-) create mode 100644 docs/acp-design.md create mode 100644 src/apps/desktop/src/api/acp_client_api.rs create mode 100644 src/crates/acp/src/client/config.rs create mode 100644 src/crates/acp/src/client/manager.rs create mode 100644 src/crates/acp/src/client/mod.rs create mode 100644 src/crates/acp/src/client/session_options.rs create mode 100644 src/crates/acp/src/client/stream.rs create mode 100644 src/crates/acp/src/client/tool.rs create mode 100644 src/crates/acp/src/client/tool_card_bridge.rs create mode 100644 src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts create mode 100644 src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts create mode 100644 src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/acpSession.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss create mode 100644 src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/acp-agents.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/acp-agents.json create mode 100644 src/web-ui/src/locales/zh-TW/settings/acp-agents.json diff --git a/docs/acp-design.md b/docs/acp-design.md new file mode 100644 index 000000000..3b12a78d8 --- /dev/null +++ b/docs/acp-design.md @@ -0,0 +1,183 @@ +# BitFun ACP Design + +## Goal + +ACP support should make BitFun usable from ACP-compatible editors without creating a second agent runtime. The ACP layer is a transport/protocol adapter around the existing runtime: + +```text +ACP client + -> bitfun-cli acp over stdio JSON-RPC + -> ACP adapter + -> ConversationCoordinator + -> SessionManager / ExecutionEngine / ToolPipeline + -> AgenticEvent stream + -> ACP session/update notifications +``` + +The core rule is the same as the rest of the repository: product behavior stays in `bitfun-core`; ACP-specific protocol and runtime binding live in the dedicated `bitfun-acp` crate. + +BitFun desktop also supports the opposite direction: acting as an ACP client for external agents such as opencode. This path is still an adapter, not a second FlowChat renderer: + +```text +FlowChat session mode acp:{client_id} + -> desktop ACP client command + -> bitfun-acp client manager + -> external ACP agent over stdio + -> ACP session/update stream + -> existing agentic:// FlowChat events +``` + +## Current State + +BitFun uses the official Rust `agent-client-protocol` crate for the ACP protocol surface: + +- `src/crates/acp` owns the typed ACP agent builder, protocol dispatch, and BitFun runtime binding; +- `src/apps/cli` only owns the `bitfun-cli acp` command and starts the ACP server; +- `src/crates/core/src/agentic/system.rs` owns shared agentic runtime assembly; +- sessions are created through `ConversationCoordinator`; +- prompts are submitted through `start_dialog_turn`; +- selected `AgenticEvent` values are translated to ACP `session/update` notifications. + +The current typed adapter is intentionally focused on the canonical ACP surface. Legacy local DTOs and hand-written JSON-RPC dispatch have been removed. + +## Placement + +Recommended end state: + +- `src/crates/acp`: ACP protocol adapter, typed server shell, and BitFun runtime binding. +- `src/crates/acp/src/client`: external ACP agent client manager, config parsing, permission bridge, and optional dynamic BitFun tool wrappers. +- `src/apps/cli`: CLI command startup, logging setup, and host lifecycle. +- `src/apps/desktop/src/api/acp_client_api.rs`: Tauri commands for desktop-only ACP process lifecycle and FlowChat event projection. +- `src/crates/core`: no ACP dependency; expose runtime capabilities through `ConversationCoordinator`, `AgentRegistry`, config, MCP, and event abstractions. + +`bitfun-acp` can depend on `bitfun-core` because no core crate depends on ACP. `bitfun-transport` remains focused on BitFun's internal transport adapters and no longer owns ACP-specific protocol code. + +## Desktop ACP Client + +Desktop ACP clients are configured under the global config key `acp_clients`: + +```json +{ + "acpClients": { + "opencode": { + "name": "opencode", + "command": "opencode", + "args": ["acp"], + "env": {}, + "enabled": true, + "autoStart": false, + "readonly": false, + "permissionMode": "ask" + } + } +} +``` + +The desktop host owns process spawning because stdio lifecycle is host-specific. The shared ACP crate owns protocol state, remote ACP sessions, permission request routing, and streaming conversion. + +FlowChat stores these sessions with mode `acp:{client_id}`. When a user sends a message in that session, the normal send path skips BitFun backend session creation and model synchronization, calls `start_acp_dialog_turn`, and receives the same `agentic://dialog-turn-started`, `agentic://model-round-started`, `agentic://text-chunk`, and completion events that native BitFun agents use. This keeps UI rendering, saving, unread state, and state-machine behavior unified. + +Configured enabled ACP clients appear in the main navigation action area. Selecting one creates a FlowChat session for the current project workspace and starts the external ACP process on demand. + +## Session Model + +Use BitFun session IDs as ACP `sessionId` values unless there is a concrete need for a client-local ID. This keeps load/list/resume aligned with persistence under `.bitfun/sessions`. + +The ACP adapter should keep per-session state: + +```text +session_id +cwd +current_turn_id +mode_id +model_id +client_capabilities +mcp_servers +pending_prompt +``` + +`session/new` maps to `ConversationCoordinator::create_session`. + +`session/load` maps to `ConversationCoordinator::restore_session` plus history replay. + +`session/prompt` maps to `ConversationCoordinator::start_dialog_turn`; streaming output comes from `AgenticEvent`. + +## Protocol Surface + +Only advertise capabilities that are implemented. Capability flags should grow with the implementation. + +Phase 1: + +- `initialize` +- `session/new` +- `session/load` with text history replay +- `session/prompt` +- `session/cancel` +- `session/list` +- text `session/update` +- basic tool status updates +- mode list from `AgentRegistry` +- tool confirmation through ACP `session/request_permission` + +Phase 2: + +- `session/resume` +- model/mode config options +- token usage updates +- thinking chunks +- richer tool output and diffs + +Phase 3: + +- MCP server injection from ACP session params +- images and embedded resources +- terminal client capability support +- fork session if BitFun adds native fork semantics + +## Event Mapping + +BitFun event to ACP update: + +| BitFun event | ACP update | +| --- | --- | +| `TextChunk` | `agent_message_chunk` | +| `ThinkingChunk` | `agent_thought_chunk` | +| `TokenUsageUpdated` | `usage_update` | +| `ToolEventData::Started` / `EarlyDetected` | `tool_call` | +| `ToolEventData::Progress` / `StreamChunk` | `tool_call_update` with `in_progress` | +| `ToolEventData::Completed` | `tool_call_update` with `completed` | +| `ToolEventData::Failed` | `tool_call_update` with `failed` | +| `ToolEventData::Cancelled` | `tool_call_update` with `cancelled` | +| `DialogTurnCompleted` | resolve prompt with `end_turn` | +| `DialogTurnCancelled` | resolve prompt with `cancelled` | +| `DialogTurnFailed` / `SystemError` | resolve prompt with `error` | + +The adapter should not consume the global event queue in a way that starves another host. The CLI ACP process is currently a single-host runtime, so direct queue consumption is acceptable short term. The transport adapter should ultimately subscribe through a fan-out event bridge. + +## Permission Bridge + +BitFun tools already emit `ToolEventData::ConfirmationNeeded` and expose `confirm_tool` / `reject_tool` on `ConversationCoordinator`. + +ACP should translate this to a client permission request: + +```text +ConfirmationNeeded + -> ACP requestPermission + -> selected allow: coordinator.confirm_tool(...) + -> selected reject or client failure: coordinator.reject_tool(...) +``` + +If the ACP client does not support permission requests, the adapter should reject by default unless the session was started with an explicit "skip confirmation" policy. + +## Compatibility Notes + +ACP protocol v1 uses numeric `protocolVersion: 1` during initialization. BitFun should accept numeric v1 and return numeric v1. + +Logs must never be written to stdout in ACP mode. Stdout is reserved for JSON-RPC frames; logs should go to stderr or a file. + +`session/prompt` is long-running, so the stdio server must continue reading requests while a prompt is active. The current CLI adapter handles ordinary requests in input order and runs prompt requests in background tasks, with a single locked stdout writer for responses and notifications. This keeps `session/new` ordering deterministic while allowing `session/cancel` to arrive during an active prompt. + +## Open Decisions + +- Whether ACP mode should use the user's global tool-confirmation setting or a stricter ACP-specific default. +- Whether model selection should expose BitFun's global model aliases or fully resolved provider/model IDs. diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 21f210142..214c95db3 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -22,6 +22,7 @@ serde_json = { workspace = true } bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] } bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] } bitfun-webdriver = { path = "../../crates/webdriver" } +bitfun-acp = { path = "../../crates/acp" } # Tauri tauri = { workspace = true } @@ -39,6 +40,7 @@ serde_json = { workspace = true } anyhow = { workspace = true } log = { workspace = true } chrono = { workspace = true } +uuid = { workspace = true } regex = { workspace = true } dirs = { workspace = true } dark-light = { workspace = true } diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs new file mode 100644 index 000000000..6b35acb28 --- /dev/null +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -0,0 +1,426 @@ +//! ACP client API + +use crate::api::app_state::AppState; +use bitfun_acp::client::{ + AcpClientInfo, AcpClientPermissionResponse, AcpClientStreamEvent, AcpSessionOptions, + SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, +}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientIdRequest { + pub client_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAcpFlowSessionRequest { + pub client_id: String, + #[serde(default)] + pub session_name: Option, + pub workspace_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAcpFlowSessionResponse { + pub session_id: String, + pub session_name: String, + pub agent_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartAcpDialogTurnRequest { + pub session_id: String, + pub client_id: String, + pub user_input: String, + #[serde(default)] + pub original_user_input: Option, + pub turn_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelAcpDialogTurnRequest { + pub session_id: String, + pub client_id: String, + #[serde(default)] + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAcpSessionOptionsRequest { + pub session_id: String, + pub client_id: String, + #[serde(default)] + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.initialize_all().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.list_clients().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn create_acp_flow_session( + state: State<'_, AppState>, + app_handle: AppHandle, + request: CreateAcpFlowSessionRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .start_client(&request.client_id) + .await + .map_err(|e| e.to_string())?; + + let session_id = format!("acp_{}_{}", request.client_id, uuid::Uuid::new_v4()); + let agent_type = format!("acp:{}", request.client_id); + let session_name = request + .session_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("{} ACP", request.client_id)); + + let _ = app_handle.emit( + "agentic://session-created", + serde_json::json!({ + "sessionId": session_id, + "sessionName": session_name, + "agentType": agent_type, + "workspacePath": request.workspace_path, + }), + ); + + Ok(CreateAcpFlowSessionResponse { + session_id, + session_name, + agent_type, + }) +} + +#[tauri::command] +pub async fn start_acp_dialog_turn( + state: State<'_, AppState>, + app_handle: AppHandle, + request: StartAcpDialogTurnRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())? + .clone(); + + let session_id = request.session_id.clone(); + let turn_id = request.turn_id.clone(); + let round_id = format!( + "round_{}_{}", + chrono::Utc::now().timestamp_millis(), + uuid::Uuid::new_v4() + ); + let user_input = request.user_input.clone(); + let original_user_input = request + .original_user_input + .clone() + .unwrap_or_else(|| request.user_input.clone()); + + app_handle + .emit( + "agentic://dialog-turn-started", + serde_json::json!({ + "sessionId": session_id, + "turnId": turn_id, + "turnIndex": null, + "userInput": user_input, + "originalUserInput": original_user_input, + "userMessageMetadata": null, + "subagentParentInfo": null, + }), + ) + .map_err(|e| e.to_string())?; + app_handle + .emit( + "agentic://model-round-started", + serde_json::json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "roundIndex": 0, + "renderHints": { + "disableExploreGrouping": true, + }, + "subagentParentInfo": null, + }), + ) + .map_err(|e| e.to_string())?; + + tokio::spawn(async move { + let result = service + .prompt_agent_stream( + &request.client_id, + request.user_input, + request.workspace_path, + Some(request.session_id.clone()), + request.timeout_seconds, + |event| { + match event { + AcpClientStreamEvent::AgentText(text) => { + app_handle + .emit( + "agentic://text-chunk", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "text": text, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::AgentThought(text) => { + app_handle + .emit( + "agentic://text-chunk", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "text": text, + "contentType": "thinking", + "isThinkingEnd": false, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::ToolEvent(tool_event) => { + app_handle + .emit( + "agentic://tool-event", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "toolEvent": tool_event, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::Completed => { + app_handle + .emit( + "agentic://dialog-turn-completed", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "subagentParentInfo": null, + "partialRecoveryReason": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::Cancelled => { + app_handle + .emit( + "agentic://dialog-turn-cancelled", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + } + Ok(()) + }, + ) + .await; + + if let Err(error) = result { + let _ = app_handle.emit( + "agentic://dialog-turn-failed", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "error": error.to_string(), + "errorCategory": null, + "errorDetail": null, + "subagentParentInfo": null, + }), + ); + } + }); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_acp_dialog_turn( + state: State<'_, AppState>, + request: CancelAcpDialogTurnRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .cancel_agent_session( + &request.client_id, + request.workspace_path, + Some(request.session_id), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_acp_session_options( + state: State<'_, AppState>, + request: GetAcpSessionOptionsRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .get_session_options( + &request.client_id, + request.workspace_path, + Some(request.session_id), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_acp_session_model( + state: State<'_, AppState>, + request: SetAcpSessionModelRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .set_session_model(request) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn start_acp_client( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .start_client(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn stop_acp_client( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .stop_client(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn restart_acp_client( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .restart_client(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn load_acp_json_config(state: State<'_, AppState>) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.load_json_config().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn save_acp_json_config( + state: State<'_, AppState>, + json_config: String, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .save_json_config(&json_config) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn submit_acp_permission_response( + state: State<'_, AppState>, + request: SubmitAcpPermissionResponseRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .submit_permission_response(request) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 90d05e995..037fceb32 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -73,6 +73,7 @@ pub struct AppState { pub ai_rules_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, + pub acp_client_service: Option>, pub token_usage_service: Arc, pub miniapp_manager: Arc, pub js_worker_pool: Option>, @@ -142,6 +143,7 @@ impl AppState { None } }; + let acp_client_service = Some(bitfun_acp::AcpClientService::new(config_service.clone())); let path_manager = workspace_service.path_manager().clone(); let announcement_scheduler = Arc::new( @@ -335,6 +337,7 @@ impl AppState { ai_rules_service, agent_registry, mcp_service, + acp_client_service, token_usage_service, miniapp_manager, js_worker_pool, diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index b7eb72de6..ffe5f97fe 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -1,5 +1,6 @@ //! API layer module +pub mod acp_client_api; pub mod agentic_api; pub mod ai_memory_api; pub mod ai_rules_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 4b05ff948..529eea5a4 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -28,6 +28,7 @@ use tauri::Manager; // Re-export API pub use api::*; +use api::acp_client_api::*; use api::ai_rules_api::*; use api::clipboard_file_api::*; use api::commands::*; @@ -314,6 +315,7 @@ pub async fn run() { } init_mcp_servers(app_handle.clone()); + init_acp_clients(app_handle.clone()); init_services(app_handle.clone(), startup_log_level); @@ -583,6 +585,19 @@ pub async fn run() { api::mcp_api::start_mcp_remote_oauth, api::mcp_api::get_mcp_remote_oauth_session, api::mcp_api::cancel_mcp_remote_oauth, + initialize_acp_clients, + get_acp_clients, + start_acp_client, + stop_acp_client, + restart_acp_client, + load_acp_json_config, + save_acp_json_config, + submit_acp_permission_response, + create_acp_flow_session, + start_acp_dialog_turn, + cancel_acp_dialog_turn, + get_acp_session_options, + set_acp_session_model, lsp_initialize, lsp_start_server_for_file, lsp_stop_server, @@ -914,6 +929,17 @@ fn init_mcp_servers(app_handle: tauri::AppHandle) { }); } +fn init_acp_clients(app_handle: tauri::AppHandle) { + tokio::spawn(async move { + let state: tauri::State<'_, api::AppState> = app_handle.state(); + if let Some(service) = state.acp_client_service.as_ref() { + if let Err(error) = service.initialize_all().await { + log::warn!("Failed to initialize ACP clients: {}", error); + } + } + }); +} + fn setup_panic_hook() { std::panic::set_hook(Box::new(move |panic_info| { let location = panic_info diff --git a/src/crates/acp/Cargo.toml b/src/crates/acp/Cargo.toml index 1774a325b..9b33fb2c5 100644 --- a/src/crates/acp/Cargo.toml +++ b/src/crates/acp/Cargo.toml @@ -16,7 +16,9 @@ agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat"] } async-trait = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } dashmap = { workspace = true } log = { workspace = true } +uuid = { workspace = true } diff --git a/src/crates/acp/src/client/config.rs b/src/crates/acp/src/client/config.rs new file mode 100644 index 000000000..08c09fdf1 --- /dev/null +++ b/src/crates/acp/src/client/config.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientConfigFile { + #[serde(default)] + pub acp_clients: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientConfig { + #[serde(default)] + pub name: Option, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub auto_start: bool, + #[serde(default)] + pub readonly: bool, + #[serde(default)] + pub permission_mode: AcpClientPermissionMode, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpClientPermissionMode { + Ask, + AllowOnce, + RejectOnce, +} + +impl Default for AcpClientPermissionMode { + fn default() -> Self { + Self::Ask + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientInfo { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub enabled: bool, + pub auto_start: bool, + pub readonly: bool, + pub permission_mode: AcpClientPermissionMode, + pub status: AcpClientStatus, + pub tool_name: String, + pub session_count: usize, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpClientStatus { + Configured, + Starting, + Running, + Stopped, + Failed, +} + +fn default_true() -> bool { + true +} diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs new file mode 100644 index 000000000..f072aefde --- /dev/null +++ b/src/crates/acp/src/client/manager.rs @@ -0,0 +1,895 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use agent_client_protocol::schema::{ + CancelNotification, ClientCapabilities, Implementation, InitializeRequest, NewSessionRequest, + PermissionOptionKind, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SelectedPermissionOutcome, SessionConfigOption, + SessionConfigOptionValue, SessionModelState, SetSessionConfigOptionRequest, + SetSessionModelRequest, StopReason, +}; +use agent_client_protocol::{ + ActiveSession, Agent, ByteStreams, Client, ConnectionTo, Error, SessionMessage, +}; +use bitfun_core::agentic::tools::registry::get_global_tool_registry; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; +use bitfun_core::service::config::ConfigService; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use dashmap::DashMap; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::process::{Child, Command}; +use tokio::sync::{oneshot, Mutex, RwLock}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use super::config::{ + AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientStatus, +}; +use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; +use super::stream::{acp_dispatch_to_stream_events, AcpClientStreamEvent}; +use super::tool::AcpAgentTool; + +const CONFIG_PATH: &str = "acp_clients"; +const PERMISSION_TIMEOUT: Duration = Duration::from_secs(600); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitAcpPermissionResponseRequest { + pub permission_id: String, + pub approve: bool, + #[serde(default)] + pub option_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientPermissionResponse { + pub permission_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAcpSessionModelRequest { + pub client_id: String, + pub session_id: String, + #[serde(default)] + pub workspace_path: Option, + pub model_id: String, +} + +pub struct AcpClientService { + config_service: Arc, + clients: DashMap>, + pending_permissions: DashMap>, + session_permission_modes: DashMap, +} + +struct AcpClientConnection { + id: String, + config: AcpClientConfig, + status: RwLock, + connection: RwLock>>, + sessions: DashMap>>, + cancel_handles: DashMap, + shutdown_tx: Mutex>>, + child: Mutex>, +} + +struct AcpRemoteSession { + active: Option>, + models: Option, + config_options: Vec, +} + +struct AcpCancelHandle { + session_id: String, + connection: ConnectionTo, +} + +impl AcpRemoteSession { + fn new() -> Self { + Self { + active: None, + models: None, + config_options: Vec::new(), + } + } +} + +impl AcpClientService { + pub fn new(config_service: Arc) -> Arc { + Arc::new(Self { + config_service, + clients: DashMap::new(), + pending_permissions: DashMap::new(), + session_permission_modes: DashMap::new(), + }) + } + + pub async fn initialize_all(self: &Arc) -> BitFunResult<()> { + let configs = self.load_configs().await?; + self.register_configured_tools(&configs).await; + + let configured_ids = configs + .keys() + .cloned() + .collect::>(); + let running_ids = self + .clients + .iter() + .map(|entry| entry.key().clone()) + .collect::>(); + for running_id in running_ids { + let should_stop = !configured_ids.contains(&running_id) + || configs + .get(&running_id) + .map(|config| !config.enabled) + .unwrap_or(true); + if should_stop { + let _ = self.stop_client(&running_id).await; + } + } + + for (id, config) in configs { + if config.enabled && config.auto_start { + if let Err(error) = self.start_client(&id).await { + warn!("Failed to auto-start ACP client: id={} error={}", id, error); + } + } + } + + Ok(()) + } + + pub async fn list_clients(self: &Arc) -> BitFunResult> { + let configs = self.load_configs().await?; + let mut infos = Vec::with_capacity(configs.len()); + for (id, config) in configs { + let client = self.clients.get(&id).map(|entry| entry.clone()); + let status = match client.as_ref() { + Some(client) => *client.status.read().await, + None => AcpClientStatus::Configured, + }; + let session_count = client + .as_ref() + .map(|client| client.sessions.len()) + .unwrap_or_default(); + infos.push(AcpClientInfo { + tool_name: AcpAgentTool::tool_name_for(&id), + name: config.name.clone().unwrap_or_else(|| id.clone()), + command: config.command.clone(), + args: config.args.clone(), + enabled: config.enabled, + auto_start: config.auto_start, + readonly: config.readonly, + permission_mode: config.permission_mode, + id, + status, + session_count, + }); + } + infos.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(infos) + } + + pub async fn start_client(self: &Arc, client_id: &str) -> BitFunResult<()> { + if let Some(existing) = self.clients.get(client_id) { + let status = *existing.status.read().await; + if matches!(status, AcpClientStatus::Running | AcpClientStatus::Starting) { + return Ok(()); + } + } + + let config = self + .load_configs() + .await? + .remove(client_id) + .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + + if !config.enabled { + return Err(BitFunError::config(format!( + "ACP client is disabled: {}", + client_id + ))); + } + + let connection = Arc::new(AcpClientConnection::new(client_id.to_string(), config)); + self.clients + .insert(client_id.to_string(), connection.clone()); + *connection.status.write().await = AcpClientStatus::Starting; + + let mut command = Command::new(&connection.config.command); + command + .args(&connection.config.args) + .envs(&connection.config.env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + self.clients.remove(client_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "Failed to spawn ACP client '{}': {}", + client_id, error + ))); + } + }; + + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + let _ = child.start_kill(); + self.clients.remove(client_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 => { + let _ = child.start_kill(); + self.clients.remove(client_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "ACP client '{}' stdin is unavailable", + client_id + ))); + } + }; + + *connection.child.lock().await = Some(child); + + let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); + let service = self.clone(); + let connection_for_task = connection.clone(); + let (cx_tx, cx_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + *connection.shutdown_tx.lock().await = Some(shutdown_tx); + + tokio::spawn(async move { + let result = Client + .builder() + .name("bitfun-acp-client") + .on_receive_request( + { + let service = service.clone(); + async move |request: RequestPermissionRequest, responder, cx| { + let service = service.clone(); + cx.spawn(async move { + responder.respond_with_result( + service.handle_permission_request(request).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx| { + let init = InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(ClientCapabilities::new()) + .client_info(Implementation::new( + "bitfun-desktop", + env!("CARGO_PKG_VERSION"), + )); + cx.send_request(init).block_task().await?; + let _ = cx_tx.send(cx); + let _ = shutdown_rx.await; + Ok(()) + }) + .await; + + if let Err(error) = result { + warn!( + "ACP client connection ended with error: id={} error={:?}", + connection_for_task.id, error + ); + *connection_for_task.status.write().await = AcpClientStatus::Failed; + } else { + *connection_for_task.status.write().await = AcpClientStatus::Stopped; + } + *connection_for_task.connection.write().await = None; + connection_for_task.sessions.clear(); + }); + + let cx = cx_rx.await.map_err(|_| { + BitFunError::service(format!( + "ACP client '{}' exited before initialization completed", + client_id + )) + })?; + *connection.connection.write().await = Some(cx); + *connection.status.write().await = AcpClientStatus::Running; + info!("ACP client started: id={}", client_id); + Ok(()) + } + + pub async fn stop_client(self: &Arc, client_id: &str) -> BitFunResult<()> { + let Some(client) = self.clients.get(client_id).map(|entry| entry.clone()) else { + return Ok(()); + }; + + if let Some(tx) = client.shutdown_tx.lock().await.take() { + let _ = tx.send(()); + } + if let Some(mut child) = client.child.lock().await.take() { + if let Err(error) = child.start_kill() { + warn!( + "Failed to kill ACP client process: id={} error={}", + client_id, error + ); + } + } + *client.connection.write().await = None; + client.sessions.clear(); + client.cancel_handles.clear(); + *client.status.write().await = AcpClientStatus::Stopped; + self.clients.remove(client_id); + info!("ACP client stopped: id={}", client_id); + Ok(()) + } + + pub async fn restart_client(self: &Arc, client_id: &str) -> BitFunResult<()> { + self.stop_client(client_id).await?; + self.start_client(client_id).await + } + + pub async fn load_json_config(&self) -> BitFunResult { + let value = self.load_config_value().await?; + serde_json::to_string_pretty(&value) + .map_err(|error| BitFunError::config(format!("Failed to render ACP config: {}", error))) + } + + pub async fn save_json_config(self: &Arc, json_config: &str) -> BitFunResult<()> { + let value: serde_json::Value = serde_json::from_str(json_config).map_err(|error| { + BitFunError::config(format!("Invalid ACP client JSON config: {}", error)) + })?; + parse_config_value(value.clone())?; + self.config_service.set_config(CONFIG_PATH, value).await?; + self.initialize_all().await + } + + pub async fn submit_permission_response( + &self, + request: SubmitAcpPermissionResponseRequest, + ) -> BitFunResult { + let Some((_, sender)) = self.pending_permissions.remove(&request.permission_id) else { + return Err(BitFunError::NotFound(format!( + "ACP permission request not found: {}", + request.permission_id + ))); + }; + + let option_id = request.option_id.unwrap_or_else(|| { + if request.approve { + "allow_once".to_string() + } else { + "reject_once".to_string() + } + }); + let response = RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )); + let _ = sender.send(response); + Ok(AcpClientPermissionResponse { + permission_id: request.permission_id, + resolved: true, + }) + } + + pub async fn get_session_options( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let mut session = session.lock().await; + self.ensure_remote_session(&client, &session_key, &cwd, &mut session) + .await?; + Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )) + } + + pub async fn set_session_model( + self: &Arc, + request: SetAcpSessionModelRequest, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session( + &request.client_id, + request.workspace_path, + Some(&request.session_id), + ) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let mut session = session.lock().await; + self.ensure_remote_session(&client, &session_key, &cwd, &mut session) + .await?; + let active = session + .active + .as_ref() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + let remote_session_id = active.session_id().to_string(); + let connection = active.connection(); + + let mut set_model_error = None; + if session.models.is_some() { + match connection + .send_request(SetSessionModelRequest::new( + remote_session_id.clone(), + request.model_id.clone(), + )) + .block_task() + .await + .map_err(protocol_error) + { + Ok(_) => { + if let Some(models) = session.models.as_mut() { + models.current_model_id = request.model_id.clone().into(); + } + return Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )); + } + Err(error) => { + set_model_error = Some(error); + } + } + } + + if let Some(config_id) = model_config_id(&session.config_options) { + let response = connection + .send_request(SetSessionConfigOptionRequest::new( + remote_session_id, + config_id, + SessionConfigOptionValue::value_id(request.model_id.clone()), + )) + .block_task() + .await + .map_err(protocol_error)?; + session.config_options = response.config_options; + return Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )); + } + + if let Some(error) = set_model_error { + return Err(error); + } + Err(BitFunError::NotFound( + "ACP session does not expose selectable models".to_string(), + )) + } + + pub async fn prompt_agent( + self: &Arc, + client_id: &str, + prompt: String, + workspace_path: Option, + bitfun_session_id: Option, + timeout_seconds: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let run = async { + let mut session = session.lock().await; + self.ensure_remote_session(&client, &session_key, &cwd, &mut session) + .await?; + + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + active.read_to_string().await.map_err(protocol_error) + }; + + if let Some(seconds) = timeout_seconds.filter(|seconds| *seconds > 0) { + tokio::time::timeout(Duration::from_secs(seconds), run) + .await + .map_err(|_| { + BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + })? + } else { + run.await + } + } + + pub async fn prompt_agent_stream( + self: &Arc, + client_id: &str, + prompt: String, + workspace_path: Option, + bitfun_session_id: Option, + timeout_seconds: Option, + mut on_event: F, + ) -> BitFunResult<()> + where + F: FnMut(AcpClientStreamEvent) -> BitFunResult<()> + Send, + { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let run = async { + let mut session = session.lock().await; + self.ensure_remote_session(&client, &session_key, &cwd, &mut session) + .await?; + + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + + loop { + match active.read_update().await.map_err(protocol_error)? { + SessionMessage::SessionMessage(dispatch) => { + for event in acp_dispatch_to_stream_events(dispatch).await? { + on_event(event)?; + } + } + SessionMessage::StopReason(stop_reason) => { + let event = if matches!(stop_reason, StopReason::Cancelled) { + AcpClientStreamEvent::Cancelled + } else { + AcpClientStreamEvent::Completed + }; + on_event(event)?; + break; + } + _ => {} + } + } + Ok(()) + }; + + if let Some(seconds) = timeout_seconds.filter(|seconds| *seconds > 0) { + tokio::time::timeout(Duration::from_secs(seconds), run) + .await + .map_err(|_| { + BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + })? + } else { + run.await + } + } + + pub async fn cancel_agent_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option, + ) -> BitFunResult<()> { + let client = self + .clients + .get(client_id) + .map(|entry| entry.clone()) + .ok_or_else(|| { + BitFunError::service(format!("ACP client is not running: {}", client_id)) + })?; + + let cwd = workspace_path + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| BitFunError::validation("Workspace path is required".to_string()))?; + let session_key = build_session_key(bitfun_session_id.as_deref(), client_id, &cwd); + let handle = client.cancel_handles.get(&session_key).ok_or_else(|| { + BitFunError::NotFound(format!( + "ACP session is not active for client '{}' in workspace '{}'", + client_id, + cwd.display() + )) + })?; + + handle + .connection + .send_notification(CancelNotification::new(handle.session_id.clone())) + .map_err(protocol_error)?; + Ok(()) + } + + async fn resolve_client_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option<&str>, + ) -> BitFunResult<(Arc, PathBuf, String)> { + self.start_client(client_id).await?; + let client = self + .clients + .get(client_id) + .map(|entry| entry.clone()) + .ok_or_else(|| { + BitFunError::service(format!("ACP client is not running: {}", client_id)) + })?; + + let cwd = workspace_path + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| BitFunError::validation("Workspace path is required".to_string()))?; + let session_key = build_session_key(bitfun_session_id, client_id, &cwd); + Ok((client, cwd, session_key)) + } + + async fn ensure_remote_session( + &self, + client: &Arc, + session_key: &str, + cwd: &Path, + session: &mut AcpRemoteSession, + ) -> BitFunResult<()> { + if session.active.is_some() { + return Ok(()); + } + + let cx = client.connection().await?; + let response = cx + .send_request(NewSessionRequest::new(cwd)) + .block_task() + .await + .map_err(protocol_error)?; + + let models = response.models.clone(); + let config_options = response.config_options.clone().unwrap_or_default(); + let active = cx + .attach_session(response, Vec::new()) + .map_err(protocol_error)?; + client.cancel_handles.insert( + session_key.to_string(), + AcpCancelHandle { + session_id: active.session_id().to_string(), + connection: active.connection(), + }, + ); + self.session_permission_modes.insert( + active.session_id().to_string(), + client.config.permission_mode, + ); + session.models = models; + session.config_options = config_options; + session.active = Some(active); + Ok(()) + } + + async fn load_configs(&self) -> BitFunResult> { + let mut configs = parse_config_value(self.load_config_value().await?)?.acp_clients; + configs + .entry("opencode".to_string()) + .or_insert_with(default_opencode_client_config); + Ok(configs) + } + + async fn load_config_value(&self) -> BitFunResult { + Ok(self + .config_service + .get_config::(Some(CONFIG_PATH)) + .await + .unwrap_or_else(|_| json!({ "acpClients": {} }))) + } + + async fn register_configured_tools( + self: &Arc, + configs: &HashMap, + ) { + let registry = get_global_tool_registry(); + let mut registry = registry.write().await; + registry.unregister_tools_by_prefix("acp__"); + + let tools = configs + .iter() + .filter(|(_, config)| config.enabled) + .map(|(id, config)| { + Arc::new(AcpAgentTool::new(id.clone(), config.clone(), self.clone())) + as Arc + }) + .collect::>(); + + for tool in tools { + debug!("Registering ACP client tool: name={}", tool.name()); + registry.register_tool(tool); + } + } + + async fn handle_permission_request( + self: Arc, + request: RequestPermissionRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let permission_mode = self.permission_mode_for_session(&session_id); + match permission_mode { + AcpClientPermissionMode::AllowOnce => { + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::AllowOnce, + true, + )); + } + AcpClientPermissionMode::RejectOnce => { + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::RejectOnce, + false, + )); + } + AcpClientPermissionMode::Ask => {} + } + + let permission_id = format!("acp_permission_{}", uuid::Uuid::new_v4()); + let (tx, rx) = oneshot::channel(); + self.pending_permissions.insert(permission_id.clone(), tx); + + let payload = json!({ + "permissionId": permission_id, + "sessionId": session_id, + "toolCall": request.tool_call, + "options": request.options, + }); + + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: "backend-event-acppermissionrequest".to_string(), + payload, + }) + .await + { + warn!("Failed to emit ACP permission request: {}", error); + } + + match tokio::time::timeout(PERMISSION_TIMEOUT, rx).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )), + Err(_) => { + self.pending_permissions.remove(&permission_id); + Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )) + } + } + } + + fn permission_mode_for_session(&self, session_id: &str) -> AcpClientPermissionMode { + self.session_permission_modes + .get(session_id) + .map(|entry| *entry.value()) + .unwrap_or(AcpClientPermissionMode::Ask) + } +} + +impl AcpClientConnection { + fn new(id: String, config: AcpClientConfig) -> Self { + Self { + id, + config, + status: RwLock::new(AcpClientStatus::Configured), + connection: RwLock::new(None), + sessions: DashMap::new(), + cancel_handles: DashMap::new(), + shutdown_tx: Mutex::new(None), + child: Mutex::new(None), + } + } + + async fn connection(&self) -> BitFunResult> { + self.connection.read().await.clone().ok_or_else(|| { + BitFunError::service(format!("ACP client is not connected: {}", self.id)) + }) + } +} + +fn parse_config_value(value: serde_json::Value) -> BitFunResult { + if value.get("acpClients").is_some() { + serde_json::from_value(value) + .map_err(|error| BitFunError::config(format!("Invalid ACP client config: {}", error))) + } else if value.is_object() { + serde_json::from_value(json!({ "acpClients": value })).map_err(|error| { + BitFunError::config(format!("Invalid ACP client config map: {}", error)) + }) + } else { + Err(BitFunError::config( + "ACP client config must be an object".to_string(), + )) + } +} + +fn default_opencode_client_config() -> AcpClientConfig { + AcpClientConfig { + name: Some("opencode".to_string()), + command: "opencode".to_string(), + args: vec!["acp".to_string()], + env: HashMap::new(), + enabled: true, + auto_start: false, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + } +} + +fn build_session_key(bitfun_session_id: Option<&str>, client_id: &str, cwd: &Path) -> String { + format!( + "{}:{}:{}", + bitfun_session_id.unwrap_or("standalone"), + client_id, + cwd.to_string_lossy() + ) +} + +fn protocol_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(format!("ACP protocol error: {}", error)) +} + +fn select_permission_by_kind( + request: &RequestPermissionRequest, + preferred: PermissionOptionKind, + approve: bool, +) -> RequestPermissionResponse { + let fallback_kind = if approve { + PermissionOptionKind::AllowAlways + } else { + PermissionOptionKind::RejectAlways + }; + let option_id = request + .options + .iter() + .find(|option| option.kind == preferred) + .or_else(|| { + request + .options + .iter() + .find(|option| option.kind == fallback_kind) + }) + .or_else(|| request.options.first()) + .map(|option| option.option_id.to_string()) + .unwrap_or_else(|| { + if approve { + "allow_once".to_string() + } else { + "reject_once".to_string() + } + }); + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )) +} diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs new file mode 100644 index 000000000..04afade5d --- /dev/null +++ b/src/crates/acp/src/client/mod.rs @@ -0,0 +1,16 @@ +mod config; +mod manager; +mod session_options; +mod stream; +mod tool; +mod tool_card_bridge; + +pub use config::{ + AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientStatus, +}; +pub use manager::{ + AcpClientPermissionResponse, AcpClientService, SetAcpSessionModelRequest, + SubmitAcpPermissionResponseRequest, +}; +pub use session_options::{AcpSessionModelOption, AcpSessionOptions}; +pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs new file mode 100644 index 000000000..3f6ea88b5 --- /dev/null +++ b/src/crates/acp/src/client/session_options.rs @@ -0,0 +1,150 @@ +use agent_client_protocol::schema::{ + ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectOptions, SessionModelState, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionOptions { + #[serde(default)] + pub current_model_id: Option, + #[serde(default)] + pub available_models: Vec, + #[serde(default)] + pub model_config_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionModelOption { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, +} + +pub(super) fn session_options_from_state( + models: Option<&SessionModelState>, + config_options: &[SessionConfigOption], +) -> AcpSessionOptions { + if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { + return AcpSessionOptions { + current_model_id: Some(models.current_model_id.to_string()), + available_models: models + .available_models + .iter() + .map(model_option_from_model_info) + .collect(), + model_config_id: None, + }; + } + + model_config_option(config_options) + .map(|option| { + let (current_model_id, available_models) = select_model_values(option); + AcpSessionOptions { + current_model_id, + available_models, + model_config_id: Some(option.id.to_string()), + } + }) + .unwrap_or_default() +} + +pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { + model_config_option(config_options).map(|option| option.id.to_string()) +} + +fn model_option_from_model_info(model: &ModelInfo) -> AcpSessionModelOption { + AcpSessionModelOption { + id: model.model_id.to_string(), + name: model.name.clone(), + description: model.description.clone(), + } +} + +fn model_config_option(config_options: &[SessionConfigOption]) -> Option<&SessionConfigOption> { + config_options + .iter() + .find(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model))) + .or_else(|| { + config_options.iter().find(|option| { + let id = option.id.to_string().to_ascii_lowercase(); + let name = option.name.to_ascii_lowercase(); + id == "model" || id.ends_with("_model") || name.contains("model") + }) + }) + .filter(|option| matches!(option.kind, SessionConfigKind::Select(_))) +} + +fn select_model_values( + option: &SessionConfigOption, +) -> (Option, Vec) { + let SessionConfigKind::Select(select) = &option.kind else { + return (None, Vec::new()); + }; + + let models = match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(|option| AcpSessionModelOption { + id: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + }) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group.options.iter().map(|option| AcpSessionModelOption { + id: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + }) + }) + .collect(), + _ => Vec::new(), + }; + + (Some(select.current_value.to_string()), models) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::{ModelInfo, SessionConfigOption}; + + #[test] + fn converts_native_model_state() { + let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); + + let options = session_options_from_state(Some(&state), &[]); + + assert_eq!(options.current_model_id.as_deref(), Some("gpt-5.4")); + assert_eq!(options.available_models.len(), 1); + assert_eq!(options.available_models[0].name, "GPT 5.4"); + assert!(options.model_config_id.is_none()); + } + + #[test] + fn converts_model_config_option_fallback() { + let config = SessionConfigOption::select( + "model", + "Model", + "fast", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new("fast", "Fast"), + agent_client_protocol::schema::SessionConfigSelectOption::new("smart", "Smart"), + ], + ) + .category(SessionConfigOptionCategory::Model); + + let options = session_options_from_state(None, &[config]); + + assert_eq!(options.current_model_id.as_deref(), Some("fast")); + assert_eq!(options.model_config_id.as_deref(), Some("model")); + assert_eq!(options.available_models.len(), 2); + assert_eq!(options.available_models[1].id, "smart"); + } +} diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs new file mode 100644 index 000000000..9c14d7f11 --- /dev/null +++ b/src/crates/acp/src/client/stream.rs @@ -0,0 +1,217 @@ +use agent_client_protocol::schema::{ + ContentBlock, ContentChunk, SessionNotification, SessionUpdate, ToolCall, ToolCallContent, + ToolCallStatus, ToolCallUpdate, +}; +use agent_client_protocol::util::MatchDispatch; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use bitfun_events::ToolEventData; + +use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; + +#[derive(Debug, Clone)] +pub enum AcpClientStreamEvent { + AgentText(String), + AgentThought(String), + ToolEvent(ToolEventData), + Completed, + Cancelled, +} + +pub async fn acp_dispatch_to_stream_events( + dispatch: agent_client_protocol::Dispatch, +) -> BitFunResult> { + let mut events = Vec::new(); + MatchDispatch::new(dispatch) + .if_notification(async |notification: SessionNotification| { + match notification.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let Some(text) = content_chunk_text(chunk) { + events.push(AcpClientStreamEvent::AgentText(text)); + } + } + SessionUpdate::AgentThoughtChunk(chunk) => { + if let Some(text) = content_chunk_text(chunk) { + events.push(AcpClientStreamEvent::AgentThought(text)); + } + } + SessionUpdate::ToolCall(tool_call) => { + events.extend(acp_tool_call_events(tool_call)); + } + SessionUpdate::ToolCallUpdate(tool_call_update) => { + if let Some(event) = acp_tool_call_update_event(tool_call_update) { + events.push(event); + } + } + _ => {} + } + Ok(()) + }) + .await + .otherwise_ignore() + .map_err(protocol_error)?; + Ok(events) +} + +fn content_chunk_text(chunk: ContentChunk) -> Option { + match chunk.content { + ContentBlock::Text(text) => Some(text.text), + _ => None, + } +} + +fn acp_tool_call_events(tool_call: ToolCall) -> Vec { + let tool_id = tool_call.tool_call_id.to_string(); + let tool_name = acp_tool_name( + &tool_call.title, + tool_call.raw_input.as_ref(), + Some(&tool_call.kind), + ); + let params = normalize_tool_params( + &tool_name, + tool_call.raw_input.clone().unwrap_or_else(|| { + serde_json::json!({ + "title": tool_call.title, + "kind": format!("{:?}", tool_call.kind), + }) + }), + ); + + let mut events = vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params, + })]; + + match tool_call.status { + ToolCallStatus::Completed => { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + tool_id, + tool_name, + result: acp_tool_result_value( + tool_call.raw_output, + Some(tool_call.content), + Some(tool_call.locations), + ), + result_for_assistant: None, + duration_ms: 0, + })); + } + ToolCallStatus::Failed => { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + tool_id, + tool_name, + error: acp_tool_error_text(tool_call.raw_output, tool_call.content), + })); + } + ToolCallStatus::Pending | ToolCallStatus::InProgress => {} + _ => {} + } + + events +} + +fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { + let tool_id = update.tool_call_id.to_string(); + let title = update.fields.title.unwrap_or_else(|| tool_id.clone()); + let tool_name = acp_tool_name( + &title, + update.fields.raw_input.as_ref(), + update.fields.kind.as_ref(), + ); + + match update.fields.status { + Some(ToolCallStatus::Completed) => { + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + tool_id, + tool_name, + result: acp_tool_result_value( + update.fields.raw_output, + update.fields.content, + update.fields.locations, + ), + result_for_assistant: None, + duration_ms: 0, + })) + } + Some(ToolCallStatus::Failed) => { + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + tool_id, + tool_name, + error: acp_tool_error_text( + update.fields.raw_output, + update.fields.content.unwrap_or_default(), + ), + })) + } + Some(ToolCallStatus::InProgress) | Some(ToolCallStatus::Pending) | Some(_) => { + let params = normalize_tool_params( + &tool_name, + update.fields.raw_input.unwrap_or_else(|| { + serde_json::json!({ + "title": title, + }) + }), + ); + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + })) + } + None => update.fields.raw_input.map(|params| { + let params = normalize_tool_params(&tool_name, params); + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + }) + }), + } +} + +fn acp_tool_result_value( + raw_output: Option, + content: Option>, + locations: Option>, +) -> serde_json::Value { + if let Some(raw_output) = raw_output { + return raw_output; + } + + let content = content.unwrap_or_default(); + let locations = locations.unwrap_or_default(); + if content.is_empty() && locations.is_empty() { + return serde_json::Value::Null; + } + + serde_json::json!({ + "content": content, + "locations": locations, + }) +} + +fn acp_tool_error_text( + raw_output: Option, + content: Vec, +) -> String { + if let Some(raw_output) = raw_output { + return value_to_display_text(&raw_output); + } + if !content.is_empty() { + return serde_json::to_string_pretty(&content).unwrap_or_else(|_| { + serde_json::to_string(&content).unwrap_or_else(|_| "ACP tool failed".to_string()) + }); + } + "ACP tool failed".to_string() +} + +fn value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn protocol_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(format!("ACP protocol error: {}", error)) +} diff --git a/src/crates/acp/src/client/tool.rs b/src/crates/acp/src/client/tool.rs new file mode 100644 index 000000000..c32d1c383 --- /dev/null +++ b/src/crates/acp/src/client/tool.rs @@ -0,0 +1,225 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_core::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde_json::{json, Value}; + +use super::config::AcpClientConfig; +use super::manager::AcpClientService; + +pub struct AcpAgentTool { + client_id: String, + config: AcpClientConfig, + service: Arc, + full_name: String, +} + +impl AcpAgentTool { + pub fn new(client_id: String, config: AcpClientConfig, service: Arc) -> Self { + let full_name = Self::tool_name_for(&client_id); + Self { + client_id, + config, + service, + full_name, + } + } + + pub fn tool_name_for(client_id: &str) -> String { + format!("acp__{}__prompt", sanitize_tool_part(client_id)) + } + + fn display_name(&self) -> String { + self.config + .name + .clone() + .unwrap_or_else(|| self.client_id.clone()) + } +} + +#[async_trait] +impl Tool for AcpAgentTool { + fn name(&self) -> &str { + &self.full_name + } + + async fn description(&self) -> BitFunResult { + Ok(format!( + "Send a prompt to the external ACP agent '{}'. Use this when another local ACP-compatible agent is better suited for a delegated task.", + self.display_name() + )) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The task or question to send to the external ACP agent." + }, + "workspace_path": { + "type": "string", + "description": "Optional absolute workspace path. Defaults to the current BitFun workspace." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 0, + "description": "Optional timeout in seconds. Use 0 or omit it to wait without a fixed timeout." + } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn user_facing_name(&self) -> String { + format!("{} (ACP)", self.display_name()) + } + + fn is_readonly(&self) -> bool { + self.config.readonly + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + !self.config.readonly + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match input.get("prompt").and_then(|value| value.as_str()) { + Some(prompt) if !prompt.trim().is_empty() => ValidationResult::default(), + Some(_) => ValidationResult { + result: false, + message: Some("prompt cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }, + None => ValidationResult { + result: false, + message: Some("prompt is required".to_string()), + error_code: Some(400), + meta: None, + }, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let prompt_preview = input + .get("prompt") + .and_then(|value| value.as_str()) + .map(truncate_prompt) + .unwrap_or_else(|| "prompt".to_string()); + format!( + "Sending ACP prompt to '{}': {}", + self.display_name(), + prompt_preview + ) + } + + fn render_tool_use_rejected_message(&self) -> String { + format!("ACP prompt to '{}' was rejected", self.display_name()) + } + + fn render_tool_result_message(&self, output: &Value) -> String { + output + .get("response") + .and_then(|value| value.as_str()) + .map(|response| { + format!( + "ACP agent '{}' responded:\n{}", + self.display_name(), + response + ) + }) + .unwrap_or_else(|| format!("ACP agent '{}' completed", self.display_name())) + } + + fn render_result_for_assistant(&self, output: &Value) -> String { + output + .get("response") + .and_then(|value| value.as_str()) + .unwrap_or("ACP agent completed without text output") + .to_string() + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let prompt = input + .get("prompt") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| BitFunError::tool("prompt is required".to_string()))? + .to_string(); + + let workspace_path = input + .get("workspace_path") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }); + let timeout_seconds = input + .get("timeout_seconds") + .and_then(|value| value.as_u64()); + + let response = self + .service + .prompt_agent( + &self.client_id, + prompt, + workspace_path, + context.session_id.clone(), + timeout_seconds, + ) + .await?; + + let data = json!({ + "client_id": self.client_id, + "response": response, + }); + Ok(vec![ToolResult::Result { + result_for_assistant: Some(self.render_result_for_assistant(&data)), + data, + image_attachments: None, + }]) + } +} + +fn sanitize_tool_part(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + sanitized.trim_matches('_').to_string() +} + +fn truncate_prompt(prompt: &str) -> String { + const LIMIT: usize = 160; + if prompt.chars().count() <= LIMIT { + prompt.to_string() + } else { + format!("{}...", prompt.chars().take(LIMIT).collect::()) + } +} diff --git a/src/crates/acp/src/client/tool_card_bridge.rs b/src/crates/acp/src/client/tool_card_bridge.rs new file mode 100644 index 000000000..f2ae9cb70 --- /dev/null +++ b/src/crates/acp/src/client/tool_card_bridge.rs @@ -0,0 +1,398 @@ +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); + } + } + } + "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)) +} + +#[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_file_tools_to_native_cards() { + let read_input = json!({ "path": "src/main.rs" }); + assert_eq!( + acp_tool_name("Read file", Some(&read_input), Some(&ToolKind::Read)), + "Read" + ); + assert_eq!( + normalize_tool_params("Read", read_input)["file_path"], + "src/main.rs" + ); + + let write_input = json!({ "path": "README.md", "content": "hello" }); + assert_eq!( + acp_tool_name("Create file", Some(&write_input), Some(&ToolKind::Edit)), + "Write" + ); + } + + #[test] + fn normalizes_search_tools_to_grep_or_glob_cards() { + let grep_input = json!({ "query": "AcpClientService" }); + assert_eq!( + acp_tool_name("Search text", Some(&grep_input), Some(&ToolKind::Search)), + "Grep" + ); + assert_eq!( + normalize_tool_params("Grep", grep_input)["pattern"], + "AcpClientService" + ); + + let glob_input = json!({ "glob_pattern": "**/*.rs" }); + assert_eq!( + acp_tool_name("Find files", Some(&glob_input), Some(&ToolKind::Search)), + "Glob" + ); + assert_eq!( + normalize_tool_params("Glob", glob_input)["pattern"], + "**/*.rs" + ); + } + + #[test] + fn search_with_path_stays_search_card() { + let input = json!({ "pattern": "ToolEventData", "path": "src" }); + assert_eq!( + acp_tool_name("Search text", Some(&input), Some(&ToolKind::Search)), + "Grep" + ); + } + + #[test] + fn normalizes_camel_case_file_params() { + let input = json!({ + "filePath": "src/lib.rs", + "oldString": "before", + "newString": "after" + }); + assert_eq!( + acp_tool_name("Edit file", Some(&input), Some(&ToolKind::Edit)), + "Edit" + ); + + let params = normalize_tool_params("Edit", input); + assert_eq!(params["file_path"], "src/lib.rs"); + assert_eq!(params["old_string"], "before"); + assert_eq!(params["new_string"], "after"); + } +} diff --git a/src/crates/acp/src/lib.rs b/src/crates/acp/src/lib.rs index a7e9ab7f2..5930b0a52 100644 --- a/src/crates/acp/src/lib.rs +++ b/src/crates/acp/src/lib.rs @@ -3,9 +3,11 @@ //! This crate owns the external ACP server surface and maps it onto BitFun's //! core agentic runtime. CLI and other hosts should only start this crate. +pub mod client; mod runtime; mod server; pub use agent_client_protocol as protocol; +pub use client::AcpClientService; pub use runtime::BitfunAcpRuntime; pub use server::AcpServer; diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 525e7ef16..fbf856d03 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -71,17 +71,24 @@ impl ToolRegistry { /// Remove all tools from the MCP server pub fn unregister_mcp_server_tools(&mut self, server_id: &str) { let prefix = format!("mcp__{}__", server_id); + self.unregister_tools_by_prefix(&prefix); + } + + /// Remove all tools whose registry name starts with the given prefix. + pub fn unregister_tools_by_prefix(&mut self, prefix: &str) -> usize { let to_remove: Vec = self .tools .keys() - .filter(|k| k.starts_with(&prefix)) + .filter(|k| k.starts_with(prefix)) .cloned() .collect(); + let count = to_remove.len(); for key in to_remove { - info!("Unregistering MCP tool: tool_name={}", key); + info!("Unregistering dynamic tool: tool_name={}", key); self.tools.shift_remove(&key); } + count } /// Register all tools diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 8c7bb1b02..185c50841 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -44,6 +44,9 @@ pub struct GlobalConfig { /// MCP server configuration (stored uniformly; supports both JSON and structured formats). #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option, + /// ACP client configuration (stored as `{ "acpClients": { ... } }`). + #[serde(skip_serializing_if = "Option::is_none")] + pub acp_clients: Option, /// Theme system configuration. #[serde(skip_serializing_if = "Option::is_none")] pub themes: Option, @@ -1175,6 +1178,7 @@ impl Default for GlobalConfig { workspace: WorkspaceConfig::default(), ai: AIConfig::default(), mcp_servers: None, + acp_clients: None, themes: Some(ThemesConfig::default()), font: None, version: "1.0.0".to_string(), diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index acfd3a893..c3a7ff7e1 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -141,8 +141,19 @@ function App() { } }; + const initACPClients = async () => { + try { + const { ACPClientAPI } = await import('../infrastructure/api/service-api/ACPClientAPI'); + await ACPClientAPI.initializeClients(); + log.debug('ACP clients initialized'); + } catch (error) { + log.error('Failed to initialize ACP clients', error); + } + }; + initIdeControl(); initMCPServers(); + initACPClients(); }, []); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index f692cea6f..dbe9bd89c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; @@ -19,6 +19,7 @@ import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace'; +import { ACPClientAPI, type AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; import SessionsSection from '../sessions/SessionsSection'; import { @@ -131,6 +132,7 @@ const WorkspaceItem: React.FC = ({ const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); + const [acpClients, setAcpClients] = useState([]); const menuRef = useRef(null); const menuAnchorRef = useRef(null); const menuPopoverRef = useRef(null); @@ -339,6 +341,28 @@ const WorkspaceItem: React.FC = ({ }; }, [menuOpen, updateMenuPosition]); + useEffect(() => { + let cancelled = false; + + const loadAcpClients = async () => { + try { + const clients = await ACPClientAPI.getClients(); + if (!cancelled) { + setAcpClients(clients.filter(client => client.enabled)); + } + } catch (_error) { + setAcpClients([]); + } + }; + + void loadAcpClients(); + window.addEventListener('bitfun:acp-clients-changed', loadAcpClients); + return () => { + cancelled = true; + window.removeEventListener('bitfun:acp-clients-changed', loadAcpClients); + }; + }, []); + const handleActivate = useCallback(async () => { if (!isActive) { await setActiveWorkspace(workspace.id); @@ -499,6 +523,33 @@ const WorkspaceItem: React.FC = ({ void handleCreateSession('Cowork'); }, [handleCreateSession]); + const handleCreateAcpSession = useCallback(async (client: AcpClientInfo) => { + setMenuOpen(false); + try { + const sessionId = await flowChatManager.createAcpChatSession( + client.id, + { + workspacePath: workspace.rootPath, + ...(isRemoteWorkspace(workspace) && workspace.connectionId + ? { remoteConnectionId: workspace.connectionId } + : {}), + ...(isRemoteWorkspace(workspace) && workspace.sshHost + ? { remoteSshHost: workspace.sshHost } + : {}), + }, + ); + await openMainSession(sessionId, { + workspaceId: workspace.id, + activateWorkspace: setActiveWorkspace, + }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), + { duration: 4000 } + ); + } + }, [setActiveWorkspace, t, workspace]); + const handleCreateInitSession = useCallback(async () => { setMenuOpen(false); @@ -999,6 +1050,22 @@ const WorkspaceItem: React.FC = ({ {t('nav.sessions.newCoworkSessionShort')} + {acpClients.map(client => { + const label = client.name || client.id; + return ( + + ); + })} + + + {dropdownOpen && ( +
+
+ ACP model + + {acpClientId} + +
+ +
+ {acpAvailableModels.map(model => { + const isSelected = currentAcpModelId === model.id; + + return ( + +
handleSelectModel(model.id)} + > +
+ + {model.modelName} + +
+ {isSelected && ( + + )} +
+
+ ); + })} +
+
+ )} + + ); + } + if (availableModels.length === 0) { return null; } diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 565f6c463..8f981c933 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -145,7 +145,9 @@ export const ModelRoundItem = React.memo( // 1) group subagent items // 2) group normal items into explore/critical via anchor tool const groupedItems = useMemo(() => { - const deferExploreGrouping = round.isStreaming && hasActiveStreamingNarrative(sortedItems); + const deferExploreGrouping = + round.renderHints?.disableExploreGrouping === true || + (round.isStreaming && hasActiveStreamingNarrative(sortedItems)); const intermediateGroups: Array<{ type: 'normal', item: FlowItem } | { type: 'subagent', parentTaskToolId: string, items: FlowItem[] }> = []; let currentSubagentGroup: { parentTaskToolId: string, items: FlowItem[] } | null = null; @@ -259,7 +261,7 @@ export const ModelRoundItem = React.memo( flushPendingAsCritical(); return finalGroups; - }, [round.isStreaming, sortedItems]); + }, [round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems]); const extractDialogTurnContent = useCallback(() => { const flowChatStore = FlowChatStore.getInstance(); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts index a5d386162..1f3e2e8e6 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts @@ -5,81 +5,108 @@ import { useCallback } from 'react'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { + ACPClientAPI, + type AcpPermissionOption, +} from '@/infrastructure/api/service-api/ACPClientAPI'; import { flowChatStore } from '../../store/FlowChatStore'; import type { DialogTurn, FlowItem, FlowToolItem, ModelRound } from '../../types/flow-chat'; const log = createLogger('useFlowChatToolActions'); interface ResolvedToolContext { - activeSessionId: string | null; + sessionId: string | null; toolItem: FlowToolItem | null; turnId: string | null; } function resolveToolContext(toolId: string): ResolvedToolContext { const latestState = flowChatStore.getState(); - const dialogTurns = Array.from(latestState.sessions.values()).flatMap(session => - session.dialogTurns as DialogTurn[], - ); - + let sessionId: string | null = null; let toolItem: FlowToolItem | null = null; let turnId: string | null = null; - for (const turn of dialogTurns) { - for (const modelRound of turn.modelRounds as ModelRound[]) { - const item = modelRound.items.find((candidate: FlowItem) => ( - candidate.type === 'tool' && candidate.id === toolId - )) as FlowToolItem | undefined; + for (const [candidateSessionId, session] of latestState.sessions) { + for (const turn of session.dialogTurns as DialogTurn[]) { + for (const modelRound of turn.modelRounds as ModelRound[]) { + const item = modelRound.items.find((candidate: FlowItem) => ( + candidate.type === 'tool' && candidate.id === toolId + )) as FlowToolItem | undefined; + + if (item) { + sessionId = candidateSessionId; + toolItem = item; + turnId = turn.id; + break; + } + } - if (item) { - toolItem = item; - turnId = turn.id; + if (toolItem) { break; } } - if (toolItem) { - break; - } + if (toolItem) break; } return { - activeSessionId: latestState.activeSessionId, + sessionId, toolItem, turnId, }; } +function selectAcpPermissionOption( + options: AcpPermissionOption[] | undefined, + approve: boolean +): AcpPermissionOption | undefined { + const preferredKinds = approve + ? ['allow_once', 'allow_always'] + : ['reject_once', 'reject_always']; + for (const kind of preferredKinds) { + const option = options?.find((candidate) => candidate.kind === kind); + if (option) return option; + } + return options?.find((candidate) => + approve ? candidate.kind.startsWith('allow') : candidate.kind.startsWith('reject') + ); +} + export function useFlowChatToolActions() { const handleToolConfirm = useCallback(async (toolId: string, updatedInput?: any) => { try { - const { activeSessionId, toolItem, turnId } = resolveToolContext(toolId); + const { sessionId, toolItem, turnId } = resolveToolContext(toolId); - if (!toolItem || !turnId) { + if (!sessionId || !toolItem || !turnId) { notificationService.error(`Tool confirmation failed: tool item ${toolId} not found in current session`); return; } const finalInput = updatedInput || toolItem.toolCall?.input; - if (activeSessionId) { - flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { - userConfirmed: true, - status: 'confirmed', - toolCall: { - ...toolItem.toolCall, - input: finalInput, - }, - } as any); - } - - if (!activeSessionId) { - throw new Error('No active session ID'); + flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { + userConfirmed: true, + status: 'confirmed', + toolCall: { + ...toolItem.toolCall, + input: finalInput, + }, + } as any); + + const acpPermission = toolItem.acpPermission; + if (acpPermission?.permissionId) { + const option = selectAcpPermissionOption(acpPermission.options, true); + await ACPClientAPI.submitPermissionResponse({ + permissionId: acpPermission.permissionId, + approve: true, + optionId: option?.optionId, + }); + return; } const { agentService } = await import('../../../shared/services/agent-service'); await agentService.confirmToolExecution( - activeSessionId, + sessionId, toolId, 'confirm', finalInput, @@ -92,27 +119,32 @@ export function useFlowChatToolActions() { const handleToolReject = useCallback(async (toolId: string) => { try { - const { activeSessionId, toolItem, turnId } = resolveToolContext(toolId); + const { sessionId, toolItem, turnId } = resolveToolContext(toolId); - if (!toolItem || !turnId) { + if (!sessionId || !toolItem || !turnId) { log.warn('Tool rejection failed: tool item not found', { toolId }); return; } - if (activeSessionId) { - flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { - userConfirmed: false, - status: 'rejected', - } as any); - } - - if (!activeSessionId) { - throw new Error('No active session ID'); + flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { + userConfirmed: false, + status: 'cancelled', + } as any); + + const acpPermission = toolItem.acpPermission; + if (acpPermission?.permissionId) { + const option = selectAcpPermissionOption(acpPermission.options, false); + await ACPClientAPI.submitPermissionResponse({ + permissionId: acpPermission.permissionId, + approve: false, + optionId: option?.optionId, + }); + return; } const { agentService } = await import('../../../shared/services/agent-service'); await agentService.confirmToolExecution( - activeSessionId, + sessionId, toolId, 'reject', ); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index f1a2ac76b..b6beb6292 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -10,6 +10,7 @@ import { processingStatusManager } from './ProcessingStatusManager'; import { FlowChatStore } from '../store/FlowChatStore'; import { AgentService } from '../../shared/services/agent-service'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; import { createLogger } from '@/shared/utils/logger'; @@ -198,6 +199,39 @@ export class FlowChatManager { return createChatSessionModule(this.context, config, mode); } + async createAcpChatSession(clientId: string, config: SessionConfig = {}): Promise { + const workspacePath = + config.workspacePath?.trim() || + this.context.currentWorkspacePath?.trim(); + if (!workspacePath) { + throw new Error('Workspace path is required to create an ACP session'); + } + + const response = await ACPClientAPI.createFlowSession({ + clientId, + workspacePath, + sessionName: `${clientId} ACP`, + }); + + this.context.flowChatStore.createSession( + response.sessionId, + { + ...config, + workspacePath, + agentType: response.agentType, + }, + undefined, + response.sessionName, + 128128, + response.agentType, + workspacePath, + config.remoteConnectionId, + config.remoteSshHost, + ); + + return response.sessionId; + } + async switchChatSession(sessionId: string): Promise { return switchChatSessionModule(this.context, sessionId); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts new file mode 100644 index 000000000..b4f658d20 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts @@ -0,0 +1,100 @@ +/** + * Bridges ACP permission requests into FlowChat tool cards. + */ + +import { FlowChatStore } from '../../store/FlowChatStore'; +import type { FlowToolItem } from '../../types/flow-chat'; +import type { AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; + +const pendingAcpPermissionRequests = new Map(); + +function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null { + const toolCallId = event.toolCall?.toolCallId; + return typeof toolCallId === 'string' && toolCallId.trim().length > 0 + ? toolCallId + : null; +} + +function findToolContextById( + store: FlowChatStore, + toolId: string +): { sessionId: string; turnId: string; itemId: string } | null { + const state = store.getState(); + for (const [sessionId, session] of state.sessions) { + for (const turn of session.dialogTurns) { + for (const round of turn.modelRounds) { + const item = round.items.find(candidate => ( + candidate.type === 'tool' && + (candidate.id === toolId || (candidate as FlowToolItem).toolCall?.id === toolId) + )) as FlowToolItem | undefined; + + if (item) { + return { sessionId, turnId: turn.id, itemId: item.id }; + } + } + } + } + return null; +} + +function applyAcpPermissionRequest( + store: FlowChatStore, + toolId: string, + event: AcpPermissionRequestEvent +): boolean { + const toolContext = findToolContextById(store, toolId); + if (!toolContext) { + return false; + } + + store.updateModelRoundItem(toolContext.sessionId, toolContext.turnId, toolContext.itemId, { + requiresConfirmation: true, + userConfirmed: false, + status: 'pending_confirmation', + acpPermission: { + permissionId: event.permissionId, + sessionId: event.sessionId, + toolCallId: toolId, + requestedAt: Date.now(), + options: event.options, + toolCall: event.toolCall, + }, + } as any); + + const activeSessionId = store.getState().activeSessionId; + if (toolContext.sessionId !== activeSessionId) { + store.setSessionNeedsAttention(toolContext.sessionId, 'tool_confirm'); + } + + return true; +} + +export function handleAcpPermissionRequestForToolCard(event: AcpPermissionRequestEvent): boolean { + const toolId = acpPermissionToolId(event); + if (!toolId) { + return false; + } + + const store = FlowChatStore.getInstance(); + if (!applyAcpPermissionRequest(store, toolId, event)) { + pendingAcpPermissionRequests.set(toolId, event); + return true; + } + + pendingAcpPermissionRequests.delete(toolId); + return true; +} + +export function applyPendingAcpPermissionForTool( + store: FlowChatStore, + toolId: string +): void { + const event = pendingAcpPermissionRequests.get(toolId); + if (!event) { + return; + } + + if (applyAcpPermissionRequest(store, toolId, event)) { + pendingAcpPermissionRequests.delete(toolId); + } +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 5a7ffcf69..cad9e01a7 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -26,6 +26,7 @@ import type { } from '@/infrastructure/api/service-api/AgentAPI'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import { MCPAPI } from '@/infrastructure/api/service-api/MCPAPI'; +import { ACPClientAPI, type AcpPermissionOption, type AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; import { globalEventBus } from '@/infrastructure/event-bus'; import type { FlowChatContext, DialogTurn, ModelRound, FlowToolItem } from './types'; import { @@ -38,6 +39,7 @@ import { type AiErrorPresentation, type AiErrorDetail, } from '@/shared/ai-errors/aiErrorPresenter'; +import { isAcpFlowSession } from '../../utils/acpSession'; const pendingImageAnalysisTurns = new Map(); // `restore_session` and assistant bootstrap can race on the same historical @@ -63,6 +65,7 @@ import { handleToolExecutionProgress, handleToolTerminalReady, } from './ToolEventModule'; +import { handleAcpPermissionRequestForToolCard } from './AcpPermissionToolCardModule'; import { routeModelRoundStartedToToolCardInternal, routeTextChunkToToolCardInternal, @@ -359,6 +362,9 @@ export async function initializeEventListeners( const unlistenMcpInteractionRequest = await listen('backend-event-mcpinteractionrequest', (event: any) => { void handleMcpInteractionRequest((event.payload as any)?.value || event.payload); }); + const unlistenAcpPermissionRequest = await listen('backend-event-acppermissionrequest', (event: any) => { + void handleAcpPermissionRequest((event.payload as any)?.value || event.payload); + }); const callbacks: AgenticEventCallbacks = { onSessionCreated: (event) => { @@ -423,6 +429,7 @@ export async function initializeEventListeners( unlistenProgress(); unlistenTerminalReady(); unlistenMcpInteractionRequest(); + unlistenAcpPermissionRequest(); agenticEventListener.stopListening(); }; } @@ -462,6 +469,46 @@ async function handleMcpInteractionRequest(rawEvent: unknown): Promise { } } +async function handleAcpPermissionRequest(rawEvent: unknown): Promise { + const event = rawEvent as AcpPermissionRequestEvent | undefined; + const permissionId = event?.permissionId; + if (!permissionId) { + log.warn('Received invalid ACP permission request event', { rawEvent }); + return; + } + + if (handleAcpPermissionRequestForToolCard(event)) return; + + log.warn('ACP permission request cannot be matched to a tool card, rejecting request', { permissionId }); + const option = selectAcpPermissionOption(event?.options, false); + try { + await ACPClientAPI.submitPermissionResponse({ + permissionId, + approve: false, + optionId: option?.optionId, + }); + } catch (error) { + log.error('Failed to submit ACP permission auto-rejection', { permissionId, error }); + notificationService.error('Failed to respond to ACP permission request'); + } +} + +function selectAcpPermissionOption( + options: AcpPermissionRequestEvent['options'], + approve: boolean +): AcpPermissionOption | undefined { + const preferredKinds = approve + ? ['allow_once', 'allow_always'] + : ['reject_once', 'reject_always']; + for (const kind of preferredKinds) { + const option = options?.find((candidate) => candidate.kind === kind); + if (option) return option; + } + return options?.find((candidate) => + approve ? candidate.kind.startsWith('allow') : candidate.kind.startsWith('reject') + ); +} + /** * Handle session created event (e.g. remote mobile created a session) */ @@ -1342,6 +1389,12 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { completeActiveTextItems(context, sessionId, turnId); + const disableExploreGrouping = + event.renderHints?.disableExploreGrouping === true || + event.metadata?.disableExploreGrouping === true || + event.disableExploreGrouping === true || + isAcpFlowSession(session); + const modelRound: ModelRound = { id: roundId, index: roundIndex || 0, @@ -1349,7 +1402,10 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { isStreaming: true, isComplete: false, status: 'streaming', - startTime: Date.now() + startTime: Date.now(), + ...(disableExploreGrouping + ? { renderHints: { disableExploreGrouping: true } } + : {}), }; context.flowChatStore.addModelRound(sessionId, turnId, modelRound); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 261737534..c4ac05c02 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -4,6 +4,7 @@ */ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { notificationService } from '../../../shared/notification-system'; @@ -29,6 +30,13 @@ const log = createLogger('MessageModule'); const ONE_SHOT_AGENT_TYPES_FOR_SESSION = new Set(['Init']); +function acpClientIdFromMode(mode: string | undefined): string | null { + const value = mode?.trim(); + if (!value?.startsWith('acp:')) return null; + const clientId = value.slice('acp:'.length).trim(); + return clientId || null; +} + function normalizeModelSelection( modelId: string | undefined, models: AIModelConfig[], @@ -127,8 +135,10 @@ export async function sendMessage( try { const refreshedSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; const currentAgentType = (agentType?.trim() || refreshedSession.mode || 'agentic').trim(); + const acpClientId = acpClientIdFromMode(currentAgentType); if ( + !acpClientId && agentType?.trim() && !ONE_SHOT_AGENT_TYPES_FOR_SESSION.has(currentAgentType) && refreshedSession.mode !== currentAgentType @@ -160,7 +170,9 @@ export async function sendMessage( return; } - await ensureBackendSession(context, sessionId); + if (!acpClientId) { + await ensureBackendSession(context, sessionId); + } const readySession = context.flowChatStore.getState().sessions.get(sessionId); if (!readySession) { @@ -227,7 +239,9 @@ export async function sendMessage( metadata: { sessionId: sessionId, dialogTurnId } }); - await syncSessionModelSelection(context, sessionId, currentAgentType); + if (!acpClientId) { + await syncSessionModelSelection(context, sessionId, currentAgentType); + } const updatedSession = context.flowChatStore.getState().sessions.get(sessionId); if (!updatedSession) { @@ -239,25 +253,17 @@ export async function sendMessage( const workspacePath = updatedSession.workspacePath; - try { - await agentAPI.startDialogTurn({ - sessionId: sessionId, + if (acpClientId) { + await ACPClientAPI.startDialogTurn({ + sessionId, + clientId: acpClientId, userInput: message, originalUserInput: displayMessage || message, turnId: dialogTurnId, - agentType: currentAgentType, workspacePath, - imageContexts: options?.imageContexts, }); - } catch (error: any) { - if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { - log.warn('Backend session still not found, retrying creation', { - sessionId: sessionId, - dialogTurnsCount: updatedSession.dialogTurns.length - }); - - await retryCreateBackendSession(context, sessionId); - + } else { + try { await agentAPI.startDialogTurn({ sessionId: sessionId, userInput: message, @@ -267,8 +273,27 @@ export async function sendMessage( workspacePath, imageContexts: options?.imageContexts, }); - } else { - throw error; + } catch (error: any) { + if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { + log.warn('Backend session still not found, retrying creation', { + sessionId: sessionId, + dialogTurnsCount: updatedSession.dialogTurns.length + }); + + await retryCreateBackendSession(context, sessionId); + + await agentAPI.startDialogTurn({ + sessionId: sessionId, + userInput: message, + originalUserInput: displayMessage || message, + turnId: dialogTurnId, + agentType: currentAgentType, + workspacePath, + imageContexts: options?.imageContexts, + }); + } else { + throw error; + } } } @@ -325,8 +350,23 @@ export async function cancelCurrentTask(context: FlowChatContext): Promise ({ item, index })) .filter(({ item }) => item.type === 'text' && !isRuntimeStatusItem(item)) diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts new file mode 100644 index 000000000..bd24366e5 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import type { AnyFlowItem, DialogTurn, FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; +import { processNormalTextChunkInternal } from './TextChunkModule'; + +function makeContext(session: Session): any { + return { + flowChatStore: { + getState: () => ({ + sessions: new Map([[session.sessionId, session]]), + }), + addModelRoundItemSilent: ( + _sessionId: string, + _turnId: string, + item: AnyFlowItem, + roundId: string, + ) => { + const round = session.dialogTurns[0].modelRounds.find(candidate => candidate.id === roundId); + round?.items.push(item); + }, + updateModelRoundItemSilent: ( + _sessionId: string, + _turnId: string, + itemId: string, + updates: Partial, + ) => { + for (const round of session.dialogTurns[0].modelRounds) { + const item = round.items.find(candidate => candidate.id === itemId); + if (item) { + Object.assign(item, updates); + return; + } + } + }, + batchUpdateModelRoundItems: () => {}, + }, + contentBuffers: new Map(), + activeTextItems: new Map(), + eventBatcher: { getBufferSize: () => 0, clear: () => {} }, + pendingTurnCompletions: new Map(), + saveDebouncers: new Map(), + lastSaveTimestamps: new Map(), + lastSaveHashes: new Map(), + turnSaveInFlight: new Map(), + turnSavePending: new Set(), + }; +} + +function makeSession(agentType?: string): Session { + const round: ModelRound = { + id: 'round-1', + index: 0, + items: [], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1000, + }; + const turn: DialogTurn = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [round], + status: 'processing', + startTime: 900, + }; + return { + sessionId: 'session-1', + dialogTurns: [turn], + status: 'active', + config: { agentType }, + createdAt: 800, + lastActiveAt: 1000, + error: null, + sessionKind: 'normal', + }; +} + +function insertTool(session: Session): void { + const tool: FlowToolItem = { + id: 'tool-1', + type: 'tool', + toolName: 'Read', + timestamp: 1001, + status: 'completed', + toolCall: { + id: 'tool-1', + input: { file_path: 'src/main.rs' }, + }, + toolResult: { + result: 'contents', + success: true, + }, + }; + session.dialogTurns[0].modelRounds[0].items.push(tool); +} + +describe('processNormalTextChunkInternal', () => { + it('keeps native sessions on the existing active text item after tools', () => { + const session = makeSession('bitfun'); + const context = makeContext(session); + + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', 'Before tools.'); + insertTool(session); + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', ' After tools.'); + + const items = session.dialogTurns[0].modelRounds[0].items; + const textItems = items.filter(item => item.type === 'text'); + expect(textItems).toHaveLength(1); + expect((textItems[0] as any).content).toBe('Before tools. After tools.'); + }); + + it('starts a new text item for ACP text that streams after tools', () => { + const session = makeSession('acp:claude-code'); + const context = makeContext(session); + + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', 'Before tools.'); + insertTool(session); + processNormalTextChunkInternal(context, 'session-1', 'turn-1', 'round-1', 'After tools.'); + + const items = session.dialogTurns[0].modelRounds[0].items; + expect(items.map(item => item.type)).toEqual(['text', 'tool', 'text']); + expect((items[0] as any).content).toBe('Before tools.'); + expect((items[2] as any).content).toBe('After tools.'); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index 240ecd375..8817a8daa 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -4,6 +4,46 @@ import type { FlowChatContext, FlowTextItem } from './types'; import { clearRuntimeStatus } from './RuntimeStatusModule'; +import { isAcpFlowSession } from '../../utils/acpSession'; + +function activeTextHasLaterRoundItem( + context: FlowChatContext, + sessionId: string, + turnId: string, + roundId: string, + textItemId: string +): boolean { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session || !isAcpFlowSession(session)) { + return false; + } + + const turn = session?.dialogTurns.find(candidate => candidate.id === turnId); + const round = turn?.modelRounds.find(candidate => candidate.id === roundId); + if (!round) return false; + + const textItemIndex = round.items.findIndex(item => item.id === textItemId); + if (textItemIndex === -1) return false; + + return round.items.slice(textItemIndex + 1).some(item => item.type !== 'text'); +} + +function closeActiveTextSegment( + context: FlowChatContext, + sessionId: string, + turnId: string, + roundId: string, + textItemId: string +): void { + context.flowChatStore.updateModelRoundItemSilent(sessionId, turnId, textItemId, { + isStreaming: false, + status: 'completed', + } as any); + + context.contentBuffers.get(sessionId)?.delete(roundId); + context.activeTextItems.get(sessionId)?.delete(roundId); +} + /** * Process a normal text chunk without notifying the store. */ @@ -26,6 +66,14 @@ export function processNormalTextChunkInternal( const sessionContentBuffer = context.contentBuffers.get(sessionId)!; const sessionActiveTextItems = context.activeTextItems.get(sessionId)!; + const activeTextItemId = sessionActiveTextItems.get(roundId); + if ( + activeTextItemId && + activeTextHasLaterRoundItem(context, sessionId, turnId, roundId, activeTextItemId) + ) { + closeActiveTextSegment(context, sessionId, turnId, roundId, activeTextItemId); + } + // Coalesce excessive newlines while appending. const currentContent = sessionContentBuffer.get(roundId) || ''; const cleanedContent = (currentContent + text).replace(/\n{3,}/g, '\n\n'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index b2a326dde..55a988967 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -8,6 +8,7 @@ import { parsePartialJson } from '../../../shared/utils/partialJsonParser'; import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext, FlowToolItem, ToolEventOptions, DialogTurn } from './types'; import { immediateSaveDialogTurn } from './PersistenceModule'; +import { applyPendingAcpPermissionForTool } from './AcpPermissionToolCardModule'; import type { CancelledToolEvent, CompletedToolEvent, @@ -204,6 +205,7 @@ function applyParamsPartial( _contentSize: hasContentField ? ((parsedParams.content || parsedParams.contents || '').length) : undefined }, silent); applyPendingTerminalSessionId(store, sessionId, turnId, toolEvent.tool_id, silent); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } } @@ -279,6 +281,7 @@ function handleEarlyDetected( if (options?.isSubagent && options.parentToolId && !shouldDisplayInMainFlow) { store.insertModelRoundItemAfterTool(sessionId, turnId, options.parentToolId, preparingToolItem); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { let lastModelRound = dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1]; if (!lastModelRound) { @@ -296,6 +299,7 @@ function handleEarlyDetected( } store.addModelRoundItem(sessionId, turnId, preparingToolItem, lastModelRound.id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } } @@ -340,6 +344,7 @@ function handleStarted( partialParams: undefined } as any); applyPendingTerminalSessionId(store, sessionId, turnId, toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { const toolItem: FlowToolItem = { id: toolEvent.tool_id, @@ -361,11 +366,13 @@ function handleStarted( if (options?.isSubagent && options.parentToolId) { store.insertModelRoundItemAfterTool(sessionId, turnId, options.parentToolId, toolItem); pendingTerminalSessionIds.delete(toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { const lastModelRound = dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1]; if (lastModelRound) { store.addModelRoundItem(sessionId, turnId, toolItem, lastModelRound.id); pendingTerminalSessionIds.delete(toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { log.error('Tool Started event without ModelRound (backend bug)', { sessionId, @@ -402,6 +409,8 @@ function handleCompleted( duration_ms: toolEvent.duration_ms }, status: 'completed' as const, + requiresConfirmation: false, + acpPermission: undefined, isParamsStreaming: false, endTime: Date.now() }; @@ -430,6 +439,8 @@ function handleFailed( error: toolEvent.error }, status: 'error', + requiresConfirmation: false, + acpPermission: undefined, endTime: Date.now() } as any); @@ -459,6 +470,8 @@ function handleCancelled( error: toolEvent.reason || 'User cancelled operation' }, status: finalStatus, + requiresConfirmation: false, + acpPermission: undefined, endTime: Date.now() } as any); diff --git a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts index 1cb64c2dd..4055412a1 100644 --- a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts +++ b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts @@ -266,6 +266,15 @@ export class SessionStateMachineImpl { const sessionId = this.context.taskId; const dialogTurnId = this.context.currentDialogTurnId; const { agentAPI } = await import('@/infrastructure/api'); + const { isAcpFlowSession } = await import('../utils/acpSession'); + const session = flowChatStore.getState().sessions.get(sessionId); + if (isAcpFlowSession(session)) { + log.debug('Skipping native backend cancellation for ACP session', { + sessionId, + dialogTurnId, + }); + return; + } try { await agentAPI.cancelDialogTurn(sessionId, dialogTurnId); @@ -318,4 +327,3 @@ export class SessionStateMachineImpl { this.notifyListeners(); } } - diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 656f48a89..9fe9fa1d0 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1549,6 +1549,7 @@ export class FlowChatStore { turnId, roundIndex, timestamp: round.startTime, + renderHints: round.renderHints, textItems, toolItems, thinkingItems, @@ -1814,6 +1815,7 @@ export class FlowChatStore { id: round.id, turnId: round.turnId, index: round.roundIndex ?? 0, + renderHints: round.renderHints, items: [ ...round.textItems.map((text: any) => ({ id: text.id, diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts new file mode 100644 index 000000000..13e2f5d51 --- /dev/null +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FlowTextItem, FlowToolItem, ModelRound, Session } from '../types/flow-chat'; + +vi.mock('./FlowChatStore', () => ({ + flowChatStore: { + getState: () => ({ + activeSessionId: null, + sessions: new Map(), + }), + }, +})); + +vi.mock('../tool-cards', () => ({ + isCollapsibleTool: (toolName: string) => ['Read', 'LS', 'Grep', 'Glob', 'WebSearch', 'Bash'].includes(toolName), + READ_TOOL_NAMES: new Set(['Read']), + SEARCH_TOOL_NAMES: new Set(['Grep', 'Glob', 'WebSearch']), + COMMAND_TOOL_NAMES: new Set(['Bash']), +})); + +import { sessionToVirtualItems } from './modernFlowChatStore'; + +function makeTextItem(id: string, content: string): FlowTextItem { + return { + id, + type: 'text', + content, + isStreaming: false, + isMarkdown: true, + timestamp: 1000, + status: 'completed', + }; +} + +function makeReadTool(id: string): FlowToolItem { + return { + id, + type: 'tool', + toolName: 'Read', + timestamp: 1001, + status: 'completed', + toolCall: { + id, + input: { file_path: 'src/main.rs' }, + }, + toolResult: { + result: 'file contents', + success: true, + }, + }; +} + +function makeRound(overrides: Partial = {}): ModelRound { + return { + id: overrides.id ?? 'round-1', + index: 0, + items: overrides.items ?? [ + makeTextItem('text-1', 'I will inspect the file.'), + makeReadTool('tool-1'), + ], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1000, + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): Session { + return { + sessionId: overrides.sessionId ?? 'session-1', + dialogTurns: overrides.dialogTurns ?? [{ + id: 'turn-1', + sessionId: overrides.sessionId ?? 'session-1', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [makeRound()], + status: 'completed', + startTime: 900, + }], + status: 'idle', + config: overrides.config ?? {}, + createdAt: 800, + lastActiveAt: 1000, + error: null, + ...overrides, + }; +} + +describe('sessionToVirtualItems explore grouping', () => { + it('groups normal rounds containing only collapsible tools and narrative', () => { + const session = makeSession({ sessionId: 'normal-session' }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); + }); + + it('keeps ACP rounds with collapsible tools in the normal transcript', () => { + const session = makeSession({ + sessionId: 'acp-session', + config: { agentType: 'acp:opencode' }, + }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'model-round']); + expect(items[1]?.type === 'model-round' && items[1].data.renderHints?.disableExploreGrouping).toBe(true); + }); + + it('honors explicit round render hints for non-ACP sessions', () => { + const round = makeRound({ + id: 'round-with-hint', + renderHints: { disableExploreGrouping: true }, + }); + const session = makeSession({ + sessionId: 'hint-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'hint-session', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [round], + status: 'completed', + startTime: 900, + }], + }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'model-round']); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 430c6f746..4bc0d0edc 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -10,6 +10,7 @@ import { immer } from 'zustand/middleware/immer'; import type { Session, DialogTurn, ModelRound, FlowItem, FlowToolItem } from '../types/flow-chat'; import { isCollapsibleTool, READ_TOOL_NAMES, SEARCH_TOOL_NAMES, COMMAND_TOOL_NAMES } from '../tool-cards'; import { flowChatStore } from './FlowChatStore'; +import { isAcpFlowSession } from '../utils/acpSession'; /** * Explore group statistics (merged computed stats) @@ -89,6 +90,10 @@ function hasActiveStreamingNarrative(round: ModelRound): boolean { function isExploreOnlyRound(round: ModelRound): boolean { if (!round.items || round.items.length === 0) return false; + if (round.renderHints?.disableExploreGrouping === true) { + return false; + } + if (round.isStreaming && hasActiveStreamingNarrative(round)) { return false; } @@ -112,6 +117,20 @@ function isExploreOnlyRound(round: ModelRound): boolean { return allItemsCollapsible; } +function withSessionRenderHints(round: ModelRound, session: Session): ModelRound { + if (!isAcpFlowSession(session) || round.renderHints?.disableExploreGrouping === true) { + return round; + } + + return { + ...round, + renderHints: { + ...round.renderHints, + disableExploreGrouping: true, + }, + }; +} + /** * Compute statistics for a single ModelRound */ @@ -134,8 +153,13 @@ function computeRoundStats(round: ModelRound): ExploreGroupStats { let cachedSession: Session | null = null; let cachedDialogTurnsRef: DialogTurn[] | null = null; +let cachedRenderHintKey: string | null = null; let cachedVirtualItems: VirtualItem[] = []; +function getSessionRenderHintKey(session: Session): string { + return isAcpFlowSession(session) ? 'acp' : 'default'; +} + /** * Convert Session to virtualized render items * @@ -150,20 +174,24 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { if (cachedSession !== null) { cachedSession = null; cachedDialogTurnsRef = null; + cachedRenderHintKey = null; cachedVirtualItems = []; } return cachedVirtualItems; } + const renderHintKey = getSessionRenderHintKey(session); if ( cachedSession?.sessionId === session.sessionId && - cachedDialogTurnsRef === session.dialogTurns + cachedDialogTurnsRef === session.dialogTurns && + cachedRenderHintKey === renderHintKey ) { return cachedVirtualItems; } cachedSession = session; cachedDialogTurnsRef = session.dialogTurns; + cachedRenderHintKey = renderHintKey; if (!session) return []; const items: VirtualItem[] = []; @@ -182,7 +210,9 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { return; } - const nonEmptyRounds = turn.modelRounds.filter(round => round.items && round.items.length > 0); + const nonEmptyRounds = turn.modelRounds + .filter(round => round.items && round.items.length > 0) + .map(round => withSessionRenderHints(round, session)); interface TempExploreGroup { rounds: ModelRound[]; @@ -332,6 +362,7 @@ export const useModernFlowChatStore = create()( clear: () => { cachedSession = null; cachedDialogTurnsRef = null; + cachedRenderHintKey = null; cachedVirtualItems = []; set((state) => { diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 48adf94b3..4f5c5bf72 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -64,6 +64,23 @@ export interface FlowToolItem extends FlowItem { }; requiresConfirmation?: boolean; userConfirmed?: boolean; + acpPermission?: { + permissionId: string; + sessionId?: string; + toolCallId?: string; + requestedAt: number; + options?: Array<{ + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + }>; + toolCall?: { + toolCallId?: string; + title?: string; + rawInput?: unknown; + content?: unknown; + }; + }; aiIntent?: string; // AI rationale for calling the tool. startTime?: number; // Tool start time. endTime?: number; // Tool end time. @@ -96,6 +113,14 @@ export interface ImageAnalysisResult { analysis_time_ms: number; // Analysis duration. } +export interface ModelRoundRenderHints { + /** + * Keep all round items in the normal transcript instead of merging + * collapsible tools and adjacent narrative into an explore group. + */ + disableExploreGrouping?: boolean; +} + // Model round: output from a single model call. export interface ModelRound { id: string; @@ -107,6 +132,7 @@ export interface ModelRound { startTime: number; endTime?: number; error?: string; + renderHints?: ModelRoundRenderHints; } // Token usage stats. diff --git a/src/web-ui/src/flow_chat/utils/acpSession.ts b/src/web-ui/src/flow_chat/utils/acpSession.ts new file mode 100644 index 000000000..27fef1e00 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/acpSession.ts @@ -0,0 +1,24 @@ +import type { Session } from '../types/flow-chat'; + +const ACP_AGENT_TYPE_PREFIX = 'acp:'; + +export function acpClientIdFromAgentType(agentType: string | null | undefined): string | null { + const value = agentType?.trim(); + if (!value?.startsWith(ACP_AGENT_TYPE_PREFIX)) return null; + + const clientId = value.slice(ACP_AGENT_TYPE_PREFIX.length).trim(); + return clientId || null; +} + +export function isAcpAgentType(agentType: string | null | undefined): boolean { + return acpClientIdFromAgentType(agentType) !== null; +} + +export function isAcpFlowSession( + session: Pick | null | undefined, +): boolean { + return Boolean( + isAcpAgentType(session?.config?.agentType) || + isAcpAgentType(session?.mode), + ); +} diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts new file mode 100644 index 000000000..f2e53514c --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -0,0 +1,165 @@ +import { api } from './ApiClient'; + +export type AcpClientPermissionMode = 'ask' | 'allow_once' | 'reject_once'; +export type AcpClientStatus = 'configured' | 'starting' | 'running' | 'stopped' | 'failed'; + +export interface AcpClientInfo { + id: string; + name: string; + command: string; + args: string[]; + enabled: boolean; + autoStart: boolean; + readonly: boolean; + permissionMode: AcpClientPermissionMode; + status: AcpClientStatus; + toolName: string; + sessionCount: number; +} + +export interface AcpClientIdRequest { + clientId: string; +} + +export interface CreateAcpFlowSessionRequest { + clientId: string; + sessionName?: string; + workspacePath: string; +} + +export interface CreateAcpFlowSessionResponse { + sessionId: string; + sessionName: string; + agentType: string; +} + +export interface StartAcpDialogTurnRequest { + sessionId: string; + clientId: string; + userInput: string; + originalUserInput?: string; + turnId: string; + workspacePath?: string; + timeoutSeconds?: number; +} + +export interface CancelAcpDialogTurnRequest { + sessionId: string; + clientId: string; + workspacePath?: string; +} + +export interface GetAcpSessionOptionsRequest { + sessionId: string; + clientId: string; + workspacePath?: string; +} + +export interface SetAcpSessionModelRequest { + sessionId: string; + clientId: string; + workspacePath?: string; + modelId: string; +} + +export interface AcpSessionModelOption { + id: string; + name: string; + description?: string; +} + +export interface AcpSessionOptions { + currentModelId?: string; + availableModels: AcpSessionModelOption[]; + modelConfigId?: string; +} + +export interface SubmitAcpPermissionResponseRequest { + permissionId: string; + approve: boolean; + optionId?: string; +} + +export interface AcpPermissionOption { + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; +} + +export interface AcpPermissionRequestEvent { + permissionId: string; + sessionId: string; + toolCall?: { + toolCallId?: string; + title?: string; + rawInput?: unknown; + content?: unknown; + }; + options?: AcpPermissionOption[]; +} + +export class ACPClientAPI { + static async initializeClients(): Promise { + await api.invoke('initialize_acp_clients'); + window.dispatchEvent(new Event('bitfun:acp-clients-changed')); + } + + static async getClients(): Promise { + return api.invoke('get_acp_clients'); + } + + static async startClient(request: AcpClientIdRequest): Promise { + return api.invoke('start_acp_client', { request }); + } + + static async stopClient(request: AcpClientIdRequest): Promise { + return api.invoke('stop_acp_client', { request }); + } + + static async restartClient(request: AcpClientIdRequest): Promise { + return api.invoke('restart_acp_client', { request }); + } + + static async loadJsonConfig(): Promise { + return api.invoke('load_acp_json_config'); + } + + static async saveJsonConfig(jsonConfig: string): Promise { + await api.invoke('save_acp_json_config', { jsonConfig }); + window.dispatchEvent(new Event('bitfun:acp-clients-changed')); + } + + static async submitPermissionResponse( + request: SubmitAcpPermissionResponseRequest + ): Promise { + return api.invoke('submit_acp_permission_response', { request }); + } + + static async createFlowSession( + request: CreateAcpFlowSessionRequest + ): Promise { + return api.invoke('create_acp_flow_session', { request }); + } + + static async startDialogTurn(request: StartAcpDialogTurnRequest): Promise { + return api.invoke('start_acp_dialog_turn', { request }); + } + + static async cancelDialogTurn(request: CancelAcpDialogTurnRequest): Promise { + return api.invoke('cancel_acp_dialog_turn', { request }); + } + + static async getSessionOptions( + request: GetAcpSessionOptionsRequest + ): Promise { + return api.invoke('get_acp_session_options', { request }); + } + + static async setSessionModel( + request: SetAcpSessionModelRequest + ): Promise { + return api.invoke('set_acp_session_model', { request }); + } +} + +export default ACPClientAPI; diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss new file mode 100644 index 000000000..e5080c445 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -0,0 +1,148 @@ +@use '../../../component-library/styles/tokens' as *; + +.bitfun-acp-agents { + &__header-actions, + &__json-actions, + &__client-actions { + display: flex; + align-items: center; + gap: $size-gap-2; + } + + &__header-actions { + justify-content: flex-end; + flex-wrap: wrap; + } + + &__json-textarea { + font-family: $font-family-mono; + font-size: var(--font-size-xs); + line-height: 1.6; + } + + &__json-actions { + justify-content: flex-end; + margin-top: $size-gap-3; + } + + &__preset-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: $size-gap-3; + } + + &__preset { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + 'icon name' + 'icon command'; + align-items: center; + gap: 2px $size-gap-2; + min-height: 58px; + padding: $size-gap-3; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: var(--element-bg); + color: var(--color-text-primary); + text-align: left; + cursor: pointer; + transition: background-color 0.16s ease, border-color 0.16s ease; + + &:hover { + background: var(--element-bg-hover); + border-color: var(--border-default); + } + + &.is-configured { + border-color: rgba($color-success, 0.28); + background: rgba($color-success, 0.05); + } + + svg { + grid-area: icon; + color: var(--color-text-secondary); + } + } + + &__preset-name { + grid-area: name; + min-width: 0; + font-size: var(--font-size-sm); + font-weight: $font-weight-semibold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__preset-command { + grid-area: command; + min-width: 0; + font-family: $font-family-mono; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__empty { + padding: $size-gap-4; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + text-align: center; + } + + &__client-list { + display: flex; + flex-direction: column; + } + + &__status { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: $size-radius-sm; + border: 1px solid transparent; + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + white-space: nowrap; + + &.is-ok { + background: rgba($color-success, 0.1); + border-color: rgba($color-success, 0.25); + color: var(--color-success); + } + + &.is-pending { + background: rgba($color-warning, 0.1); + border-color: rgba($color-warning, 0.25); + color: var(--color-warning); + } + + &.is-error { + background: rgba($color-error, 0.1); + border-color: rgba($color-error, 0.25); + color: var(--color-error); + } + + &.is-muted { + background: var(--element-bg-medium); + border-color: var(--border-subtle); + color: var(--color-text-muted); + } + } + + &__client-details { + display: grid; + gap: $size-gap-3; + } + + &__client-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: $size-gap-3; + align-items: start; + } +} diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx new file mode 100644 index 000000000..8aa396b78 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -0,0 +1,634 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Bot, + CheckCircle, + Clock, + FileJson, + Play, + RefreshCw, + RotateCcw, + Save, + Square, + XCircle, +} from 'lucide-react'; +import { Button, IconButton, Input, Select, Switch, Textarea } from '@/component-library'; +import { + ConfigCollectionItem, + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageSection, +} from './common'; +import { ACPClientAPI, type AcpClientInfo, type AcpClientPermissionMode } from '../../api/service-api/ACPClientAPI'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import './AcpAgentsConfig.scss'; + +const log = createLogger('AcpAgentsConfig'); + +interface AcpClientConfig { + name?: string; + command: string; + args: string[]; + env: Record; + enabled: boolean; + autoStart: boolean; + readonly: boolean; + permissionMode: AcpClientPermissionMode; +} + +interface AcpClientConfigFile { + acpClients: Record; +} + +interface AcpClientPreset { + id: string; + name: string; + command: string; + args: string[]; +} + +const PRESETS: AcpClientPreset[] = [ + { + id: 'opencode', + name: 'opencode', + command: 'opencode', + args: ['acp'], + }, + { + id: 'claude-code', + name: 'Claude Code', + command: 'npx', + args: ['--yes', '@zed-industries/claude-code-acp@latest'], + }, + { + id: 'codex', + name: 'Codex', + command: 'npx', + args: ['--yes', '@zed-industries/codex-acp@latest'], + }, +]; + +const PRESET_BY_ID = new Map(PRESETS.map(preset => [preset.id, preset])); + +function defaultConfigForPreset(preset: AcpClientPreset): AcpClientConfig { + return { + name: preset.name, + command: preset.command, + args: preset.args, + env: {}, + enabled: preset.id === 'opencode', + autoStart: false, + readonly: false, + permissionMode: 'ask', + }; +} + +function normalizeConfigValue(value: unknown): AcpClientConfigFile { + const candidate = value && typeof value === 'object' ? value as Record : {}; + const rawClients = ( + candidate.acpClients && typeof candidate.acpClients === 'object' && !Array.isArray(candidate.acpClients) + ) + ? candidate.acpClients as Record + : candidate; + + const acpClients: Record = {}; + for (const [id, rawConfig] of Object.entries(rawClients)) { + if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) { + continue; + } + + const item = rawConfig as Record; + const command = typeof item.command === 'string' ? item.command.trim() : ''; + if (!command) { + continue; + } + + acpClients[id] = { + name: typeof item.name === 'string' ? item.name : undefined, + command, + args: Array.isArray(item.args) ? item.args.map(String) : [], + env: normalizeEnvObject(item.env), + enabled: item.enabled !== false, + autoStart: item.autoStart === true, + readonly: item.readonly === true, + permissionMode: normalizePermissionMode(item.permissionMode), + }; + } + + return { acpClients }; +} + +function normalizeEnvObject(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as Record).map(([key, envValue]) => [key, String(envValue)]) + ); +} + +function normalizePermissionMode(value: unknown): AcpClientPermissionMode { + return value === 'allow_once' || value === 'reject_once' ? value : 'ask'; +} + +function formatConfig(config: AcpClientConfigFile): string { + return JSON.stringify(config, null, 2); +} + +function parseArgsText(value: string): string[] { + return value + .split('\n') + .map(line => line.trim()) + .filter(Boolean); +} + +function formatArgs(args: string[]): string { + return args.join('\n'); +} + +function parseEnvText(value: string): Record { + const env: Record = {}; + for (const rawLine of value.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + const separator = line.indexOf('='); + if (separator <= 0) { + throw new Error(`Invalid env line: ${line}`); + } + env[line.slice(0, separator).trim()] = line.slice(separator + 1); + } + return env; +} + +function formatEnv(env: Record): string { + return Object.entries(env).map(([key, value]) => `${key}=${value}`).join('\n'); +} + +function statusTone(status?: AcpClientInfo['status']): 'ok' | 'pending' | 'error' | 'muted' { + if (status === 'running') return 'ok'; + if (status === 'starting') return 'pending'; + if (status === 'failed') return 'error'; + return 'muted'; +} + +function StatusIcon({ status }: { status?: AcpClientInfo['status'] }) { + if (status === 'running') return ; + if (status === 'starting') return ; + if (status === 'failed') return ; + return ; +} + +const AcpAgentsConfig: React.FC = () => { + const { t } = useTranslation('settings/acp-agents'); + const { error: notifyError, success: notifySuccess } = useNotification(); + const jsonEditorRef = useRef(null); + + const [config, setConfig] = useState({ acpClients: {} }); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [expandedClientId, setExpandedClientId] = useState(null); + const [showJsonEditor, setShowJsonEditor] = useState(false); + const [jsonConfig, setJsonConfig] = useState(''); + const [envDrafts, setEnvDrafts] = useState>({}); + const [operationClientId, setOperationClientId] = useState(null); + + const clientsById = useMemo(() => new Map(clients.map(client => [client.id, client])), [clients]); + const clientRows = useMemo(() => { + const ids = new Set([ + ...PRESETS.map(preset => preset.id), + ...Object.keys(config.acpClients), + ...clients.map(client => client.id), + ]); + + return Array.from(ids).sort((a, b) => { + const presetA = PRESETS.findIndex(preset => preset.id === a); + const presetB = PRESETS.findIndex(preset => preset.id === b); + if (presetA !== -1 || presetB !== -1) { + return (presetA === -1 ? Number.MAX_SAFE_INTEGER : presetA) - + (presetB === -1 ? Number.MAX_SAFE_INTEGER : presetB); + } + return a.localeCompare(b); + }); + }, [clients, config.acpClients]); + + const loadConfig = useCallback(async () => { + try { + setLoading(true); + const [rawConfig, nextClients] = await Promise.all([ + ACPClientAPI.loadJsonConfig(), + ACPClientAPI.getClients(), + ]); + const parsed = normalizeConfigValue(JSON.parse(rawConfig || '{}')); + setConfig(parsed); + setJsonConfig(formatConfig(parsed)); + setEnvDrafts( + Object.fromEntries( + Object.entries(parsed.acpClients).map(([clientId, clientConfig]) => [ + clientId, + formatEnv(clientConfig.env), + ]) + ) + ); + setClients(nextClients); + setDirty(false); + } catch (error) { + log.error('Failed to load ACP agent config', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.loadFailed'), + }); + } finally { + setLoading(false); + } + }, [notifyError, t]); + + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + + const patchClientConfig = (clientId: string, patch: Partial) => { + setConfig(prev => { + const preset = PRESET_BY_ID.get(clientId); + const current = prev.acpClients[clientId] ?? + (preset ? defaultConfigForPreset(preset) : undefined); + if (!current) return prev; + + const next = { + acpClients: { + ...prev.acpClients, + [clientId]: { + ...current, + ...patch, + }, + }, + }; + setJsonConfig(formatConfig(next)); + return next; + }); + setDirty(true); + }; + + const applyPreset = (preset: AcpClientPreset) => { + setEnvDrafts(prev => ({ + ...prev, + [preset.id]: '', + })); + patchClientConfig(preset.id, { + ...defaultConfigForPreset(preset), + enabled: true, + }); + setExpandedClientId(preset.id); + }; + + const mergeEnvDrafts = (baseConfig: AcpClientConfigFile): AcpClientConfigFile => ({ + acpClients: Object.fromEntries( + Object.entries(baseConfig.acpClients).map(([clientId, clientConfig]) => [ + clientId, + { + ...clientConfig, + env: envDrafts[clientId] !== undefined + ? parseEnvText(envDrafts[clientId]) + : clientConfig.env, + }, + ]) + ), + }); + + const saveConfig = async (nextConfig = config, options: { mergeEnvDrafts?: boolean } = {}) => { + try { + setSaving(true); + const configToSave = options.mergeEnvDrafts === false + ? nextConfig + : mergeEnvDrafts(nextConfig); + await ACPClientAPI.saveJsonConfig(formatConfig(configToSave)); + const nextClients = await ACPClientAPI.getClients(); + setClients(nextClients); + setConfig(configToSave); + setJsonConfig(formatConfig(configToSave)); + setDirty(false); + notifySuccess(t('notifications.saveSuccess')); + } catch (error) { + log.error('Failed to save ACP agent config', error); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.saveFailed'), + }); + } finally { + setSaving(false); + } + }; + + const saveJsonConfig = async () => { + try { + const parsed = normalizeConfigValue(JSON.parse(jsonConfig)); + await saveConfig(parsed, { mergeEnvDrafts: false }); + setConfig(parsed); + setEnvDrafts( + Object.fromEntries( + Object.entries(parsed.acpClients).map(([clientId, clientConfig]) => [ + clientId, + formatEnv(clientConfig.env), + ]) + ) + ); + setShowJsonEditor(false); + } catch (error) { + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.invalidJson'), + }); + } + }; + + const runClientOperation = async ( + clientId: string, + operation: 'start' | 'stop' | 'restart', + ) => { + try { + setOperationClientId(clientId); + if (operation === 'start') { + await ACPClientAPI.startClient({ clientId }); + } else if (operation === 'stop') { + await ACPClientAPI.stopClient({ clientId }); + } else { + await ACPClientAPI.restartClient({ clientId }); + } + setClients(await ACPClientAPI.getClients()); + } catch (error) { + log.error('ACP client operation failed', { clientId, operation, error }); + notifyError(error instanceof Error ? error.message : String(error), { + title: t('notifications.operationFailed'), + }); + } finally { + setOperationClientId(null); + } + }; + + const permissionOptions = useMemo(() => [ + { value: 'ask', label: t('permissionMode.ask') }, + { value: 'allow_once', label: t('permissionMode.allowOnce') }, + { value: 'reject_once', label: t('permissionMode.rejectOnce') }, + ], [t]); + + return ( + + + + + + )} + /> + + + {showJsonEditor && ( + +