/// ACP (Agent Client Protocol) JSON-RPC 2.0 client. /// /// Walks an agent runtime subprocess (e.g. `hermes acp` or `node reasonix-acp-wrapper.mjs`) /// over NDJSON stdio: one JSON object per line, newline-delimited. /// /// Reference implementations: /// - `reference-code/hermes-vscode-main/src/acpClient.ts` (primary reference) /// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts` /// /// Wire format: /// Request: { jsonrpc: "2.0", id: number, method: string, params?: object } /// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } } /// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id) /// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent) use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::{oneshot, Mutex}; use tokio::time::{timeout, Duration}; use tracing::{debug, info, warn}; // ── Error types ────────────────────────────────────── #[derive(Debug)] pub enum AcpError { Spawn(std::io::Error), JsonParse(serde_json::Error), JsonRpc { code: i64, message: String }, Timeout(u64), Closed, Internal(String), } impl std::fmt::Display for AcpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AcpError::Spawn(e) => write!(f, "ACP spawn failed: {e}"), AcpError::JsonParse(e) => write!(f, "ACP JSON parse error: {e}"), AcpError::JsonRpc { code, message } => { write!(f, "ACP JSON-RPC error [{code}]: {message}") } AcpError::Timeout(secs) => write!(f, "ACP request timed out after {secs}s"), AcpError::Closed => write!(f, "ACP connection closed"), AcpError::Internal(msg) => write!(f, "ACP internal: {msg}"), } } } impl std::error::Error for AcpError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { AcpError::Spawn(e) => Some(e), AcpError::JsonParse(e) => Some(e), _ => None, } } } impl From for AcpError { fn from(e: std::io::Error) -> Self { AcpError::Spawn(e) } } impl From for AcpError { fn from(e: serde_json::Error) -> Self { AcpError::JsonParse(e) } } // ── Notification handler type ──────────────────────── type NotificationHandler = Box; /// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits). type NotificationHandlerMutex = std::sync::Mutex>; // ── Incoming request handler type ──────────────────── /// /// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。 /// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message /// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`] /// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。 type IncomingRequestHandler = Box bool + Send + 'static>; /// incoming request handler 的线程安全容器。 type IncomingRequestHandlerMutex = std::sync::Mutex>; // ── Pending request entry ──────────────────────────── type PendingEntry = oneshot::Sender>; // ── AcpClient ──────────────────────────────────────── /// ACP JSON-RPC 2.0 client over stdio. /// /// Create via [`AcpClient::spawn`], then use [`request`](Self::request) for RPC /// calls and [`notification`](Self::notification) for fire-and-forget messages. /// Register a handler with [`on_notification`](Self::on_notification) to receive /// agent push events (e.g. `session/update`). // Manual Debug impl: Child doesn't impl Debug, so we skip it impl std::fmt::Debug for AcpClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AcpClient") .field("next_id", &self.next_id) .field("pending_count", &self.pending.blocking_lock().len()) .finish_non_exhaustive() } } pub struct AcpClient { child: Option, writer: Arc>>, pending: Arc>>, next_id: AtomicU64, notification_handler: Arc, /// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。 /// 未设置时会直接回复 method-not-found。 incoming_request_handler: Arc, } impl AcpClient { /// Spawn an ACP subprocess and establish the JSON-RPC connection. /// /// After spawn, sends an `initialize` handshake (as Hermes does in acpClient.ts /// `start()` → `call('initialize', { protocolVersion: 1 })`). /// Launches a background tokio task that reads NDJSON lines from the child's stdout. /// /// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup) pub async fn spawn(bin: &str, args: &[&str]) -> Result { Self::spawn_with_env(bin, args, None).await } /// Spawn an ACP subprocess with extra environment variables. pub async fn spawn_with_env( bin: &str, args: &[&str], env_overrides: Option<&HashMap>, ) -> Result { let mut command = Command::new(bin); command .args(args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); if let Some(env) = env_overrides { if let Some(workspace_root) = env.get("MNOTE_AI_WORKSPACE_ROOT") { let workspace_root = std::path::Path::new(workspace_root); if workspace_root.is_dir() { // 本地 workspace run 以授权根目录作为进程工作目录,贴近 VSCode agent 行为。 command.current_dir(workspace_root); } } command.envs(env); } let mut child = command.spawn().map_err(AcpError::Spawn)?; let stdin = child .stdin .take() .ok_or_else(|| AcpError::Internal("failed to take child stdin".into()))?; let stdout = child .stdout .take() .ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?; let writer = Arc::new(Mutex::new(BufWriter::new(stdin))); let reader = BufReader::new(stdout); let pending: Arc>> = Arc::new(Mutex::new(HashMap::new())); let notification_handler: Arc = Arc::new(std::sync::Mutex::new(None)); let incoming_request_handler: Arc = Arc::new(std::sync::Mutex::new(None)); // Start background reader task let pending_clone = pending.clone(); let handler_clone = notification_handler.clone(); let incoming_clone = incoming_request_handler.clone(); let writer_clone = writer.clone(); let child_pid = child.id().unwrap_or(0); tokio::spawn(async move { Self::reader_loop( reader, writer_clone, pending_clone, handler_clone, incoming_clone, ) .await; info!("ACP reader loop ended (pid={})", child_pid); }); let client = Self { child: Some(child), writer, pending, next_id: AtomicU64::new(1), notification_handler, incoming_request_handler, }; // Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1})) let init_result: Value = client .request("initialize", json!({ "protocolVersion": 1 })) .await?; debug!(?init_result, "ACP initialize OK"); Ok(client) } /// Send a JSON-RPC request and await the response. /// /// Returns `Result` where `R` is the deserialized `result` field. /// On JSON-RPC error, returns [`AcpError::JsonRpc`]. /// Default timeout: 300 seconds. Override with `MNOTE_ACP_REQUEST_TIMEOUT_SECS`. /// /// Reference: `acpClient.ts` L95-110 (`call()` method) pub async fn request( &self, method: &str, params: P, ) -> Result { let timeout_secs = std::env::var("MNOTE_ACP_REQUEST_TIMEOUT_SECS") .ok() .and_then(|value| value.parse::().ok()) .filter(|value| *value >= 30) .unwrap_or(300); self.request_with_timeout(method, params, Duration::from_secs(timeout_secs)) .await } /// Same as [`request`] but with a configurable timeout. pub async fn request_with_timeout( &self, method: &str, params: P, dur: Duration, ) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); self.pending.lock().await.insert(id, tx); let req = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params, }); let line = serde_json::to_string(&req)?; debug!("ACP --> {} #{} ({} bytes)", method, id, line.len()); { let mut writer = self.writer.lock().await; writer.write_all(line.as_bytes()).await?; writer.write_all(b"\n").await?; writer.flush().await?; } match timeout(dur, rx).await { Ok(Ok(Ok(value))) => { let result: R = serde_json::from_value(value)?; Ok(result) } Ok(Ok(Err(err))) => Err(err), Ok(Err(_recv_err)) => Err(AcpError::Closed), Err(_elapsed) => Err(AcpError::Timeout(dur.as_secs())), } } /// Send a fire-and-forget notification (no id, no response expected). /// /// Reference: `acpClient.ts` L115-118 (`notify()`) pub async fn notification(&self, method: &str, params: Value) -> Result<(), AcpError> { let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params, }); let line = serde_json::to_string(&msg)?; debug!("ACP ~~> {} ({} bytes)", method, line.len()); { let mut writer = self.writer.lock().await; writer.write_all(line.as_bytes()).await?; writer.write_all(b"\n").await?; writer.flush().await?; } Ok(()) } /// Register a handler for incoming notifications (messages with `method` but no `id`). /// Only one handler at a time — subsequent calls replace the previous. pub fn on_notification(&self, handler: F) where F: Fn(String, Value) + Send + 'static, { let mut guard = self.notification_handler.lock().unwrap(); *guard = Some(Box::new(handler)); } /// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。 /// handler 接收原始 request id、method 和 params,并应稍后通过 /// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。 /// 同一时间只保留一个 handler,后续注册会覆盖前一个。 pub fn on_incoming_request(&self, handler: F) where F: Fn(Value, String, Value) -> bool + Send + 'static, { let mut guard = self.incoming_request_handler.lock().unwrap(); *guard = Some(Box::new(handler)); } /// 用 result 响应 agent 发来的 incoming JSON-RPC request。 /// /// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。 pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> { let msg = json!({ "jsonrpc": "2.0", "id": id.clone(), "result": result, }); debug!("ACP <-- respond to incoming #{}", id); Self::write_jsonrpc_message(&self.writer, &msg).await } /// 用 error 响应 incoming JSON-RPC request。 pub async fn respond_to_incoming_error( &self, id: Value, code: i64, message: &str, ) -> Result<(), AcpError> { let msg = json!({ "jsonrpc": "2.0", "id": id.clone(), "error": { "code": code, "message": message, }, }); debug!( "ACP <-- respond error to incoming #{}: [{}] {}", id, code, message ); Self::write_jsonrpc_message(&self.writer, &msg).await } /// Gracefully close the ACP connection and kill the subprocess. pub async fn close(&mut self) { if let Some(mut child) = self.child.take() { let _ = child.start_kill(); let _ = child.wait().await; } // Resolve all pending with Closed error let mut pending = self.pending.lock().await; for (_, tx) in pending.drain() { let _ = tx.send(Err(AcpError::Closed)); } } // ── Background reader ──────────────────────────── /// Background loop: reads NDJSON lines from the child's stdout, /// routes responses to pending requests and notifications to the handler. /// /// Reference: `acpClient.ts` L120-180 (onData + dispatch) async fn reader_loop( mut reader: BufReader, writer: Arc>>, pending: Arc>>, notification_handler: Arc, incoming_request_handler: Arc, ) { let mut line_buf = String::new(); loop { line_buf.clear(); match reader.read_line(&mut line_buf).await { Ok(0) => { info!("ACP stdout closed (EOF)"); break; } Ok(_n) => {} Err(e) => { warn!("ACP read error: {e}"); break; } } let trimmed = line_buf.trim(); if trimmed.is_empty() { continue; } let msg: Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { warn!( "ACP parse error: {e} (line: {})", &trimmed[..trimmed.len().min(80)] ); continue; } }; Self::dispatch_message( msg, &writer, &pending, ¬ification_handler, &incoming_request_handler, ) .await; } // Process died or EOF — resolve all pending let mut pending_guard = pending.lock().await; for (_, tx) in pending_guard.drain() { let _ = tx.send(Err(AcpError::Closed)); } } /// Route a single JSON message to pending request, notification handler, or incoming request. /// /// Reference: `acpClient.ts` L160-200 (dispatch) async fn dispatch_message( msg: Value, writer: &Arc>>, pending: &Arc>>, notification_handler: &Arc, incoming_request_handler: &Arc, ) { let has_id = msg.get("id").is_some(); let has_method = msg .get("method") .and_then(|v| v.as_str()) .map(|s| !s.is_empty()) .unwrap_or(false); if has_id && has_method { // agent 发来的 incoming request,例如 session/request_permission。 let method = msg["method"].as_str().unwrap_or("unknown").to_string(); let params = msg.get("params").cloned().unwrap_or(Value::Null); let id_val = msg.get("id").cloned().unwrap_or(Value::Null); // 若已注册 handler,则由 handler 决定是否负责稍后响应。 let handled = { let handler_guard = incoming_request_handler.lock().unwrap(); if let Some(ref handler) = *handler_guard { handler(id_val.clone(), method.clone(), params.clone()) } else { false } }; if handled { debug!("ACP incoming request dispatched: {method} #{}", id_val); } else { // 没有 handler 时必须立即响应,避免 agent 一直等待。 warn!("ACP incoming request not handled (no handler registered): {method}"); let response = json!({ "jsonrpc": "2.0", "id": id_val, "error": { "code": -32601, "message": format!("ACP incoming request not supported: {method}") } }); if let Err(error) = Self::write_jsonrpc_message(writer, &response).await { warn!("ACP incoming request response write failed: {error}"); } } } else if has_id { // Response to one of our requests if let Some(id) = msg["id"].as_u64() { let mut pending_guard = pending.lock().await; if let Some(tx) = pending_guard.remove(&id) { if let Some(error) = msg.get("error") { let code = error["code"].as_i64().unwrap_or(-1); let message = error["message"] .as_str() .unwrap_or("unknown error") .to_string(); let _ = tx.send(Err(AcpError::JsonRpc { code, message })); } else if let Some(result) = msg.get("result") { let _ = tx.send(Ok(result.clone())); } else { let _ = tx.send(Err(AcpError::Internal( "response without result or error".into(), ))); } } else { debug!("ACP response for unknown request id={id}"); } } } else if has_method { // Notification (no id) let method = msg["method"].as_str().unwrap_or("unknown").to_string(); let params = msg.get("params").cloned().unwrap_or(Value::Null); let handler_guard = notification_handler.lock().unwrap(); if let Some(ref handler) = *handler_guard { handler(method, params); } else { debug!("ACP notification unhandled: {method}"); } } } async fn write_jsonrpc_message( writer: &Arc>>, msg: &Value, ) -> Result<(), AcpError> { let line = serde_json::to_string(msg)?; let mut writer = writer.lock().await; writer.write_all(line.as_bytes()).await?; writer.write_all(b"\n").await?; writer.flush().await?; Ok(()) } } impl Drop for AcpClient { fn drop(&mut self) { if let Some(mut child) = self.child.take() { let _ = child.start_kill(); } } } // ── Tests ──────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; /// Helper: create a mock subprocess that echoes back requests as responses. /// Simulates a minimal ACP server for testing. async fn spawn_mock_acp_server() -> AcpClient { // We spawn a small node script that reads NDJSON and echoes back let script = r#" import * as readline from 'node:readline'; import { stdin as input, stdout as output } from 'node:process'; const rl = readline.createInterface({ input, output, terminal: false }); rl.on('line', (line) => { const msg = JSON.parse(line); if (msg.id !== undefined && msg.method) { if (msg.method === 'initialize') { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } }) + '\n'); } else { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { ok: true, echo: msg.params } }) + '\n'); } } else if (msg.method && msg.id === undefined) { // Notification → ignore } }); "#; // Write script to temp file let dir = std::env::temp_dir(); let script_path = dir.join("acp_test_mock.mjs"); std::fs::write(&script_path, script).expect("write mock script"); AcpClient::spawn("node", &[script_path.to_str().unwrap()]) .await .expect("spawn mock ACP") } async fn spawn_permission_request_mock_server() -> AcpClient { let script = r#" import * as readline from 'node:readline'; import { stdin as input, stdout as output } from 'node:process'; let permissionResponse = null; const rl = readline.createInterface({ input, output, terminal: false }); function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } rl.on('line', (line) => { const msg = JSON.parse(line); if (msg.id !== undefined && msg.method === 'initialize') { send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } }); setTimeout(() => send({ jsonrpc: '2.0', id: 77, method: 'session/request_permission', params: { reason: 'test permission' } }), 10); } else if (msg.id === 77 && msg.method === undefined) { permissionResponse = msg; } else if (msg.id !== undefined && msg.method === 'get_permission_response') { send({ jsonrpc: '2.0', id: msg.id, result: { permissionResponse } }); } }); "#; let dir = std::env::temp_dir(); let script_path = dir.join("acp_permission_request_mock.mjs"); std::fs::write(&script_path, script).expect("write permission mock script"); AcpClient::spawn("node", &[script_path.to_str().unwrap()]) .await .expect("spawn permission mock ACP") } async fn spawn_string_id_permission_request_mock_server() -> AcpClient { let script = r#" import * as readline from 'node:readline'; import { stdin as input, stdout as output } from 'node:process'; let permissionResponse = null; const rl = readline.createInterface({ input, output, terminal: false }); function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } rl.on('line', (line) => { const msg = JSON.parse(line); if (msg.id !== undefined && msg.method === 'initialize') { send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } }); setTimeout(() => send({ jsonrpc: '2.0', id: 'perm-string-id', method: 'session/request_permission', params: { reason: 'test permission' } }), 10); } else if (msg.id === 'perm-string-id' && msg.method === undefined) { permissionResponse = msg; } else if (msg.id !== undefined && msg.method === 'get_permission_response') { send({ jsonrpc: '2.0', id: msg.id, result: { permissionResponse } }); } }); "#; let dir = std::env::temp_dir(); let script_path = dir.join("acp_permission_request_string_id_mock.mjs"); std::fs::write(&script_path, script).expect("write permission string id mock script"); AcpClient::spawn("node", &[script_path.to_str().unwrap()]) .await .expect("spawn permission string id mock ACP") } #[tokio::test] async fn test_request_response() { let client = spawn_mock_acp_server().await; let result: Value = client .request("test_method", json!({ "hello": "world" })) .await .expect("request should succeed"); assert_eq!(result["ok"], true); assert_eq!(result["echo"]["hello"], "world"); } #[tokio::test] async fn test_notification() { let client = spawn_mock_acp_server().await; // Notifications are fire-and-forget, no response expected client .notification("test_notify", json!({ "foo": "bar" })) .await .expect("notification should succeed"); } #[tokio::test] async fn test_on_notification_received() { use std::sync::atomic::AtomicBool; let client = spawn_mock_acp_server().await; let received = Arc::new(AtomicBool::new(false)); let received_clone = received.clone(); client.on_notification(move |method, _params| { if method == "test_push" { received_clone.store(true, Ordering::SeqCst); } }); // Send a notification that the mock server will echo back as... // Actually the mock doesn't send unsolicited notifications. // This test just validates the handler registration doesn't crash. client .notification("test_push", json!({})) .await .expect("notification"); // Give background task time to process tokio::time::sleep(Duration::from_millis(100)).await; // In this mock, no notification will be received; that's OK } #[tokio::test] async fn test_close() { let mut client = spawn_mock_acp_server().await; client.close().await; // Second close should be no-op client.close().await; } #[tokio::test] async fn test_initialize_handshake() { // spawn already calls initialize; if it fails, the test fails let _client = spawn_mock_acp_server().await; } #[tokio::test] async fn test_incoming_permission_request_gets_response() { let client = spawn_permission_request_mock_server().await; tokio::time::sleep(Duration::from_millis(100)).await; let result: Value = client .request("get_permission_response", json!({})) .await .expect("permission response probe"); let response = &result["permissionResponse"]; assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], 77); assert!(response.get("result").is_some() || response.get("error").is_some()); } #[tokio::test] async fn test_incoming_permission_request_preserves_string_id() { let client = spawn_string_id_permission_request_mock_server().await; tokio::time::sleep(Duration::from_millis(100)).await; let result: Value = client .request("get_permission_response", json!({})) .await .expect("permission response probe"); let response = &result["permissionResponse"]; assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], "perm-string-id"); assert!(response.get("result").is_some() || response.get("error").is_some()); } }