use std::io;
use std::sync::{Arc, Mutex};
use a2a::{
DeleteTaskPushNotificationConfigRequest, GetTaskRequest, Message, Part, Role,
SendMessageRequest,
};
use a2a_client::jsonrpc::JsonRpcTransport;
use a2a_client::{ServiceParams, Transport};
use futures::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct Captured(Arc<Mutex<Vec<u8>>>);
impl Captured {
fn text(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl io::Write for Captured {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for Captured {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
fn capture() -> (Captured, tracing::subscriber::DefaultGuard) {
let captured = Captured::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(captured.clone())
.with_max_level(tracing::Level::DEBUG)
.with_ansi(false)
.finish();
let guard = tracing::subscriber::set_default(subscriber);
(captured, guard)
}
async fn one_shot(response: &'static str) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let _ = socket.read(&mut buf).await;
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.flush().await;
});
format!("http://{addr}/jsonrpc")
}
fn http_ok(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
}
fn http_truncated() -> &'static str {
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort"
}
fn send_request() -> SendMessageRequest {
SendMessageRequest {
message: Message {
message_id: "m1".to_string(),
context_id: None,
task_id: None,
role: Role::User,
parts: vec![Part::text("hello")],
metadata: None,
extensions: None,
reference_task_ids: None,
},
configuration: None,
metadata: None,
tenant: None,
}
}
fn transport(endpoint: String) -> JsonRpcTransport {
JsonRpcTransport::new(reqwest::Client::new(), endpoint)
}
#[tokio::test]
async fn a_credential_header_is_logged_by_name_without_its_value() {
let endpoint = one_shot(Box::leak(
http_ok(r#"{"jsonrpc":"2.0","id":"1","result":{"id":"t1","contextId":"c1"}}"#)
.into_boxed_str(),
))
.await;
let (captured, _guard) = capture();
let mut params = ServiceParams::new();
params.insert(
"Authorization".to_string(),
vec!["Bearer must-not-be-logged".to_string()],
);
params.insert("A2A-Version".to_string(), vec!["1.0".to_string()]);
let _ = transport(endpoint)
.get_task(
¶ms,
&GetTaskRequest {
id: "t1".to_string(),
history_length: None,
tenant: None,
},
)
.await;
let log = captured.text();
assert!(log.contains("A2A wire request"), "no request logged: {log}");
assert!(log.contains("authorization: (redacted)"), "{log}");
assert!(
!log.contains("must-not-be-logged"),
"the credential value reached the log"
);
assert!(log.contains("a2a-version: 1.0"), "{log}");
}
#[tokio::test]
async fn the_response_body_is_logged() {
let body = r#"{"jsonrpc":"2.0","id":"1","result":{"id":"task-42","contextId":"c1"}}"#;
let endpoint = one_shot(Box::leak(http_ok(body).into_boxed_str())).await;
let (captured, _guard) = capture();
let _ = transport(endpoint)
.get_task(
&ServiceParams::new(),
&GetTaskRequest {
id: "task-42".to_string(),
history_length: None,
tenant: None,
},
)
.await;
let log = captured.text();
assert!(log.contains("A2A wire response"), "{log}");
assert!(log.contains("status=200"), "{log}");
assert!(log.contains("task-42"), "response body not logged: {log}");
}
#[tokio::test]
async fn a_malformed_response_is_logged_and_reported_as_a_parse_failure() {
let endpoint = one_shot(Box::leak(
http_ok("this is not json at all").into_boxed_str(),
))
.await;
let (captured, _guard) = capture();
let error = transport(endpoint)
.delete_push_config(
&ServiceParams::new(),
&DeleteTaskPushNotificationConfigRequest {
task_id: "t1".to_string(),
id: "c1".to_string(),
tenant: None,
},
)
.await
.expect_err("a non-JSON body cannot deserialize");
assert!(
error.message.contains("failed to parse JSON-RPC response"),
"unexpected error: {error}"
);
let log = captured.text();
assert!(
log.contains("this is not json at all"),
"the unparseable body was not logged: {log}"
);
}
#[tokio::test]
async fn a_streaming_call_answered_with_malformed_json_reports_a_parse_failure() {
let endpoint = one_shot(Box::leak(http_ok("{not json").into_boxed_str())).await;
let (captured, _guard) = capture();
let error = transport(endpoint)
.send_streaming_message(&ServiceParams::new(), &send_request())
.await
.err()
.expect("a malformed non-SSE body cannot deserialize");
assert!(
error.message.contains("failed to parse JSON-RPC response"),
"unexpected error: {error}"
);
assert!(captured.text().contains("{not json"), "body not logged");
}
#[tokio::test]
async fn an_unreachable_endpoint_logs_the_attempt_and_fails() {
let (captured, _guard) = capture();
let error = transport("http://127.0.0.1:1/jsonrpc".to_string())
.get_task(
&ServiceParams::new(),
&GetTaskRequest {
id: "t1".to_string(),
history_length: None,
tenant: None,
},
)
.await
.expect_err("nothing is listening");
assert!(
error.message.contains("HTTP request failed"),
"unexpected error: {error}"
);
assert!(
captured.text().contains("A2A wire request"),
"the attempt was not logged"
);
}
#[tokio::test]
async fn a_truncated_unary_body_is_reported_as_a_read_failure() {
let endpoint = one_shot(http_truncated()).await;
let (_captured, _guard) = capture();
let error = transport(endpoint)
.get_task(
&ServiceParams::new(),
&GetTaskRequest {
id: "t1".to_string(),
history_length: None,
tenant: None,
},
)
.await
.expect_err("the body ends before Content-Length");
assert!(
error.message.contains("failed to parse JSON-RPC response"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn a_truncated_streaming_body_is_reported_as_a_read_failure() {
let endpoint = one_shot(http_truncated()).await;
let (_captured, _guard) = capture();
let error = transport(endpoint)
.send_streaming_message(&ServiceParams::new(), &send_request())
.await
.err()
.expect("the body ends before Content-Length");
assert!(
error.message.contains("failed to parse JSON-RPC response"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn a_truncated_delete_body_is_reported_as_a_read_failure() {
let endpoint = one_shot(http_truncated()).await;
let (_captured, _guard) = capture();
let error = transport(endpoint)
.delete_push_config(
&ServiceParams::new(),
&DeleteTaskPushNotificationConfigRequest {
task_id: "t1".to_string(),
id: "c1".to_string(),
tenant: None,
},
)
.await
.expect_err("the body ends before Content-Length");
assert!(
error.message.contains("failed to parse JSON-RPC response"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn each_raw_stream_frame_is_logged_as_it_arrives() {
let endpoint = one_shot(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\ndata: {\"unparseable\":true}\n\n",
)
.await;
let (captured, _guard) = capture();
let mut stream = transport(endpoint)
.send_streaming_message(&ServiceParams::new(), &send_request())
.await
.expect("an event-stream content type opens a stream");
let _ = stream.next().await;
let log = captured.text();
assert!(
log.contains("A2A wire stream event"),
"the frame was not logged: {log}"
);
assert!(
log.contains("unparseable"),
"the raw frame contents were not logged: {log}"
);
}
#[tokio::test]
async fn an_unsendable_header_value_fails_the_request() {
let (_captured, _guard) = capture();
let mut params = ServiceParams::new();
params.insert(
"X-Broken".to_string(),
vec!["header\nvalues cannot contain newlines".to_string()],
);
let error = transport("http://127.0.0.1:1/jsonrpc".to_string())
.get_task(
¶ms,
&GetTaskRequest {
id: "t1".to_string(),
history_length: None,
tenant: None,
},
)
.await
.expect_err("the header value is not representable");
assert!(
error.message.contains("HTTP request failed"),
"unexpected error: {error}"
);
}