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::GrpcConfig;
23use super::helpers::{server_error_to_status, validated_metadata};
24use super::pb::a2a_service_server::A2aService;
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 let next = tokio::select! {
82 biased;
83 () = tx.closed() => break,
84 next = reader.read() => next,
85 };
86 match next {
87 Some(Ok(event)) => {
88 let item = apb::StreamResponse::try_from(event.event).map_err(bad_response);
89 let is_err = item.is_err();
90 if tx.send(item).await.is_err() || is_err {
91 break;
92 }
93 }
94 Some(Err(_)) => {
95 let _ = tx.send(Err(Status::internal("event queue error"))).await;
96 break;
97 }
98 None => break,
99 }
100 }
101 });
102 Box::pin(ReceiverStream::new(rx))
103}
104
105pub struct A2aServiceImpl {
110 pub(super) handler: Arc<RequestHandler>,
111 pub(super) config: GrpcConfig,
112}
113
114impl A2aServiceImpl {
115 fn rpc_span(
117 &self,
118 method: &str,
119 headers: &std::collections::HashMap<String, String>,
120 ) -> crate::rpc_span::ServerSpan {
121 crate::rpc_span::ServerSpan::open(
122 &self.handler,
123 crate::rpc_span::RpcSystem::Grpc,
124 method,
125 Some(headers),
126 )
127 }
128}
129
130#[tonic::async_trait]
131impl A2aService for A2aServiceImpl {
132 async fn send_message(
135 &self,
136 request: Request<apb::SendMessageRequest>,
137 ) -> Result<Response<apb::SendMessageResponse>, Status> {
138 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
139 let params: a2a_protocol_types::params::MessageSendParams =
140 request.into_inner().try_into().map_err(bad_request)?;
141 match self
142 .rpc_span("SendMessage", &headers)
143 .run(self.handler.on_send_message(params, false, Some(&headers)))
144 .await
145 {
146 Ok(SendMessageResult::Response(resp)) => {
147 Ok(Response::new(resp.try_into().map_err(bad_response)?))
148 }
149 Ok(SendMessageResult::Stream(_)) => Err(Status::internal(
150 "unexpected stream response for unary call",
151 )),
152 Err(e) => Err(server_error_to_status(&e)),
153 }
154 }
155
156 type SendStreamingMessageStream = NativeStream;
157
158 async fn send_streaming_message(
159 &self,
160 request: Request<apb::SendMessageRequest>,
161 ) -> Result<Response<Self::SendStreamingMessageStream>, Status> {
162 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
163 let params: a2a_protocol_types::params::MessageSendParams =
164 request.into_inner().try_into().map_err(bad_request)?;
165 match self
166 .rpc_span("SendStreamingMessage", &headers)
167 .run(self.handler.on_send_message(params, true, Some(&headers)))
168 .await
169 {
170 Ok(SendMessageResult::Stream(reader)) => Ok(Response::new(reader_to_native_stream(
171 reader,
172 self.config.stream_channel_capacity,
173 ))),
174 Ok(SendMessageResult::Response(resp)) => {
175 let payload = send_result_to_stream(resp).map_err(bad_response)?;
177 let stream = Box::pin(tokio_stream::once(Ok(payload)));
178 Ok(Response::new(stream as NativeStream))
179 }
180 Err(e) => Err(server_error_to_status(&e)),
181 }
182 }
183
184 async fn get_task(
187 &self,
188 request: Request<apb::GetTaskRequest>,
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::TaskQueryParams =
192 request.into_inner().try_into().map_err(bad_request)?;
193 match self
194 .rpc_span("GetTask", &headers)
195 .run(self.handler.on_get_task(params, Some(&headers)))
196 .await
197 {
198 Ok(task) => Ok(Response::new(task.try_into().map_err(bad_response)?)),
199 Err(e) => Err(server_error_to_status(&e)),
200 }
201 }
202
203 async fn list_tasks(
204 &self,
205 request: Request<apb::ListTasksRequest>,
206 ) -> Result<Response<apb::ListTasksResponse>, Status> {
207 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
208 let params: a2a_protocol_types::params::ListTasksParams =
209 request.into_inner().try_into().map_err(bad_request)?;
210 match self
211 .rpc_span("ListTasks", &headers)
212 .run(self.handler.on_list_tasks(params, Some(&headers)))
213 .await
214 {
215 Ok(resp) => Ok(Response::new(resp.try_into().map_err(bad_response)?)),
216 Err(e) => Err(server_error_to_status(&e)),
217 }
218 }
219
220 async fn cancel_task(
221 &self,
222 request: Request<apb::CancelTaskRequest>,
223 ) -> Result<Response<apb::Task>, Status> {
224 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
225 let params: a2a_protocol_types::params::CancelTaskParams =
226 request.into_inner().try_into().map_err(bad_request)?;
227 match self
228 .rpc_span("CancelTask", &headers)
229 .run(self.handler.on_cancel_task(params, Some(&headers)))
230 .await
231 {
232 Ok(task) => Ok(Response::new(task.try_into().map_err(bad_response)?)),
233 Err(e) => Err(server_error_to_status(&e)),
234 }
235 }
236
237 type SubscribeToTaskStream = NativeStream;
238
239 async fn subscribe_to_task(
240 &self,
241 request: Request<apb::SubscribeToTaskRequest>,
242 ) -> Result<Response<Self::SubscribeToTaskStream>, Status> {
243 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
244 let params: a2a_protocol_types::params::TaskIdParams = request.into_inner().into();
245 match self
246 .rpc_span("SubscribeToTask", &headers)
247 .run(self.handler.on_resubscribe(params, Some(&headers)))
248 .await
249 {
250 Ok(reader) => Ok(Response::new(reader_to_native_stream(
251 reader,
252 self.config.stream_channel_capacity,
253 ))),
254 Err(e) => Err(server_error_to_status(&e)),
255 }
256 }
257
258 async fn create_task_push_notification_config(
261 &self,
262 request: Request<apb::TaskPushNotificationConfig>,
263 ) -> Result<Response<apb::TaskPushNotificationConfig>, Status> {
264 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
265 let config: a2a_protocol_types::push::TaskPushNotificationConfig =
266 request.into_inner().into();
267 match self
268 .rpc_span("CreateTaskPushNotificationConfig", &headers)
269 .run(self.handler.on_set_push_config(config, Some(&headers)))
270 .await
271 {
272 Ok(cfg) => Ok(Response::new(cfg.into())),
273 Err(e) => Err(server_error_to_status(&e)),
274 }
275 }
276
277 async fn get_task_push_notification_config(
278 &self,
279 request: Request<apb::GetTaskPushNotificationConfigRequest>,
280 ) -> Result<Response<apb::TaskPushNotificationConfig>, Status> {
281 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
282 let params: a2a_protocol_types::params::GetPushConfigParams = request.into_inner().into();
283 match self
284 .rpc_span("GetTaskPushNotificationConfig", &headers)
285 .run(self.handler.on_get_push_config(params, Some(&headers)))
286 .await
287 {
288 Ok(cfg) => Ok(Response::new(cfg.into())),
289 Err(e) => Err(server_error_to_status(&e)),
290 }
291 }
292
293 async fn list_task_push_notification_configs(
294 &self,
295 request: Request<apb::ListTaskPushNotificationConfigsRequest>,
296 ) -> Result<Response<apb::ListTaskPushNotificationConfigsResponse>, Status> {
297 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
298 let params: a2a_protocol_types::params::ListPushConfigsParams =
299 request.into_inner().try_into().map_err(bad_request)?;
300 match self
301 .rpc_span("ListTaskPushNotificationConfigs", &headers)
302 .run(self.handler.on_list_push_configs(
303 ¶ms.task_id,
304 params.tenant.as_deref(),
305 Some(&headers),
306 ))
307 .await
308 {
309 Ok(configs) => Ok(Response::new(
310 apb::ListTaskPushNotificationConfigsResponse {
311 configs: configs.into_iter().map(Into::into).collect(),
312 next_page_token: String::new(),
313 },
314 )),
315 Err(e) => Err(server_error_to_status(&e)),
316 }
317 }
318
319 async fn delete_task_push_notification_config(
320 &self,
321 request: Request<apb::DeleteTaskPushNotificationConfigRequest>,
322 ) -> Result<Response<()>, Status> {
323 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
324 let params: a2a_protocol_types::params::DeletePushConfigParams =
325 request.into_inner().into();
326 match self
327 .rpc_span("DeleteTaskPushNotificationConfig", &headers)
328 .run(self.handler.on_delete_push_config(params, Some(&headers)))
329 .await
330 {
331 Ok(()) => Ok(Response::new(())),
332 Err(e) => Err(server_error_to_status(&e)),
333 }
334 }
335
336 async fn get_extended_agent_card(
339 &self,
340 request: Request<apb::GetExtendedAgentCardRequest>,
341 ) -> Result<Response<apb::AgentCard>, Status> {
342 let headers = validated_metadata(request.metadata(), self.config.require_version_header)?;
343 match self
344 .rpc_span("GetExtendedAgentCard", &headers)
345 .run(self.handler.on_get_extended_agent_card(Some(&headers)))
346 .await
347 {
348 Ok(card) => Ok(Response::new(card.try_into().map_err(bad_response)?)),
349 Err(e) => Err(server_error_to_status(&e)),
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use a2a_protocol_types::message::{Message, MessageId, MessageRole, Part, PartContent};
358 use a2a_protocol_types::responses::SendMessageResponse;
359 use a2a_protocol_types::task::{ContextId, TaskId};
360
361 #[tokio::test]
367 async fn a_cancelled_stream_releases_its_reader_while_the_task_is_quiet() {
368 use crate::streaming::EventQueueWriter as _;
369 let (writer, reader) = crate::streaming::event_queue::new_in_memory_queue();
370 let stream = reader_to_native_stream(reader, 8);
371 tokio::task::yield_now().await;
373 drop(stream);
374 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
375
376 let event = a2a_protocol_types::events::StreamResponse::StatusUpdate(
377 a2a_protocol_types::events::TaskStatusUpdateEvent {
378 task_id: TaskId::new("t"),
379 context_id: ContextId::new("c"),
380 status: a2a_protocol_types::task::TaskStatus::new(
381 a2a_protocol_types::task::TaskState::Working,
382 ),
383 metadata: None,
384 },
385 );
386 assert!(
387 writer.write(event).await.is_err(),
388 "a reader for a cancelled gRPC stream was still attached to the queue"
389 );
390 }
391
392 #[test]
393 fn bad_request_maps_to_invalid_argument() {
394 let status = bad_request(ConvertError {
395 field: "message.role",
396 reason: "unknown Role number 9".into(),
397 });
398 assert_eq!(status.code(), tonic::Code::InvalidArgument);
399 assert!(status.message().contains("message.role"));
400 }
401
402 #[test]
403 fn bad_response_maps_to_internal() {
404 let status = bad_response(ConvertError {
405 field: "task.metadata",
406 reason: "boom".into(),
407 });
408 assert_eq!(status.code(), tonic::Code::Internal);
409 }
410
411 #[test]
412 fn send_result_message_wraps_into_stream_payload() {
413 let resp = SendMessageResponse::Message(Message {
414 id: MessageId("m".into()),
415 role: MessageRole::Agent,
416 parts: vec![Part {
417 content: PartContent::Text("hi".into()),
418 metadata: None,
419 filename: None,
420 media_type: None,
421 }],
422 task_id: Some(TaskId("t".into())),
423 context_id: Some(ContextId("c".into())),
424 reference_task_ids: None,
425 extensions: None,
426 metadata: None,
427 });
428 let stream = send_result_to_stream(resp).unwrap();
429 assert!(matches!(
430 stream.payload,
431 Some(apb::stream_response::Payload::Message(_))
432 ));
433 }
434
435 use crate::agent_executor;
448 use crate::builder::RequestHandlerBuilder;
449 use a2a_protocol_types::agent_card::{
450 AgentCapabilities, AgentCard, AgentInterface, AgentSkill,
451 };
452
453 struct NoopExecutor;
454 agent_executor!(NoopExecutor, |_ctx, _queue| async { Ok(()) });
455
456 struct NoopSender;
460
461 impl crate::push::PushSender for NoopSender {
462 fn send<'a>(
463 &'a self,
464 _url: &'a str,
465 _event: &'a a2a_protocol_types::events::StreamResponse,
466 _config: &'a a2a_protocol_types::push::TaskPushNotificationConfig,
467 ) -> Pin<
468 Box<
469 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
470 + Send
471 + 'a,
472 >,
473 > {
474 Box::pin(async { Ok(()) })
475 }
476
477 fn allows_private_urls(&self) -> bool {
478 true
479 }
480 }
481
482 fn test_card() -> AgentCard {
483 AgentCard {
484 url: None,
485 name: "native-grpc-test-agent".into(),
486 description: "Fixture for the canonical gRPC binding".into(),
487 version: "1.0.0".into(),
488 supported_interfaces: vec![AgentInterface {
489 url: "grpc://localhost:50051".into(),
490 protocol_binding: "gRPC".into(),
491 protocol_version: "1.0.0".into(),
492 tenant: None,
493 }],
494 default_input_modes: vec!["text/plain".into()],
495 default_output_modes: vec!["text/plain".into()],
496 skills: vec![AgentSkill {
497 id: "noop".into(),
498 name: "Noop".into(),
499 description: "Does nothing".into(),
500 tags: vec!["test".into()],
501 examples: None,
502 input_modes: None,
503 output_modes: None,
504 security_requirements: None,
505 }],
506 capabilities: AgentCapabilities::none()
507 .with_extended_agent_card(true)
508 .with_push_notifications(true),
511 provider: None,
512 icon_url: None,
513 documentation_url: None,
514 security_schemes: None,
515 security_requirements: None,
516 signatures: None,
517 }
518 }
519
520 fn service() -> A2aServiceImpl {
521 A2aServiceImpl {
528 handler: Arc::new(
529 RequestHandlerBuilder::new(NoopExecutor)
530 .with_agent_card(test_card())
531 .with_push_sender(NoopSender)
532 .allow_unauthenticated_extended_card()
535 .build()
536 .expect("default build should succeed"),
537 ),
538 config: GrpcConfig::default().with_require_version_header(false),
539 }
540 }
541
542 fn send_request(text: &str) -> apb::SendMessageRequest {
543 apb::SendMessageRequest {
544 tenant: String::new(),
545 message: Some(apb::Message {
546 message_id: format!("msg-{text}"),
547 context_id: String::new(),
548 task_id: String::new(),
549 role: apb::Role::User as i32,
550 parts: vec![apb::Part {
551 metadata: None,
552 filename: String::new(),
553 media_type: String::new(),
554 content: Some(apb::part::Content::Text(text.into())),
555 }],
556 metadata: None,
557 extensions: Vec::new(),
558 reference_task_ids: Vec::new(),
559 }),
560 configuration: None,
561 metadata: None,
562 }
563 }
564
565 async fn seed_task(svc: &A2aServiceImpl) -> String {
567 let resp = svc
568 .send_message(Request::new(send_request("seed")))
569 .await
570 .expect("send_message should succeed")
571 .into_inner();
572 match resp.payload {
573 Some(apb::send_message_response::Payload::Task(t)) => t.id,
574 other => panic!("expected a Task payload, got {other:?}"),
575 }
576 }
577
578 #[tokio::test]
579 async fn send_message_returns_a_populated_payload() {
580 let svc = service();
581 let resp = svc
582 .send_message(Request::new(send_request("hello")))
583 .await
584 .expect("send_message should succeed")
585 .into_inner();
586
587 let payload = resp.payload.expect("a default response carries no payload");
588 match payload {
589 apb::send_message_response::Payload::Task(t) => {
590 assert!(!t.id.is_empty(), "the created task must carry an id");
591 }
592 apb::send_message_response::Payload::Message(m) => {
593 assert!(!m.message_id.is_empty(), "the reply must carry an id");
594 }
595 }
596 }
597
598 #[tokio::test]
599 async fn get_task_returns_the_task_that_was_created() {
600 let svc = service();
601 let id = seed_task(&svc).await;
602
603 let task = svc
604 .get_task(Request::new(apb::GetTaskRequest {
605 tenant: String::new(),
606 id: id.clone(),
607 history_length: None,
608 }))
609 .await
610 .expect("get_task should find the seeded task")
611 .into_inner();
612
613 assert_eq!(task.id, id, "the id round-trips through the binding");
614 }
615
616 #[tokio::test]
617 async fn get_task_maps_a_missing_task_to_not_found() {
618 let svc = service();
619 let status = svc
620 .get_task(Request::new(apb::GetTaskRequest {
621 tenant: String::new(),
622 id: "no-such-task".into(),
623 history_length: None,
624 }))
625 .await
626 .expect_err("an unknown id must not resolve");
627
628 assert_eq!(status.code(), tonic::Code::NotFound);
629 }
630
631 #[tokio::test]
632 async fn default_config_rejects_a_versionless_rpc() {
633 use tonic_types::StatusExt as _;
634 let svc = A2aServiceImpl {
640 handler: Arc::new(
641 RequestHandlerBuilder::new(NoopExecutor)
642 .with_agent_card(test_card())
643 .with_push_sender(NoopSender)
644 .allow_unauthenticated_extended_card()
645 .build()
646 .expect("default build should succeed"),
647 ),
648 config: GrpcConfig::default(),
649 };
650 let status = svc
651 .get_task(Request::new(apb::GetTaskRequest {
652 tenant: String::new(),
653 id: "any".into(),
654 history_length: None,
655 }))
656 .await
657 .expect_err("a versionless RPC must be rejected under the default config");
658 assert_eq!(status.code(), tonic::Code::FailedPrecondition);
659 assert_eq!(
660 status
661 .get_details_error_info()
662 .expect("version rejection carries ErrorInfo")
663 .reason,
664 "VERSION_NOT_SUPPORTED"
665 );
666 }
667
668 #[tokio::test]
669 async fn list_tasks_returns_the_seeded_task() {
670 let svc = service();
671 let id = seed_task(&svc).await;
672
673 let resp = svc
674 .list_tasks(Request::new(apb::ListTasksRequest::default()))
675 .await
676 .expect("list_tasks should succeed")
677 .into_inner();
678
679 assert!(
680 resp.tasks.iter().any(|t| t.id == id),
681 "the seeded task must appear in the listing, got {:?}",
682 resp.tasks.iter().map(|t| &t.id).collect::<Vec<_>>()
683 );
684 }
685
686 #[tokio::test]
687 async fn cancel_task_moves_the_task_out_of_a_running_state() {
688 let svc = service();
689 let id = seed_task(&svc).await;
690
691 let task = svc
692 .cancel_task(Request::new(apb::CancelTaskRequest {
693 tenant: String::new(),
694 id: id.clone(),
695 metadata: None,
696 }))
697 .await
698 .expect("cancel_task should succeed")
699 .into_inner();
700
701 assert_eq!(task.id, id);
702 let status = task.status.expect("a cancelled task carries a status");
703 assert_eq!(
704 status.state,
705 apb::TaskState::Canceled as i32,
706 "cancel must actually transition the task"
707 );
708 }
709
710 #[tokio::test]
711 async fn cancel_task_maps_a_missing_task_to_not_found() {
712 let svc = service();
713 let status = svc
714 .cancel_task(Request::new(apb::CancelTaskRequest {
715 tenant: String::new(),
716 id: "no-such-task".into(),
717 metadata: None,
718 }))
719 .await
720 .expect_err("an unknown id must not resolve");
721
722 assert_eq!(status.code(), tonic::Code::NotFound);
723 }
724
725 async fn create_push(
730 svc: &A2aServiceImpl,
731 task_id: &str,
732 url: &str,
733 ) -> apb::TaskPushNotificationConfig {
734 svc.create_task_push_notification_config(Request::new(apb::TaskPushNotificationConfig {
735 tenant: String::new(),
736 id: String::new(),
737 task_id: task_id.to_owned(),
738 url: url.to_owned(),
739 token: String::new(),
740 authentication: None,
741 }))
742 .await
743 .expect("create_task_push_notification_config should succeed")
744 .into_inner()
745 }
746
747 async fn list_push(
748 svc: &A2aServiceImpl,
749 task_id: &str,
750 ) -> Vec<apb::TaskPushNotificationConfig> {
751 svc.list_task_push_notification_configs(Request::new(
752 apb::ListTaskPushNotificationConfigsRequest {
753 tenant: String::new(),
754 task_id: task_id.to_owned(),
755 page_size: 0,
756 page_token: String::new(),
757 },
758 ))
759 .await
760 .expect("list_task_push_notification_configs should succeed")
761 .into_inner()
762 .configs
763 }
764
765 #[tokio::test]
766 async fn create_push_config_echoes_the_registered_url() {
767 let svc = service();
768 let task_id = seed_task(&svc).await;
769
770 let created = create_push(&svc, &task_id, "https://example.test/hook").await;
771
772 assert_eq!(created.url, "https://example.test/hook");
773 assert_eq!(created.task_id, task_id);
774 }
775
776 #[tokio::test]
777 async fn get_push_config_returns_what_create_stored() {
778 let svc = service();
779 let task_id = seed_task(&svc).await;
780 let created = create_push(&svc, &task_id, "https://example.test/get").await;
781
782 let fetched = svc
783 .get_task_push_notification_config(Request::new(
784 apb::GetTaskPushNotificationConfigRequest {
785 tenant: String::new(),
786 task_id: task_id.clone(),
787 id: created.id.clone(),
788 },
789 ))
790 .await
791 .expect("the config was just created")
792 .into_inner();
793
794 assert_eq!(fetched.id, created.id);
795 assert_eq!(fetched.url, "https://example.test/get");
796 }
797
798 #[tokio::test]
799 async fn list_push_configs_includes_the_created_one() {
800 let svc = service();
801 let task_id = seed_task(&svc).await;
802 let created = create_push(&svc, &task_id, "https://example.test/list").await;
803
804 let configs = list_push(&svc, &task_id).await;
805
806 assert!(
807 configs.iter().any(|c| c.id == created.id),
808 "the created config must appear in the listing"
809 );
810 }
811
812 #[tokio::test]
819 async fn delete_push_config_removes_it_from_the_listing() {
820 let svc = service();
821 let task_id = seed_task(&svc).await;
822 let created = create_push(&svc, &task_id, "https://example.test/delete").await;
823 assert_eq!(list_push(&svc, &task_id).await.len(), 1, "precondition");
824
825 svc.delete_task_push_notification_config(Request::new(
826 apb::DeleteTaskPushNotificationConfigRequest {
827 tenant: String::new(),
828 task_id: task_id.clone(),
829 id: created.id.clone(),
830 },
831 ))
832 .await
833 .expect("delete_task_push_notification_config should succeed");
834
835 assert!(
836 list_push(&svc, &task_id).await.is_empty(),
837 "delete must actually remove the config"
838 );
839 }
840
841 #[tokio::test]
851 async fn reader_to_native_stream_stops_after_a_conversion_error() {
852 use a2a_protocol_types::events::StreamResponse;
853 use tokio_stream::StreamExt;
854
855 use crate::streaming::EventQueueWriter;
856 use crate::streaming::event_queue::new_in_memory_queue;
857
858 fn message(metadata: serde_json::Value) -> Message {
859 Message {
860 id: MessageId("m".into()),
861 role: MessageRole::Agent,
862 parts: vec![Part {
863 content: PartContent::Text("hi".into()),
864 metadata: None,
865 filename: None,
866 media_type: None,
867 }],
868 task_id: Some(TaskId("t".into())),
869 context_id: Some(ContextId("c".into())),
870 reference_task_ids: None,
871 extensions: None,
872 metadata: Some(metadata),
873 }
874 }
875
876 let (writer, reader) = new_in_memory_queue();
877 writer
880 .write(StreamResponse::Message(message(serde_json::json!(
881 "not-an-object"
882 ))))
883 .await
884 .expect("write of the unconvertible event");
885 writer
888 .write(StreamResponse::Message(message(
889 serde_json::json!({"ok": true}),
890 )))
891 .await
892 .expect("write of the convertible event");
893 drop(writer);
894
895 let mut stream = reader_to_native_stream(reader, 8);
896
897 let first = stream.next().await.expect("the error item is delivered");
898 let status = first.expect_err("an unconvertible event surfaces as an error");
899 assert_eq!(status.code(), tonic::Code::Internal);
900
901 assert!(
902 stream.next().await.is_none(),
903 "the stream must end at the conversion error, not carry on"
904 );
905 }
906
907 #[tokio::test]
908 async fn get_extended_agent_card_returns_the_configured_card() {
909 let svc = service();
910 let card = svc
911 .get_extended_agent_card(Request::new(apb::GetExtendedAgentCardRequest {
912 tenant: String::new(),
913 }))
914 .await
915 .expect("the card is configured and unauthenticated access is opted in")
916 .into_inner();
917
918 assert_eq!(
919 card.name, "native-grpc-test-agent",
920 "a default card would carry an empty name"
921 );
922 }
923}