Skip to main content

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}