1use std::pin::Pin;
14use std::sync::Arc;
15
16use a2a_protocol_types::proto as apb;
17use a2a_protocol_types::proto::convert::ConvertError;
18use tokio::sync::mpsc;
19use tokio_stream::wrappers::ReceiverStream;
20use tonic::{Request, Response, Status};
21
22use super::helpers::{server_error_to_status, validated_metadata};
23use super::pb::a2a_service_server::A2aService;
24use super::GrpcConfig;
25use crate::handler::{RequestHandler, SendMessageResult};
26
27type NativeStream =
29 Pin<Box<dyn tokio_stream::Stream<Item = Result<apb::StreamResponse, Status>> + Send + 'static>>;
30
31#[allow(clippy::needless_pass_by_value)]
33fn bad_request(err: ConvertError) -> Status {
34 Status::invalid_argument(err.to_string())
35}
36
37#[allow(clippy::needless_pass_by_value)]
40fn bad_response(err: ConvertError) -> Status {
41 Status::internal(format!("response not representable in protobuf: {err}"))
42}
43
44fn send_result_to_stream(
46 resp: a2a_protocol_types::responses::SendMessageResponse,
47) -> Result<apb::StreamResponse, ConvertError> {
48 let payload = match resp {
49 a2a_protocol_types::responses::SendMessageResponse::Task(t) => {
50 apb::stream_response::Payload::Task(t.try_into()?)
51 }
52 a2a_protocol_types::responses::SendMessageResponse::Message(m) => {
53 apb::stream_response::Payload::Message(m.try_into()?)
54 }
55 other => {
56 return Err(ConvertError {
57 field: "sendMessageResponse.payload",
58 reason: format!("unsupported response variant: {other:?}"),
59 })
60 }
61 };
62 Ok(apb::StreamResponse {
63 payload: Some(payload),
64 })
65}
66
67fn reader_to_native_stream(
69 mut reader: crate::streaming::InMemoryQueueReader,
70 capacity: usize,
71) -> NativeStream {
72 use crate::streaming::EventQueueReader;
73 let (tx, rx) = mpsc::channel(capacity);
74 tokio::spawn(async move {
75 loop {
76 match reader.read().await {
77 Some(Ok(event)) => {
78 let item = apb::StreamResponse::try_from(event).map_err(bad_response);
79 let is_err = item.is_err();
80 if tx.send(item).await.is_err() || is_err {
81 break;
82 }
83 }
84 Some(Err(_)) => {
85 let _ = tx.send(Err(Status::internal("event queue error"))).await;
86 break;
87 }
88 None => break,
89 }
90 }
91 });
92 Box::pin(ReceiverStream::new(rx))
93}
94
95pub struct A2aServiceImpl {
100 pub(super) handler: Arc<RequestHandler>,
101 pub(super) config: GrpcConfig,
102}
103
104#[tonic::async_trait]
105impl A2aService for A2aServiceImpl {
106 async fn send_message(
109 &self,
110 request: Request<apb::SendMessageRequest>,
111 ) -> Result<Response<apb::SendMessageResponse>, Status> {
112 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
113 let params: a2a_protocol_types::params::MessageSendParams =
114 request.into_inner().try_into().map_err(bad_request)?;
115 match self
116 .handler
117 .on_send_message(params, false, Some(&headers))
118 .await
119 {
120 Ok(SendMessageResult::Response(resp)) => {
121 Ok(Response::new(resp.try_into().map_err(bad_response)?))
122 }
123 Ok(SendMessageResult::Stream(_)) => Err(Status::internal(
124 "unexpected stream response for unary call",
125 )),
126 Err(e) => Err(server_error_to_status(&e)),
127 }
128 }
129
130 type SendStreamingMessageStream = NativeStream;
131
132 async fn send_streaming_message(
133 &self,
134 request: Request<apb::SendMessageRequest>,
135 ) -> Result<Response<Self::SendStreamingMessageStream>, Status> {
136 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
137 let params: a2a_protocol_types::params::MessageSendParams =
138 request.into_inner().try_into().map_err(bad_request)?;
139 match self
140 .handler
141 .on_send_message(params, true, Some(&headers))
142 .await
143 {
144 Ok(SendMessageResult::Stream(reader)) => Ok(Response::new(reader_to_native_stream(
145 reader,
146 self.config.stream_channel_capacity,
147 ))),
148 Ok(SendMessageResult::Response(resp)) => {
149 let payload = send_result_to_stream(resp).map_err(bad_response)?;
151 let stream = Box::pin(tokio_stream::once(Ok(payload)));
152 Ok(Response::new(stream as NativeStream))
153 }
154 Err(e) => Err(server_error_to_status(&e)),
155 }
156 }
157
158 async fn get_task(
161 &self,
162 request: Request<apb::GetTaskRequest>,
163 ) -> Result<Response<apb::Task>, Status> {
164 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
165 let params: a2a_protocol_types::params::TaskQueryParams =
166 request.into_inner().try_into().map_err(bad_request)?;
167 match self.handler.on_get_task(params, Some(&headers)).await {
168 Ok(task) => Ok(Response::new(task.try_into().map_err(bad_response)?)),
169 Err(e) => Err(server_error_to_status(&e)),
170 }
171 }
172
173 async fn list_tasks(
174 &self,
175 request: Request<apb::ListTasksRequest>,
176 ) -> Result<Response<apb::ListTasksResponse>, Status> {
177 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
178 let params: a2a_protocol_types::params::ListTasksParams =
179 request.into_inner().try_into().map_err(bad_request)?;
180 match self.handler.on_list_tasks(params, Some(&headers)).await {
181 Ok(resp) => Ok(Response::new(resp.try_into().map_err(bad_response)?)),
182 Err(e) => Err(server_error_to_status(&e)),
183 }
184 }
185
186 async fn cancel_task(
187 &self,
188 request: Request<apb::CancelTaskRequest>,
189 ) -> Result<Response<apb::Task>, Status> {
190 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
191 let params: a2a_protocol_types::params::CancelTaskParams =
192 request.into_inner().try_into().map_err(bad_request)?;
193 match self.handler.on_cancel_task(params, Some(&headers)).await {
194 Ok(task) => Ok(Response::new(task.try_into().map_err(bad_response)?)),
195 Err(e) => Err(server_error_to_status(&e)),
196 }
197 }
198
199 type SubscribeToTaskStream = NativeStream;
200
201 async fn subscribe_to_task(
202 &self,
203 request: Request<apb::SubscribeToTaskRequest>,
204 ) -> Result<Response<Self::SubscribeToTaskStream>, Status> {
205 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
206 let params: a2a_protocol_types::params::TaskIdParams = request.into_inner().into();
207 match self.handler.on_resubscribe(params, Some(&headers)).await {
208 Ok(reader) => Ok(Response::new(reader_to_native_stream(
209 reader,
210 self.config.stream_channel_capacity,
211 ))),
212 Err(e) => Err(server_error_to_status(&e)),
213 }
214 }
215
216 async fn create_task_push_notification_config(
219 &self,
220 request: Request<apb::TaskPushNotificationConfig>,
221 ) -> Result<Response<apb::TaskPushNotificationConfig>, Status> {
222 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
223 let config: a2a_protocol_types::push::TaskPushNotificationConfig =
224 request.into_inner().into();
225 match self
226 .handler
227 .on_set_push_config(config, Some(&headers))
228 .await
229 {
230 Ok(cfg) => Ok(Response::new(cfg.into())),
231 Err(e) => Err(server_error_to_status(&e)),
232 }
233 }
234
235 async fn get_task_push_notification_config(
236 &self,
237 request: Request<apb::GetTaskPushNotificationConfigRequest>,
238 ) -> Result<Response<apb::TaskPushNotificationConfig>, Status> {
239 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
240 let params: a2a_protocol_types::params::GetPushConfigParams = request.into_inner().into();
241 match self
242 .handler
243 .on_get_push_config(params, Some(&headers))
244 .await
245 {
246 Ok(cfg) => Ok(Response::new(cfg.into())),
247 Err(e) => Err(server_error_to_status(&e)),
248 }
249 }
250
251 async fn list_task_push_notification_configs(
252 &self,
253 request: Request<apb::ListTaskPushNotificationConfigsRequest>,
254 ) -> Result<Response<apb::ListTaskPushNotificationConfigsResponse>, Status> {
255 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
256 let params: a2a_protocol_types::params::ListPushConfigsParams =
257 request.into_inner().try_into().map_err(bad_request)?;
258 match self
259 .handler
260 .on_list_push_configs(¶ms.task_id, params.tenant.as_deref(), Some(&headers))
261 .await
262 {
263 Ok(configs) => Ok(Response::new(
264 apb::ListTaskPushNotificationConfigsResponse {
265 configs: configs.into_iter().map(Into::into).collect(),
266 next_page_token: String::new(),
267 },
268 )),
269 Err(e) => Err(server_error_to_status(&e)),
270 }
271 }
272
273 async fn delete_task_push_notification_config(
274 &self,
275 request: Request<apb::DeleteTaskPushNotificationConfigRequest>,
276 ) -> Result<Response<()>, Status> {
277 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
278 let params: a2a_protocol_types::params::DeletePushConfigParams =
279 request.into_inner().into();
280 match self
281 .handler
282 .on_delete_push_config(params, Some(&headers))
283 .await
284 {
285 Ok(()) => Ok(Response::new(())),
286 Err(e) => Err(server_error_to_status(&e)),
287 }
288 }
289
290 async fn get_extended_agent_card(
293 &self,
294 request: Request<apb::GetExtendedAgentCardRequest>,
295 ) -> Result<Response<apb::AgentCard>, Status> {
296 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
297 match self
298 .handler
299 .on_get_extended_agent_card(Some(&headers))
300 .await
301 {
302 Ok(card) => Ok(Response::new(card.try_into().map_err(bad_response)?)),
303 Err(e) => Err(server_error_to_status(&e)),
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use a2a_protocol_types::message::{Message, MessageId, MessageRole, Part, PartContent};
312 use a2a_protocol_types::responses::SendMessageResponse;
313 use a2a_protocol_types::task::{ContextId, TaskId};
314
315 #[test]
316 fn bad_request_maps_to_invalid_argument() {
317 let status = bad_request(ConvertError {
318 field: "message.role",
319 reason: "unknown Role number 9".into(),
320 });
321 assert_eq!(status.code(), tonic::Code::InvalidArgument);
322 assert!(status.message().contains("message.role"));
323 }
324
325 #[test]
326 fn bad_response_maps_to_internal() {
327 let status = bad_response(ConvertError {
328 field: "task.metadata",
329 reason: "boom".into(),
330 });
331 assert_eq!(status.code(), tonic::Code::Internal);
332 }
333
334 #[test]
335 fn send_result_message_wraps_into_stream_payload() {
336 let resp = SendMessageResponse::Message(Message {
337 id: MessageId("m".into()),
338 role: MessageRole::Agent,
339 parts: vec![Part {
340 content: PartContent::Text("hi".into()),
341 metadata: None,
342 filename: None,
343 media_type: None,
344 }],
345 task_id: Some(TaskId("t".into())),
346 context_id: Some(ContextId("c".into())),
347 reference_task_ids: None,
348 extensions: None,
349 metadata: None,
350 });
351 let stream = send_result_to_stream(resp).unwrap();
352 assert!(matches!(
353 stream.payload,
354 Some(apb::stream_response::Payload::Message(_))
355 ));
356 }
357
358 use crate::agent_executor;
371 use crate::builder::RequestHandlerBuilder;
372 use a2a_protocol_types::agent_card::{
373 AgentCapabilities, AgentCard, AgentInterface, AgentSkill,
374 };
375
376 struct NoopExecutor;
377 agent_executor!(NoopExecutor, |_ctx, _queue| async { Ok(()) });
378
379 struct NoopSender;
383
384 impl crate::push::PushSender for NoopSender {
385 fn send<'a>(
386 &'a self,
387 _url: &'a str,
388 _event: &'a a2a_protocol_types::events::StreamResponse,
389 _config: &'a a2a_protocol_types::push::TaskPushNotificationConfig,
390 ) -> Pin<
391 Box<
392 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
393 + Send
394 + 'a,
395 >,
396 > {
397 Box::pin(async { Ok(()) })
398 }
399
400 fn allows_private_urls(&self) -> bool {
401 true
402 }
403 }
404
405 fn test_card() -> AgentCard {
406 AgentCard {
407 url: None,
408 name: "native-grpc-test-agent".into(),
409 description: "Fixture for the canonical gRPC binding".into(),
410 version: "1.0.0".into(),
411 supported_interfaces: vec![AgentInterface {
412 url: "grpc://localhost:50051".into(),
413 protocol_binding: "gRPC".into(),
414 protocol_version: "1.0.0".into(),
415 tenant: None,
416 }],
417 default_input_modes: vec!["text/plain".into()],
418 default_output_modes: vec!["text/plain".into()],
419 skills: vec![AgentSkill {
420 id: "noop".into(),
421 name: "Noop".into(),
422 description: "Does nothing".into(),
423 tags: vec!["test".into()],
424 examples: None,
425 input_modes: None,
426 output_modes: None,
427 security_requirements: None,
428 }],
429 capabilities: AgentCapabilities::none()
430 .with_extended_agent_card(true)
431 .with_push_notifications(true),
434 provider: None,
435 icon_url: None,
436 documentation_url: None,
437 security_schemes: None,
438 security_requirements: None,
439 signatures: None,
440 }
441 }
442
443 fn service() -> A2aServiceImpl {
444 A2aServiceImpl {
451 handler: Arc::new(
452 RequestHandlerBuilder::new(NoopExecutor)
453 .with_agent_card(test_card())
454 .with_push_sender(NoopSender)
455 .allow_unauthenticated_extended_card()
458 .build()
459 .expect("default build should succeed"),
460 ),
461 config: GrpcConfig::default().with_require_version_header(false),
462 }
463 }
464
465 fn send_request(text: &str) -> apb::SendMessageRequest {
466 apb::SendMessageRequest {
467 tenant: String::new(),
468 message: Some(apb::Message {
469 message_id: format!("msg-{text}"),
470 context_id: String::new(),
471 task_id: String::new(),
472 role: apb::Role::User as i32,
473 parts: vec![apb::Part {
474 metadata: None,
475 filename: String::new(),
476 media_type: String::new(),
477 content: Some(apb::part::Content::Text(text.into())),
478 }],
479 metadata: None,
480 extensions: Vec::new(),
481 reference_task_ids: Vec::new(),
482 }),
483 configuration: None,
484 metadata: None,
485 }
486 }
487
488 async fn seed_task(svc: &A2aServiceImpl) -> String {
490 let resp = svc
491 .send_message(Request::new(send_request("seed")))
492 .await
493 .expect("send_message should succeed")
494 .into_inner();
495 match resp.payload {
496 Some(apb::send_message_response::Payload::Task(t)) => t.id,
497 other => panic!("expected a Task payload, got {other:?}"),
498 }
499 }
500
501 #[tokio::test]
502 async fn send_message_returns_a_populated_payload() {
503 let svc = service();
504 let resp = svc
505 .send_message(Request::new(send_request("hello")))
506 .await
507 .expect("send_message should succeed")
508 .into_inner();
509
510 let payload = resp.payload.expect("a default response carries no payload");
511 match payload {
512 apb::send_message_response::Payload::Task(t) => {
513 assert!(!t.id.is_empty(), "the created task must carry an id");
514 }
515 apb::send_message_response::Payload::Message(m) => {
516 assert!(!m.message_id.is_empty(), "the reply must carry an id");
517 }
518 }
519 }
520
521 #[tokio::test]
522 async fn get_task_returns_the_task_that_was_created() {
523 let svc = service();
524 let id = seed_task(&svc).await;
525
526 let task = svc
527 .get_task(Request::new(apb::GetTaskRequest {
528 tenant: String::new(),
529 id: id.clone(),
530 history_length: None,
531 }))
532 .await
533 .expect("get_task should find the seeded task")
534 .into_inner();
535
536 assert_eq!(task.id, id, "the id round-trips through the binding");
537 }
538
539 #[tokio::test]
540 async fn get_task_maps_a_missing_task_to_not_found() {
541 let svc = service();
542 let status = svc
543 .get_task(Request::new(apb::GetTaskRequest {
544 tenant: String::new(),
545 id: "no-such-task".into(),
546 history_length: None,
547 }))
548 .await
549 .expect_err("an unknown id must not resolve");
550
551 assert_eq!(status.code(), tonic::Code::NotFound);
552 }
553
554 #[tokio::test]
555 async fn default_config_rejects_a_versionless_rpc() {
556 use tonic_types::StatusExt as _;
557 let svc = A2aServiceImpl {
563 handler: Arc::new(
564 RequestHandlerBuilder::new(NoopExecutor)
565 .with_agent_card(test_card())
566 .with_push_sender(NoopSender)
567 .allow_unauthenticated_extended_card()
568 .build()
569 .expect("default build should succeed"),
570 ),
571 config: GrpcConfig::default(),
572 };
573 let status = svc
574 .get_task(Request::new(apb::GetTaskRequest {
575 tenant: String::new(),
576 id: "any".into(),
577 history_length: None,
578 }))
579 .await
580 .expect_err("a versionless RPC must be rejected under the default config");
581 assert_eq!(status.code(), tonic::Code::Unimplemented);
582 assert_eq!(
583 status
584 .get_details_error_info()
585 .expect("version rejection carries ErrorInfo")
586 .reason,
587 "VERSION_NOT_SUPPORTED"
588 );
589 }
590
591 #[tokio::test]
592 async fn list_tasks_returns_the_seeded_task() {
593 let svc = service();
594 let id = seed_task(&svc).await;
595
596 let resp = svc
597 .list_tasks(Request::new(apb::ListTasksRequest::default()))
598 .await
599 .expect("list_tasks should succeed")
600 .into_inner();
601
602 assert!(
603 resp.tasks.iter().any(|t| t.id == id),
604 "the seeded task must appear in the listing, got {:?}",
605 resp.tasks.iter().map(|t| &t.id).collect::<Vec<_>>()
606 );
607 }
608
609 #[tokio::test]
610 async fn cancel_task_moves_the_task_out_of_a_running_state() {
611 let svc = service();
612 let id = seed_task(&svc).await;
613
614 let task = svc
615 .cancel_task(Request::new(apb::CancelTaskRequest {
616 tenant: String::new(),
617 id: id.clone(),
618 metadata: None,
619 }))
620 .await
621 .expect("cancel_task should succeed")
622 .into_inner();
623
624 assert_eq!(task.id, id);
625 let status = task.status.expect("a cancelled task carries a status");
626 assert_eq!(
627 status.state,
628 apb::TaskState::Canceled as i32,
629 "cancel must actually transition the task"
630 );
631 }
632
633 #[tokio::test]
634 async fn cancel_task_maps_a_missing_task_to_not_found() {
635 let svc = service();
636 let status = svc
637 .cancel_task(Request::new(apb::CancelTaskRequest {
638 tenant: String::new(),
639 id: "no-such-task".into(),
640 metadata: None,
641 }))
642 .await
643 .expect_err("an unknown id must not resolve");
644
645 assert_eq!(status.code(), tonic::Code::NotFound);
646 }
647
648 async fn create_push(
653 svc: &A2aServiceImpl,
654 task_id: &str,
655 url: &str,
656 ) -> apb::TaskPushNotificationConfig {
657 svc.create_task_push_notification_config(Request::new(apb::TaskPushNotificationConfig {
658 tenant: String::new(),
659 id: String::new(),
660 task_id: task_id.to_owned(),
661 url: url.to_owned(),
662 token: String::new(),
663 authentication: None,
664 }))
665 .await
666 .expect("create_task_push_notification_config should succeed")
667 .into_inner()
668 }
669
670 async fn list_push(
671 svc: &A2aServiceImpl,
672 task_id: &str,
673 ) -> Vec<apb::TaskPushNotificationConfig> {
674 svc.list_task_push_notification_configs(Request::new(
675 apb::ListTaskPushNotificationConfigsRequest {
676 tenant: String::new(),
677 task_id: task_id.to_owned(),
678 page_size: 0,
679 page_token: String::new(),
680 },
681 ))
682 .await
683 .expect("list_task_push_notification_configs should succeed")
684 .into_inner()
685 .configs
686 }
687
688 #[tokio::test]
689 async fn create_push_config_echoes_the_registered_url() {
690 let svc = service();
691 let task_id = seed_task(&svc).await;
692
693 let created = create_push(&svc, &task_id, "https://example.test/hook").await;
694
695 assert_eq!(created.url, "https://example.test/hook");
696 assert_eq!(created.task_id, task_id);
697 }
698
699 #[tokio::test]
700 async fn get_push_config_returns_what_create_stored() {
701 let svc = service();
702 let task_id = seed_task(&svc).await;
703 let created = create_push(&svc, &task_id, "https://example.test/get").await;
704
705 let fetched = svc
706 .get_task_push_notification_config(Request::new(
707 apb::GetTaskPushNotificationConfigRequest {
708 tenant: String::new(),
709 task_id: task_id.clone(),
710 id: created.id.clone(),
711 },
712 ))
713 .await
714 .expect("the config was just created")
715 .into_inner();
716
717 assert_eq!(fetched.id, created.id);
718 assert_eq!(fetched.url, "https://example.test/get");
719 }
720
721 #[tokio::test]
722 async fn list_push_configs_includes_the_created_one() {
723 let svc = service();
724 let task_id = seed_task(&svc).await;
725 let created = create_push(&svc, &task_id, "https://example.test/list").await;
726
727 let configs = list_push(&svc, &task_id).await;
728
729 assert!(
730 configs.iter().any(|c| c.id == created.id),
731 "the created config must appear in the listing"
732 );
733 }
734
735 #[tokio::test]
742 async fn delete_push_config_removes_it_from_the_listing() {
743 let svc = service();
744 let task_id = seed_task(&svc).await;
745 let created = create_push(&svc, &task_id, "https://example.test/delete").await;
746 assert_eq!(list_push(&svc, &task_id).await.len(), 1, "precondition");
747
748 svc.delete_task_push_notification_config(Request::new(
749 apb::DeleteTaskPushNotificationConfigRequest {
750 tenant: String::new(),
751 task_id: task_id.clone(),
752 id: created.id.clone(),
753 },
754 ))
755 .await
756 .expect("delete_task_push_notification_config should succeed");
757
758 assert!(
759 list_push(&svc, &task_id).await.is_empty(),
760 "delete must actually remove the config"
761 );
762 }
763
764 #[tokio::test]
774 async fn reader_to_native_stream_stops_after_a_conversion_error() {
775 use a2a_protocol_types::events::StreamResponse;
776 use tokio_stream::StreamExt;
777
778 use crate::streaming::event_queue::new_in_memory_queue;
779 use crate::streaming::EventQueueWriter;
780
781 fn message(metadata: serde_json::Value) -> Message {
782 Message {
783 id: MessageId("m".into()),
784 role: MessageRole::Agent,
785 parts: vec![Part {
786 content: PartContent::Text("hi".into()),
787 metadata: None,
788 filename: None,
789 media_type: None,
790 }],
791 task_id: Some(TaskId("t".into())),
792 context_id: Some(ContextId("c".into())),
793 reference_task_ids: None,
794 extensions: None,
795 metadata: Some(metadata),
796 }
797 }
798
799 let (writer, reader) = new_in_memory_queue();
800 writer
803 .write(StreamResponse::Message(message(serde_json::json!(
804 "not-an-object"
805 ))))
806 .await
807 .expect("write of the unconvertible event");
808 writer
811 .write(StreamResponse::Message(message(
812 serde_json::json!({"ok": true}),
813 )))
814 .await
815 .expect("write of the convertible event");
816 drop(writer);
817
818 let mut stream = reader_to_native_stream(reader, 8);
819
820 let first = stream.next().await.expect("the error item is delivered");
821 let status = first.expect_err("an unconvertible event surfaces as an error");
822 assert_eq!(status.code(), tonic::Code::Internal);
823
824 assert!(
825 stream.next().await.is_none(),
826 "the stream must end at the conversion error, not carry on"
827 );
828 }
829
830 #[tokio::test]
831 async fn get_extended_agent_card_returns_the_configured_card() {
832 let svc = service();
833 let card = svc
834 .get_extended_agent_card(Request::new(apb::GetExtendedAgentCardRequest {
835 tenant: String::new(),
836 }))
837 .await
838 .expect("the card is configured and unauthenticated access is opted in")
839 .into_inner();
840
841 assert_eq!(
842 card.name, "native-grpc-test-agent",
843 "a default card would carry an empty name"
844 );
845 }
846}