From b711e4983b208d3bb06c9e621b6ea5064438a6db Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 27 Jul 2026 18:00:07 -0600 Subject: [PATCH] feat: implement ACP user-interaction to request_permission bridge --- src/acp/server.rs | 42 +++++++++++++++++++++++++++++++- src/function/user_interaction.rs | 12 ++++++++- src/main.rs | 3 +++ src/utils/mod.rs | 19 ++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/acp/server.rs b/src/acp/server.rs index 5464c99..8147e4e 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -28,7 +28,8 @@ where R: tokio::io::AsyncRead + Unpin, W: AsyncWrite + Unpin, { - use crate::utils::create_abort_signal; + use crate::utils::{create_abort_signal, drain_acp_permissions}; + drain_acp_permissions(); let state = AcpServerState { ctx: None, abort: create_abort_signal(), @@ -55,6 +56,9 @@ where continue; } if let Some(response) = dispatch(&line, &mut state).await { + for params in crate::utils::drain_acp_permissions() { + emit_notification(&mut writer, "session/request_permission", params).await?; + } emit(&mut writer, &response).await?; } } @@ -209,6 +213,23 @@ async fn emit(writer: &mut W, response: &Response) -> Res Ok(()) } +async fn emit_notification( + writer: &mut W, + method: &str, + params: serde_json::Value, +) -> Result<()> { + let frame = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + }); + let mut line = serde_json::to_string(&frame)?; + line.push('\n'); + writer.write_all(line.as_bytes()).await?; + writer.flush().await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -453,4 +474,23 @@ mod tests { assert_eq!(v["id"], 6); assert!(v["error"].is_object()); } + + #[tokio::test] + async fn emit_notification_produces_valid_json_rpc_frame() { + let mut output = Vec::new(); + emit_notification( + &mut output, + "session/request_permission", + serde_json::json!({"action": "confirm", "question": "Proceed?"}), + ) + .await + .unwrap(); + + let s = String::from_utf8(output).unwrap(); + let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["method"], "session/request_permission"); + assert!(v["params"]["action"].is_string()); + assert!(!v.as_object().unwrap().contains_key("id")); + } } diff --git a/src/function/user_interaction.rs b/src/function/user_interaction.rs index 7d121b2..795742e 100644 --- a/src/function/user_interaction.rs +++ b/src/function/user_interaction.rs @@ -1,7 +1,7 @@ use super::{FunctionDeclaration, JsonSchema}; use crate::config::RequestContext; use crate::supervisor::escalation::{EscalationRequest, new_escalation_id}; -use crate::utils::HEADLESS; +use crate::utils::{ACP_SERVER, HEADLESS, queue_acp_permission}; use anyhow::{Result, anyhow, bail}; use indexmap::IndexMap; @@ -138,6 +138,16 @@ pub async fn handle_user_tool( .strip_prefix(USER_FUNCTION_PREFIX) .unwrap_or(cmd_name); + if ACP_SERVER.load(Ordering::SeqCst) { + let result = handle_headless(action, args); + queue_acp_permission(json!({ + "action": action, + "question": result["question"], + "options": result["options"], + })); + return Ok(result); + } + if HEADLESS.load(Ordering::SeqCst) { return Ok(handle_headless(action, args)); } diff --git a/src/main.rs b/src/main.rs index 995c7ba..3755850 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,9 @@ async fn main() -> Result<()> { env::set_var("AUTO_CONFIRM", "true"); } HEADLESS.store(true, Ordering::SeqCst); + if cli.acp_server { + ACP_SERVER.store(true, Ordering::SeqCst); + } } let info_flag = cli.info diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 7779c93..6297ae7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -33,8 +33,9 @@ use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use is_terminal::IsTerminal; use nu_ansi_term::Color; use std::borrow::Cow; +use std::collections::VecDeque; use std::sync::atomic::AtomicBool; -use std::sync::{LazyLock, OnceLock}; +use std::sync::{LazyLock, Mutex, OnceLock}; use std::{cmp, env, path::PathBuf, process}; use syntect::highlighting::{Highlighter, Theme}; use syntect::parsing::Scope; @@ -45,6 +46,22 @@ pub static THINK_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?s)^\s*.*?(\s*|$)").unwrap()); pub static IS_STDOUT_TERMINAL: LazyLock = LazyLock::new(|| std::io::stdout().is_terminal()); pub static HEADLESS: AtomicBool = AtomicBool::new(false); +pub static ACP_SERVER: AtomicBool = AtomicBool::new(false); + +static ACP_PERMISSION_QUEUE: Mutex> = Mutex::new(VecDeque::new()); + +pub fn queue_acp_permission(notification: serde_json::Value) { + if let Ok(mut q) = ACP_PERMISSION_QUEUE.lock() { + q.push_back(notification); + } +} + +pub fn drain_acp_permissions() -> Vec { + ACP_PERMISSION_QUEUE + .lock() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default() +} pub static NO_COLOR: LazyLock = LazyLock::new(|| { env::var("NO_COLOR") .ok()