fix: ACP spec conformance — ContentBlock prompt params and protocolVersion type
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:
+119
-7
@@ -109,7 +109,7 @@ fn handle_initialize(req: Request) -> Response {
|
|||||||
json!({
|
json!({
|
||||||
"name": "coyote",
|
"name": "coyote",
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"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");
|
return Response::err(req.id, -32000, "No active session; call session/new first");
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = match req
|
let params = req.params.as_ref();
|
||||||
.params
|
let from_content_blocks: Option<String> = params
|
||||||
.as_ref()
|
.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(|p| p.get("text"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
{
|
.filter(|s| !s.is_empty())
|
||||||
Some(t) => t.to_string(),
|
.map(str::to_string);
|
||||||
None => return Response::err(req.id, -32602, "Missing params.text"),
|
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() {
|
let ctx = match state.ctx.as_mut() {
|
||||||
@@ -537,4 +556,97 @@ mod tests {
|
|||||||
assert!(v["params"]["action"].is_string());
|
assert!(v["params"]["action"].is_string());
|
||||||
assert!(!v.as_object().unwrap().contains_key("id"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user