feat: add ACP server skeleton with stdout-purity test
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
mod server;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use server::run_acp_server;
|
||||||
@@ -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<R, W>(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<Response> {
|
||||||
|
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<W: AsyncWrite + Unpin>(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Value>,
|
||||||
|
pub method: String,
|
||||||
|
pub params: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Response {
|
||||||
|
pub jsonrpc: &'static str,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<Value>,
|
||||||
|
#[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<Value>, result: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
body: ResponseBody::Ok { result },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn err(id: Option<Value>, code: i32, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id,
|
||||||
|
body: ResponseBody::Err {
|
||||||
|
error: RpcError {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -234,6 +234,10 @@ pub struct Cli {
|
|||||||
/// prompting. Implies --dangerously-skip-permissions. Incompatible with REPL mode (requires a prompt).
|
/// prompting. Implies --dangerously-skip-permissions. Incompatible with REPL mode (requires a prompt).
|
||||||
#[arg(long, help_heading = "Sandbox")]
|
#[arg(long, help_heading = "Sandbox")]
|
||||||
pub headless: bool,
|
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
|
/// Display information
|
||||||
#[arg(long, help_heading = "Diagnostics & Tools")]
|
#[arg(long, help_heading = "Diagnostics & Tools")]
|
||||||
pub info: bool,
|
pub info: bool,
|
||||||
@@ -505,6 +509,17 @@ mod tests {
|
|||||||
assert!(!parse(&[]).headless);
|
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]
|
#[test]
|
||||||
fn parse_sync_models_flag() {
|
fn parse_sync_models_flag() {
|
||||||
let cli = parse(&["--sync-models"]);
|
let cli = parse(&["--sync-models"]);
|
||||||
|
|||||||
+14
-5
@@ -1,3 +1,4 @@
|
|||||||
|
mod acp;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod client;
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -82,8 +83,8 @@ async fn main() -> Result<()> {
|
|||||||
WorkingMode::Cmd
|
WorkingMode::Cmd
|
||||||
};
|
};
|
||||||
|
|
||||||
if cli.headless {
|
if cli.headless || cli.acp_server {
|
||||||
if text.is_none() && cli.file.is_empty() {
|
if cli.headless && !cli.acp_server && text.is_none() && cli.file.is_empty() {
|
||||||
bail!("--headless requires a prompt argument; REPL mode is not supported");
|
bail!("--headless requires a prompt argument; REPL mode is not supported");
|
||||||
}
|
}
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -107,7 +108,11 @@ async fn main() -> Result<()> {
|
|||||||
|| cli.delete_secret.is_some()
|
|| cli.delete_secret.is_some()
|
||||||
|| cli.list_secrets;
|
|| 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 {
|
if let Some(version) = &cli.update {
|
||||||
let version = version.clone();
|
let version = version.clone();
|
||||||
@@ -694,7 +699,7 @@ async fn create_input(
|
|||||||
Ok(input)
|
Ok(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_logger() -> Result<Option<PathBuf>> {
|
fn setup_logger(acp_mode: bool) -> Result<Option<PathBuf>> {
|
||||||
let (log_level, log_path) = paths::log_config()?;
|
let (log_level, log_path) = paths::log_config()?;
|
||||||
if log_level == LevelFilter::Off {
|
if log_level == LevelFilter::Off {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -705,7 +710,11 @@ fn setup_logger() -> Result<Option<PathBuf>> {
|
|||||||
let log_filter = env::var(get_env_name("log_filter")).ok();
|
let log_filter = env::var(get_env_name("log_filter")).ok();
|
||||||
match log_path.clone() {
|
match log_path.clone() {
|
||||||
None => {
|
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))?;
|
log4rs::init_config(init_console_logger(log_level, log_filter, console_appender))?;
|
||||||
}
|
}
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user