Skip to main content

llm_browser_testkit/
a2a.rs

1//! A2A (Agent-to-Agent) protocol client.
2//!
3//! Implements the A2A JSON-RPC over HTTP protocol for sending tasks to
4//! remote agents and receiving responses.
5
6use std::time::Duration;
7
8use anyhow::Context;
9use serde_json::Value;
10
11/// A2A client for communicating with a remote agent.
12#[derive(Debug)]
13pub struct A2aClient {
14    /// Agent base URL.
15    url: String,
16    /// HTTP client for requests.
17    client: reqwest::Client,
18}
19
20impl A2aClient {
21    /// Creates a new A2A client for the given agent URL.
22    #[must_use]
23    pub fn new(url: &str, timeout: Duration) -> Self {
24        Self {
25            url: url.trim_end_matches('/').to_owned(),
26            client: reqwest::Client::builder()
27                .timeout(timeout)
28                .build()
29                .expect("build reqwest client"),
30        }
31    }
32
33    /// Sends a task to the agent and returns the response text.
34    ///
35    /// Uses the A2A JSON-RPC protocol:
36    /// `{"jsonrpc": "2.0", "method": "tasks/send", "params": {...}, "id": 1}`
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the HTTP request fails or the response cannot
41    /// be parsed.
42    pub async fn send_task(&self, task: &str) -> anyhow::Result<String> {
43        let payload = serde_json::json!({
44            "jsonrpc": "2.0",
45            "method": "tasks/send",
46            "params": {
47                "message": {
48                    "role": "user",
49                    "parts": [{"type": "text", "text": task}]
50                }
51            },
52            "id": 1
53        });
54
55        let resp = self
56            .client
57            .post(&self.url)
58            .header("Content-Type", "application/json")
59            .json(&payload)
60            .send()
61            .await
62            .context("A2A: request failed")?;
63
64        let json: Value = resp.json().await.context("A2A: failed to parse response")?;
65
66        // Try extracting the result from the A2A response.
67        // The response structure is:
68        // {"jsonrpc": "2.0", "result": {"id": "...", "messages": [{"parts": [{"text": "..."}]}]}, "id": 1}
69        if let Some(text) = extract_a2a_text(&json) {
70            return Ok(text);
71        }
72
73        // Fallback: try error extraction
74        if let Some(error) = json["error"]["message"].as_str() {
75            anyhow::bail!("A2A error: {error}");
76        }
77
78        Ok(serde_json::to_string(&json)?)
79    }
80}
81
82/// Extracts the text content from an A2A response.
83fn extract_a2a_text(value: &Value) -> Option<String> {
84    value["result"]["messages"]
85        .as_array()
86        .and_then(|msgs| msgs.last())
87        .and_then(|msg| msg["parts"].as_array())
88        .and_then(|parts| parts.iter().find_map(|p| p["text"].as_str()))
89        .map(String::from)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use std::time::Duration;
96
97    #[test]
98    fn test_extract_a2a_text() {
99        let json = serde_json::json!({
100            "result": {
101                "messages": [
102                    {
103                        "parts": [
104                            {"type": "text", "text": "PASS: everything looks good"}
105                        ]
106                    }
107                ]
108            }
109        });
110        let text = extract_a2a_text(&json);
111        assert_eq!(text.as_deref(), Some("PASS: everything looks good"));
112    }
113
114    #[test]
115    fn test_extract_a2a_text_empty() {
116        let json = serde_json::json!({});
117        let text = extract_a2a_text(&json);
118        assert!(text.is_none());
119    }
120
121    #[test]
122    fn test_extract_a2a_text_multiple_messages() {
123        let json = serde_json::json!({
124            "result": {
125                "messages": [
126                    {
127                        "parts": [{"type": "text", "text": "Thinking..."}]
128                    },
129                    {
130                        "parts": [{"type": "text", "text": "Final answer"}]
131                    }
132                ]
133            }
134        });
135        let text = extract_a2a_text(&json);
136        assert_eq!(text.as_deref(), Some("Final answer"));
137    }
138
139    #[test]
140    fn test_extract_a2a_text_error_response() {
141        let json = serde_json::json!({
142            "error": {
143                "code": -32600,
144                "message": "Invalid Request"
145            }
146        });
147        let text = extract_a2a_text(&json);
148        assert!(text.is_none());
149    }
150
151    #[test]
152    fn test_extract_a2a_text_no_messages() {
153        let json = serde_json::json!({
154            "result": {
155                "id": "task-123"
156            }
157        });
158        let text = extract_a2a_text(&json);
159        assert!(text.is_none());
160    }
161
162    #[test]
163    fn test_extract_a2a_text_empty_messages() {
164        let json = serde_json::json!({
165            "result": {
166                "messages": []
167            }
168        });
169        let text = extract_a2a_text(&json);
170        assert!(text.is_none());
171    }
172
173    #[test]
174    fn test_extract_a2a_text_no_text_part() {
175        let json = serde_json::json!({
176            "result": {
177                "messages": [
178                    {
179                        "parts": [{"type": "image", "data": "base64..."}]
180                    }
181                ]
182            }
183        });
184        let text = extract_a2a_text(&json);
185        assert!(text.is_none());
186    }
187
188    #[test]
189    fn test_a2a_client_creation() {
190        let client = A2aClient::new("http://localhost:9090", Duration::from_secs(30));
191        assert_eq!(client.url, "http://localhost:9090");
192    }
193}