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