llm_browser_testkit/
a2a.rs1use std::time::Duration;
7
8use anyhow::Context;
9use serde_json::Value;
10
11#[derive(Debug)]
13pub struct A2aClient {
14 url: String,
16 client: reqwest::Client,
18}
19
20impl A2aClient {
21 #[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 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 if let Some(text) = extract_a2a_text(&json) {
70 return Ok(text);
71 }
72
73 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
82fn 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}