use futures::StreamExt as _;
use serde_json::{Value, json};
use uuid::Uuid;
use crate::types::{SendMessageResponse, StreamResponse, Task, TaskState, TaskStatusUpdateEvent};
#[derive(Debug, Clone)]
pub struct PeerCard {
pub name: String,
pub url: String,
pub public_key: Vec<u8>,
pub supports_streaming: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PeerOutcome {
Completed {
text: String,
},
InputRequired {
prompt: String,
},
Failed {
message: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("transport error: {0}")]
Transport(String),
#[error("peer agent card is not validly self-signed")]
UnverifiedCard,
#[error("malformed agent card: missing `{0}`")]
MalformedCard(&'static str),
#[error("malformed JSON-RPC response: {0}")]
MalformedResponse(String),
#[error("peer returned JSON-RPC error {code}: {message}")]
RpcError {
code: i64,
message: String,
},
#[error("peer does not advertise the streaming capability")]
PeerDoesNotSupportStreaming,
#[error("peer card endpoint escapes the configured peer origin")]
EndpointEscapesConfiguredOrigin,
#[error("peer response headers exceed the {cap_bytes} byte cap")]
ResponseHeadersTooLarge {
cap_bytes: usize,
},
#[error("peer response body exceeds the {cap_bytes} byte cap")]
ResponseBodyTooLarge {
cap_bytes: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResponseBounds {
pub max_header_bytes: usize,
pub max_decoded_body_bytes: usize,
}
impl ResponseBounds {
#[must_use]
pub const fn unbounded() -> Self {
Self {
max_header_bytes: usize::MAX,
max_decoded_body_bytes: usize::MAX,
}
}
}
#[must_use]
pub fn build_send_request(text: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": Uuid::new_v4().to_string(),
"method": "SendMessage",
"params": {
"message": {
"messageId": Uuid::now_v7().to_string(),
"role": "ROLE_USER",
"parts": [{ "text": text }]
}
},
})
}
#[must_use]
pub fn build_send_streaming_request(text: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": Uuid::new_v4().to_string(),
"method": "SendStreamingMessage",
"params": {
"message": {
"messageId": Uuid::now_v7().to_string(),
"role": "ROLE_USER",
"parts": [{ "text": text }]
}
},
})
}
#[must_use]
pub fn build_get_task_request(task_id: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": Uuid::new_v4().to_string(),
"method": "GetTask",
"params": { "id": task_id },
})
}
pub fn parse_send_response(body: &[u8]) -> Result<PeerOutcome, ClientError> {
let result = jsonrpc_result(body)?;
let response: SendMessageResponse = serde_json::from_value(result)
.map_err(|e| ClientError::MalformedResponse(e.to_string()))?;
Ok(match response {
SendMessageResponse::Task(task) => outcome_from_task(&task),
SendMessageResponse::Message(message) => PeerOutcome::Completed {
text: message.text(),
},
})
}
pub fn parse_task_response(body: &[u8]) -> Result<PeerOutcome, ClientError> {
let result = jsonrpc_result(body)?;
let task: Task = serde_json::from_value(result)
.map_err(|e| ClientError::MalformedResponse(e.to_string()))?;
Ok(outcome_from_task(&task))
}
fn jsonrpc_result(body: &[u8]) -> Result<Value, ClientError> {
let response: Value =
serde_json::from_slice(body).map_err(|e| ClientError::MalformedResponse(e.to_string()))?;
if let Some(error) = response.get("error").filter(|e| !e.is_null()) {
return Err(ClientError::RpcError {
code: error.get("code").and_then(Value::as_i64).unwrap_or(0),
message: error
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
});
}
response
.get("result")
.cloned()
.ok_or_else(|| ClientError::MalformedResponse("no `result` and no `error`".to_owned()))
}
fn outcome_from_task(task: &Task) -> PeerOutcome {
let status_text = task
.status
.message
.as_ref()
.map(crate::types::Message::text)
.unwrap_or_default();
match task.status.state {
TaskState::Completed => PeerOutcome::Completed {
text: if status_text.is_empty() {
artifact_text(task)
} else {
status_text
},
},
TaskState::InputRequired | TaskState::AuthRequired => PeerOutcome::InputRequired {
prompt: status_text,
},
TaskState::Failed | TaskState::Rejected => PeerOutcome::Failed {
message: status_text,
},
TaskState::Canceled => PeerOutcome::Failed {
message: if status_text.is_empty() {
"task canceled".to_owned()
} else {
status_text
},
},
TaskState::Submitted | TaskState::Working | TaskState::Unspecified => PeerOutcome::Failed {
message: "peer returned a non-terminal task state".to_owned(),
},
}
}
fn artifact_text(task: &Task) -> String {
task.artifacts
.iter()
.flatten()
.flat_map(|a| a.parts.iter())
.filter_map(crate::types::Part::as_text)
.collect::<Vec<_>>()
.join("\n")
}
fn outcome_from_status(update: &TaskStatusUpdateEvent) -> PeerOutcome {
let status_text = update
.status
.message
.as_ref()
.map(crate::types::Message::text)
.unwrap_or_default();
match update.status.state {
TaskState::Completed => PeerOutcome::Completed { text: status_text },
TaskState::InputRequired | TaskState::AuthRequired => PeerOutcome::InputRequired {
prompt: status_text,
},
TaskState::Failed | TaskState::Rejected => PeerOutcome::Failed {
message: status_text,
},
TaskState::Canceled => PeerOutcome::Failed {
message: if status_text.is_empty() {
"task canceled".to_owned()
} else {
status_text
},
},
TaskState::Submitted | TaskState::Working | TaskState::Unspecified => PeerOutcome::Failed {
message: "peer returned a non-terminal task state".to_owned(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StreamStep {
Progress {
task_id: Option<String>,
},
Terminal(PeerOutcome),
}
fn parse_stream_step(body: &[u8]) -> Result<StreamStep, ClientError> {
let result = jsonrpc_result(body)?;
let response: StreamResponse = serde_json::from_value(result)
.map_err(|e| ClientError::MalformedResponse(e.to_string()))?;
Ok(match response {
StreamResponse::Task(task) => StreamStep::Progress {
task_id: Some(task.id),
},
StreamResponse::ArtifactUpdate(update) => StreamStep::Progress {
task_id: Some(update.task_id),
},
StreamResponse::StatusUpdate(update) if update.is_final => {
StreamStep::Terminal(outcome_from_status(&update))
}
StreamResponse::StatusUpdate(update) => StreamStep::Progress {
task_id: Some(update.task_id),
},
})
}
fn drain_sse_events(buffer: &mut String) -> Vec<String> {
let mut events = Vec::new();
while let Some(end) = blank_line_end(buffer) {
let raw: String = buffer.drain(..end).collect();
let data = raw
.lines()
.filter_map(|line| {
let rest = line.strip_prefix("data:")?;
Some(rest.strip_prefix(' ').unwrap_or(rest))
})
.collect::<Vec<_>>()
.join("\n");
if !data.is_empty() {
events.push(data);
}
}
events
}
fn blank_line_end(buffer: &str) -> Option<usize> {
let lf = buffer.find("\n\n").map(|i| i + 2);
let crlf = buffer.find("\r\n\r\n").map(|i| i + 4);
match (lf, crlf) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
pub fn verify_card_doc(card_bytes: &[u8]) -> Result<PeerCard, ClientError> {
let card: Value = serde_json::from_slice(card_bytes)
.map_err(|e| ClientError::MalformedResponse(e.to_string()))?;
let public_key = crate::card::verify_self_signed(&card).ok_or(ClientError::UnverifiedCard)?;
let name = card
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or(ClientError::MalformedCard("name"))?;
let url = card
.get("supportedInterfaces")
.and_then(Value::as_array)
.and_then(|interfaces| interfaces.first())
.and_then(|interface| interface.get("url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or(ClientError::MalformedCard("supportedInterfaces[].url"))?;
let supports_streaming = card
.get("capabilities")
.and_then(|capabilities| capabilities.get("streaming"))
.and_then(Value::as_bool)
.unwrap_or(false);
Ok(PeerCard {
name,
url,
public_key,
supports_streaming,
})
}
#[derive(Debug, Clone)]
pub struct A2aClient {
http: reqwest::Client,
base_url: String,
response_bounds: ResponseBounds,
}
impl A2aClient {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default();
Self {
http,
base_url: base_url.into(),
response_bounds: ResponseBounds::unbounded(),
}
}
#[must_use]
pub fn with_bearer_token(base_url: impl Into<String>, token: &str) -> Self {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) {
headers.insert(reqwest::header::AUTHORIZATION, value);
}
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.default_headers(headers)
.build()
.unwrap_or_default();
Self {
http,
base_url: base_url.into(),
response_bounds: ResponseBounds::unbounded(),
}
}
#[must_use]
pub fn with_transport(
base_url: impl Into<String>,
http: reqwest::Client,
response_bounds: ResponseBounds,
) -> Self {
Self {
http,
base_url: base_url.into(),
response_bounds,
}
}
pub async fn fetch_card(&self) -> Result<PeerCard, ClientError> {
let url = format!(
"{}/.well-known/agent-card.json",
self.base_url.trim_end_matches('/')
);
let bytes = self.get(&url).await?;
verify_card_doc(&bytes)
}
pub async fn send_message(
&self,
endpoint: &str,
text: &str,
) -> Result<PeerOutcome, ClientError> {
let bytes = self.post(endpoint, &build_send_request(text)).await?;
parse_send_response(&bytes)
}
pub async fn send_message_streaming(
&self,
peer: &PeerCard,
text: &str,
) -> Result<PeerOutcome, ClientError> {
if !peer.supports_streaming {
return Err(ClientError::PeerDoesNotSupportStreaming);
}
let response = self
.http
.post(&peer.url)
.header(reqwest::header::ACCEPT, "text/event-stream")
.json(&build_send_streaming_request(text))
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| ClientError::Transport(e.to_string()))?;
let mut bytes_stream = response.bytes_stream();
let mut buffer = String::new();
let mut last_task_id: Option<String> = None;
loop {
let Some(chunk) = bytes_stream.next().await else {
break;
};
let Ok(chunk) = chunk else {
break; };
buffer.push_str(&String::from_utf8_lossy(&chunk));
for event in drain_sse_events(&mut buffer) {
match parse_stream_step(event.as_bytes())? {
StreamStep::Progress { task_id } => {
last_task_id = task_id.or(last_task_id);
}
StreamStep::Terminal(outcome) => return Ok(outcome),
}
}
}
match last_task_id {
Some(task_id) => self.get_task(&peer.url, &task_id).await,
None => Err(ClientError::Transport(
"stream ended before any task id or terminal event was seen".to_owned(),
)),
}
}
pub async fn get_task(
&self,
endpoint: &str,
task_id: &str,
) -> Result<PeerOutcome, ClientError> {
let bytes = self
.post(endpoint, &build_get_task_request(task_id))
.await?;
parse_task_response(&bytes)
}
pub async fn call(&self, text: &str) -> Result<(PeerCard, PeerOutcome), ClientError> {
let card = self.fetch_card().await?;
if !same_origin(&self.base_url, &card.url) {
return Err(ClientError::EndpointEscapesConfiguredOrigin);
}
let outcome = self.send_message(&card.url, text).await?;
Ok((card, outcome))
}
async fn get(&self, url: &str) -> Result<Vec<u8>, ClientError> {
let response = self
.http
.get(url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| ClientError::Transport(e.to_string()))?;
self.read_response(response).await
}
async fn post(&self, url: &str, body: &Value) -> Result<Vec<u8>, ClientError> {
let response = self
.http
.post(url)
.json(body)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| ClientError::Transport(e.to_string()))?;
self.read_response(response).await
}
async fn read_response(&self, response: reqwest::Response) -> Result<Vec<u8>, ClientError> {
let header_bytes = response
.headers()
.iter()
.map(|(name, value)| name.as_str().len() + value.as_bytes().len() + 4)
.sum::<usize>();
if header_bytes > self.response_bounds.max_header_bytes {
return Err(ClientError::ResponseHeadersTooLarge {
cap_bytes: self.response_bounds.max_header_bytes,
});
}
if response
.content_length()
.is_some_and(|bytes| bytes > self.response_bounds.max_decoded_body_bytes as u64)
{
return Err(ClientError::ResponseBodyTooLarge {
cap_bytes: self.response_bounds.max_decoded_body_bytes,
});
}
let mut stream = response.bytes_stream();
let mut body = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| ClientError::Transport(error.to_string()))?;
if body.len().saturating_add(chunk.len()) > self.response_bounds.max_decoded_body_bytes
{
return Err(ClientError::ResponseBodyTooLarge {
cap_bytes: self.response_bounds.max_decoded_body_bytes,
});
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
}
fn same_origin(base: &str, endpoint: &str) -> bool {
let Ok(base) = reqwest::Url::parse(base) else {
return false;
};
let Ok(endpoint) = reqwest::Url::parse(endpoint) else {
return false;
};
base.scheme() == endpoint.scheme()
&& base.host_str() == endpoint.host_str()
&& base.port_or_known_default() == endpoint.port_or_known_default()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use futures::Stream;
use polyc_crypto::Signer;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::card::{CardConfig, signed_card};
use crate::server::AppState;
use crate::store::test_double::InMemoryTaskStore;
use crate::task::{
TurnOutcome, TurnRequest, TurnRunner, TurnStreamEvent, UnconfiguredApprovalResponder,
};
fn rpc_result(result: Value) -> Vec<u8> {
serde_json::to_vec(&json!({ "jsonrpc": "2.0", "id": "1", "result": result })).unwrap()
}
#[test]
fn build_send_request_is_v1_shape() {
let req = build_send_request("hello");
assert_eq!(req["method"], "SendMessage");
assert_eq!(req["params"]["message"]["role"], "ROLE_USER");
assert_eq!(req["params"]["message"]["parts"][0]["text"], "hello");
assert!(req["params"]["message"]["parts"][0].get("kind").is_none());
}
#[test]
fn parse_completed_wrapped_task() {
let result = json!({ "task": {
"id": "t", "contextId": "c",
"status": { "state": "TASK_STATE_COMPLETED", "message": {
"messageId": "r", "role": "ROLE_AGENT", "parts": [{ "text": "the answer is 42" }]
}},
}});
assert_eq!(
parse_send_response(&rpc_result(result)).unwrap(),
PeerOutcome::Completed {
text: "the answer is 42".to_owned()
}
);
}
#[test]
fn parse_completed_from_artifacts_fallback() {
let result = json!({ "task": {
"id": "t", "contextId": "c",
"status": { "state": "TASK_STATE_COMPLETED" },
"artifacts": [{ "artifactId": "a", "parts": [{ "text": "from artifact" }] }],
}});
assert_eq!(
parse_send_response(&rpc_result(result)).unwrap(),
PeerOutcome::Completed {
text: "from artifact".to_owned()
}
);
}
#[test]
fn parse_input_required_task() {
let result = json!({ "task": {
"id": "t", "contextId": "c",
"status": { "state": "TASK_STATE_INPUT_REQUIRED", "message": {
"messageId": "r", "role": "ROLE_AGENT", "parts": [{ "text": "approve?" }]
}},
}});
assert_eq!(
parse_send_response(&rpc_result(result)).unwrap(),
PeerOutcome::InputRequired {
prompt: "approve?".to_owned()
}
);
}
#[test]
fn parse_immediate_message_reply() {
let result = json!({ "message": {
"messageId": "r", "role": "ROLE_AGENT", "parts": [{ "text": "hi there" }]
}});
assert_eq!(
parse_send_response(&rpc_result(result)).unwrap(),
PeerOutcome::Completed {
text: "hi there".to_owned()
}
);
}
#[test]
fn parse_rpc_error_and_null_error() {
let err = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": "1", "error": { "code": -32001, "message": "task not found" }
}))
.unwrap();
assert!(matches!(
parse_send_response(&err).unwrap_err(),
ClientError::RpcError { code: -32001, .. }
));
let ok = serde_json::to_vec(&json!({
"jsonrpc": "2.0", "id": "1", "error": null,
"result": { "message": { "messageId": "r", "role": "ROLE_AGENT", "parts": [{ "text": "ok" }] }}
}))
.unwrap();
assert_eq!(
parse_send_response(&ok).unwrap(),
PeerOutcome::Completed {
text: "ok".to_owned()
}
);
}
#[test]
fn verify_card_doc_reads_supported_interfaces_url() {
let signer = Signer::from_seed(9);
let card = signed_card(
&CardConfig {
name: "Peer".to_owned(),
description: "d".to_owned(),
url: "http://peer/".to_owned(),
version: "1".to_owned(),
},
&signer,
);
let peer = verify_card_doc(&serde_json::to_vec(&card).unwrap()).expect("verifies");
assert_eq!(peer.name, "Peer");
assert_eq!(peer.url, "http://peer/");
assert_eq!(peer.public_key, signer.public_key_bytes());
assert!(
peer.supports_streaming,
"the default card advertises `capabilities.streaming: true` (`#371`)"
);
let mut tampered = card;
tampered["name"] = json!("Imposter");
assert!(matches!(
verify_card_doc(&serde_json::to_vec(&tampered).unwrap()).unwrap_err(),
ClientError::UnverifiedCard
));
}
struct StubRunner(TurnOutcome);
impl TurnRunner for StubRunner {
fn run_turn<'a>(
&'a self,
_req: TurnRequest,
) -> Pin<Box<dyn Future<Output = TurnOutcome> + Send + 'a>> {
let outcome = self.0.clone();
Box::pin(async move { outcome })
}
}
const TOKEN: &str = "round-trip-bearer-token";
#[tokio::test]
async fn client_round_trips_against_the_real_server() {
let signer = Signer::from_seed(7);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base = format!("http://{addr}/");
let card = signed_card(
&CardConfig {
name: "Peer".to_owned(),
description: "a peer agent".to_owned(),
url: base.clone(),
version: "1".to_owned(),
},
&signer,
);
let state = AppState {
card: Arc::new(card),
runner: Arc::new(StubRunner(TurnOutcome::Completed {
text: "42".to_owned(),
})),
approvals: Arc::new(UnconfiguredApprovalResponder),
store: Arc::new(InMemoryTaskStore::new()),
turn_limit: polyc_runtime::admission::AdmissionGate::new(64),
peers: crate::server::PeerAuthenticator::single("test-peer", TOKEN).unwrap(),
};
let app = crate::router(state);
let shutdown = CancellationToken::new();
let server = tokio::spawn({
let shutdown = shutdown.clone();
async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move { shutdown.cancelled().await })
.await
.unwrap();
}
});
let client = A2aClient::with_bearer_token(base, TOKEN);
let peer = client.fetch_card().await.expect("fetch card");
assert_eq!(peer.name, "Peer");
assert_eq!(peer.public_key, signer.public_key_bytes());
let outcome = client.send_message(&peer.url, "hi").await.expect("send");
assert_eq!(
outcome,
PeerOutcome::Completed {
text: "42".to_owned()
}
);
let (peer2, outcome2) = client.call("hi again").await.expect("call");
assert_eq!(peer2.name, "Peer");
assert_eq!(
outcome2,
PeerOutcome::Completed {
text: "42".to_owned()
}
);
shutdown.cancel();
let _ = server.await;
}
struct StreamingStubRunner(Vec<TurnStreamEvent>);
impl TurnRunner for StreamingStubRunner {
fn run_turn<'a>(
&'a self,
_req: TurnRequest,
) -> Pin<Box<dyn Future<Output = TurnOutcome> + Send + 'a>> {
let outcome = self
.0
.iter()
.find_map(|e| match e {
TurnStreamEvent::Outcome(o) => Some(o.clone()),
TurnStreamEvent::DurablyReceived | TurnStreamEvent::TextDelta(_) => None,
})
.unwrap_or(TurnOutcome::Completed {
text: String::new(),
});
Box::pin(async move { outcome })
}
fn run_turn_streaming<'a>(
&'a self,
_req: TurnRequest,
) -> Pin<Box<dyn Stream<Item = TurnStreamEvent> + Send + 'a>> {
Box::pin(futures::stream::iter(self.0.clone()))
}
}
#[tokio::test]
async fn client_streams_send_streaming_message_against_the_real_server() {
let signer = Signer::from_seed(13);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base = format!("http://{addr}/");
let card = signed_card(
&CardConfig {
name: "Peer".to_owned(),
description: "a peer agent".to_owned(),
url: base.clone(),
version: "1".to_owned(),
},
&signer,
);
let state = AppState {
card: Arc::new(card),
runner: Arc::new(StreamingStubRunner(vec![
TurnStreamEvent::TextDelta("Hello, ".to_owned()),
TurnStreamEvent::TextDelta("world.".to_owned()),
TurnStreamEvent::Outcome(TurnOutcome::Completed {
text: "Hello, world.".to_owned(),
}),
])),
approvals: Arc::new(UnconfiguredApprovalResponder),
store: Arc::new(InMemoryTaskStore::new()),
turn_limit: polyc_runtime::admission::AdmissionGate::new(64),
peers: crate::server::PeerAuthenticator::single("test-peer", TOKEN).unwrap(),
};
let app = crate::router(state);
let shutdown = CancellationToken::new();
let server = tokio::spawn({
let shutdown = shutdown.clone();
async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move { shutdown.cancelled().await })
.await
.unwrap();
}
});
let client = A2aClient::with_bearer_token(base, TOKEN);
let peer = client.fetch_card().await.expect("fetch card");
assert!(
peer.supports_streaming,
"the served card advertises streaming"
);
let outcome = client
.send_message_streaming(&peer, "hi")
.await
.expect("stream");
assert_eq!(
outcome,
PeerOutcome::Completed {
text: "Hello, world.".to_owned()
}
);
shutdown.cancel();
let _ = server.await;
}
#[tokio::test]
async fn bearer_auth_rejects_both_the_unary_and_streaming_routes() {
let signer = Signer::from_seed(17);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base = format!("http://{addr}/");
let card = signed_card(
&CardConfig {
name: "Peer".to_owned(),
description: "a peer agent".to_owned(),
url: base.clone(),
version: "1".to_owned(),
},
&signer,
);
let state = AppState {
card: Arc::new(card),
runner: Arc::new(StubRunner(TurnOutcome::Completed {
text: "42".to_owned(),
})),
approvals: Arc::new(UnconfiguredApprovalResponder),
store: Arc::new(InMemoryTaskStore::new()),
turn_limit: polyc_runtime::admission::AdmissionGate::new(64),
peers: crate::server::PeerAuthenticator::single("test-peer", TOKEN).unwrap(),
};
let app = crate::router(state);
let shutdown = CancellationToken::new();
let server = tokio::spawn({
let shutdown = shutdown.clone();
async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move { shutdown.cancelled().await })
.await
.unwrap();
}
});
let http = reqwest::Client::new();
let unary = http
.post(&base)
.header(reqwest::header::AUTHORIZATION, "Bearer wrong-token")
.json(&build_send_request("hi"))
.send()
.await
.unwrap();
assert_eq!(unary.status(), reqwest::StatusCode::UNAUTHORIZED);
let streaming = http
.post(&base)
.header(reqwest::header::AUTHORIZATION, "Bearer wrong-token")
.header(reqwest::header::ACCEPT, "text/event-stream")
.json(&build_send_streaming_request("hi"))
.send()
.await
.unwrap();
assert_eq!(streaming.status(), reqwest::StatusCode::UNAUTHORIZED);
shutdown.cancel();
let _ = server.await;
}
#[tokio::test]
async fn send_message_streaming_refuses_a_peer_without_the_capability() {
let client = A2aClient::new("http://127.0.0.1:1/");
let peer = PeerCard {
name: "Peer".to_owned(),
url: "http://127.0.0.1:1/".to_owned(),
public_key: Vec::new(),
supports_streaming: false,
};
let err = client
.send_message_streaming(&peer, "hi")
.await
.expect_err("must refuse a non-streaming peer");
assert!(matches!(err, ClientError::PeerDoesNotSupportStreaming));
}
#[test]
fn a_card_endpoint_stays_under_its_configured_origin() {
assert!(same_origin(
"https://finance.example/peer",
"https://finance.example/rpc/v1"
));
assert!(!same_origin(
"https://finance.example/",
"https://metadata.google.internal/compute"
));
assert!(!same_origin(
"https://finance.example/",
"http://finance.example/rpc"
));
assert!(!same_origin(
"https://finance.example/",
"https://finance.example:8443/rpc"
));
}
#[test]
fn drain_sse_events_splits_multiple_events_and_keeps_a_trailing_partial() {
let mut buffer =
String::from("data: {\"a\":1}\n\ndata: {\"a\":2}\n\ndata: {\"a\":3 (not yet complete)");
let events = drain_sse_events(&mut buffer);
assert_eq!(events, vec!["{\"a\":1}".to_owned(), "{\"a\":2}".to_owned()]);
assert_eq!(
buffer, "data: {\"a\":3 (not yet complete)",
"a trailing event with no blank-line terminator yet is retained for the next chunk"
);
}
#[test]
fn drain_sse_events_ignores_comments_and_non_data_fields() {
let mut buffer =
String::from(": keep-alive\r\nevent: message\r\ndata: {\"a\":1}\r\nid: 5\r\n\r\n");
let events = drain_sse_events(&mut buffer);
assert_eq!(events, vec!["{\"a\":1}".to_owned()]);
assert!(buffer.is_empty());
}
#[test]
fn parse_stream_step_classifies_progress_vs_terminal() {
let submitted = json!({
"jsonrpc": "2.0", "id": "1",
"result": { "task": {
"id": "t1", "contextId": "c1", "status": { "state": "TASK_STATE_SUBMITTED" }
}}
});
assert_eq!(
parse_stream_step(&serde_json::to_vec(&submitted).unwrap()).unwrap(),
StreamStep::Progress {
task_id: Some("t1".to_owned())
}
);
let terminal = json!({
"jsonrpc": "2.0", "id": "1",
"result": { "statusUpdate": {
"taskId": "t1", "contextId": "c1",
"status": { "state": "TASK_STATE_COMPLETED", "message": {
"messageId": "r", "role": "ROLE_AGENT", "parts": [{ "text": "42" }]
}},
"final": true
}}
});
assert_eq!(
parse_stream_step(&serde_json::to_vec(&terminal).unwrap()).unwrap(),
StreamStep::Terminal(PeerOutcome::Completed {
text: "42".to_owned()
})
);
let working = json!({
"jsonrpc": "2.0", "id": "1",
"result": { "statusUpdate": {
"taskId": "t1", "contextId": "c1",
"status": { "state": "TASK_STATE_WORKING" }, "final": false
}}
});
assert_eq!(
parse_stream_step(&serde_json::to_vec(&working).unwrap()).unwrap(),
StreamStep::Progress {
task_id: Some("t1".to_owned())
}
);
}
}