#![cfg(all(unix, feature = "a2a"))]
mod common;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
fn sigterm(pid: u32) {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn post_raw(addr: &str, body: &str) -> Result<String, String> {
let mut s = TcpStream::connect(addr).map_err(|e| format!("connect: {e}"))?;
s.set_read_timeout(Some(Duration::from_secs(30))).ok();
let head = format!(
"POST / HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
s.write_all(head.as_bytes()).map_err(|e| e.to_string())?;
s.write_all(body.as_bytes()).map_err(|e| e.to_string())?;
s.flush().map_err(|e| e.to_string())?;
let mut reader = BufReader::new(s);
let mut status = String::new();
if reader.read_line(&mut status).map_err(|e| e.to_string())? == 0 {
return Err("the listener closed the connection without a response".into());
}
loop {
let mut l = String::new();
if reader.read_line(&mut l).map_err(|e| e.to_string())? == 0 {
break;
}
if l.trim().is_empty() {
break;
}
}
let mut b = String::new();
reader.read_to_string(&mut b).map_err(|e| e.to_string())?;
Ok(b)
}
fn post_rpc(addr: &str, id: i64, method: &str, params: Value) -> Result<Value, String> {
let body = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let resp = post_raw(addr, &body)?;
serde_json::from_str(&resp).map_err(|e| format!("non-JSON response ({e}): {resp:?}"))
}
fn wait_ready(addr: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if TcpStream::connect(addr).is_ok() {
return;
}
assert!(
Instant::now() < deadline,
"a2a listener never became connectable"
);
std::thread::sleep(Duration::from_millis(25));
}
}
struct Daemon {
child: Child,
stderr_path: String,
}
impl Daemon {
fn alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
fn stderr(&self) -> String {
std::fs::read_to_string(&self.stderr_path).unwrap_or_default()
}
}
impl Drop for Daemon {
fn drop(&mut self) {
sigterm(self.child.id());
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if matches!(self.child.try_wait(), Ok(Some(_))) {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_file(&self.stderr_path);
}
}
fn spawn_daemon(config: &str) -> Daemon {
let stderr_path = common::unique_path("a2a-malformed-daemon", "log");
let errf = std::fs::File::create(&stderr_path).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_agentd"))
.args(["--config", config])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(errf))
.spawn()
.expect("spawn agentd a2a daemon");
Daemon { child, stderr_path }
}
fn config(port: u16) -> String {
format!(
"config_version: \"1\"\n\
agent:\n name: a2a-malformed\n instruction: You are a test agent.\n preflight: never\n\
intelligence:\n endpoints: https://127.0.0.1:9\n model: mock\n\
store:\n kind: memory\n\
a2a:\n listen: http://127.0.0.1:{port}\n\
interface:\n enabled: true\n debug: false\n\
lifecycle:\n run_until: drained\n\
observability:\n log_level: info\n"
)
}
fn hello_frame(addr: &str, from_seq: u64) -> Value {
let body = json!({"jsonrpc": "2.0", "id": 77, "method": "SubscribeToEvents",
"params": {"fromSeq": from_seq}})
.to_string();
let mut s = TcpStream::connect(addr).expect("connect sse");
s.set_read_timeout(Some(Duration::from_secs(10))).ok();
let head = format!(
"POST / HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
s.write_all(head.as_bytes()).unwrap();
s.write_all(body.as_bytes()).unwrap();
let mut reader = BufReader::new(s);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => panic!("the feed never sent a hello frame"),
Ok(_) => {}
}
if let Some(data) = line.strip_prefix("data:")
&& let Ok(v) = serde_json::from_str::<Value>(data.trim())
&& let Some(hello) = v["result"].get("hello")
{
return hello.clone();
}
}
}
#[test]
fn malformed_send_params_are_refused_and_the_daemon_keeps_serving() {
let port = free_port();
let addr = format!("127.0.0.1:{port}");
let cfg_path = common::unique_path("a2a-malformed", "yaml");
std::fs::write(&cfg_path, config(port)).unwrap();
let mut daemon = spawn_daemon(&cfg_path);
wait_ready(&addr);
let shapes = [
json!([]),
json!({"message": "hi"}),
json!({"message": 3}),
json!({"message": []}),
json!("send"),
json!(7),
Value::Null,
];
for (i, params) in shapes.iter().enumerate() {
let id = 100 + i as i64;
let v = post_rpc(&addr, id, "SendMessage", params.clone())
.unwrap_or_else(|e| panic!("params {params} killed the request: {e}"));
assert!(
v.get("error").is_some(),
"params {params} must be refused, not accepted: {v}"
);
assert!(
v.get("result").is_none(),
"params {params} must not produce a result: {v}"
);
assert!(
daemon.alive(),
"the daemon died on params {params}\nstderr:\n{}",
daemon.stderr()
);
}
let status =
json!({"message": {"messageId": "m1", "parts": [{"data": {"agentd": {"op": "status"}}}]}});
let v = post_rpc(&addr, 1, "SendMessage", status)
.unwrap_or_else(|e| panic!("the listener stopped answering after malformed input: {e}"));
assert_eq!(
v["result"]["task"]["status"]["state"], "TASK_STATE_COMPLETED",
"a good request after the bad ones: {v}"
);
assert!(daemon.alive(), "the daemon survived the whole sequence");
std::fs::remove_file(&cfg_path).ok();
}
#[test]
fn a_feed_cursor_ahead_of_the_feed_is_told_to_resync() {
let port = free_port();
let addr = format!("127.0.0.1:{port}");
let cfg_path = common::unique_path("a2a-malformed-feed", "yaml");
std::fs::write(&cfg_path, config(port)).unwrap();
let mut daemon = spawn_daemon(&cfg_path);
wait_ready(&addr);
let fresh = hello_frame(&addr, 0);
assert_eq!(fresh["resync"], false, "a zero cursor is honoured: {fresh}");
let stale = hello_frame(&addr, 9_000_000);
assert_eq!(
stale["resync"], true,
"a cursor past the end of the feed must resync: {stale}"
);
assert!(
stale["seq"].as_u64().unwrap_or(u64::MAX) < 9_000_000,
"the feed really is behind that cursor: {stale}"
);
assert!(
daemon.alive(),
"the daemon is still up: {}",
daemon.stderr()
);
std::fs::remove_file(&cfg_path).ok();
}