1use std::collections::HashMap;
40use std::future::Future;
41use std::pin::Pin;
42use std::sync::Arc;
43use std::time::Duration;
44
45use a2a_protocol_types::proto as apb;
46use a2a_protocol_types::proto::convert::ConvertError;
47use tokio::sync::mpsc;
48use tonic::transport::Channel;
49
50use crate::error::{ClientError, ClientResult};
51use crate::streaming::EventStream;
52use crate::transport::Transport;
53
54mod proto {
57 #![allow(
58 clippy::all,
59 clippy::pedantic,
60 clippy::nursery,
61 missing_docs,
62 unused_qualifications
63 )]
64 tonic::include_proto!("lf.a2a.v1");
65}
66
67use proto::a2a_service_client::A2aServiceClient;
68
69#[derive(Debug, Clone)]
84pub struct GrpcTransportConfig {
85 pub timeout: Duration,
87 pub connect_timeout: Duration,
89 pub max_message_size: usize,
91 pub stream_channel_capacity: usize,
93}
94
95impl Default for GrpcTransportConfig {
96 fn default() -> Self {
97 Self {
98 timeout: Duration::from_secs(30),
99 connect_timeout: Duration::from_secs(10),
100 max_message_size: 4 * 1024 * 1024,
101 stream_channel_capacity: 64,
102 }
103 }
104}
105
106impl GrpcTransportConfig {
107 #[must_use]
109 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
110 self.timeout = timeout;
111 self
112 }
113
114 #[must_use]
116 pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
117 self.connect_timeout = timeout;
118 self
119 }
120
121 #[must_use]
123 pub const fn with_max_message_size(mut self, size: usize) -> Self {
124 self.max_message_size = size;
125 self
126 }
127
128 #[must_use]
130 pub const fn with_stream_channel_capacity(mut self, capacity: usize) -> Self {
131 self.stream_channel_capacity = capacity;
132 self
133 }
134}
135
136#[derive(Clone, Debug)]
144pub struct GrpcTransport {
145 inner: Arc<Inner>,
146}
147
148#[derive(Debug)]
149struct Inner {
150 channel: Channel,
154 endpoint: String,
155 config: GrpcTransportConfig,
156}
157
158impl GrpcTransport {
159 pub async fn connect(endpoint: impl Into<String>) -> ClientResult<Self> {
167 Self::connect_with_config(endpoint, GrpcTransportConfig::default()).await
168 }
169
170 pub async fn connect_with_config(
176 endpoint: impl Into<String>,
177 config: GrpcTransportConfig,
178 ) -> ClientResult<Self> {
179 let endpoint_str = endpoint.into();
180 validate_url(&endpoint_str)?;
181
182 let channel = tonic::transport::Channel::from_shared(endpoint_str.clone())
183 .map_err(|e| ClientError::InvalidEndpoint(format!("invalid gRPC endpoint: {e}")))?
184 .connect_timeout(config.connect_timeout)
185 .timeout(config.timeout)
186 .connect()
187 .await
188 .map_err(|e| ClientError::Transport(format!("gRPC connect failed: {e}")))?;
189
190 Ok(Self {
191 inner: Arc::new(Inner {
192 channel,
193 endpoint: endpoint_str,
194 config,
195 }),
196 })
197 }
198
199 #[must_use]
201 pub fn endpoint(&self) -> &str {
202 &self.inner.endpoint
203 }
204
205 fn client(&self) -> A2aServiceClient<Channel> {
208 A2aServiceClient::new(self.inner.channel.clone())
212 .max_decoding_message_size(self.inner.config.max_message_size)
213 .max_encoding_message_size(self.inner.config.max_message_size)
214 }
215
216 fn request<T>(
217 &self,
218 message: T,
219 extra_headers: &HashMap<String, String>,
220 with_deadline: bool,
221 ) -> ClientResult<tonic::Request<T>> {
222 let mut req = tonic::Request::new(message);
223 if with_deadline {
224 req.set_timeout(self.inner.config.timeout);
225 }
226 Self::add_metadata(&mut req, extra_headers)?;
227 Ok(req)
228 }
229
230 fn add_metadata<T>(
231 req: &mut tonic::Request<T>,
232 extra_headers: &HashMap<String, String>,
233 ) -> ClientResult<()> {
234 let md = req.metadata_mut();
235 md.insert(
236 "a2a-version",
237 a2a_protocol_types::A2A_VERSION
238 .parse()
239 .unwrap_or_else(|_| tonic::metadata::MetadataValue::from_static("")),
240 );
241 for (k, v) in extra_headers {
242 let key = k.parse::<tonic::metadata::MetadataKey<_>>().map_err(|e| {
249 ClientError::Transport(format!("invalid gRPC metadata key {k:?}: {e}"))
250 })?;
251 let val = v
252 .parse::<tonic::metadata::MetadataValue<_>>()
253 .map_err(|_| {
254 ClientError::Transport(format!("invalid gRPC metadata value for key {k:?}"))
255 })?;
256 md.insert(key, val);
257 }
258 Ok(())
259 }
260
261 fn parse_params<T: serde::de::DeserializeOwned>(params: serde_json::Value) -> ClientResult<T> {
262 serde_json::from_value(params).map_err(ClientError::Serialization)
263 }
264
265 fn to_json<T: serde::Serialize>(value: &T) -> ClientResult<serde_json::Value> {
266 serde_json::to_value(value).map_err(ClientError::Serialization)
267 }
268
269 fn status_to_error(status: &tonic::Status) -> ClientError {
270 match status.code() {
273 tonic::Code::DeadlineExceeded => {
274 ClientError::Timeout(format!("gRPC deadline exceeded: {}", status.message()))
275 }
276 tonic::Code::Cancelled => {
277 ClientError::Timeout(format!("gRPC request cancelled: {}", status.message()))
278 }
279 tonic::Code::Unavailable => {
280 ClientError::HttpClient(format!("gRPC unavailable: {}", status.message()))
281 }
282 tonic::Code::ResourceExhausted => ClientError::UnexpectedStatus {
287 status: 429,
288 body: status.message().to_owned(),
289 retry_after: None,
290 },
291 _ => {
292 use tonic_types::StatusExt as _;
298 let code = status
299 .get_details_error_info()
300 .and_then(|info| a2a_protocol_types::ErrorCode::from_a2a_reason(&info.reason))
301 .unwrap_or_else(|| grpc_code_to_error_code(status.code()));
302 let a2a = a2a_protocol_types::A2aError::new(code, status.message().to_owned());
303 ClientError::Protocol(a2a)
304 }
305 }
306 }
307
308 async fn execute_unary(
309 &self,
310 method: &str,
311 params: serde_json::Value,
312 extra_headers: &HashMap<String, String>,
313 ) -> ClientResult<serde_json::Value> {
314 trace_info!(
315 method,
316 endpoint = %self.inner.endpoint,
317 "sending gRPC request"
318 );
319
320 let mut client = self.client();
321 tokio::time::timeout(
322 self.inner.config.timeout,
323 self.dispatch_unary(&mut client, method, params, extra_headers),
324 )
325 .await
326 .map_err(|_| {
327 trace_error!(method, "gRPC request timed out");
328 ClientError::Timeout("gRPC request timed out".into())
329 })?
330 }
331
332 #[allow(clippy::too_many_lines)]
338 async fn dispatch_unary(
339 &self,
340 client: &mut A2aServiceClient<Channel>,
341 method: &str,
342 params: serde_json::Value,
343 extra_headers: &HashMap<String, String>,
344 ) -> ClientResult<serde_json::Value> {
345 match method {
346 "SendMessage" => {
347 let p: a2a_protocol_types::params::MessageSendParams = Self::parse_params(params)?;
348 let req = apb::SendMessageRequest::try_from(p).map_err(convert_error)?;
349 let resp = client
350 .send_message(self.request(req, extra_headers, true)?)
351 .await
352 .map_err(|s| Self::status_to_error(&s))?;
353 let domain: a2a_protocol_types::responses::SendMessageResponse =
354 resp.into_inner().try_into().map_err(convert_error)?;
355 Self::to_json(&domain)
356 }
357 "GetTask" => {
358 let p: a2a_protocol_types::params::TaskQueryParams = Self::parse_params(params)?;
359 let req = apb::GetTaskRequest::try_from(p).map_err(convert_error)?;
360 let resp = client
361 .get_task(self.request(req, extra_headers, true)?)
362 .await
363 .map_err(|s| Self::status_to_error(&s))?;
364 let domain: a2a_protocol_types::task::Task =
365 resp.into_inner().try_into().map_err(convert_error)?;
366 Self::to_json(&domain)
367 }
368 "ListTasks" => {
369 let p: a2a_protocol_types::params::ListTasksParams = Self::parse_params(params)?;
370 let req = apb::ListTasksRequest::try_from(p).map_err(convert_error)?;
371 let resp = client
372 .list_tasks(self.request(req, extra_headers, true)?)
373 .await
374 .map_err(|s| Self::status_to_error(&s))?;
375 let domain: a2a_protocol_types::responses::TaskListResponse =
376 resp.into_inner().try_into().map_err(convert_error)?;
377 Self::to_json(&domain)
378 }
379 "CancelTask" => {
380 let p: a2a_protocol_types::params::CancelTaskParams = Self::parse_params(params)?;
381 let req = apb::CancelTaskRequest::try_from(p).map_err(convert_error)?;
382 let resp = client
383 .cancel_task(self.request(req, extra_headers, true)?)
384 .await
385 .map_err(|s| Self::status_to_error(&s))?;
386 let domain: a2a_protocol_types::task::Task =
387 resp.into_inner().try_into().map_err(convert_error)?;
388 Self::to_json(&domain)
389 }
390 "CreateTaskPushNotificationConfig" => {
391 let p: a2a_protocol_types::push::TaskPushNotificationConfig =
392 Self::parse_params(params)?;
393 let req = apb::TaskPushNotificationConfig::from(p);
394 let resp = client
395 .create_task_push_notification_config(self.request(req, extra_headers, true)?)
396 .await
397 .map_err(|s| Self::status_to_error(&s))?;
398 let domain: a2a_protocol_types::push::TaskPushNotificationConfig =
399 resp.into_inner().into();
400 Self::to_json(&domain)
401 }
402 "GetTaskPushNotificationConfig" => {
403 let p: a2a_protocol_types::params::GetPushConfigParams =
404 Self::parse_params(params)?;
405 let req = apb::GetTaskPushNotificationConfigRequest::from(p);
406 let resp = client
407 .get_task_push_notification_config(self.request(req, extra_headers, true)?)
408 .await
409 .map_err(|s| Self::status_to_error(&s))?;
410 let domain: a2a_protocol_types::push::TaskPushNotificationConfig =
411 resp.into_inner().into();
412 Self::to_json(&domain)
413 }
414 "ListTaskPushNotificationConfigs" => {
415 let p: a2a_protocol_types::params::ListPushConfigsParams =
416 Self::parse_params(params)?;
417 let req = apb::ListTaskPushNotificationConfigsRequest::try_from(p)
418 .map_err(convert_error)?;
419 let resp = client
420 .list_task_push_notification_configs(self.request(req, extra_headers, true)?)
421 .await
422 .map_err(|s| Self::status_to_error(&s))?;
423 let domain: a2a_protocol_types::responses::ListPushConfigsResponse =
424 resp.into_inner().into();
425 Self::to_json(&domain)
426 }
427 "DeleteTaskPushNotificationConfig" => {
428 let p: a2a_protocol_types::params::DeletePushConfigParams =
429 Self::parse_params(params)?;
430 let req = apb::DeleteTaskPushNotificationConfigRequest::from(p);
431 client
432 .delete_task_push_notification_config(self.request(req, extra_headers, true)?)
433 .await
434 .map_err(|s| Self::status_to_error(&s))?;
435 Ok(serde_json::json!({}))
436 }
437 "GetExtendedAgentCard" => {
438 let params = if params.is_null() {
440 serde_json::json!({})
441 } else {
442 params
443 };
444 let p: a2a_protocol_types::params::GetExtendedAgentCardParams =
445 Self::parse_params(params)?;
446 let req = apb::GetExtendedAgentCardRequest::from(p);
447 let resp = client
448 .get_extended_agent_card(self.request(req, extra_headers, true)?)
449 .await
450 .map_err(|s| Self::status_to_error(&s))?;
451 let domain: a2a_protocol_types::agent_card::AgentCard =
452 resp.into_inner().try_into().map_err(convert_error)?;
453 Self::to_json(&domain)
454 }
455 other => Err(ClientError::Protocol(a2a_protocol_types::A2aError::new(
456 a2a_protocol_types::ErrorCode::MethodNotFound,
457 format!("unknown gRPC method: {other}"),
458 ))),
459 }
460 }
461
462 async fn execute_streaming(
463 &self,
464 method: &str,
465 params: serde_json::Value,
466 extra_headers: &HashMap<String, String>,
467 ) -> ClientResult<EventStream> {
468 trace_info!(
469 method,
470 endpoint = %self.inner.endpoint,
471 "opening gRPC stream"
472 );
473
474 let mut client = self.client();
475 let stream = tokio::time::timeout(self.inner.config.timeout, async {
476 match method {
477 "SendStreamingMessage" => {
478 let p: a2a_protocol_types::params::MessageSendParams =
479 Self::parse_params(params)?;
480 let req = apb::SendMessageRequest::try_from(p).map_err(convert_error)?;
481 client
482 .send_streaming_message(self.request(req, extra_headers, false)?)
485 .await
486 .map(tonic::Response::into_inner)
487 .map_err(|s| Self::status_to_error(&s))
488 }
489 "SubscribeToTask" => {
490 let p: a2a_protocol_types::params::TaskIdParams = Self::parse_params(params)?;
491 let req = apb::SubscribeToTaskRequest::from(p);
492 client
493 .subscribe_to_task(self.request(req, extra_headers, false)?)
494 .await
495 .map(tonic::Response::into_inner)
496 .map_err(|s| Self::status_to_error(&s))
497 }
498 other => Err(ClientError::Protocol(a2a_protocol_types::A2aError::new(
499 a2a_protocol_types::ErrorCode::MethodNotFound,
500 format!("unknown streaming gRPC method: {other}"),
501 ))),
502 }
503 })
504 .await
505 .map_err(|_| {
506 trace_error!(method, "gRPC stream connect timed out");
507 ClientError::Timeout("gRPC stream connect timed out".into())
508 })??;
509
510 let cap = self.inner.config.stream_channel_capacity;
511 let (tx, rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(cap);
512
513 let task_handle = tokio::spawn(async move {
514 grpc_stream_reader_task(stream, tx).await;
515 });
516
517 Ok(
526 EventStream::with_status(rx, task_handle.abort_handle(), 200)
527 .with_first_event_timeout(self.inner.config.timeout),
528 )
529 }
530}
531
532impl Transport for GrpcTransport {
533 fn send_request<'a>(
534 &'a self,
535 method: &'a str,
536 params: serde_json::Value,
537 extra_headers: &'a HashMap<String, String>,
538 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
539 Box::pin(self.execute_unary(method, params, extra_headers))
540 }
541
542 fn send_streaming_request<'a>(
543 &'a self,
544 method: &'a str,
545 params: serde_json::Value,
546 extra_headers: &'a HashMap<String, String>,
547 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
548 Box::pin(self.execute_streaming(method, params, extra_headers))
549 }
550}
551
552async fn grpc_stream_reader_task<S>(
562 mut stream: S,
563 tx: mpsc::Sender<crate::streaming::event_stream::BodyChunk>,
564) where
565 S: tonic::codegen::tokio_stream::Stream<Item = Result<apb::StreamResponse, tonic::Status>>
566 + Unpin,
567{
568 use tonic::codegen::tokio_stream::StreamExt;
569
570 loop {
571 match stream.next().await {
572 Some(Ok(pb_event)) => {
573 let event: a2a_protocol_types::events::StreamResponse =
574 match pb_event.try_into().map_err(convert_error) {
575 Ok(e) => e,
576 Err(err) => {
577 let _ = tx.send(Err(err)).await;
578 break;
579 }
580 };
581 let json_str = match serde_json::to_string(&event) {
582 Ok(s) => s,
583 Err(e) => {
584 let _ = tx.send(Err(ClientError::Serialization(e))).await;
585 break;
586 }
587 };
588 let envelope =
591 format!("data: {{\"jsonrpc\":\"2.0\",\"id\":null,\"result\":{json_str}}}\n\n");
592 if tx
593 .send(Ok(hyper::body::Bytes::from(envelope)))
594 .await
595 .is_err()
596 {
597 break;
598 }
599 }
600 Some(Err(status)) => {
601 let _ = tx.send(Err(GrpcTransport::status_to_error(&status))).await;
606 break;
607 }
608 None => break,
609 }
610 }
611}
612
613#[allow(clippy::needless_pass_by_value)]
617fn convert_error(err: ConvertError) -> ClientError {
618 ClientError::Transport(format!("protobuf conversion failed: {err}"))
619}
620
621fn validate_url(url: &str) -> ClientResult<()> {
622 if url.is_empty() {
623 return Err(ClientError::InvalidEndpoint("URL must not be empty".into()));
624 }
625 if !url.starts_with("http://") && !url.starts_with("https://") {
626 return Err(ClientError::InvalidEndpoint(format!(
627 "URL must start with http:// or https://: {url}"
628 )));
629 }
630 Ok(())
631}
632
633const fn grpc_code_to_error_code(code: tonic::Code) -> a2a_protocol_types::ErrorCode {
634 match code {
638 tonic::Code::NotFound => a2a_protocol_types::ErrorCode::TaskNotFound,
639 tonic::Code::InvalidArgument
640 | tonic::Code::Unauthenticated
641 | tonic::Code::PermissionDenied
642 | tonic::Code::ResourceExhausted => a2a_protocol_types::ErrorCode::InvalidParams,
643 tonic::Code::Unimplemented => a2a_protocol_types::ErrorCode::MethodNotFound,
644 tonic::Code::FailedPrecondition => a2a_protocol_types::ErrorCode::TaskNotCancelable,
645 _ => a2a_protocol_types::ErrorCode::InternalError,
646 }
647}
648
649#[cfg(test)]
652mod tests {
653 use super::*;
654 use a2a_protocol_types::events::TaskStatusUpdateEvent;
655 use a2a_protocol_types::task::{ContextId, TaskId, TaskState, TaskStatus};
656
657 #[test]
658 fn validate_url_rejects_empty() {
659 assert!(validate_url("").is_err());
660 }
661
662 #[test]
663 fn validate_url_rejects_non_http() {
664 assert!(validate_url("ftp://example.com").is_err());
665 }
666
667 #[test]
668 fn validate_url_accepts_http() {
669 assert!(validate_url("http://localhost:50051").is_ok());
670 }
671
672 #[test]
673 fn config_default_timeout() {
674 let cfg = GrpcTransportConfig::default();
675 assert_eq!(cfg.timeout, Duration::from_secs(30));
676 }
677
678 #[test]
679 fn config_builder() {
680 let cfg = GrpcTransportConfig::default()
681 .with_timeout(Duration::from_secs(60))
682 .with_max_message_size(8 * 1024 * 1024)
683 .with_stream_channel_capacity(128);
684 assert_eq!(cfg.timeout, Duration::from_secs(60));
685 assert_eq!(cfg.max_message_size, 8 * 1024 * 1024);
686 assert_eq!(cfg.stream_channel_capacity, 128);
687 }
688
689 #[test]
690 fn convert_error_maps_to_non_retryable_transport() {
691 let err = convert_error(ConvertError {
692 field: "part.raw",
693 reason: "invalid base64".into(),
694 });
695 assert!(
696 matches!(err, ClientError::Transport(_)),
697 "conversion failures must be non-retryable: {err:?}"
698 );
699 assert!(!err.is_retryable());
700 }
701
702 #[test]
703 fn grpc_code_not_found_maps_to_task_not_found() {
704 assert_eq!(
705 grpc_code_to_error_code(tonic::Code::NotFound),
706 a2a_protocol_types::ErrorCode::TaskNotFound,
707 );
708 }
709
710 #[test]
711 fn grpc_code_invalid_argument_maps_to_invalid_params() {
712 assert_eq!(
713 grpc_code_to_error_code(tonic::Code::InvalidArgument),
714 a2a_protocol_types::ErrorCode::InvalidParams,
715 );
716 }
717
718 #[test]
719 fn grpc_code_unauthenticated_maps_to_invalid_params() {
720 assert_eq!(
721 grpc_code_to_error_code(tonic::Code::Unauthenticated),
722 a2a_protocol_types::ErrorCode::InvalidParams,
723 );
724 }
725
726 #[test]
727 fn grpc_code_permission_denied_maps_to_invalid_params() {
728 assert_eq!(
729 grpc_code_to_error_code(tonic::Code::PermissionDenied),
730 a2a_protocol_types::ErrorCode::InvalidParams,
731 );
732 }
733
734 #[test]
735 fn grpc_code_resource_exhausted_maps_to_invalid_params() {
736 assert_eq!(
737 grpc_code_to_error_code(tonic::Code::ResourceExhausted),
738 a2a_protocol_types::ErrorCode::InvalidParams,
739 );
740 }
741
742 #[test]
743 fn grpc_code_unimplemented_maps_to_method_not_found() {
744 assert_eq!(
745 grpc_code_to_error_code(tonic::Code::Unimplemented),
746 a2a_protocol_types::ErrorCode::MethodNotFound,
747 );
748 }
749
750 #[test]
751 fn grpc_code_failed_precondition_maps_to_task_not_cancelable() {
752 assert_eq!(
753 grpc_code_to_error_code(tonic::Code::FailedPrecondition),
754 a2a_protocol_types::ErrorCode::TaskNotCancelable,
755 );
756 }
757
758 #[test]
759 fn grpc_code_deadline_exceeded_maps_to_internal() {
760 assert_eq!(
761 grpc_code_to_error_code(tonic::Code::DeadlineExceeded),
762 a2a_protocol_types::ErrorCode::InternalError,
763 );
764 }
765
766 #[test]
767 fn grpc_code_cancelled_maps_to_internal() {
768 assert_eq!(
769 grpc_code_to_error_code(tonic::Code::Cancelled),
770 a2a_protocol_types::ErrorCode::InternalError,
771 );
772 }
773
774 #[test]
775 fn grpc_code_unknown_maps_to_internal() {
776 assert_eq!(
777 grpc_code_to_error_code(tonic::Code::Unknown),
778 a2a_protocol_types::ErrorCode::InternalError,
779 );
780 }
781
782 #[test]
783 fn add_metadata_injects_a2a_version() {
784 let mut req = tonic::Request::new(());
785 let headers = HashMap::new();
786 GrpcTransport::add_metadata(&mut req, &headers).expect("valid headers");
787 let md = req.metadata();
788 let version_value = md
789 .get("a2a-version")
790 .expect("a2a-version header should be present");
791 assert_eq!(
792 version_value.to_str().unwrap(),
793 a2a_protocol_types::A2A_VERSION,
794 );
795 }
796
797 #[test]
798 fn add_metadata_injects_extra_headers() {
799 let mut req = tonic::Request::new(());
800 let mut headers = HashMap::new();
801 headers.insert("x-custom".to_string(), "value123".to_string());
802 GrpcTransport::add_metadata(&mut req, &headers).expect("valid headers");
803 let md = req.metadata();
804 assert_eq!(md.get("x-custom").unwrap().to_str().unwrap(), "value123",);
805 }
806
807 #[test]
808 fn add_metadata_fails_closed_on_invalid_header() {
809 let mut req = tonic::Request::new(());
813 let mut headers = HashMap::new();
814 headers.insert("authorization".to_string(), "Bearer bad\nvalue".to_string());
815 let result = GrpcTransport::add_metadata(&mut req, &headers);
816 assert!(
817 matches!(result, Err(ClientError::Transport(_))),
818 "invalid metadata must fail closed, got: {result:?}"
819 );
820 if let Err(ClientError::Transport(msg)) = result {
822 assert!(!msg.contains("Bearer bad"), "value leaked in error: {msg}");
823 }
824 }
825
826 #[test]
827 fn resource_exhausted_maps_to_retryable_429() {
828 let status = tonic::Status::resource_exhausted("slow down");
829 let err = GrpcTransport::status_to_error(&status);
830 assert!(
831 matches!(err, ClientError::UnexpectedStatus { status: 429, .. }),
832 "ResourceExhausted should map to 429, got {err:?}"
833 );
834 assert!(
835 err.is_retryable(),
836 "gRPC ResourceExhausted must be retryable"
837 );
838 }
839
840 #[test]
843 fn status_to_error_deadline_exceeded_is_timeout() {
844 let status = tonic::Status::deadline_exceeded("test deadline");
845 let err = GrpcTransport::status_to_error(&status);
846 assert!(
847 matches!(err, ClientError::Timeout(_)),
848 "DeadlineExceeded should map to Timeout, got: {err:?}"
849 );
850 }
851
852 #[test]
853 fn status_to_error_cancelled_is_timeout() {
854 let status = tonic::Status::cancelled("test cancel");
855 let err = GrpcTransport::status_to_error(&status);
856 assert!(
857 matches!(err, ClientError::Timeout(_)),
858 "Cancelled should map to Timeout, got: {err:?}"
859 );
860 }
861
862 #[test]
863 fn status_to_error_unavailable_is_http_client() {
864 let status = tonic::Status::unavailable("test unavailable");
865 let err = GrpcTransport::status_to_error(&status);
866 assert!(
867 matches!(err, ClientError::HttpClient(_)),
868 "Unavailable should map to HttpClient, got: {err:?}"
869 );
870 }
871
872 #[test]
873 fn status_to_error_other_is_protocol() {
874 let status = tonic::Status::internal("test internal");
875 let err = GrpcTransport::status_to_error(&status);
876 assert!(
877 matches!(err, ClientError::Protocol(_)),
878 "other codes should map to Protocol, got: {err:?}"
879 );
880 }
881
882 #[test]
885 fn status_to_error_prefers_error_info_reason() {
886 use tonic_types::StatusExt as _;
887 let mut details = tonic_types::ErrorDetails::new();
888 details.set_error_info(
889 "TASK_NOT_CANCELABLE",
890 "a2a-protocol.org",
891 std::collections::HashMap::<String, String>::new(),
892 );
893 let status = tonic::Status::with_error_details(
895 tonic::Code::FailedPrecondition,
896 "task done",
897 details,
898 );
899 let err = GrpcTransport::status_to_error(&status);
900 match err {
901 ClientError::Protocol(a2a) => assert_eq!(
902 a2a.code,
903 a2a_protocol_types::ErrorCode::TaskNotCancelable,
904 "ErrorInfo reason must resolve the exact A2A code"
905 ),
906 other => panic!("expected Protocol error, got: {other:?}"),
907 }
908 }
909
910 #[test]
912 fn status_to_error_unknown_reason_falls_back_to_code() {
913 use tonic_types::StatusExt as _;
914 let mut details = tonic_types::ErrorDetails::new();
915 details.set_error_info(
916 "SOMETHING_NOVEL",
917 "a2a-protocol.org",
918 std::collections::HashMap::<String, String>::new(),
919 );
920 let status = tonic::Status::with_error_details(tonic::Code::NotFound, "missing", details);
921 let err = GrpcTransport::status_to_error(&status);
922 match err {
923 ClientError::Protocol(a2a) => assert_eq!(
924 a2a.code,
925 a2a_protocol_types::ErrorCode::TaskNotFound,
926 "unknown reason must fall back to code-based mapping"
927 ),
928 other => panic!("expected Protocol error, got: {other:?}"),
929 }
930 }
931
932 fn status_update_event() -> apb::StreamResponse {
940 let event = TaskStatusUpdateEvent {
941 task_id: TaskId("t-1".into()),
942 context_id: ContextId("c-1".into()),
943 status: TaskStatus {
944 state: TaskState::Working,
945 message: None,
946 timestamp: None,
947 },
948 metadata: None,
949 };
950 apb::StreamResponse {
951 payload: Some(apb::stream_response::Payload::StatusUpdate(
952 event.try_into().unwrap(),
953 )),
954 }
955 }
956
957 #[tokio::test]
958 async fn grpc_stream_reader_task_forwards_typed_event_as_sse() {
959 let payloads = vec![Ok(status_update_event())];
960 let stream = tonic::codegen::tokio_stream::iter(payloads);
961 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
962
963 grpc_stream_reader_task(stream, tx).await;
964
965 let first = rx.recv().await.expect("expected one chunk");
966 let bytes = first.expect("expected Ok chunk");
967 let text = std::str::from_utf8(&bytes).expect("utf8");
968 assert!(
969 text.starts_with("data: "),
970 "chunk must be SSE-framed: {text}"
971 );
972 assert!(
973 text.contains("\"jsonrpc\":\"2.0\""),
974 "chunk must be JSON-RPC envelope: {text}"
975 );
976 assert!(
977 text.contains("\"statusUpdate\""),
978 "typed event must serialize as the domain union: {text}"
979 );
980 assert!(
981 text.contains("TASK_STATE_WORKING"),
982 "state must use canonical wire encoding: {text}"
983 );
984 assert!(rx.recv().await.is_none());
986 }
987
988 #[tokio::test]
989 async fn grpc_stream_reader_task_forwards_multiple_payloads() {
990 let payloads = vec![
991 Ok(status_update_event()),
992 Ok(status_update_event()),
993 Ok(status_update_event()),
994 ];
995 let stream = tonic::codegen::tokio_stream::iter(payloads);
996 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
997
998 grpc_stream_reader_task(stream, tx).await;
999
1000 let mut received = 0;
1001 while let Some(item) = rx.recv().await {
1002 assert!(item.is_ok());
1003 received += 1;
1004 }
1005 assert_eq!(received, 3, "all three payloads must be forwarded");
1006 }
1007
1008 #[tokio::test]
1009 async fn grpc_stream_reader_task_maps_status_error_to_protocol_error() {
1010 let payloads: Vec<Result<apb::StreamResponse, tonic::Status>> =
1011 vec![Err(tonic::Status::not_found("missing"))];
1012 let stream = tonic::codegen::tokio_stream::iter(payloads);
1013 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
1014
1015 grpc_stream_reader_task(stream, tx).await;
1016
1017 let chunk = rx.recv().await.expect("expected an error chunk");
1018 match chunk {
1019 Err(ClientError::Protocol(a2a)) => {
1020 assert_eq!(a2a.code, a2a_protocol_types::ErrorCode::TaskNotFound);
1021 assert!(a2a.message.contains("missing"));
1022 }
1023 other => panic!("expected Protocol(TaskNotFound), got {other:?}"),
1024 }
1025 }
1026
1027 #[tokio::test]
1028 async fn grpc_stream_reader_task_rejects_empty_payload() {
1029 let payloads = vec![Ok(apb::StreamResponse { payload: None })];
1032 let stream = tonic::codegen::tokio_stream::iter(payloads);
1033 let (tx, mut rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(8);
1034
1035 grpc_stream_reader_task(stream, tx).await;
1036
1037 let chunk = rx.recv().await.expect("expected an error chunk");
1038 match chunk {
1039 Err(ClientError::Transport(msg)) => {
1040 assert!(
1041 msg.contains("streamResponse.payload"),
1042 "msg should name the field: {msg}"
1043 );
1044 }
1045 other => panic!("expected Transport error, got {other:?}"),
1046 }
1047 }
1048
1049 #[tokio::test]
1057 async fn grpc_transport_endpoint_returns_input_url() {
1058 let endpoint_str = "http://localhost:50055".to_string();
1059 let channel = tonic::transport::Channel::from_shared(endpoint_str.clone())
1060 .expect("valid endpoint")
1061 .connect_lazy();
1062 let transport = GrpcTransport {
1063 inner: Arc::new(Inner {
1064 channel,
1065 endpoint: endpoint_str.clone(),
1066 config: GrpcTransportConfig::default(),
1067 }),
1068 };
1069 assert_eq!(transport.endpoint(), endpoint_str);
1070 }
1071
1072 #[tokio::test]
1073 async fn grpc_transport_endpoint_preserves_distinct_urls() {
1074 let a = "http://example.com:1234".to_string();
1075 let b = "https://other.test:9000".to_string();
1076 let mk = |s: String| {
1077 let ch = tonic::transport::Channel::from_shared(s.clone())
1078 .unwrap()
1079 .connect_lazy();
1080 GrpcTransport {
1081 inner: Arc::new(Inner {
1082 channel: ch,
1083 endpoint: s,
1084 config: GrpcTransportConfig::default(),
1085 }),
1086 }
1087 };
1088 let ta = mk(a.clone());
1089 let tb = mk(b.clone());
1090 assert_eq!(ta.endpoint(), a);
1091 assert_eq!(tb.endpoint(), b);
1092 assert_ne!(ta.endpoint(), tb.endpoint());
1093 }
1094}