#![cfg(feature = "jsonrpc-server")]
mod common;
use a2a_rs::adapter::transport::jsonrpc::{JsonRpcId, JsonRpcRequest, error_code, methods};
use a2a_rs::adapter::{InMemoryTaskStorage, JsonRpcAdapter, SimpleAgentInfo};
use a2a_rs::domain::{ContextId, TaskId, TaskState};
use a2a_rs::port::AsyncTaskLifecycle;
use common::TestBusinessHandler;
use serde_json::{Value, json};
fn adapter() -> JsonRpcAdapter {
adapter_with_handler().0
}
fn adapter_with_handler() -> (JsonRpcAdapter, TestBusinessHandler) {
let handler = TestBusinessHandler::with_storage(InMemoryTaskStorage::new());
let agent_info = SimpleAgentInfo::new("test-agent".to_string(), "http://localhost".to_string());
(
JsonRpcAdapter::with_handler(handler.clone(), agent_info),
handler,
)
}
fn request(method: &str, params: Value) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Num(1),
method: method.to_string(),
params: Some(params),
}
}
fn send_message_params(task_id: &str) -> Value {
send_message_params_ids(Some(task_id), None)
}
fn send_message_params_ids(task_id: Option<&str>, context_id: Option<&str>) -> Value {
let mut message = json!({
"messageId": "m1",
"role": "ROLE_USER",
"parts": [{ "text": "hello" }],
});
if let Some(id) = task_id {
message["taskId"] = id.into();
}
if let Some(id) = context_id {
message["contextId"] = id.into();
}
json!({ "message": message })
}
async fn send_ok(a: &JsonRpcAdapter, params: Value) -> Value {
let value = serde_json::to_value(
a.handle_unary(request(methods::SEND_MESSAGE, params), None)
.await,
)
.unwrap();
assert!(value.get("error").is_none(), "unexpected error: {value:?}");
value["result"]["task"].clone()
}
#[tokio::test]
async fn send_message_returns_task_union() {
let resp = adapter()
.handle_unary(
request(methods::SEND_MESSAGE, send_message_params("task-1")),
None,
)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["jsonrpc"], "2.0");
assert_eq!(value["id"], 1);
assert!(value.get("error").is_none(), "unexpected error: {value:?}");
let task = &value["result"]["task"];
assert_eq!(task["id"], "task-1");
assert!(
task["status"]["state"]
.as_str()
.is_some_and(|s| s.starts_with("TASK_STATE_")),
"unexpected status: {:?}",
task["status"],
);
}
#[tokio::test]
async fn get_task_round_trips() {
let a = adapter();
a.handle_unary(
request(methods::SEND_MESSAGE, send_message_params("task-2")),
None,
)
.await;
let resp = a
.handle_unary(request(methods::GET_TASK, json!({ "id": "task-2" })), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("error").is_none(), "unexpected error: {value:?}");
assert_eq!(value["result"]["id"], "task-2");
}
#[tokio::test]
async fn cancel_task_returns_canceled_state() {
let (a, handler) = adapter_with_handler();
let id: TaskId = "task-3".parse().unwrap();
handler
.create(&id, &"ctx".parse::<ContextId>().unwrap())
.await
.unwrap();
handler
.update_status(&id, TaskState::Working, None)
.await
.unwrap();
let resp = a
.handle_unary(
request(methods::CANCEL_TASK, json!({ "id": "task-3" })),
None,
)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("error").is_none(), "unexpected error: {value:?}");
assert_eq!(value["result"]["id"], "task-3");
assert_eq!(value["result"]["status"]["state"], "TASK_STATE_CANCELED");
}
#[tokio::test]
async fn unknown_method_is_method_not_found() {
let resp = adapter()
.handle_unary(request("NoSuchMethod", json!({})), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("result").is_none());
assert_eq!(value["error"]["code"], error_code::METHOD_NOT_FOUND);
}
#[tokio::test]
async fn invalid_params_is_invalid_params() {
let resp = adapter()
.handle_unary(
request(methods::SEND_MESSAGE, json!({ "message": 42 })),
None,
)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["error"]["code"], error_code::INVALID_PARAMS);
}
#[tokio::test]
async fn missing_message_is_invalid_params() {
let resp = adapter()
.handle_unary(request(methods::SEND_MESSAGE, json!({})), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["error"]["code"], error_code::INVALID_PARAMS);
}
#[tokio::test]
async fn get_missing_task_is_task_not_found() {
let resp = adapter()
.handle_unary(request(methods::GET_TASK, json!({ "id": "nope" })), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["error"]["code"], error_code::TASK_NOT_FOUND);
}
#[tokio::test]
async fn list_tasks_returns_response_envelope() {
let a = adapter();
a.handle_unary(
request(methods::SEND_MESSAGE, send_message_params("task-4")),
None,
)
.await;
let resp = a
.handle_unary(request(methods::LIST_TASKS, json!({})), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("error").is_none(), "unexpected error: {value:?}");
assert!(value["result"]["tasks"].is_array());
}
mod interceptors {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use a2a_rs::domain::A2AError;
use a2a_rs::port::{CallContext, CallInterceptor, CallSide};
use async_trait::async_trait;
use serde_json::json;
use super::{adapter, methods, request};
#[derive(Clone, Default)]
struct Counting {
before: Arc<AtomicUsize>,
after_ok: Arc<AtomicUsize>,
after_err: Arc<AtomicUsize>,
}
#[async_trait]
impl CallInterceptor for Counting {
async fn before(&self, ctx: &CallContext) -> Result<(), A2AError> {
assert_eq!(ctx.side, CallSide::Server);
self.before.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn after(&self, _ctx: &CallContext, outcome: Result<(), &A2AError>) {
match outcome {
Ok(()) => self.after_ok.fetch_add(1, Ordering::SeqCst),
Err(_) => self.after_err.fetch_add(1, Ordering::SeqCst),
};
}
}
struct Rejecting;
#[async_trait]
impl CallInterceptor for Rejecting {
async fn before(&self, _ctx: &CallContext) -> Result<(), A2AError> {
Err(A2AError::UnsupportedOperation(
"rejected by interceptor".to_string(),
))
}
}
#[tokio::test]
async fn before_and_after_wrap_each_dispatch() {
let counter = Counting::default();
let a = adapter().with_interceptor(counter.clone());
a.handle_unary(
request(methods::SEND_MESSAGE, super::send_message_params("ti-1")),
None,
)
.await;
let resp = a
.handle_unary(request(methods::GET_TASK, json!({ "id": "ghost" })), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("error").is_some(), "expected an error: {value:?}");
assert_eq!(counter.before.load(Ordering::SeqCst), 2);
assert_eq!(counter.after_ok.load(Ordering::SeqCst), 1);
assert_eq!(counter.after_err.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn rejecting_before_short_circuits_dispatch() {
let a = adapter()
.with_interceptor(Rejecting)
.with_interceptor(Counting::default());
let resp = a
.handle_unary(request(methods::GET_TASK, json!({ "id": "task-x" })), None)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(
value["error"]["message"],
"Unsupported operation: rejected by interceptor"
);
assert!(value.get("result").is_none() || value["result"].is_null());
}
}
#[tokio::test]
async fn omitted_task_id_gets_a_server_assigned_one() {
let task = send_ok(&adapter(), send_message_params_ids(None, None)).await;
let id = task["id"].as_str().expect("task id");
let context_id = task["contextId"].as_str().expect("context id");
assert!(!id.is_empty(), "server must name the task");
assert!(!context_id.is_empty(), "server must name the context");
assert_eq!(task["history"][0]["taskId"], id);
assert_eq!(task["history"][0]["contextId"], context_id);
}
#[tokio::test]
async fn each_id_less_send_starts_its_own_task() {
let a = adapter();
let first = send_ok(&a, send_message_params_ids(None, None)).await;
let second = send_ok(&a, send_message_params_ids(None, None)).await;
assert_ne!(first["id"], second["id"]);
assert_ne!(first["contextId"], second["contextId"]);
}
#[tokio::test]
async fn a_supplied_context_id_survives_an_omitted_task_id() {
let task = send_ok(&adapter(), send_message_params_ids(None, Some("ctx-kept"))).await;
assert_eq!(task["contextId"], "ctx-kept");
assert!(!task["id"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn omitted_context_id_is_inferred_from_the_task() {
let a = adapter();
send_ok(&a, send_message_params_ids(Some("t-ctx"), Some("ctx-1"))).await;
let task = send_ok(&a, send_message_params_ids(Some("t-ctx"), None)).await;
assert_eq!(task["contextId"], "ctx-1");
}
#[tokio::test]
async fn context_id_contradicting_the_task_is_rejected() {
let a = adapter();
send_ok(&a, send_message_params_ids(Some("t-clash"), Some("ctx-1"))).await;
let resp = a
.handle_unary(
request(
methods::SEND_MESSAGE,
send_message_params_ids(Some("t-clash"), Some("ctx-2")),
),
None,
)
.await;
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["error"]["code"], error_code::INVALID_PARAMS);
assert!(
value["error"]["message"]
.as_str()
.is_some_and(|m| m.contains("context_id")),
"error should name the field: {value:?}",
);
}
#[tokio::test]
async fn a_client_chosen_task_id_is_kept() {
let task = send_ok(&adapter(), send_message_params_ids(Some("t-mine"), None)).await;
assert_eq!(task["id"], "t-mine");
assert!(!task["contextId"].as_str().unwrap().is_empty());
}