From f11d4ca7605c9747d16470b82182ec80075edfd8 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 27 Jul 2026 17:31:47 -0600 Subject: [PATCH] feat: add ACP server skeleton with stdout-purity test --- src/acp/mod.rs | 4 ++ src/acp/server.rs | 150 ++++++++++++++++++++++++++++++++++++++++++++++ src/acp/types.rs | 59 ++++++++++++++++++ src/cli/mod.rs | 15 +++++ src/main.rs | 19 ++++-- 5 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/acp/mod.rs create mode 100644 src/acp/server.rs create mode 100644 src/acp/types.rs diff --git a/src/acp/mod.rs b/src/acp/mod.rs new file mode 100644 index 0000000..6f1eccb --- /dev/null +++ b/src/acp/mod.rs @@ -0,0 +1,4 @@ +mod server; +mod types; + +pub use server::run_acp_server; diff --git a/src/acp/server.rs b/src/acp/server.rs new file mode 100644 index 0000000..c5ffab5 --- /dev/null +++ b/src/acp/server.rs @@ -0,0 +1,150 @@ +use super::types::{METHOD_NOT_FOUND, PARSE_ERROR, Request, Response}; +use anyhow::Result; +use serde_json::json; +use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; + +pub async fn run_acp_server() -> Result<()> { + run_acp_server_on(tokio::io::stdin(), tokio::io::stdout()).await +} + +pub(crate) async fn run_acp_server_on(reader: R, mut writer: W) -> Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let reader = BufReader::new(reader); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + if let Some(response) = dispatch(&line) { + emit(&mut writer, &response).await?; + } + } + + Ok(()) +} + +fn dispatch(raw: &str) -> Option { + let req: Request = match serde_json::from_str(raw) { + Ok(r) => r, + Err(_) => return Some(Response::err(None, PARSE_ERROR, "Parse error")), + }; + + req.id.as_ref()?; + + Some(match req.method.as_str() { + "initialize" => handle_initialize(req), + _ => Response::err( + req.id, + METHOD_NOT_FOUND, + format!("Method not found: {}", req.method), + ), + }) +} + +fn handle_initialize(req: Request) -> Response { + Response::ok( + req.id, + json!({ + "name": "coyote", + "version": env!("CARGO_PKG_VERSION"), + "protocolVersion": "1", + }), + ) +} + +async fn emit(writer: &mut W, response: &Response) -> Result<()> { + let mut line = serde_json::to_string(response)?; + line.push('\n'); + writer.write_all(line.as_bytes()).await?; + writer.flush().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn all_stdout_is_valid_json_rpc() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"name":"test","version":"0.1.0"}}"#, + "\n", + ); + let mut output = Vec::new(); + run_acp_server_on(input.as_bytes(), &mut output) + .await + .unwrap(); + + for line in output.split(|&b| b == b'\n').filter(|l| !l.is_empty()) { + let s = std::str::from_utf8(line).expect("non-UTF8 in ACP stdout"); + let _: serde_json::Value = serde_json::from_str(s) + .unwrap_or_else(|_| panic!("ACP stdout not valid JSON: {s}")); + } + } + + #[tokio::test] + async fn unknown_method_returns_method_not_found() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":2,"method":"nonexistent","params":{}}"#, + "\n", + ); + let mut output = Vec::new(); + run_acp_server_on(input.as_bytes(), &mut output) + .await + .unwrap(); + + let s = String::from_utf8(output).unwrap(); + let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); + assert_eq!(v["error"]["code"], METHOD_NOT_FOUND); + assert_eq!(v["id"], 2); + } + + #[tokio::test] + async fn invalid_json_returns_parse_error() { + let input = "not json\n"; + let mut output = Vec::new(); + run_acp_server_on(input.as_bytes(), &mut output) + .await + .unwrap(); + + let s = String::from_utf8(output).unwrap(); + let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); + assert_eq!(v["error"]["code"], PARSE_ERROR); + } + + #[tokio::test] + async fn notification_without_id_produces_no_output() { + let input = concat!( + r#"{"jsonrpc":"2.0","method":"session/cancel","params":{}}"#, + "\n", + ); + let mut output = Vec::new(); + run_acp_server_on(input.as_bytes(), &mut output) + .await + .unwrap(); + assert!(output.is_empty()); + } + + #[tokio::test] + async fn initialize_returns_server_info() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"name":"test","version":"0.1.0"}}"#, + "\n", + ); + let mut output = Vec::new(); + run_acp_server_on(input.as_bytes(), &mut output) + .await + .unwrap(); + + let s = String::from_utf8(output).unwrap(); + let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); + assert_eq!(v["id"], 1); + assert_eq!(v["result"]["name"], "coyote"); + assert!(v["result"]["version"].is_string()); + } +} diff --git a/src/acp/types.rs b/src/acp/types.rs new file mode 100644 index 0000000..58d612e --- /dev/null +++ b/src/acp/types.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const PARSE_ERROR: i32 = -32700; + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct Request { + pub jsonrpc: String, + pub id: Option, + pub method: String, + pub params: Option, +} + +#[derive(Debug, Serialize)] +pub struct Response { + pub jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(flatten)] + pub body: ResponseBody, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum ResponseBody { + Ok { result: Value }, + Err { error: RpcError }, +} + +#[derive(Debug, Serialize)] +pub struct RpcError { + pub code: i32, + pub message: String, +} + +impl Response { + pub fn ok(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + body: ResponseBody::Ok { result }, + } + } + + pub fn err(id: Option, code: i32, message: impl Into) -> Self { + Self { + jsonrpc: "2.0", + id, + body: ResponseBody::Err { + error: RpcError { + code, + message: message.into(), + }, + }, + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 06c6e9c..c054552 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -234,6 +234,10 @@ pub struct Cli { /// prompting. Implies --dangerously-skip-permissions. Incompatible with REPL mode (requires a prompt). #[arg(long, help_heading = "Sandbox")] pub headless: bool, + /// Run as an ACP agent server over stdio (JSON-RPC 2.0). Every stdout byte must be valid JSON-RPC. + /// Implies --headless. Single session per process. + #[arg(long, help_heading = "Sandbox")] + pub acp_server: bool, /// Display information #[arg(long, help_heading = "Diagnostics & Tools")] pub info: bool, @@ -505,6 +509,17 @@ mod tests { assert!(!parse(&[]).headless); } + #[test] + fn parse_acp_server_flag() { + let cli = parse(&["--acp-server"]); + assert!(cli.acp_server); + } + + #[test] + fn parse_acp_server_default_off() { + assert!(!parse(&[]).acp_server); + } + #[test] fn parse_sync_models_flag() { let cli = parse(&["--sync-models"]); diff --git a/src/main.rs b/src/main.rs index 2741184..23a97fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod acp; mod cli; mod client; mod config; @@ -82,8 +83,8 @@ async fn main() -> Result<()> { WorkingMode::Cmd }; - if cli.headless { - if text.is_none() && cli.file.is_empty() { + if cli.headless || cli.acp_server { + if cli.headless && !cli.acp_server && text.is_none() && cli.file.is_empty() { bail!("--headless requires a prompt argument; REPL mode is not supported"); } unsafe { @@ -107,7 +108,11 @@ async fn main() -> Result<()> { || cli.delete_secret.is_some() || cli.list_secrets; - let log_path = setup_logger()?; + let log_path = setup_logger(cli.acp_server)?; + + if cli.acp_server { + return acp::run_acp_server().await; + } if let Some(version) = &cli.update { let version = version.clone(); @@ -694,7 +699,7 @@ async fn create_input( Ok(input) } -fn setup_logger() -> Result> { +fn setup_logger(acp_mode: bool) -> Result> { let (log_level, log_path) = paths::log_config()?; if log_level == LevelFilter::Off { return Ok(None); @@ -705,7 +710,11 @@ fn setup_logger() -> Result> { let log_filter = env::var(get_env_name("log_filter")).ok(); match log_path.clone() { None => { - let console_appender = ConsoleAppender::builder().encoder(encoder).build(); + let mut builder = ConsoleAppender::builder().encoder(encoder); + if acp_mode { + builder = builder.target(log4rs::append::console::Target::Stderr); + } + let console_appender = builder.build(); log4rs::init_config(init_console_logger(log_level, log_filter, console_appender))?; } Some(path) => {