From 06b2c384e37538a719c600eb436b7162505c0e44 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 28 Jul 2026 14:41:22 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20ACP=20spec=20conformance=20=E2=80=94=20C?= =?UTF-8?q?ontentBlock=20prompt=20params=20and=20protocolVersion=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFECT 1: session/prompt now accepts the spec-shaped params as primary: {"prompt": [{"type": "text", "text": "..."}]} Text blocks are joined with newlines. Non-text block types are silently ignored. The legacy params.text alias is preserved as a fallback. -32602 is returned only when neither a non-empty prompt array with text blocks nor a non-empty text field is present. DEFECT 2: initialize result now emits protocolVersion as integer 1 instead of the string "1", matching the ACP spec's InitializeResponse. Tests: 4 new unit tests pin the spec-shaped prompt path, the non-text block ignore behavior, the missing-both -32602 path, and the numeric protocolVersion type. --- src/acp/server.rs | 126 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 7 deletions(-) diff --git a/src/acp/server.rs b/src/acp/server.rs index 86c398f..9759b65 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -109,7 +109,7 @@ fn handle_initialize(req: Request) -> Response { json!({ "name": "coyote", "version": env!("CARGO_PKG_VERSION"), - "protocolVersion": "1", + "protocolVersion": 1, }), ) } @@ -148,14 +148,33 @@ async fn handle_session_prompt(req: Request, state: &mut AcpServerState) -> Resp return Response::err(req.id, -32000, "No active session; call session/new first"); } - let text = match req - .params - .as_ref() + let params = req.params.as_ref(); + let from_content_blocks: Option = params + .and_then(|p| p.get("prompt")) + .and_then(Value::as_array) + .map(|blocks| { + blocks + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") + }) + .filter(|s| !s.is_empty()); + let from_text: Option = params .and_then(|p| p.get("text")) .and_then(Value::as_str) - { - Some(t) => t.to_string(), - None => return Response::err(req.id, -32602, "Missing params.text"), + .filter(|s| !s.is_empty()) + .map(str::to_string); + let text = match from_content_blocks.or(from_text) { + Some(t) => t, + None => { + return Response::err( + req.id, + -32602, + "Missing params: expected prompt (ContentBlock array) or text", + ); + } }; let ctx = match state.ctx.as_mut() { @@ -537,4 +556,97 @@ mod tests { assert!(v["params"]["action"].is_string()); assert!(!v.as_object().unwrap().contains_key("id")); } + + #[tokio::test] + async fn initialize_protocol_version_is_number() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","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: Value = serde_json::from_str(s.trim()).unwrap(); + + assert!( + v["result"]["protocolVersion"].is_number(), + "protocolVersion must be a JSON number, got: {:?}", + v["result"]["protocolVersion"] + ); + assert_eq!(v["result"]["protocolVersion"], 1); + } + + #[tokio::test] + async fn session_prompt_spec_content_blocks_not_rejected() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"default","prompt":[{"type":"text","text":"hello"}]}}"#, + "\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 mut lines = s.lines().filter(|l| !l.is_empty()); + let _first: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + + assert_eq!(second["id"], 2); + let code = second["error"]["code"].as_i64().unwrap_or(0); + assert_ne!( + code, -32602, + "spec-shaped ContentBlock prompt must not get a params error" + ); + } + + #[tokio::test] + async fn session_prompt_non_text_blocks_ignored() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"prompt":[{"type":"image","data":"abc"},{"type":"text","text":"hello"}]}}"#, + "\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 mut lines = s.lines().filter(|l| !l.is_empty()); + let _first: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + + assert_eq!(second["id"], 2); + let code = second["error"]["code"].as_i64().unwrap_or(0); + assert_ne!(code, -32602, "non-text blocks must be silently ignored"); + } + + #[tokio::test] + async fn session_prompt_missing_both_text_and_prompt_errors() { + let input = concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"default"}}"#, + "\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 mut lines = s.lines().filter(|l| !l.is_empty()); + let _first: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + let second: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + + assert_eq!(second["id"], 2); + assert_eq!(second["error"]["code"], -32602); + } }