use turul_a2a_types::wire::jsonrpc as methods;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompatMode {
V10,
V03,
}
pub fn detect_compat_mode(method: &str, headers: &http::HeaderMap) -> CompatMode {
if let Some(version) = headers.get("a2a-version").and_then(|v| v.to_str().ok()) {
if version == "1.0" {
return CompatMode::V10;
}
}
if method.contains('/') {
return CompatMode::V03;
}
CompatMode::V10
}
pub const fn root_post_compat_mode() -> CompatMode {
CompatMode::V03
}
pub fn maybe_normalize_method(method: &str, mode: CompatMode) -> String {
match mode {
CompatMode::V10 => method.to_string(),
CompatMode::V03 => normalize_jsonrpc_method(method),
}
}
fn normalize_jsonrpc_method(method: &str) -> String {
match method {
"message/send" => methods::SEND_MESSAGE.to_string(),
"message/stream" => methods::SEND_STREAMING_MESSAGE.to_string(),
"tasks/get" => methods::GET_TASK.to_string(),
"tasks/list" => methods::LIST_TASKS.to_string(),
"tasks/cancel" => methods::CANCEL_TASK.to_string(),
"tasks/subscribe" => methods::SUBSCRIBE_TO_TASK.to_string(),
"tasks/pushNotificationConfig/set" => {
methods::CREATE_TASK_PUSH_NOTIFICATION_CONFIG.to_string()
}
"tasks/pushNotificationConfig/get" => {
methods::GET_TASK_PUSH_NOTIFICATION_CONFIG.to_string()
}
"tasks/pushNotificationConfig/list" => {
methods::LIST_TASK_PUSH_NOTIFICATION_CONFIGS.to_string()
}
"tasks/pushNotificationConfig/delete" => {
methods::DELETE_TASK_PUSH_NOTIFICATION_CONFIG.to_string()
}
other => other.to_string(),
}
}
pub fn maybe_normalize_params(params: &mut serde_json::Value, mode: CompatMode) {
if mode != CompatMode::V03 {
return;
}
normalize_roles(params);
}
fn normalize_roles(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let Some(role) = map.get_mut("role") {
if let Some(s) = role.as_str() {
let normalized = match s {
"user" => "ROLE_USER",
"agent" => "ROLE_AGENT",
other => other,
};
*role = serde_json::Value::String(normalized.to_string());
}
}
for (_, v) in map.iter_mut() {
normalize_roles(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
normalize_roles(v);
}
}
_ => {}
}
}
pub fn maybe_normalize_response(response: &mut serde_json::Value, mode: CompatMode) {
if mode != CompatMode::V03 {
return;
}
if let Some(result) = response.get_mut("result") {
if let Some(task) = result.get("task").cloned() {
*result = task;
}
normalize_task_state_enums(result);
denormalize_roles(result);
}
}
fn denormalize_roles(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let Some(role) = map.get_mut("role") {
if let Some(s) = role.as_str() {
let denormalized = match s {
"ROLE_USER" => "user",
"ROLE_AGENT" => "agent",
other => other,
};
*role = serde_json::Value::String(denormalized.to_string());
}
}
for (_, v) in map.iter_mut() {
denormalize_roles(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
denormalize_roles(v);
}
}
_ => {}
}
}
fn normalize_task_state_enums(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let Some(state) = map.get_mut("state") {
if let Some(s) = state.as_str() {
let normalized = match s {
"TASK_STATE_SUBMITTED" => "submitted",
"TASK_STATE_WORKING" => "working",
"TASK_STATE_COMPLETED" => "completed",
"TASK_STATE_CANCELED" => "canceled",
"TASK_STATE_FAILED" => "failed",
"TASK_STATE_INPUT_REQUIRED" => "input-required",
other => other,
};
*state = serde_json::Value::String(normalized.to_string());
}
}
for (_, v) in map.iter_mut() {
normalize_task_state_enums(v);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
normalize_task_state_enums(v);
}
}
_ => {}
}
}
pub fn inject_agent_card_compat(mut card: serde_json::Value) -> serde_json::Value {
if let Some(obj) = card.as_object_mut() {
let interfaces = obj
.get("supportedInterfaces")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if let Some(primary) = interfaces.first() {
if !obj.contains_key("url") {
if let Some(url) = primary.get("url") {
obj.insert("url".to_string(), url.clone());
}
}
let additional: Vec<serde_json::Value> = interfaces
.iter()
.map(|iface| {
let mut ai = serde_json::Map::new();
if let Some(url) = iface.get("url") {
ai.insert("url".to_string(), url.clone());
}
if let Some(binding) = iface.get("protocolBinding") {
ai.insert("transport".to_string(), binding.clone());
}
serde_json::Value::Object(ai)
})
.collect();
if !obj.contains_key("additionalInterfaces") {
obj.insert(
"additionalInterfaces".to_string(),
serde_json::Value::Array(additional),
);
}
}
if !obj.contains_key("protocolVersion") {
obj.insert(
"protocolVersion".to_string(),
serde_json::Value::String("0.3.0".to_string()),
);
}
}
card
}
#[cfg(test)]
mod tests {
use super::*;
use http::HeaderMap;
#[test]
fn v03_compat_detect_v10_with_header() {
let mut headers = HeaderMap::new();
headers.insert("a2a-version", "1.0".parse().unwrap());
assert_eq!(
detect_compat_mode("message/send", &headers),
CompatMode::V10,
"Explicit A2A-Version: 1.0 forces V10 even with slash method"
);
}
#[test]
fn v03_compat_detect_v03_by_method() {
let headers = HeaderMap::new();
assert_eq!(
detect_compat_mode("message/send", &headers),
CompatMode::V03
);
assert_eq!(detect_compat_mode("tasks/get", &headers), CompatMode::V03);
}
#[test]
fn v03_compat_detect_v10_default_for_pascal_case() {
let headers = HeaderMap::new();
assert_eq!(detect_compat_mode("SendMessage", &headers), CompatMode::V10);
assert_eq!(detect_compat_mode("GetTask", &headers), CompatMode::V10);
}
#[test]
fn v03_compat_detect_v10_for_unknown_method() {
let headers = HeaderMap::new();
assert_eq!(
detect_compat_mode("SomethingNew", &headers),
CompatMode::V10,
"Unknown methods default to V10 — let dispatcher reject clearly"
);
}
#[test]
fn v03_compat_normalize_only_in_v03_mode() {
assert_eq!(
maybe_normalize_method("message/send", CompatMode::V03),
"SendMessage"
);
assert_eq!(
maybe_normalize_method("message/send", CompatMode::V10),
"message/send",
"V10 mode must NOT normalize — dispatcher rejects unknown methods"
);
}
#[test]
fn v03_compat_v10_methods_pass_through_in_both_modes() {
assert_eq!(
maybe_normalize_method("SendMessage", CompatMode::V10),
"SendMessage"
);
assert_eq!(
maybe_normalize_method("SendMessage", CompatMode::V03),
"SendMessage"
);
}
#[test]
fn v03_compat_agent_card_injects_url() {
let card = serde_json::json!({
"name": "Test",
"supportedInterfaces": [{
"url": "https://example.com/agent",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}]
});
let result = inject_agent_card_compat(card);
assert_eq!(result["url"], "https://example.com/agent");
assert_eq!(result["protocolVersion"], "0.3.0");
assert!(result["additionalInterfaces"].is_array());
assert_eq!(result["additionalInterfaces"][0]["transport"], "JSONRPC");
assert!(result["supportedInterfaces"].is_array());
}
#[test]
fn v03_compat_agent_card_does_not_overwrite_existing_fields() {
let card = serde_json::json!({
"name": "Test",
"url": "https://custom.example.com",
"protocolVersion": "1.0",
"supportedInterfaces": [{
"url": "https://example.com/agent",
"protocolBinding": "JSONRPC"
}]
});
let result = inject_agent_card_compat(card);
assert_eq!(result["url"], "https://custom.example.com");
assert_eq!(result["protocolVersion"], "1.0");
}
#[test]
fn v03_compat_role_normalization() {
let mut params = serde_json::json!({
"message": {
"messageId": "test",
"role": "user",
"parts": [{"kind": "text", "text": "hello"}]
}
});
maybe_normalize_params(&mut params, CompatMode::V03);
assert_eq!(params["message"]["role"], "ROLE_USER");
}
#[test]
fn v03_compat_role_not_normalized_in_v10_mode() {
let mut params = serde_json::json!({
"message": {
"messageId": "test",
"role": "user",
"parts": []
}
});
maybe_normalize_params(&mut params, CompatMode::V10);
assert_eq!(
params["message"]["role"], "user",
"V10 mode must NOT normalize — let proto reject invalid enums"
);
}
}