use serde_json::value::RawValue;
use crate::application::jsonrpc::{MethodTable, RouteError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum A2aMethod {
SendMessage,
SendStreamingMessage,
GetTask,
ListTasks,
CancelTask,
SubscribeToTask,
CreateTaskPushNotificationConfig,
GetTaskPushNotificationConfig,
ListTaskPushNotificationConfigs,
DeleteTaskPushNotificationConfig,
GetExtendedAgentCard,
}
impl A2aMethod {
pub const ALL: [Self; 11] = [
Self::SendMessage,
Self::SendStreamingMessage,
Self::GetTask,
Self::ListTasks,
Self::CancelTask,
Self::SubscribeToTask,
Self::CreateTaskPushNotificationConfig,
Self::GetTaskPushNotificationConfig,
Self::ListTaskPushNotificationConfigs,
Self::DeleteTaskPushNotificationConfig,
Self::GetExtendedAgentCard,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::SendMessage => "SendMessage",
Self::SendStreamingMessage => "SendStreamingMessage",
Self::GetTask => "GetTask",
Self::ListTasks => "ListTasks",
Self::CancelTask => "CancelTask",
Self::SubscribeToTask => "SubscribeToTask",
Self::CreateTaskPushNotificationConfig => "CreateTaskPushNotificationConfig",
Self::GetTaskPushNotificationConfig => "GetTaskPushNotificationConfig",
Self::ListTaskPushNotificationConfigs => "ListTaskPushNotificationConfigs",
Self::DeleteTaskPushNotificationConfig => "DeleteTaskPushNotificationConfig",
Self::GetExtendedAgentCard => "GetExtendedAgentCard",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct A2aMethodTable;
impl A2aMethodTable {
pub const TABLE: Self = Self;
}
impl MethodTable for A2aMethodTable {
type MethodId = A2aMethod;
type RequestParams<'a> = Option<&'a RawValue>;
type NotificationParams<'a> = Option<&'a RawValue>;
fn lookup(&self, name: &str) -> Option<A2aMethod> {
match name {
"SendMessage" => Some(A2aMethod::SendMessage),
"SendStreamingMessage" => Some(A2aMethod::SendStreamingMessage),
"GetTask" => Some(A2aMethod::GetTask),
"ListTasks" => Some(A2aMethod::ListTasks),
"CancelTask" => Some(A2aMethod::CancelTask),
"SubscribeToTask" => Some(A2aMethod::SubscribeToTask),
"CreateTaskPushNotificationConfig" => Some(A2aMethod::CreateTaskPushNotificationConfig),
"GetTaskPushNotificationConfig" => Some(A2aMethod::GetTaskPushNotificationConfig),
"ListTaskPushNotificationConfigs" => Some(A2aMethod::ListTaskPushNotificationConfigs),
"DeleteTaskPushNotificationConfig" => Some(A2aMethod::DeleteTaskPushNotificationConfig),
"GetExtendedAgentCard" => Some(A2aMethod::GetExtendedAgentCard),
_ => None,
}
}
fn decode_request_params<'a>(
&self,
method: A2aMethod,
params: Option<&'a RawValue>,
) -> Result<Self::RequestParams<'a>, RouteError<'a>> {
let _ = method;
Ok(params)
}
fn decode_notification_params<'a>(
&self,
method: A2aMethod,
params: Option<&'a RawValue>,
) -> Result<Self::NotificationParams<'a>, RouteError<'a>> {
let _ = method;
Ok(params)
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
clippy::panic,
reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
)]
use assert_matches::assert_matches;
use serde_json::value::RawValue;
use crate::application::jsonrpc::{MethodTable, RouteError, Routed, route_one};
use crate::domain::jsonrpc::{Frame, decode};
use super::{A2aMethod, A2aMethodTable};
type TestError = Box<dyn std::error::Error>;
const NOT_A2A: &str = "does/not/exist";
const LEGACY_A2A: &str = "message/send";
fn route(
input: &[u8],
) -> Result<Routed<'_, A2aMethod, Option<&RawValue>, Option<&RawValue>>, RouteError<'_>> {
let envelope = match decode(input)? {
Frame::Single(envelope) => envelope,
Frame::Batch(_) => return Err(RouteError::BatchNotAllowed),
};
route_one(envelope, &A2aMethodTable::TABLE)
}
fn request(method: &str) -> String {
format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#)
}
const EVERY_METHOD: &[(&str, A2aMethod)] = &[
("SendMessage", A2aMethod::SendMessage),
("SendStreamingMessage", A2aMethod::SendStreamingMessage),
("GetTask", A2aMethod::GetTask),
("ListTasks", A2aMethod::ListTasks),
("CancelTask", A2aMethod::CancelTask),
("SubscribeToTask", A2aMethod::SubscribeToTask),
(
"CreateTaskPushNotificationConfig",
A2aMethod::CreateTaskPushNotificationConfig,
),
(
"GetTaskPushNotificationConfig",
A2aMethod::GetTaskPushNotificationConfig,
),
(
"ListTaskPushNotificationConfigs",
A2aMethod::ListTaskPushNotificationConfigs,
),
(
"DeleteTaskPushNotificationConfig",
A2aMethod::DeleteTaskPushNotificationConfig,
),
("GetExtendedAgentCard", A2aMethod::GetExtendedAgentCard),
];
#[test]
fn every_method_is_reachable_and_agrees_about_its_wire_name() {
for (name, expected) in EVERY_METHOD {
let found = A2aMethodTable::TABLE
.lookup(name)
.unwrap_or_else(|| panic!("{name} is in the table and must be found"));
assert_eq!(found, *expected, "{name} must resolve to its own variant");
assert_eq!(
found.as_str(),
*name,
"as_str must be the name lookup accepts, or a reply echoes the wrong method"
);
}
}
#[test]
fn every_variant_is_in_the_list() {
for method in A2aMethod::ALL {
assert!(
EVERY_METHOD.iter().any(|(_, listed)| *listed == method),
"{method:?} is a variant but no test names it, so nothing would fail if \
lookup stopped resolving it"
);
}
}
#[test]
fn an_unknown_method_is_method_not_found() {
let input = request(NOT_A2A);
let error = route(input.as_bytes()).expect_err("no such A2A method");
assert_eq!(error.code(), crate::domain::ir::IrErrorCode::MethodNotFound);
assert_matches!(error, RouteError::MethodNotFound(name) if name == NOT_A2A);
}
#[test]
fn a_method_name_is_not_normalised() {
for near in [
"sendmessage",
"sendMessage",
"send_message",
" SendMessage",
"SendMessage ",
] {
let input = request(near);
let error = route(input.as_bytes())
.expect_err("the specification names methods case-sensitively");
assert_eq!(
error.code(),
crate::domain::ir::IrErrorCode::MethodNotFound,
"{near} is not a method name"
);
}
}
#[test]
fn the_legacy_0_3_x_slash_form_is_not_a_v1_0_method() {
let input = request(LEGACY_A2A);
let error = route(input.as_bytes())
.expect_err("0.3.x slash-form must not resolve on the v1.0 table");
assert_eq!(error.code(), crate::domain::ir::IrErrorCode::MethodNotFound);
assert_matches!(error, RouteError::MethodNotFound(name) if name == LEGACY_A2A);
}
#[test]
fn a_request_routes_to_its_method_and_keeps_its_params_borrowed() -> Result<(), TestError> {
let input = br#"{"jsonrpc":"2.0","id":7,"method":"SendMessage","params":{"message":{"role":"user","parts":[]}}}"#;
match route(input) {
Ok(Routed::Request {
method, id, params, ..
}) => {
assert_eq!(method, A2aMethod::SendMessage);
assert_eq!(id.as_number(), Some(7));
let raw = params.ok_or("SendMessage has params")?;
assert_eq!(raw.get(), r#"{"message":{"role":"user","parts":[]}}"#);
}
other => return Err(format!("expected a request, got {other:?}").into()),
}
Ok(())
}
#[test]
fn a_request_without_params_routes_with_none() -> Result<(), TestError> {
let input = br#"{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{"id":"task-1"}}"#;
match route(input) {
Ok(Routed::Request { method, params, .. }) => {
assert_eq!(method, A2aMethod::GetTask);
let raw = params.ok_or("GetTask has params")?;
assert_eq!(raw.get(), r#"{"id":"task-1"}"#);
}
other => return Err(format!("expected a request, got {other:?}").into()),
}
Ok(())
}
}