klieo-a2a 0.4.0

Durable A2A v1.0 protocol layer atop klieo-bus traits.
Documentation
//! JSON-RPC 2.0 envelope + A2A method enum + standard A2A headers.
//!
//! Wire format mirrors the A2A v1.0 spec §9 ("JSON-RPC Surface"). See
//! <https://a2a-protocol.org/latest/specification/>.

use crate::error::A2aError;
use serde::{Deserialize, Serialize};

/// JSON-RPC 2.0 request envelope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
    /// Always `"2.0"`.
    pub jsonrpc: String,
    /// Request id (number, string, or null).
    pub id: serde_json::Value,
    /// One of the 11 [`A2aMethod`] variants.
    pub method: String,
    /// Method-specific params payload.
    #[serde(default)]
    pub params: serde_json::Value,
}

/// JSON-RPC 2.0 response envelope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
    /// Always `"2.0"`.
    pub jsonrpc: String,
    /// Mirrors the request id.
    pub id: serde_json::Value,
    /// Successful result (mutually exclusive with `error`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub result: Option<serde_json::Value>,
    /// Error payload (mutually exclusive with `result`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<JsonRpcError>,
}

/// JSON-RPC 2.0 error payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
    /// Numeric code (negative for server errors).
    pub code: i32,
    /// Human-readable message.
    pub message: String,
    /// Optional structured detail.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub data: Option<serde_json::Value>,
}

/// The 11 method names from A2A v1.0 spec §9.4.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum A2aMethod {
    /// §9.4.1.
    SendMessage,
    /// §9.4.2.
    SendStreamingMessage,
    /// §9.4.3.
    GetTask,
    /// §9.4.4.
    ListTasks,
    /// §9.4.5.
    CancelTask,
    /// §9.4.6.
    SubscribeToTask,
    /// §9.4.7.
    CreateTaskPushNotificationConfig,
    /// §9.4.8.
    GetTaskPushNotificationConfig,
    /// §9.4.9.
    ListTaskPushNotificationConfigs,
    /// §9.4.10.
    DeleteTaskPushNotificationConfig,
    /// §9.4.11.
    GetExtendedAgentCard,
}

impl A2aMethod {
    /// Parse a JSON-RPC `method` string. Unknown method names return
    /// [`A2aError::MethodNotFound`].
    #[allow(clippy::should_implement_trait)] // Inherent fn matches the plan; trait `FromStr` would change error type.
    pub fn from_str(s: &str) -> Result<Self, A2aError> {
        Ok(match s {
            "SendMessage" => Self::SendMessage,
            "SendStreamingMessage" => Self::SendStreamingMessage,
            "GetTask" => Self::GetTask,
            "ListTasks" => Self::ListTasks,
            "CancelTask" => Self::CancelTask,
            "SubscribeToTask" => Self::SubscribeToTask,
            "CreateTaskPushNotificationConfig" => Self::CreateTaskPushNotificationConfig,
            "GetTaskPushNotificationConfig" => Self::GetTaskPushNotificationConfig,
            "ListTaskPushNotificationConfigs" => Self::ListTaskPushNotificationConfigs,
            "DeleteTaskPushNotificationConfig" => Self::DeleteTaskPushNotificationConfig,
            "GetExtendedAgentCard" => Self::GetExtendedAgentCard,
            other => return Err(A2aError::MethodNotFound(other.to_string())),
        })
    }
}

/// Standard A2A headers extracted from a transport-layer header bag.
///
/// Header names match A2A v1.0 spec §8 ("Headers"). Defaults for
/// missing headers: `a2a_version = "1.0"`, everything else `None` /
/// empty.
#[derive(Debug, Clone)]
pub struct A2aHeaders {
    /// `Authorization` header (full value, including scheme).
    pub authorization: Option<String>,
    /// `Content-Type` header.
    pub content_type: Option<String>,
    /// `A2A-Version` — defaults to `"1.0"` when absent.
    pub a2a_version: String,
    /// `A2A-Extensions` — comma-split, trimmed.
    pub a2a_extensions: Vec<String>,
}

impl A2aHeaders {
    /// Decode the four standard A2A headers from a [`klieo_core::Headers`]
    /// map. Missing headers fall back to defaults.
    pub fn decode_from(headers: &klieo_core::Headers) -> Self {
        let get = |k: &str| headers.get(k).cloned();
        Self {
            authorization: get("Authorization"),
            content_type: get("Content-Type"),
            a2a_version: get("A2A-Version").unwrap_or_else(|| "1.0".to_string()),
            a2a_extensions: get("A2A-Extensions")
                .map(|v| {
                    v.split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                })
                .unwrap_or_default(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn envelope_request_round_trips() {
        let req = JsonRpcRequest {
            jsonrpc: "2.0".into(),
            id: json!(42),
            method: "SendMessage".into(),
            params: json!({"message": {"messageId": "m-1", "role": "user", "parts": []}}),
        };
        let v = serde_json::to_value(&req).unwrap();
        let back: JsonRpcRequest = serde_json::from_value(v).unwrap();
        assert_eq!(back.method, "SendMessage");
        assert_eq!(back.id, json!(42));
    }

    #[test]
    fn method_send_message_parses() {
        let m = A2aMethod::from_str("SendMessage").unwrap();
        assert_eq!(m, A2aMethod::SendMessage);
    }

    #[test]
    fn unknown_method_returns_method_not_found() {
        let err = A2aMethod::from_str("Frobnicate").unwrap_err();
        assert!(matches!(err, crate::error::A2aError::MethodNotFound(_)));
    }

    #[test]
    fn headers_decode_with_all_four_fields() {
        let mut h = klieo_core::Headers::new();
        h.insert("Authorization".into(), "Bearer abc".into());
        h.insert("Content-Type".into(), "application/json".into());
        h.insert("A2A-Version".into(), "1.0".into());
        h.insert("A2A-Extensions".into(), "ext1,ext2".into());
        let decoded = A2aHeaders::decode_from(&h);
        assert_eq!(decoded.authorization.as_deref(), Some("Bearer abc"));
        assert_eq!(decoded.a2a_version, "1.0");
        assert_eq!(
            decoded.a2a_extensions,
            vec!["ext1".to_string(), "ext2".to_string()]
        );
    }

    #[test]
    fn missing_version_defaults_to_one_zero() {
        let h = klieo_core::Headers::new();
        let decoded = A2aHeaders::decode_from(&h);
        assert_eq!(decoded.a2a_version, "1.0");
    }
}