#![allow(
clippy::expect_used,
reason = "examples assert on fallible calls with named failure messages; a panic in main is the right shape for an example"
)]
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use futures_util::stream;
use protocol_droid::adapters::primary::a2a::jsonrpc_serve::serve_jsonrpc;
use protocol_droid::adapters::primary::a2a::streaming_serve::{
A2aStreamingRequestHandler, serve_streaming,
};
use protocol_droid::adapters::primary::a2a::well_known_serve::serve_well_known;
use protocol_droid::adapters::secondary::a2a::A2aMethod;
use protocol_droid::adapters::secondary::a2a::ssrf_denylist::SsrfDenylist;
use protocol_droid::adapters::secondary::a2a::webhook_delivery::WebhookDelivery;
use protocol_droid::application::a2a::{
A2aError, A2aRequestContext, A2aRequestHandler, NotificationConfig, PushNotificationDelivery,
WebhookUrl,
};
use protocol_droid::domain::a2a::A2aVersion;
use protocol_droid::domain::a2a::agent_card::{
AgentCapabilities, AgentCard, AgentCardBuilder, AgentInterface, AgentSkill,
HTTPAuthSecurityScheme, JSONRPC, SecurityScheme,
};
use protocol_droid::domain::a2a::extended_agent_card::{
GetExtendedAgentCardRequest, GetExtendedAgentCardResponse,
};
use protocol_droid::domain::a2a::list_tasks::{ListTasksRequest, ListTasksResponse};
use protocol_droid::domain::a2a::message::{Message, MessageRole, Part, TextPart};
use protocol_droid::domain::a2a::push_notification::TaskPushNotificationConfig;
use protocol_droid::domain::a2a::push_notification_methods::{
CreateTaskPushNotificationConfigRequest, CreateTaskPushNotificationConfigResponse,
DeleteTaskPushNotificationConfigRequest, DeleteTaskPushNotificationConfigResponse,
GetTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigResponse,
ListTaskPushNotificationConfigsRequest, ListTaskPushNotificationConfigsResponse,
};
use protocol_droid::domain::a2a::send_message::{SendMessageRequest, SendMessageResponse};
use protocol_droid::domain::a2a::streaming_events::{StreamingEvent, TaskStatusUpdateEvent};
use protocol_droid::domain::a2a::task::{Task, TaskState, TaskStatus};
use protocol_droid::domain::a2a::task_lifecycle::{
CancelTaskRequest, CancelTaskResponse, GetTaskRequest, GetTaskResponse,
};
#[derive(Default)]
struct TaskStore {
next_id: u64,
tasks: HashMap<String, Task>,
next_config_id: u64,
push_configs: HashMap<String, TaskPushNotificationConfig>,
}
impl TaskStore {
fn insert(&mut self, task: Task) {
self.tasks.insert(task.id.to_string(), task);
}
fn get(&self, id: &str) -> Option<Task> {
self.tasks.get(id).cloned()
}
fn cancel(&mut self, id: &str) -> Option<Task> {
let task = self.tasks.get_mut(id)?;
task.status = TaskStatus {
state: TaskState::Cancelled,
message: None,
timestamp: None,
};
Some(task.clone())
}
fn list(&self, context: Option<&str>, state: Option<TaskState>) -> Vec<Task> {
self.tasks
.values()
.filter(|task| context.is_none_or(|ctx| task.context_id.as_deref() == Some(ctx)))
.filter(|task| state.is_none_or(|s| task.status.state == s))
.cloned()
.collect()
}
fn insert_push_config(&mut self, mut config: TaskPushNotificationConfig) -> String {
self.next_config_id = self.next_config_id.wrapping_add(1);
let id = format!("pc-{}", self.next_config_id);
config.id.clone_from(&id);
self.push_configs.insert(id.clone(), config);
id
}
fn get_push_config(&self, id: &str) -> Option<TaskPushNotificationConfig> {
self.push_configs.get(id).cloned()
}
fn list_push_configs(&self, task_id: &str) -> Vec<TaskPushNotificationConfig> {
self.push_configs
.values()
.filter(|config| config.task_id == task_id)
.cloned()
.collect()
}
fn delete_push_config(&mut self, id: &str) -> Option<TaskPushNotificationConfig> {
self.push_configs.remove(id)
}
fn len(&self) -> usize {
self.tasks.len()
}
}
#[derive(Clone)]
struct EchoAgent {
store: Arc<std::sync::Mutex<TaskStore>>,
delivery: Arc<WebhookDelivery>,
handle: tokio::runtime::Handle,
card: Arc<AgentCard>,
}
impl A2aRequestHandler for EchoAgent {
fn dispatch(
&self,
method: A2aMethod,
params: Option<serde_json::Value>,
_context: &A2aRequestContext,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<serde_json::Value, A2aError>> + Send + '_>,
> {
match method {
A2aMethod::SendMessage => {
let request = match decode_params::<SendMessageRequest>(params) {
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_send_message(&request);
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::GetTask => {
let request = match decode_params::<GetTaskRequest>(params) {
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_get_task(&request)?;
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::CancelTask => {
let request = match decode_params::<CancelTaskRequest>(params) {
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_cancel_task(&request)?;
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::ListTasks => {
let request = match decode_params::<ListTasksRequest>(params) {
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_list_tasks(&request);
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::CreateTaskPushNotificationConfig => {
let request = match decode_params::<CreateTaskPushNotificationConfigRequest>(params)
{
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_create_push_notification_config(&request)?;
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::GetTaskPushNotificationConfig => {
let request = match decode_params::<GetTaskPushNotificationConfigRequest>(params) {
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_get_push_notification_config(&request)?;
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::ListTaskPushNotificationConfigs => {
let request = match decode_params::<ListTaskPushNotificationConfigsRequest>(params)
{
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_list_push_notification_configs(&request);
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::DeleteTaskPushNotificationConfig => {
let request = match decode_params::<DeleteTaskPushNotificationConfigRequest>(params)
{
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_delete_push_notification_config(&request)?;
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
A2aMethod::GetExtendedAgentCard => {
let request = match decode_params::<GetExtendedAgentCardRequest>(params) {
Ok(request) => request,
Err(error) => return decode_failed(error),
};
Box::pin(async move {
let value = self.handle_get_extended_agent_card(&request);
serde_json::to_value(value).map_err(|error| serialise_error(&error))
})
}
_other => Box::pin(std::future::ready(Err(A2aError::MethodNotFound))),
}
}
}
fn serialise_error(error: &serde_json::Error) -> A2aError {
A2aError::InternalError {
message: format!("serialise: {error}"),
}
}
fn decode_failed(
error: A2aError,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<serde_json::Value, A2aError>> + Send + 'static>,
> {
Box::pin(std::future::ready(Err(error)))
}
fn decode_params<T: serde::de::DeserializeOwned>(
params: Option<serde_json::Value>,
) -> Result<T, A2aError> {
let value = params.unwrap_or(serde_json::Value::Null);
serde_json::from_value(value).map_err(|error| A2aError::InvalidParams {
reason: format!("params do not match the method shape: {error}"),
})
}
impl EchoAgent {
fn handle_send_message(&self, request: &SendMessageRequest) -> SendMessageResponse {
let reply = reply_for(&request.message);
let next_id = {
let mut store = self.store.lock().expect("store lock");
store.next_id = store.next_id.wrapping_add(1);
store.next_id
};
let mut task = Task::new("").with_status(TaskStatus {
state: TaskState::Completed,
message: Some(reply),
timestamp: None,
});
task.id = Arc::from(format!("t-{next_id}"));
let stored = task.clone();
self.store.lock().expect("store lock").insert(stored);
self.deliver_for_task(task.id.as_ref());
SendMessageResponse::Task(task)
}
fn handle_get_task(&self, request: &GetTaskRequest) -> Result<GetTaskResponse, A2aError> {
self.store
.lock()
.expect("store lock")
.get(&request.id)
.map(GetTaskResponse::from)
.ok_or_else(|| A2aError::InvalidParams {
reason: format!("no task with id '{}'", request.id),
})
}
fn handle_cancel_task(
&self,
request: &CancelTaskRequest,
) -> Result<CancelTaskResponse, A2aError> {
self.store
.lock()
.expect("store lock")
.cancel(&request.id)
.map(CancelTaskResponse::from)
.ok_or_else(|| A2aError::InvalidParams {
reason: format!("no task with id '{}'", request.id),
})
}
fn handle_list_tasks(&self, request: &ListTasksRequest) -> ListTasksResponse {
let store = self.store.lock().expect("store lock");
let total_size = store.tasks.len();
let filtered = store.list(request.context_id.as_deref(), request.status);
drop(store);
paginate_tasks(
filtered,
request.page_size,
request.page_token.as_deref(),
total_size,
)
}
fn handle_create_push_notification_config(
&self,
request: &CreateTaskPushNotificationConfigRequest,
) -> Result<CreateTaskPushNotificationConfigResponse, A2aError> {
let url: url::Url = request
.0
.url
.parse()
.map_err(|error| A2aError::InvalidParams {
reason: format!("webhook URL is not a valid URL: {error}"),
})?;
let _validated =
WebhookUrl::new(&SsrfDenylist::FILTER, url, A2aVersion::V1_0).map_err(|denial| {
A2aError::InvalidParams {
reason: format!("webhook URL was rejected: {denial}"),
}
})?;
let id = {
let mut store = self.store.lock().expect("store lock");
store.insert_push_config(request.0.clone())
};
let stored = self
.store
.lock()
.expect("store lock")
.get_push_config(&id)
.expect("config we just inserted");
Ok(CreateTaskPushNotificationConfigResponse(stored))
}
fn handle_get_push_notification_config(
&self,
request: &GetTaskPushNotificationConfigRequest,
) -> Result<GetTaskPushNotificationConfigResponse, A2aError> {
self.store
.lock()
.expect("store lock")
.get_push_config(&request.id)
.ok_or_else(|| A2aError::InvalidParams {
reason: format!(
"no push config with id '{}' for task '{}'",
request.id, request.task_id
),
})
.map(GetTaskPushNotificationConfigResponse)
}
fn handle_list_push_notification_configs(
&self,
request: &ListTaskPushNotificationConfigsRequest,
) -> ListTaskPushNotificationConfigsResponse {
let configs = self
.store
.lock()
.expect("store lock")
.list_push_configs(&request.task_id);
ListTaskPushNotificationConfigsResponse::new(configs)
}
fn handle_delete_push_notification_config(
&self,
request: &DeleteTaskPushNotificationConfigRequest,
) -> Result<DeleteTaskPushNotificationConfigResponse, A2aError> {
let removed = self
.store
.lock()
.expect("store lock")
.delete_push_config(&request.id)
.ok_or_else(|| A2aError::InvalidParams {
reason: format!(
"no push config with id '{}' for task '{}'",
request.id, request.task_id
),
})?;
let _ = removed;
Ok(DeleteTaskPushNotificationConfigResponse::default())
}
fn handle_get_extended_agent_card(
&self,
_request: &GetExtendedAgentCardRequest,
) -> GetExtendedAgentCardResponse {
let _ = self.store.lock().expect("store lock").len();
GetExtendedAgentCardResponse((*self.card).clone())
}
fn handle_streaming_send(
&self,
request: &SendMessageRequest,
) -> std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<StreamingEvent, A2aError>> + Send>>
{
let store = self.store.clone();
let next_id = {
let mut store = store.lock().expect("store lock");
store.next_id = store.next_id.wrapping_add(1);
store.next_id
};
let task_id = format!("t-{next_id}");
let task = Task::new(task_id.clone()).with_status(TaskStatus {
state: TaskState::Completed,
message: Some(reply_for(&request.message)),
timestamp: None,
});
let stored = task.clone();
store.lock().expect("store lock").insert(stored);
self.deliver_for_task(&task_id);
let working_reply = Message::new(MessageRole::Agent).with_part(Part::Text(TextPart {
text: "thinking…".to_owned(),
metadata: std::collections::BTreeMap::new(),
}));
let working = StreamingEvent::Status(TaskStatusUpdateEvent {
task_id: task_id.clone(),
context_id: String::new(),
status: TaskStatus {
state: TaskState::Working,
message: Some(working_reply),
timestamp: None,
},
is_final: false,
metadata: std::collections::BTreeMap::new(),
});
let completed = StreamingEvent::Status(TaskStatusUpdateEvent {
task_id,
context_id: String::new(),
status: task.status,
is_final: true,
metadata: std::collections::BTreeMap::new(),
});
Box::pin(stream::iter([Ok(working), Ok(completed)]))
}
fn handle_subscribe_to_task(
&self,
request: &GetTaskRequest,
) -> std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<StreamingEvent, A2aError>> + Send>>
{
let store = self.store.clone();
let event = store
.lock()
.expect("store lock")
.get(&request.id)
.map_or_else(
|| {
Err(A2aError::InvalidParams {
reason: format!("no task with id '{}'", request.id),
})
},
|task| {
Ok(StreamingEvent::Status(TaskStatusUpdateEvent {
task_id: task.id.to_string(),
context_id: String::new(),
status: task.status,
is_final: true,
metadata: std::collections::BTreeMap::new(),
}))
},
);
Box::pin(stream::iter([event]))
}
}
impl A2aStreamingRequestHandler for EchoAgent {
fn dispatch_stream(
&self,
method: A2aMethod,
params: Option<serde_json::Value>,
_context: &A2aRequestContext,
) -> std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<StreamingEvent, A2aError>> + Send>>
{
match method {
A2aMethod::SendStreamingMessage => {
let request: SendMessageRequest = match decode_params(params) {
Ok(r) => r,
Err(error) => {
return Box::pin(stream::iter(std::iter::once(Err(error))));
}
};
self.handle_streaming_send(&request)
}
A2aMethod::SubscribeToTask => {
let request: GetTaskRequest = match decode_params(params) {
Ok(r) => r,
Err(error) => {
return Box::pin(stream::iter(std::iter::once(Err(error))));
}
};
self.handle_subscribe_to_task(&request)
}
_other => Box::pin(stream::iter(std::iter::once(Err(A2aError::MethodNotFound)))),
}
}
}
impl EchoAgent {
fn deliver_for_task(&self, task_id: &str) {
let configs = self
.store
.lock()
.expect("store lock")
.list_push_configs(task_id);
if configs.is_empty() {
return;
}
let Some(task) = self.store.lock().expect("store lock").get(task_id) else {
return;
};
let Ok(body) = serde_json::to_vec(&task) else {
return;
};
for config in configs {
let Ok(url) = config.url.parse::<url::Url>() else {
continue;
};
let Ok(webhook) = WebhookUrl::new(&SsrfDenylist::FILTER, url, A2aVersion::V1_0) else {
continue;
};
let notification = NotificationConfig {
webhook,
token: config.token.as_deref().map(std::sync::Arc::from),
};
let delivery = self.delivery.clone();
let body = body.clone();
self.handle.spawn(async move {
let _ = delivery.deliver(¬ification, &body).await;
});
}
}
}
fn paginate_tasks(
filtered: Vec<Task>,
request_page_size: Option<u32>,
request_page_token: Option<&str>,
total_size: usize,
) -> ListTasksResponse {
let requested_size = request_page_size.filter(|size| *size > 0);
let filtered_len = filtered.len();
let page_size = requested_size
.and_then(|size| usize::try_from(size).ok())
.filter(|size| *size <= filtered_len.max(1))
.unwrap_or_else(|| filtered_len.max(1));
let offset = request_page_token
.and_then(|token| token.parse::<usize>().ok())
.unwrap_or(0);
let effective_offset = offset.min(filtered_len);
let trimmed: Vec<Task> = filtered
.into_iter()
.skip(effective_offset)
.take(page_size)
.collect();
let next_offset = effective_offset.saturating_add(page_size);
let next_token = if next_offset < filtered_len {
next_offset.to_string()
} else {
String::new()
};
ListTasksResponse::new(trimmed)
.with_next_page_token(next_token)
.with_page_size(u32::try_from(page_size).unwrap_or(u32::MAX))
.with_total_size(u32::try_from(total_size).unwrap_or(u32::MAX))
}
fn reply_for(message: &Message) -> Message {
let echo = message
.parts
.iter()
.filter_map(|part| match part {
Part::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ");
Message::new(MessageRole::Agent).with_part(Part::Text(TextPart {
text: format!("echo: {echo}"),
metadata: std::collections::BTreeMap::new(),
}))
}
fn build_card(addr: SocketAddr, bearer: Option<&str>) -> Arc<AgentCard> {
let capabilities = AgentCapabilities {
streaming: true,
push_notifications: true,
..Default::default()
};
let interface_url = format!("http://{addr}/a2a");
let mut builder = AgentCardBuilder::new()
.with_name("Echo Agent")
.with_description("An A2A example that echoes messages back as completed tasks.")
.with_version("0.1.0")
.with_interface(AgentInterface {
url: url::Url::parse(&interface_url).expect("addr-derived URL parses"),
protocol_binding: JSONRPC.clone(),
protocol_version: A2aVersion::V1_0,
tenant: None,
})
.with_capabilities(capabilities)
.with_input_mode("text/plain")
.with_output_mode("text/plain")
.with_skill(AgentSkill {
id: "echo".into(),
name: "Echo".into(),
description: "Echoes the user's message back as a completed task.".into(),
tags: Vec::new(),
});
if let Some(token) = bearer {
builder = builder.with_security_scheme(
"bearer",
SecurityScheme::Http(HTTPAuthSecurityScheme {
scheme: std::sync::Arc::from("bearer"),
bearer_format: None,
description: Some(std::sync::Arc::from(
"Set A2A_BEARER_TOKEN to require this token on every JSON-RPC and streaming request.",
)),
}),
);
let _ = token;
}
Arc::new(builder.build().expect("card is built from valid inputs"))
}
fn build_router(addr: SocketAddr) -> Router {
let bearer = bearer_token();
let card = build_card(addr, bearer.as_deref());
let delivery = WebhookDelivery::new(Arc::new(SsrfDenylist::FILTER));
let agent = EchoAgent {
store: Arc::new(std::sync::Mutex::new(TaskStore::default())),
delivery: Arc::new(delivery),
handle: tokio::runtime::Handle::current(),
card: card.clone(),
};
let request_handler: Arc<dyn A2aRequestHandler> = Arc::new(agent.clone());
let streaming_handler: Arc<dyn A2aStreamingRequestHandler> = Arc::new(agent);
let mut protected = Router::new()
.merge(serve_jsonrpc("/a2a", request_handler, A2aVersion::V1_0))
.merge(serve_streaming(
"/a2a/stream",
streaming_handler,
A2aVersion::V1_0,
));
if let Some(token) = bearer {
protected = protected.route_layer(axum::middleware::from_fn_with_state(
std::sync::Arc::from(token),
check_bearer,
));
}
serve_well_known(card)
.merge(protected)
.merge(health_router())
}
fn bearer_token() -> Option<String> {
std::env::var("A2A_BEARER_TOKEN")
.ok()
.filter(|value| !value.is_empty())
}
async fn check_bearer(
axum::extract::State(expected): axum::extract::State<std::sync::Arc<str>>,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let header = request
.headers()
.get(reqwest::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
if let Some(value) = header
&& let Some(token) = value.strip_prefix("Bearer ")
&& token == expected.as_ref()
{
return next.run(request).await;
}
axum::response::Response::builder()
.status(axum::http::StatusCode::UNAUTHORIZED)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from("{\"error\":\"unauthorized\"}"))
.expect("a static 401 response builds")
}
#[cfg(test)]
#[expect(
clippy::expect_used,
reason = "tests assert on fallible calls; .expect() is the standard test pattern"
)]
mod tests {
use super::*;
fn task(id: &str) -> Task {
Task::new(id.to_owned()).with_status(TaskStatus {
state: TaskState::Completed,
message: None,
timestamp: None,
})
}
fn user_text_message(text: &str) -> Message {
Message::new(MessageRole::User).with_part(Part::Text(TextPart {
text: text.to_owned(),
metadata: std::collections::BTreeMap::new(),
}))
}
#[test]
fn paginate_tasks_sets_positive_page_size() -> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(vec![task("t-1")], None, None, 1);
if response.page_size == 0 {
return Err(format!(
"page_size must be at least 1 per the A2A spec; got {}",
response.page_size
)
.into());
}
if response.total_size != 1 {
return Err(format!("total_size must be 1, got {}", response.total_size).into());
}
if !response.next_page_token.is_empty() {
return Err(format!(
"next_page_token must be empty for a single page; got {:?}",
response.next_page_token
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_trims_to_requested_page_size() -> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(
vec![
task("t-1"),
task("t-2"),
task("t-3"),
task("t-4"),
task("t-5"),
],
Some(2),
None,
5,
);
if response.tasks.len() != 2 {
return Err(format!("expected 2 tasks, got {}", response.tasks.len()).into());
}
if response.page_size != 2 {
return Err(format!("expected page_size 2, got {}", response.page_size).into());
}
if response.total_size != 5 {
return Err(format!("expected total_size 5, got {}", response.total_size).into());
}
if response.next_page_token != "2" {
return Err(format!(
"expected next_page_token \"2\", got {:?}",
response.next_page_token
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_empty_filter_keeps_page_size_at_one() -> Result<(), Box<dyn std::error::Error>>
{
let response = paginate_tasks(Vec::new(), None, None, 0);
if response.page_size == 0 {
return Err("page_size must be at least 1 even for an empty result".into());
}
if response.total_size != 0 {
return Err(format!("expected total_size 0, got {}", response.total_size).into());
}
if !response.next_page_token.is_empty() {
return Err(format!(
"next_page_token must be empty for an empty result; got {:?}",
response.next_page_token
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_treats_zero_page_size_as_unset() -> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(vec![task("t-1"), task("t-2")], Some(0), None, 2);
if response.page_size == 0 {
return Err(
"page_size must be at least 1; a peer-supplied 0 is treated as unset".into(),
);
}
if response.tasks.len() != 2 {
return Err(format!(
"expected both tasks in the page, got {}",
response.tasks.len()
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_resumes_from_page_token() -> Result<(), Box<dyn std::error::Error>> {
let tasks: Vec<Task> = (0..5).map(|i| task(&format!("t-{i}"))).collect();
let first = paginate_tasks(tasks.clone(), Some(2), None, 5);
if first.tasks.len() != 2 {
return Err(format!("first page: expected 2, got {}", first.tasks.len()).into());
}
if first.next_page_token != "2" {
return Err(format!(
"first page: expected next_page_token \"2\", got {:?}",
first.next_page_token
)
.into());
}
let second = paginate_tasks(tasks.clone(), Some(2), Some(&first.next_page_token), 5);
if second.tasks.len() != 2 {
return Err(format!("second page: expected 2, got {}", second.tasks.len()).into());
}
if second.next_page_token != "4" {
return Err(format!(
"second page: expected next_page_token \"4\", got {:?}",
second.next_page_token
)
.into());
}
let last = paginate_tasks(tasks, Some(2), Some(&second.next_page_token), 5);
if last.tasks.len() != 1 {
return Err(format!("last page: expected 1, got {}", last.tasks.len()).into());
}
if !last.next_page_token.is_empty() {
return Err(format!(
"last page: expected empty next_page_token, got {:?}",
last.next_page_token
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_past_end_token_returns_empty_page() -> Result<(), Box<dyn std::error::Error>>
{
let response = paginate_tasks(vec![task("t-1"), task("t-2")], Some(2), Some("999"), 2);
if !response.tasks.is_empty() {
return Err(
format!("expected empty tasks, got {} entries", response.tasks.len()).into(),
);
}
if !response.next_page_token.is_empty() {
return Err(format!(
"expected empty next_page_token, got {:?}",
response.next_page_token
)
.into());
}
if response.total_size != 2 {
return Err(format!("expected total_size 2, got {}", response.total_size).into());
}
Ok(())
}
#[test]
fn paginate_tasks_invalid_token_falls_back_to_start() -> Result<(), Box<dyn std::error::Error>>
{
let response = paginate_tasks(
vec![task("t-1"), task("t-2")],
Some(2),
Some("not-a-number"),
2,
);
if response.tasks.len() != 2 {
return Err(
format!("expected 2 tasks (full page), got {}", response.tasks.len()).into(),
);
}
Ok(())
}
#[test]
fn paginate_tasks_page_size_larger_than_filtered_returns_all()
-> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(vec![task("t-1"), task("t-2")], Some(50), None, 2);
if response.tasks.len() != 2 {
return Err(format!("expected 2 tasks, got {}", response.tasks.len()).into());
}
if !response.next_page_token.is_empty() {
return Err(format!(
"expected empty next_page_token, got {:?}",
response.next_page_token
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_total_size_reflects_caller_supplied_value()
-> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(vec![task("t-1"), task("t-2")], Some(1), None, 100);
if response.total_size != 100 {
return Err(format!(
"expected total_size 100 (caller-supplied), got {}",
response.total_size
)
.into());
}
if response.tasks.len() != 1 {
return Err(
format!("expected 1 task in the page, got {}", response.tasks.len()).into(),
);
}
if response.next_page_token != "1" {
return Err(format!(
"expected next_page_token '1' (offset 1), got {:?}",
response.next_page_token
)
.into());
}
Ok(())
}
#[test]
fn paginate_tasks_page_size_equal_to_filtered_returns_whole_list()
-> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(
vec![task("t-1"), task("t-2"), task("t-3")],
Some(3),
None,
3,
);
if response.tasks.len() != 3 {
return Err(format!("expected 3 tasks, got {}", response.tasks.len()).into());
}
if !response.next_page_token.is_empty() {
return Err(format!(
"expected empty next_page_token, got {:?}",
response.next_page_token
)
.into());
}
if response.page_size != 3 {
return Err(format!("expected page_size 3, got {}", response.page_size).into());
}
Ok(())
}
#[test]
fn paginate_tasks_page_size_one_returns_singleton() -> Result<(), Box<dyn std::error::Error>> {
let response = paginate_tasks(
vec![task("t-1"), task("t-2"), task("t-3")],
Some(1),
None,
3,
);
if response.tasks.len() != 1 {
return Err(format!("expected 1 task, got {}", response.tasks.len()).into());
}
if response.next_page_token != "1" {
return Err(format!(
"expected next_page_token '1', got {:?}",
response.next_page_token
)
.into());
}
if response.page_size != 1 {
return Err(format!("expected page_size 1, got {}", response.page_size).into());
}
Ok(())
}
#[test]
fn task_store_insert_and_get_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let original = task("t-1");
store.insert(original.clone());
let read_back = store.get("t-1").ok_or("get returned None")?;
if read_back != original {
return Err("get returned a task that does not match the inserted task".into());
}
Ok(())
}
#[test]
fn task_store_cancel_unknown_id_returns_none() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
if store.cancel("missing").is_some() {
return Err("cancel of an unknown id should return None".into());
}
Ok(())
}
#[test]
fn task_store_cancel_marks_state_cancelled() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
store.insert(task("t-1"));
let cancelled = store.cancel("t-1").ok_or("cancel returned None")?;
if cancelled.status.state != TaskState::Cancelled {
return Err(
format!("expected state Cancelled, got {:?}", cancelled.status.state).into(),
);
}
let read_back = store.get("t-1").ok_or("get returned None")?;
if read_back.status.state != TaskState::Cancelled {
return Err("cancel did not persist; a follow-up get saw the old state".into());
}
Ok(())
}
#[test]
fn task_store_list_no_filter_returns_all() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
store.insert(task("t-1"));
store.insert(task("t-2"));
store.insert(task("t-3"));
let listed = store.list(None, None);
if listed.len() != 3 {
return Err(format!("expected 3 tasks, got {}", listed.len()).into());
}
Ok(())
}
#[test]
fn task_store_list_filters_by_context_id() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let mut with_ctx = task("t-1");
with_ctx.context_id = Some(Arc::from("ctx-a"));
let mut other_ctx = task("t-2");
other_ctx.context_id = Some(Arc::from("ctx-b"));
store.insert(with_ctx);
store.insert(other_ctx);
store.insert(task("t-3"));
let listed = store.list(Some("ctx-a"), None);
if listed.len() != 1 {
return Err(format!("expected 1 task, got {}", listed.len()).into());
}
if listed.first().map_or(true, |t| t.id.as_ref() != "t-1") {
return Err("expected the listed task to be t-1".into());
}
Ok(())
}
#[test]
fn task_store_list_filters_by_state() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let mut cancelled = task("t-1");
cancelled.status.state = TaskState::Cancelled;
store.insert(cancelled);
store.insert(task("t-2"));
let listed = store.list(None, Some(TaskState::Cancelled));
if listed.len() != 1 {
return Err(format!("expected 1 task, got {}", listed.len()).into());
}
if listed.first().map_or(true, |t| t.id.as_ref() != "t-1") {
return Err("expected the listed task to be t-1".into());
}
Ok(())
}
#[test]
fn task_store_list_combines_context_and_state_as_and() -> Result<(), Box<dyn std::error::Error>>
{
let mut store = TaskStore::default();
let mut both = task("t-1");
both.context_id = Some(Arc::from("ctx-a"));
both.status.state = TaskState::Cancelled;
let mut ctx_only = task("t-2");
ctx_only.context_id = Some(Arc::from("ctx-a"));
store.insert(both);
store.insert(ctx_only);
store.insert(task("t-3"));
let listed = store.list(Some("ctx-a"), Some(TaskState::Cancelled));
if listed.len() != 1 {
return Err(format!("expected 1 task (the AND), got {}", listed.len()).into());
}
if listed.first().map_or(true, |t| t.id.as_ref() != "t-1") {
return Err("expected the listed task to be t-1".into());
}
Ok(())
}
#[test]
fn task_store_list_with_no_match_returns_empty() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
store.insert(task("t-1"));
store.insert(task("t-2"));
let listed = store.list(Some("ctx-missing"), None);
if !listed.is_empty() {
return Err(format!("expected empty list, got {} tasks", listed.len()).into());
}
Ok(())
}
#[test]
fn task_store_push_config_list_on_unconfigured_task_returns_empty()
-> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let config = TaskPushNotificationConfig::new(
String::new(),
"t-other".to_owned(),
"https://example.com/a".to_owned(),
);
store.insert_push_config(config);
let listed = store.list_push_configs("t-1");
if !listed.is_empty() {
return Err(format!(
"expected empty list for unconfigured task, got {} configs",
listed.len()
)
.into());
}
Ok(())
}
#[test]
fn task_store_push_config_delete_missing_id_returns_none()
-> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let deleted = store.delete_push_config("pc-missing");
if deleted.is_some() {
return Err("expected None for a missing push config id".into());
}
Ok(())
}
#[test]
fn task_store_push_config_insert_assigns_id() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let config = TaskPushNotificationConfig::new(
String::new(),
"t-1".to_owned(),
"https://example.com/webhook".to_owned(),
);
let id = store.insert_push_config(config.clone());
if id.is_empty() {
return Err("insert_push_config returned an empty id".into());
}
let read_back = store
.get_push_config(&id)
.ok_or("get_push_config returned None for the just-inserted id")?;
if read_back.id != id {
return Err(format!(
"stored config id {:?} does not match the assigned id {:?}",
read_back.id, id
)
.into());
}
if read_back.url != config.url {
return Err("stored config url does not match".into());
}
Ok(())
}
#[test]
fn task_store_push_config_list_filters_by_task() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
let config_a = TaskPushNotificationConfig::new(
String::new(),
"t-1".to_owned(),
"https://example.com/a".to_owned(),
);
let config_b = TaskPushNotificationConfig::new(
String::new(),
"t-2".to_owned(),
"https://example.com/b".to_owned(),
);
store.insert_push_config(config_a);
store.insert_push_config(config_b);
let listed = store.list_push_configs("t-1");
if listed.len() != 1 {
return Err(format!("expected 1 config, got {}", listed.len()).into());
}
if listed.first().map_or(true, |c| c.task_id != "t-1") {
return Err("expected the listed config to be scoped to t-1".into());
}
Ok(())
}
#[test]
fn task_store_push_config_delete_returns_the_config() -> Result<(), Box<dyn std::error::Error>>
{
let mut store = TaskStore::default();
let config = TaskPushNotificationConfig::new(
String::new(),
"t-1".to_owned(),
"https://example.com/a".to_owned(),
);
let id = store.insert_push_config(config);
let deleted = store
.delete_push_config(&id)
.ok_or("delete returned None for a known id")?;
if deleted.id != id {
return Err("delete returned a config with a different id".into());
}
if store.get_push_config(&id).is_some() {
return Err("get after delete returned Some; the deletion did not persist".into());
}
Ok(())
}
#[test]
fn task_store_push_config_insert_assigns_unique_ids() -> Result<(), Box<dyn std::error::Error>>
{
let mut store = TaskStore::default();
let first = TaskPushNotificationConfig::new(
String::new(),
"t-1".to_owned(),
"https://example.com/a".to_owned(),
);
let second = TaskPushNotificationConfig::new(
String::new(),
"t-1".to_owned(),
"https://example.com/b".to_owned(),
);
let id_a = store.insert_push_config(first);
let id_b = store.insert_push_config(second);
if id_a == id_b {
return Err(format!(
"two inserts returned the same id {id_a:?}; the counter did not advance"
)
.into());
}
if store.get_push_config(&id_a).is_none() || store.get_push_config(&id_b).is_none() {
return Err("insert overwrote an earlier config; the store is not a list".into());
}
Ok(())
}
#[test]
fn task_store_len_counts_only_tasks() -> Result<(), Box<dyn std::error::Error>> {
let mut store = TaskStore::default();
store.insert(task("t-1"));
store.insert(task("t-2"));
let config = TaskPushNotificationConfig::new(
String::new(),
"t-1".to_owned(),
"https://example.com/a".to_owned(),
);
store.insert_push_config(config);
if store.len() != 2 {
return Err(format!("expected len 2, got {}", store.len()).into());
}
Ok(())
}
#[test]
fn reply_for_single_text_part_carries_echo_prefix() -> Result<(), Box<dyn std::error::Error>> {
let user = Message::new(MessageRole::User).with_part(Part::Text(TextPart {
text: "hello".to_owned(),
metadata: std::collections::BTreeMap::new(),
}));
let reply = reply_for(&user);
if reply.role != MessageRole::Agent {
return Err(format!("expected Agent role, got {:?}", reply.role).into());
}
let [Part::Text(reply_text)] = &reply.parts[..] else {
return Err("expected exactly one text part on the reply".into());
};
if reply_text.text != "echo: hello" {
return Err(format!("expected 'echo: hello', got {:?}", reply_text.text).into());
}
Ok(())
}
#[test]
fn reply_for_joins_text_parts_with_space() -> Result<(), Box<dyn std::error::Error>> {
let user = Message::new(MessageRole::User)
.with_part(Part::Text(TextPart {
text: "alpha".to_owned(),
metadata: std::collections::BTreeMap::new(),
}))
.with_part(Part::Text(TextPart {
text: "beta".to_owned(),
metadata: std::collections::BTreeMap::new(),
}));
let reply = reply_for(&user);
let [Part::Text(reply_text)] = &reply.parts[..] else {
return Err("expected exactly one text part on the reply".into());
};
if reply_text.text != "echo: alpha beta" {
return Err(format!("expected 'echo: alpha beta', got {:?}", reply_text.text).into());
}
Ok(())
}
#[test]
fn reply_for_message_with_no_text_parts_returns_echo_with_empty_body()
-> Result<(), Box<dyn std::error::Error>> {
let user = Message::new(MessageRole::User);
let reply = reply_for(&user);
if reply.role != MessageRole::Agent {
return Err(format!("expected Agent role, got {:?}", reply.role).into());
}
let [Part::Text(reply_text)] = &reply.parts[..] else {
return Err("expected exactly one text part on the reply".into());
};
if reply_text.text != "echo: " {
return Err(format!(
"expected 'echo: ' (with empty body), got {:?}",
reply_text.text
)
.into());
}
Ok(())
}
#[test]
fn build_card_declares_streaming_and_push_notifications()
-> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = "127.0.0.1:8080".parse()?;
let card = build_card(addr, None);
if !card.capabilities.streaming {
return Err("card.capabilities.streaming must be true".into());
}
if !card.capabilities.push_notifications {
return Err("card.capabilities.push_notifications must be true".into());
}
Ok(())
}
#[test]
fn build_card_interface_url_matches_bound_addr() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = "127.0.0.1:8084".parse()?;
let card = build_card(addr, None);
if card.supported_interfaces.len() != 1 {
return Err(format!(
"expected 1 supported interface, got {}",
card.supported_interfaces.len()
)
.into());
}
let interface = &card.supported_interfaces[0];
let expected = format!("http://{addr}/a2a");
if interface.url.as_str() != expected {
return Err(format!(
"expected interface URL {expected:?}, got {:?}",
interface.url.as_str()
)
.into());
}
Ok(())
}
#[test]
fn build_card_without_bearer_omits_security_schemes() -> Result<(), Box<dyn std::error::Error>>
{
let addr: SocketAddr = "127.0.0.1:8080".parse()?;
let card = build_card(addr, None);
if !card.security_schemes.is_empty() {
return Err(format!(
"expected no security schemes, got {}",
card.security_schemes.len()
)
.into());
}
Ok(())
}
#[test]
fn build_card_with_bearer_publishes_bearer_scheme() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = "127.0.0.1:8080".parse()?;
let card = build_card(addr, Some("secret-token"));
if !card.security_schemes.contains_key("bearer") {
return Err("expected a 'bearer' security scheme".into());
}
let scheme = &card.security_schemes["bearer"];
if let SecurityScheme::Http(http) = scheme {
if http.scheme.as_ref() != "bearer" {
return Err(format!(
"expected http scheme 'bearer', got {:?}",
http.scheme.as_ref()
)
.into());
}
} else {
return Err(format!(
"expected SecurityScheme::Http, got a different variant: {scheme:?}"
)
.into());
}
Ok(())
}
#[test]
fn build_card_advertises_echo_skill() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = "127.0.0.1:8080".parse()?;
let card = build_card(addr, None);
let has_echo = card.skills.iter().any(|skill| skill.id.as_ref() == "echo");
if !has_echo {
return Err("card did not advertise the 'echo' skill".into());
}
Ok(())
}
#[test]
fn serialise_error_wraps_serde_error_as_internal() -> Result<(), Box<dyn std::error::Error>> {
let result: Result<serde_json::Value, _> = serde_json::from_str("not json");
let error = match result {
Err(error) => error,
Ok(_) => return Err("expected a parse error".into()),
};
let wrapped = serialise_error(&error);
match wrapped {
A2aError::InternalError { message } => {
if !message.starts_with("serialise:") {
return Err(format!("expected 'serialise:' prefix, got {message:?}").into());
}
}
other => {
return Err(format!("expected InternalError, got {other:?}").into());
}
}
Ok(())
}
#[test]
fn decode_params_yields_typed_request_for_well_formed_value()
-> Result<(), Box<dyn std::error::Error>> {
let params = Some(serde_json::json!({ "id": "t-1" }));
let request: GetTaskRequest =
decode_params(params).map_err(|error| format!("decode failed: {error:?}"))?;
if request.id != "t-1" {
return Err(format!("expected id 't-1', got {:?}", request.id).into());
}
Ok(())
}
#[test]
fn decode_params_yields_invalid_params_for_malformed_value()
-> Result<(), Box<dyn std::error::Error>> {
let params = Some(serde_json::json!({}));
let result: Result<GetTaskRequest, A2aError> = decode_params(params);
match result {
Err(A2aError::InvalidParams { reason }) => {
if !reason.starts_with("params do not match") {
return Err(
format!("expected 'params do not match' prefix, got {reason:?}").into(),
);
}
}
Ok(_) => {
return Err("expected decode to fail for missing field".into());
}
Err(other) => {
return Err(format!("expected InvalidParams, got {other:?}").into());
}
}
Ok(())
}
#[test]
fn decode_params_treats_none_as_null_and_fails() -> Result<(), Box<dyn std::error::Error>> {
let result: Result<GetTaskRequest, A2aError> = decode_params(None);
if result.is_ok() {
return Err("expected decode_params(None) to fail".into());
}
Ok(())
}
#[tokio::test]
async fn decode_failed_returns_a_future_with_the_supplied_error()
-> Result<(), Box<dyn std::error::Error>> {
let supplied = A2aError::InvalidParams {
reason: "test reason".to_owned(),
};
let future = decode_failed(supplied.clone());
let result = future.await;
let error = match result {
Err(error) => error,
Ok(_) => return Err("expected the future to resolve to Err".into()),
};
if error != supplied {
return Err(
format!("expected the same error back, got {error:?} vs {supplied:?}").into(),
);
}
Ok(())
}
fn minimal_agent() -> Result<EchoAgent, Box<dyn std::error::Error>> {
let addr: SocketAddr = "127.0.0.1:8080".parse()?;
let delivery = WebhookDelivery::new(Arc::new(SsrfDenylist::FILTER));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let agent = EchoAgent {
store: Arc::new(std::sync::Mutex::new(TaskStore::default())),
delivery: Arc::new(delivery),
handle: runtime.handle().clone(),
card: build_card(addr, None),
};
drop(runtime);
Ok(agent)
}
#[test]
fn handle_send_message_creates_a_completed_task_with_echo_reply()
-> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
let request = SendMessageRequest::new(user_text_message("hello"));
let response = agent.handle_send_message(&request);
let SendMessageResponse::Task(task) = response else {
return Err("expected SendMessageResponse::Task".into());
};
if !task.id.as_ref().starts_with("t-") {
return Err(
format!("expected id to start with 't-', got {:?}", task.id.as_ref()).into(),
);
}
if task.status.state != TaskState::Completed {
return Err(format!("expected state Completed, got {:?}", task.status.state).into());
}
let reply_text = task
.status
.message
.as_ref()
.and_then(|message| message.parts.first())
.and_then(|part| match part {
Part::Text(text) => Some(text.text.as_str()),
_ => None,
})
.ok_or("expected a text reply")?;
if reply_text != "echo: hello" {
return Err(format!("expected 'echo: hello', got {reply_text:?}").into());
}
Ok(())
}
#[test]
fn handle_get_task_returns_a_known_task() -> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
let sent = agent.handle_send_message(&SendMessageRequest::new(user_text_message("first")));
let SendMessageResponse::Task(sent_task) = sent else {
return Err("expected SendMessageResponse::Task".into());
};
let id = sent_task.id.to_string();
let got = agent
.handle_get_task(&GetTaskRequest::new(id.clone()))
.map_err(|error| format!("handle_get_task failed: {error:?}"))?;
if got.id.as_ref() != id {
return Err(format!("expected id {id:?}, got {:?}", got.id.as_ref()).into());
}
if got.status.state != TaskState::Completed {
return Err(format!("expected state Completed, got {:?}", got.status.state).into());
}
Ok(())
}
#[test]
fn handle_get_task_missing_id_is_invalid_params() -> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
let result = agent.handle_get_task(&GetTaskRequest::new("t-missing"));
match result {
Err(A2aError::InvalidParams { reason }) => {
if !reason.contains("t-missing") {
return Err(
format!("expected reason to name the missing id, got {reason:?}").into(),
);
}
}
Ok(_) => return Err("expected an InvalidParams error".into()),
Err(other) => return Err(format!("expected InvalidParams, got {other:?}").into()),
}
Ok(())
}
#[test]
fn handle_cancel_task_flips_state_to_cancelled() -> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
let sent =
agent.handle_send_message(&SendMessageRequest::new(user_text_message("to-cancel")));
let SendMessageResponse::Task(sent_task) = sent else {
return Err("expected SendMessageResponse::Task".into());
};
let id = sent_task.id.to_string();
let cancelled = agent
.handle_cancel_task(&CancelTaskRequest::new(id.clone()))
.map_err(|error| format!("handle_cancel_task failed: {error:?}"))?;
if cancelled.status.state != TaskState::Cancelled {
return Err(
format!("expected state Cancelled, got {:?}", cancelled.status.state).into(),
);
}
let read_back = agent
.handle_get_task(&GetTaskRequest::new(id.clone()))
.map_err(|error| format!("handle_get_task failed: {error:?}"))?;
if read_back.status.state != TaskState::Cancelled {
return Err(format!(
"expected GetTask to read Cancelled, got {:?}",
read_back.status.state
)
.into());
}
Ok(())
}
#[test]
fn handle_cancel_task_missing_id_is_invalid_params() -> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
let result = agent.handle_cancel_task(&CancelTaskRequest::new("t-missing"));
match result {
Err(A2aError::InvalidParams { reason }) => {
if !reason.contains("t-missing") {
return Err(
format!("expected reason to name the missing id, got {reason:?}").into(),
);
}
}
Ok(_) => return Err("expected an InvalidParams error".into()),
Err(other) => return Err(format!("expected InvalidParams, got {other:?}").into()),
}
Ok(())
}
#[test]
fn handle_list_tasks_reports_total_size_of_populated_store()
-> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
for text in ["one", "two"] {
let _ = agent.handle_send_message(&SendMessageRequest::new(user_text_message(text)));
}
let response = agent.handle_list_tasks(&ListTasksRequest {
context_id: None,
status: None,
page_size: None,
page_token: None,
history_length: None,
status_timestamp_after: None,
metadata: std::collections::BTreeMap::new(),
});
if response.total_size != 2 {
return Err(format!("expected total_size 2, got {}", response.total_size).into());
}
if response.tasks.len() != 2 {
return Err(
format!("expected 2 tasks in the page, got {}", response.tasks.len()).into(),
);
}
Ok(())
}
#[test]
fn handle_get_extended_agent_card_returns_the_agent_card()
-> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
let response = agent.handle_get_extended_agent_card(&GetExtendedAgentCardRequest::new());
if response.0.name != agent.card.name {
return Err(format!(
"expected name {:?}, got {:?}",
agent.card.name, response.0.name
)
.into());
}
if response.0.version != agent.card.version {
return Err(format!(
"expected version {:?}, got {:?}",
agent.card.version, response.0.version
)
.into());
}
Ok(())
}
#[tokio::test]
async fn well_known_card_is_unauthenticated_when_bearer_is_required()
-> Result<(), Box<dyn std::error::Error>> {
let router = build_protected_router("top-secret")?;
let url = spawn_test_router(router).await?;
let response = reqwest::Client::new()
.get(format!("{url}/.well-known/agent-card.json"))
.send()
.await?;
if response.status() != reqwest::StatusCode::OK {
return Err(format!(
"the well-known card is the discovery endpoint; an unauthenticated GET must \
return 200 so a peer can learn the bearer scheme, got {}",
response.status()
)
.into());
}
Ok(())
}
#[tokio::test]
async fn jsonrpc_without_bearer_is_a_401() -> Result<(), Box<dyn std::error::Error>> {
let router = build_protected_router("top-secret")?;
let url = spawn_test_router(router).await?;
let body = jsonrpc_send_message_body();
let response = reqwest::Client::new()
.post(format!("{url}/a2a"))
.header("content-type", "application/json")
.body(serde_json::to_vec(&body)?)
.send()
.await?;
if response.status() != reqwest::StatusCode::UNAUTHORIZED {
return Err(format!(
"expected 401 without a bearer token, got {}",
response.status()
)
.into());
}
Ok(())
}
#[tokio::test]
async fn jsonrpc_with_bearer_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let router = build_protected_router("top-secret")?;
let url = spawn_test_router(router).await?;
let body = jsonrpc_send_message_body();
let response = reqwest::Client::new()
.post(format!("{url}/a2a"))
.header("content-type", "application/json")
.header("authorization", "Bearer top-secret")
.body(serde_json::to_vec(&body)?)
.send()
.await?;
if response.status() != reqwest::StatusCode::OK {
return Err(format!(
"expected 200 with the right bearer token, got {}",
response.status()
)
.into());
}
let body_bytes = response.bytes().await?;
let body: serde_json::Value = serde_json::from_slice(&body_bytes)?;
let state = body
.pointer("/result/status/state")
.and_then(serde_json::Value::as_str);
if state != Some("completed") {
return Err(format!("expected completed state, got body {body}").into());
}
Ok(())
}
#[tokio::test]
async fn jsonrpc_with_wrong_bearer_is_a_401() -> Result<(), Box<dyn std::error::Error>> {
let router = build_protected_router("top-secret")?;
let url = spawn_test_router(router).await?;
let body = jsonrpc_send_message_body();
let response = reqwest::Client::new()
.post(format!("{url}/a2a"))
.header("content-type", "application/json")
.header("authorization", "Bearer wrong-token")
.body(serde_json::to_vec(&body)?)
.send()
.await?;
if response.status() != reqwest::StatusCode::UNAUTHORIZED {
return Err(format!(
"expected 401 with a wrong bearer token, got {}",
response.status()
)
.into());
}
Ok(())
}
#[tokio::test]
async fn health_route_returns_ok() -> Result<(), Box<dyn std::error::Error>> {
let url = spawn_test_router(health_router()).await?;
let response = reqwest::Client::new()
.get(format!("{url}/health"))
.send()
.await?;
if response.status() != reqwest::StatusCode::OK {
return Err(format!("expected 200 from /health, got {}", response.status()).into());
}
let body = response.text().await?;
if body != "ok\n" {
return Err(format!("expected 'ok\\n', got {body:?}").into());
}
Ok(())
}
#[test]
fn deliver_for_task_with_no_configs_is_a_noop() -> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
agent.deliver_for_task("t-none");
let len = agent.store.lock().expect("store lock").len();
if len != 0 {
return Err(format!("expected an empty store, got {len} tasks").into());
}
Ok(())
}
#[test]
fn deliver_for_task_for_missing_task_is_a_noop() -> Result<(), Box<dyn std::error::Error>> {
let agent = minimal_agent()?;
agent.deliver_for_task("t-missing");
Ok(())
}
fn build_protected_router(token: &str) -> Result<Router, Box<dyn std::error::Error>> {
let addr: SocketAddr = "127.0.0.1:0".parse()?;
let card = build_card(addr, Some(token));
let delivery = WebhookDelivery::new(Arc::new(SsrfDenylist::FILTER));
let handle = tokio::runtime::Handle::current();
let agent = EchoAgent {
store: Arc::new(std::sync::Mutex::new(TaskStore::default())),
delivery: Arc::new(delivery),
handle: handle.clone(),
card: Arc::clone(&card),
};
let request_handler: Arc<dyn A2aRequestHandler> = Arc::new(agent);
let streaming_handler: Arc<dyn A2aStreamingRequestHandler> = Arc::new(EchoAgent {
store: Arc::new(std::sync::Mutex::new(TaskStore::default())),
delivery: Arc::new(WebhookDelivery::new(Arc::new(SsrfDenylist::FILTER))),
handle: handle.clone(),
card: Arc::clone(&card),
});
let mut protected = Router::new()
.merge(serve_jsonrpc("/a2a", request_handler, A2aVersion::V1_0))
.merge(serve_streaming(
"/a2a/stream",
streaming_handler,
A2aVersion::V1_0,
));
protected = protected.route_layer(axum::middleware::from_fn_with_state(
std::sync::Arc::from(token),
check_bearer,
));
Ok(serve_well_known(card).merge(protected))
}
async fn spawn_test_router(router: Router) -> Result<String, Box<dyn std::error::Error>> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let bound_addr = listener.local_addr()?;
let url = format!("http://{bound_addr}");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Ok(url)
}
fn jsonrpc_send_message_body() -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "SendMessage",
"params": {
"message": {
"role": "user",
"parts": [{"kind": "text", "text": "hi"}]
}
}
})
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(serve())
}
async fn serve() -> Result<(), Box<dyn std::error::Error>> {
let addr = bind_addr();
serve_with_addr(addr).await
}
fn bind_addr() -> SocketAddr {
let raw = std::env::var("A2A_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_owned());
match raw.parse() {
Ok(addr) => addr,
Err(error) => {
eprintln!("a2a_server: A2A_ADDR={raw:?} is not a valid socket address: {error}");
"127.0.0.1:8080".parse().expect("default address parses")
}
}
}
async fn serve_with_addr(addr: SocketAddr) -> Result<(), Box<dyn std::error::Error>> {
let router = build_router(addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
eprintln!("a2a_server: listening on {addr}");
eprintln!("a2a_server: card at http://{addr}/.well-known/agent-card.json");
eprintln!("a2a_server: JSON-RPC at http://{addr}/a2a");
eprintln!("a2a_server: streaming at http://{addr}/a2a/stream");
eprintln!("a2a_server: health at http://{addr}/health");
axum::serve(listener, router).await?;
Ok(())
}
fn health_router() -> Router {
async fn health() -> &'static str {
"ok\n"
}
Router::new().route("/health", axum::routing::get(health))
}