protocol_droid/adapters/secondary/a2a/client.rs
1//! A typed A2A client over HTTP + JSON-RPC + SSE.
2//!
3//! Drives an A2A peer end-to-end: discovers the
4//! [`AgentCard`] at
5//! `/.well-known/agent-card.json`, sends request/response
6//! JSON-RPC envelopes to the JSON-RPC method table, and
7//! reads streaming SSE responses into typed
8//! [`StreamingEvent`]
9//! values.
10//!
11//! # Why this exists
12//!
13//! Every consumer who wants to drive an A2A peer is
14//! reaching for `reqwest` and rolling the same five
15//! hundred lines: a counter for ids, a JSON-RPC
16//! envelope shape, an SSE parser, a stream of typed
17//! events. The [`a2a_client`](https://github.com/greysquirr3l/protocol-droid/blob/main/crates/protocol-droid/examples/a2a_client.rs)
18//! example is the proof — five hundred lines of
19//! `Box<dyn Error>` plumbing a typed consumer would
20//! rather not write.
21//!
22//! [`A2aClient`] is the typed version. One struct, one
23//! constructor, ten methods that take the A2A
24//! request/response types the library already defines
25//! and return the typed answers the library already
26//! parses. The four lines of envelope munging the
27//! example is built on are folded into a private
28//! `post_json_rpc` helper; the SSE parsing the example
29//! rolls is a private `post_sse_collect`; the id
30//! counter is a private [`AtomicI64`]. None of those
31//! is a decision the consumer should be making.
32//!
33//! # What it does
34//!
35//! 1. `A2aClient::new(peer)` builds a client pointed
36//! at the peer's base URL. The base URL is the
37//! one the well-known card resolves to, e.g.
38//! `http://peer:8080`; the well-known URI is
39//! appended automatically.
40//! 2. `card()` fetches the well-known
41//! `AgentCard` and parses it. The card is not
42//! cached — a consumer who wants the card once
43//! calls it once; a consumer who wants it for
44//! every request calls it for every request.
45//! 3. The four request/response methods
46//! ([`send_message`](A2aClient::send_message),
47//! [`get_task`](A2aClient::get_task),
48//! [`list_tasks`](A2aClient::list_tasks),
49//! [`cancel_task`](A2aClient::cancel_task)) POST a
50//! JSON-RPC envelope and parse the typed
51//! response. Each method takes the library's
52//! request type and returns the library's
53//! response type, so a consumer is matching on
54//! real A2A types, not on `serde_json::Value`.
55//! 4. The two streaming methods
56//! ([`send_streaming_message`](A2aClient::send_streaming_message),
57//! [`subscribe_to_task`](A2aClient::subscribe_to_task))
58//! POST a JSON-RPC envelope with
59//! `accept: text/event-stream` and collect the
60//! SSE data lines into a `Vec<StreamingEvent>`.
61//! 5. The four push-notification-config methods
62//! ([`create_push_notification_config`](A2aClient::create_push_notification_config),
63//! [`get_push_notification_config`](A2aClient::get_push_notification_config),
64//! [`list_push_notification_configs`](A2aClient::list_push_notification_configs),
65//! [`delete_push_notification_config`](A2aClient::delete_push_notification_config))
66//! round-trip a `TaskPushNotificationConfig`
67//! through the same JSON-RPC envelope.
68//! 6. [`get_extended_agent_card`](A2aClient::get_extended_agent_card)
69//! fetches the authenticated extended agent
70//! card. The A2A spec splits the card into a
71//! public read (the well-known path, no
72//! authentication) and an extended read (this
73//! method, requires the same bearer token the
74//! JSON-RPC and streaming routes require). The
75//! method exists so a consumer does not have
76//! to drop down to `reqwest` for one
77//! authenticated card fetch.
78//!
79//! # What it deliberately does not do
80//!
81//! - It does not cache the agent card. A consumer
82//! that wants the card cached can wrap the call;
83//! a client that did cache by default would
84//! silently serve a stale card and the
85//! `list_changed` semantics a future revision
86//! adds would be wrong.
87//! - It does not pool reqwest connections. The
88//! [`HttpClient`](reqwest::Client) inside
89//! [`A2aClient`] is one per client, and
90//! `reqwest::Client` already pools by default — a
91//! second layer is a second opinion on what
92//! `reqwest` is already doing.
93//! - It does not retry on transport error. A
94//! retry policy is a decision the consumer should
95//! make (the A2A spec does not define one); a
96//! client that retried by default would be wrong
97//! in the cases nobody tests.
98//! - It does not stream events as they arrive. A
99//! streaming consumer wants events one at a time
100//! so it can decide when to stop. The current
101//! shape (`Vec<StreamingEvent>`) is a
102//! convenience; a future revision will swap it
103//! for `Pin<Box<dyn Stream<Item = …>>>` without
104//! changing the public method names.
105//!
106//! # Errors
107//!
108//! Every method returns [`Result<_, A2aClientError>`].
109//! The error variants name the four failure modes:
110//! transport (the peer could not be reached),
111//! JSON-RPC (the peer answered the envelope with
112//! an `error` member), parse (the response was
113//! well-formed JSON but not the typed shape the
114//! method expected), and stream (an SSE reply
115//! ended before the method's expected terminal
116//! event). The HTTP status code is in the
117//! transport error, the JSON-RPC code and message
118//! are in the JSON-RPC error, and the parse error
119//! is the [`serde_json`] error verbatim — a
120//! consumer matching on the variant gets the
121//! detail from `Display` or `source()`.
122
123use std::sync::Arc;
124use std::sync::atomic::{AtomicI64, Ordering};
125
126use reqwest::Client as HttpClient;
127use serde::de::DeserializeOwned;
128use serde_json::{Value, json};
129
130use crate::adapters::secondary::a2a::A2aMethod;
131use crate::domain::a2a::agent_card::AgentCard;
132use crate::domain::a2a::extended_agent_card::{
133 GetExtendedAgentCardRequest, GetExtendedAgentCardResponse,
134};
135use crate::domain::a2a::list_tasks::{ListTasksRequest, ListTasksResponse};
136use crate::domain::a2a::push_notification_methods::{
137 CreateTaskPushNotificationConfigRequest, CreateTaskPushNotificationConfigResponse,
138 DeleteTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigRequest,
139 GetTaskPushNotificationConfigResponse, ListTaskPushNotificationConfigsRequest,
140 ListTaskPushNotificationConfigsResponse,
141};
142use crate::domain::a2a::send_message::{SendMessageRequest, SendMessageResponse};
143use crate::domain::a2a::streaming_events::StreamingEvent;
144use crate::domain::a2a::task_lifecycle::{
145 CancelTaskRequest, CancelTaskResponse, GetTaskRequest, GetTaskResponse,
146};
147use crate::domain::a2a::well_known::AGENT_CARD_WELL_KNOWN_PATH;
148
149/// What failed when [`A2aClient`] could not complete a call.
150#[derive(Debug, thiserror::Error)]
151pub enum A2aClientError {
152 /// The peer could not be reached over HTTP, or returned a non-2xx status.
153 #[error("the peer could not be reached: {0}")]
154 Io(#[from] reqwest::Error),
155
156 /// The peer answered the JSON-RPC envelope with an `error` member.
157 ///
158 /// The code and message are the JSON-RPC `code` and `message`
159 /// fields, copied verbatim. The `data` member, when present,
160 /// is dropped: it is the spec's escape hatch for vendor
161 /// extensions, and a typed client that lifted it would force
162 /// every consumer to know every extension.
163 #[error("the peer answered with a JSON-RPC error {code}: {message}")]
164 JsonRpc {
165 /// The JSON-RPC error code the peer returned.
166 code: i64,
167 /// The JSON-RPC error message the peer returned.
168 message: String,
169 },
170
171 /// The response could not be parsed as the expected typed shape.
172 #[error("the response could not be parsed: {0}")]
173 Parse(#[from] serde_json::Error),
174
175 /// The SSE stream ended before the method's expected terminal event.
176 #[error("the SSE stream ended: {0}")]
177 StreamEnded(String),
178}
179
180/// The base URL of an A2A peer.
181///
182/// `peer` is the URL the well-known card resolves to, e.g.
183/// `http://peer:8080`. The well-known URI and the JSON-RPC
184/// method table are appended to this base, so a peer that
185/// exposes its card at `http://peer:8080/.well-known/agent-card.json`
186/// and its method table at `http://peer:8080/a2a` is driven
187/// with `A2aClient::new("http://peer:8080")`.
188///
189/// The four constructors below build a client aimed at the
190/// conventional paths. A consumer whose peer uses a
191/// non-conventional layout — e.g. the card at
192/// `/card` and the JSON-RPC at `/rpc` — uses
193/// [`A2aClient::with_paths`](A2aClient::with_paths) to point the client at
194/// non-default paths.
195///
196#[derive(Debug)]
197pub struct A2aClient {
198 base: Arc<str>,
199 card_path: Arc<str>,
200 jsonrpc_path: Arc<str>,
201 streaming_path: Arc<str>,
202 bearer: Option<Arc<str>>,
203 http: HttpClient,
204 next_id: Arc<AtomicI64>,
205}
206
207impl Clone for A2aClient {
208 /// A clone is cheap: the [`HttpClient`]
209 /// `reqwest` ships already pools and
210 /// shares connections across clones,
211 /// and the id counter is an `Arc` so a
212 /// clone and its original issue distinct
213 /// ids. The [`Arc<str>`] paths are
214 /// reference-counted copies, not deep
215 /// clones. The bearer token, if any, is
216 /// also `Arc<str>` — a clone points at
217 /// the same allocation, not a copy of
218 /// the secret.
219 fn clone(&self) -> Self {
220 Self {
221 base: Arc::clone(&self.base),
222 card_path: Arc::clone(&self.card_path),
223 jsonrpc_path: Arc::clone(&self.jsonrpc_path),
224 streaming_path: Arc::clone(&self.streaming_path),
225 bearer: self.bearer.as_ref().map(Arc::clone),
226 http: self.http.clone(),
227 next_id: Arc::clone(&self.next_id),
228 }
229 }
230}
231
232impl A2aClient {
233 /// A client pointed at the conventional paths.
234 ///
235 /// The well-known card is fetched from
236 /// `peer + /.well-known/agent-card.json`; the
237 /// JSON-RPC method table is `POST`ed to
238 /// `peer + /a2a`. The two paths match the
239 /// A2A v1.0 §8 discovery and §3 method
240 /// routes; a consumer whose peer does not
241 /// follow them uses
242 /// [`with_paths`](Self::with_paths) instead.
243 ///
244 /// # Errors
245 ///
246 /// [`A2aClientError::Io`] when the underlying
247 /// `reqwest::Client` cannot be built (a TLS
248 /// configuration error or an invalid
249 /// default-header value). The transport is
250 /// built once at construction; every method
251 /// on the client reuses it.
252 pub fn new(peer: impl Into<String>) -> Result<Self, A2aClientError> {
253 let http = HttpClient::builder()
254 .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
255 .build()?;
256 Ok(Self {
257 base: Arc::from(peer.into()),
258 card_path: Arc::from(AGENT_CARD_WELL_KNOWN_PATH),
259 jsonrpc_path: Arc::from("/a2a"),
260 streaming_path: Arc::from("/a2a/stream"),
261 bearer: None,
262 http,
263 next_id: Arc::new(AtomicI64::new(1)),
264 })
265 }
266
267 /// A client pointed at non-default card and JSON-RPC paths.
268 ///
269 /// `card_path` is the URI the GET to fetch the
270 /// agent card is sent to, relative to `peer`
271 /// (e.g. `/custom/card`). `jsonrpc_path` is the
272 /// URI the POST to the JSON-RPC method table
273 /// is sent to, relative to `peer`
274 /// (e.g. `/custom/rpc`). `streaming_path` is
275 /// the URI the streaming methods
276 /// ([`send_streaming_message`](A2aClient::send_streaming_message)
277 /// and [`subscribe_to_task`](A2aClient::subscribe_to_task))
278 /// POST to, relative to `peer`.
279 ///
280 /// # Errors
281 ///
282 /// [`A2aClientError::Io`] when the underlying
283 /// `reqwest::Client` cannot be built. The
284 /// construction mirrors [`A2aClient::new`].
285 pub fn with_paths(
286 peer: impl Into<String>,
287 card_path: impl Into<String>,
288 jsonrpc_path: impl Into<String>,
289 streaming_path: impl Into<String>,
290 ) -> Result<Self, A2aClientError> {
291 let http = HttpClient::builder()
292 .user_agent(concat!("protocol-droid/", env!("CARGO_PKG_VERSION")))
293 .build()?;
294 Ok(Self {
295 base: Arc::from(peer.into()),
296 card_path: Arc::from(card_path.into()),
297 jsonrpc_path: Arc::from(jsonrpc_path.into()),
298 streaming_path: Arc::from(streaming_path.into()),
299 bearer: None,
300 http,
301 next_id: Arc::new(AtomicI64::new(1)),
302 })
303 }
304
305 /// Attaches a Bearer token to every subsequent request.
306 ///
307 /// The A2A v1.0 spec lets an agent declare a
308 /// `securitySchemes` map on its card; the most
309 /// common value is `Http` with `Bearer` (RFC
310 /// 6750). A consumer that has an opaque token
311 /// from the peer's auth endpoint passes it
312 /// here, and every `card()`, `send_message()`,
313 /// and `send_streaming_message()` call carries
314 /// `Authorization: Bearer <token>`. The card
315 /// itself is fetched unauthenticated by default
316 /// — a peer that gates the card behind auth
317 /// needs a separate way to get the token, which
318 /// is out of scope for this commit.
319 ///
320 /// The token is held in an `Arc<str>`; a clone
321 /// shares the same allocation, not a copy of
322 /// the secret.
323 #[must_use]
324 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
325 self.bearer = Some(Arc::from(token.into()));
326 self
327 }
328
329 /// Fetches the agent card at the well-known URI.
330 ///
331 /// Not cached. A consumer who wants the card
332 /// once calls it once; a consumer who wants
333 /// the card to reflect a server-side update
334 /// calls it again.
335 ///
336 /// # Errors
337 ///
338 /// [`A2aClientError::Io`] on a transport
339 /// failure or non-2xx status, or
340 /// [`A2aClientError::Parse`] when the body
341 /// is well-formed JSON but not the
342 /// `AgentCard` shape.
343 pub async fn card(&self) -> Result<AgentCard, A2aClientError> {
344 let url = format!("{}{}", self.base, self.card_path);
345 let mut request = self
346 .http
347 .get(&url)
348 .header(reqwest::header::ACCEPT, "application/json");
349 if let Some(token) = self.bearer.as_deref() {
350 request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
351 }
352 let response = request.send().await?.error_for_status()?;
353 let body = response.text().await?;
354 let card: AgentCard = serde_json::from_str(&body)?;
355 Ok(card)
356 }
357
358 /// `SendMessage` — send a message to the agent.
359 ///
360 /// # Errors
361 ///
362 /// [`A2aClientError`] on transport, JSON-RPC, or parse failure.
363 pub async fn send_message(
364 &self,
365 request: SendMessageRequest,
366 ) -> Result<SendMessageResponse, A2aClientError> {
367 let params = serde_json::to_value(&request)?;
368 self.post_json_rpc(A2aMethod::SendMessage, params).await
369 }
370
371 /// `GetTask` — read a task by id.
372 pub async fn get_task(
373 &self,
374 request: GetTaskRequest,
375 ) -> Result<GetTaskResponse, A2aClientError> {
376 let params = serde_json::to_value(&request)?;
377 self.post_json_rpc(A2aMethod::GetTask, params).await
378 }
379
380 /// `ListTasks` — list tasks with optional filtering.
381 pub async fn list_tasks(
382 &self,
383 request: ListTasksRequest,
384 ) -> Result<ListTasksResponse, A2aClientError> {
385 let params = serde_json::to_value(&request)?;
386 self.post_json_rpc(A2aMethod::ListTasks, params).await
387 }
388
389 /// `CancelTask` — cancel a task in progress.
390 pub async fn cancel_task(
391 &self,
392 request: CancelTaskRequest,
393 ) -> Result<CancelTaskResponse, A2aClientError> {
394 let params = serde_json::to_value(&request)?;
395 self.post_json_rpc(A2aMethod::CancelTask, params).await
396 }
397
398 /// `SendStreamingMessage` — send a message and stream the agent's
399 /// progress as a sequence of typed events.
400 ///
401 /// Returns every event the server sent before
402 /// closing the stream. The A2A spec terminates
403 /// a `SendStreamingMessage` with a `status`
404 /// event whose `final` flag is `true`; a
405 /// consumer who needs that terminal event
406 /// checks `StreamingEvent::Status(…).final`
407 /// on the last element.
408 pub async fn send_streaming_message(
409 &self,
410 request: SendMessageRequest,
411 ) -> Result<Vec<StreamingEvent>, A2aClientError> {
412 let params = serde_json::to_value(&request)?;
413 self.post_sse_collect(A2aMethod::SendStreamingMessage, params)
414 .await
415 }
416
417 /// `SubscribeToTask` — open an SSE stream of updates
418 /// for an existing non-terminal task.
419 pub async fn subscribe_to_task(
420 &self,
421 task_id: String,
422 ) -> Result<Vec<StreamingEvent>, A2aClientError> {
423 let params = json!({ "id": task_id });
424 self.post_sse_collect(A2aMethod::SubscribeToTask, params)
425 .await
426 }
427
428 /// `CreateTaskPushNotificationConfig` — register a
429 /// push notification config for a task.
430 pub async fn create_push_notification_config(
431 &self,
432 request: CreateTaskPushNotificationConfigRequest,
433 ) -> Result<CreateTaskPushNotificationConfigResponse, A2aClientError> {
434 let params = serde_json::to_value(&request)?;
435 self.post_json_rpc(A2aMethod::CreateTaskPushNotificationConfig, params)
436 .await
437 }
438
439 /// `GetTaskPushNotificationConfig` — read a config by id.
440 pub async fn get_push_notification_config(
441 &self,
442 request: GetTaskPushNotificationConfigRequest,
443 ) -> Result<GetTaskPushNotificationConfigResponse, A2aClientError> {
444 let params = serde_json::to_value(&request)?;
445 self.post_json_rpc(A2aMethod::GetTaskPushNotificationConfig, params)
446 .await
447 }
448
449 /// `ListTaskPushNotificationConfigs` — list a task's configs.
450 pub async fn list_push_notification_configs(
451 &self,
452 request: ListTaskPushNotificationConfigsRequest,
453 ) -> Result<ListTaskPushNotificationConfigsResponse, A2aClientError> {
454 let params = serde_json::to_value(&request)?;
455 self.post_json_rpc(A2aMethod::ListTaskPushNotificationConfigs, params)
456 .await
457 }
458
459 /// `DeleteTaskPushNotificationConfig` — remove a config.
460 ///
461 /// Returns `Ok(())` when the peer confirms the
462 /// deletion. The A2A spec answers a successful
463 /// delete with the empty JSON-RPC `result`; the
464 /// helper's `()` deserialisation accepts that
465 /// without an extra wrapper.
466 pub async fn delete_push_notification_config(
467 &self,
468 request: DeleteTaskPushNotificationConfigRequest,
469 ) -> Result<(), A2aClientError> {
470 let params = serde_json::to_value(&request)?;
471 let _: Value = self
472 .post_json_rpc(A2aMethod::DeleteTaskPushNotificationConfig, params)
473 .await?;
474 Ok(())
475 }
476
477 /// `GetExtendedAgentCard` — fetch the
478 /// authenticated extended agent card.
479 ///
480 /// The A2A spec splits the agent card into
481 /// two reads: the *public* card served on the
482 /// well-known path with no authentication,
483 /// and the *extended* card returned by this
484 /// method, which requires the same bearer
485 /// token the JSON-RPC and streaming routes
486 /// require. The extended card is the same
487 /// card plus any additional capabilities the
488 /// server chooses not to advertise publicly
489 /// (a paid tool, an admin endpoint, a
490 /// tenant-scoped skill).
491 ///
492 /// A consumer who has not set a bearer token
493 /// via [`with_bearer_token`](Self::with_bearer_token)
494 /// will see a 401 from the peer — that is
495 /// the spec's "you must authenticate to read
496 /// this card" signal, not a transport error.
497 /// A consumer who has set a token gets the
498 /// extended card in the response.
499 pub async fn get_extended_agent_card(
500 &self,
501 request: GetExtendedAgentCardRequest,
502 ) -> Result<GetExtendedAgentCardResponse, A2aClientError> {
503 let params = serde_json::to_value(&request)?;
504 self.post_json_rpc(A2aMethod::GetExtendedAgentCard, params)
505 .await
506 }
507
508 /// POSTs a JSON-RPC envelope to the method table and parses the typed result.
509 async fn post_json_rpc<T: DeserializeOwned>(
510 &self,
511 method: A2aMethod,
512 params: Value,
513 ) -> Result<T, A2aClientError> {
514 let url = format!("{}{}", self.base, self.jsonrpc_path);
515 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
516 let envelope = json!({
517 "jsonrpc": "2.0",
518 "id": id,
519 "method": method.as_str(),
520 "params": params,
521 });
522 let body = serde_json::to_vec(&envelope)?;
523 let mut request = self
524 .http
525 .post(&url)
526 .header(reqwest::header::CONTENT_TYPE, "application/json")
527 .header(reqwest::header::ACCEPT, "application/json")
528 .body(body);
529 if let Some(token) = self.bearer.as_deref() {
530 request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
531 }
532 let response = request.send().await?.error_for_status()?;
533 let body = response.text().await?;
534 let envelope: Value = serde_json::from_str(&body)?;
535 Self::extract_result(&envelope)
536 }
537
538 /// POSTs a JSON-RPC envelope with `accept: text/event-stream` and
539 /// collects every `data:` line into a typed event.
540 async fn post_sse_collect(
541 &self,
542 method: A2aMethod,
543 params: Value,
544 ) -> Result<Vec<StreamingEvent>, A2aClientError> {
545 let url = format!("{}{}", self.base, self.streaming_path);
546 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
547 let envelope = json!({
548 "jsonrpc": "2.0",
549 "id": id,
550 "method": method.as_str(),
551 "params": params,
552 });
553 let body = serde_json::to_vec(&envelope)?;
554 let mut request = self
555 .http
556 .post(&url)
557 .header(reqwest::header::CONTENT_TYPE, "application/json")
558 .header(reqwest::header::ACCEPT, "text/event-stream")
559 .body(body);
560 if let Some(token) = self.bearer.as_deref() {
561 request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
562 }
563 let response = request.send().await?.error_for_status()?;
564 let body = response.text().await?;
565 Self::parse_sse_blocks(&body)
566 }
567
568 /// Lifts the typed `result` out of a JSON-RPC envelope.
569 fn extract_result<T: DeserializeOwned>(envelope: &Value) -> Result<T, A2aClientError> {
570 if let Some(error) = envelope.get("error") {
571 let code = error.get("code").and_then(Value::as_i64).unwrap_or(0);
572 let message = error
573 .get("message")
574 .and_then(Value::as_str)
575 .unwrap_or("unspecified")
576 .to_owned();
577 return Err(A2aClientError::JsonRpc { code, message });
578 }
579 let Some(result) = envelope.get("result") else {
580 return Err(A2aClientError::Parse(serde_json::Error::io(
581 std::io::Error::other("missing `result` member in the JSON-RPC envelope"),
582 )));
583 };
584 Ok(serde_json::from_value(result.clone())?)
585 }
586
587 /// Parses an SSE body into typed events.
588 ///
589 /// The wire format is one event per blank-line
590 /// boundary; an event is one or more
591 /// `field: value` lines; the `data:` lines
592 /// carry the JSON-RPC envelope the spec names.
593 /// Anything else (`event:`, `id:`, `retry:`) is
594 /// dropped — the A2A spec does not use them.
595 fn parse_sse_blocks(body: &str) -> Result<Vec<StreamingEvent>, A2aClientError> {
596 let mut events = Vec::new();
597 for block in body.split("\n\n") {
598 for line in block.lines() {
599 let Some(data) = line.strip_prefix("data: ") else {
600 continue;
601 };
602 let envelope: Value = match serde_json::from_str(data) {
603 Ok(value) => value,
604 Err(error) => {
605 return Err(A2aClientError::StreamEnded(format!(
606 "a `data:` line was not well-formed JSON: {error}"
607 )));
608 }
609 };
610 let event: StreamingEvent = Self::extract_result(&envelope)?;
611 events.push(event);
612 }
613 }
614 Ok(events)
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 fn empty_envelope() -> Value {
623 json!({ "jsonrpc": "2.0" })
624 }
625
626 #[test]
627 fn extract_result_pulls_typed_value() {
628 let envelope = json!({
629 "jsonrpc": "2.0",
630 "id": 1,
631 "result": { "name": "alpha" }
632 });
633 let value: Result<serde_json::Value, A2aClientError> = A2aClient::extract_result(&envelope);
634 match value {
635 Ok(value) => assert_eq!(value, json!({ "name": "alpha" })),
636 Err(error) => unreachable!("a `result` member produces a typed value, got {error}"),
637 }
638 }
639
640 #[test]
641 fn extract_result_promotes_error_member_to_typed_error() {
642 let envelope = json!({
643 "jsonrpc": "2.0",
644 "id": 1,
645 "error": { "code": -32601, "message": "Method not found" }
646 });
647 match A2aClient::extract_result::<serde_json::Value>(&envelope) {
648 Ok(value) => {
649 unreachable!("the error member surfaces as `JsonRpc`, got Ok({value})")
650 }
651 Err(A2aClientError::JsonRpc { code, message }) => {
652 assert_eq!(code, -32601);
653 assert_eq!(message, "Method not found");
654 }
655 Err(other) => unreachable!("the error member surfaces as `JsonRpc`, got {other}"),
656 }
657 }
658
659 #[test]
660 fn extract_result_without_result_is_a_parse_error() {
661 match A2aClient::extract_result::<serde_json::Value>(&empty_envelope()) {
662 Ok(value) => {
663 unreachable!("an envelope without `result` is a parse error, got Ok({value})")
664 }
665 Err(A2aClientError::Parse(_)) => {}
666 Err(other) => {
667 unreachable!("an envelope without `result` is a parse error, got {other}")
668 }
669 }
670 }
671
672 #[test]
673 fn parse_sse_blocks_skips_lines_without_data_prefix() {
674 let body = "event: ping\n\n: heartbeat\n\n";
675 match A2aClient::parse_sse_blocks(body) {
676 Ok(events) => assert!(events.is_empty()),
677 Err(error) => {
678 unreachable!("lines without `data:` are skipped, not errors, got {error}")
679 }
680 }
681 }
682
683 #[test]
684 fn with_bearer_token_is_carried_on_a_clone() -> Result<(), A2aClientError> {
685 // The token is an `Arc<str>`; cloning the client
686 // shares the same allocation, not a deep copy.
687 let original = A2aClient::new("http://peer")?.with_bearer_token("secret");
688 let cloned = original.clone();
689 // Both clients point at the same peer and same
690 // paths; the wire-level assertion is that every
691 // outbound request carries `Authorization: Bearer
692 // secret`, which the integration test pins against
693 // a real server. This unit test only checks the
694 // construction: no panic, the same path.
695 assert_eq!(cloned.card_path, original.card_path);
696 Ok(())
697 }
698
699 #[test]
700 fn without_bearer_token_the_authorization_header_is_absent() -> Result<(), A2aClientError> {
701 // The default client is unauthenticated. The
702 // struct's `bearer` field is `None`, so the
703 // request builder skips the Authorization header.
704 let client = A2aClient::new("http://peer")?;
705 match &client.bearer {
706 None => {}
707 Some(_) => unreachable!("a fresh `A2aClient::new` has no bearer token"),
708 }
709 Ok(())
710 }
711
712 #[test]
713 fn get_extended_agent_card_request_serialises_as_empty_object()
714 -> Result<(), Box<dyn std::error::Error>> {
715 // The spec's `GetExtendedAgentCardRequest`
716 // has only a `tenant` path parameter, so
717 // the wire body is `{}` (not `null`). The
718 // test pins the shape so a future revision
719 // that adds a real field catches the
720 // round-trip drift here, not in a wire
721 // test against a real server.
722 let request = GetExtendedAgentCardRequest::new();
723 let value = serde_json::to_value(&request)?;
724 let object = value
725 .as_object()
726 .ok_or("the extended-card request must serialise to a JSON object")?;
727 if !object.is_empty() {
728 return Err(format!("expected `{{}}`, got {value}").into());
729 }
730 Ok(())
731 }
732}