fix: ACP spec conformance — ContentBlock prompt params and protocolVersion type
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

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.
This commit is contained in:
2026-07-28 14:41:22 -06:00
parent d50de7c06a
commit 06b2c384e3
+119 -7
View File
@@ -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<String> = 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::<Vec<_>>()
.join("\n")
})
.filter(|s| !s.is_empty());
let from_text: Option<String> = 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);
}
}