1mod adapter;
82mod auth;
83mod event;
84mod no_streaming;
85mod scheduled_recovery;
86mod stream_recovery;
87
88#[cfg(feature = "sqs")]
89mod durable;
90
91#[cfg(test)]
92mod builder_tests;
93#[cfg(test)]
94mod scheduled_recovery_tests;
95#[cfg(test)]
96mod stream_recovery_tests;
97
98pub use adapter::{axum_to_lambda_response, lambda_to_axum_request};
99pub use auth::{AuthorizerMapping, LambdaAuthorizerMiddleware};
100pub use event::{LambdaEvent, classify_event};
101pub use no_streaming::NoStreamingLayer;
102pub use scheduled_recovery::{
103 LambdaScheduledRecoveryConfig, LambdaScheduledRecoveryHandler, LambdaScheduledRecoveryResponse,
104 handle_maintenance_trigger,
105};
106pub use stream_recovery::LambdaStreamRecoveryHandler;
107
108#[cfg(feature = "sqs")]
109pub use durable::{SqsDurableExecutorQueue, drive_sqs_batch};
110
111use std::sync::Arc;
112
113use turul_a2a::error::A2aError;
114use turul_a2a::executor::AgentExecutor;
115use turul_a2a::middleware::{A2aMiddleware, MiddlewareStack};
116use turul_a2a::push::A2aPushDeliveryStore;
117use turul_a2a::router::{AppState, build_router};
118use turul_a2a::server::RuntimeConfig;
119use turul_a2a::storage::{
120 A2aAtomicStore, A2aCancellationSupervisor, A2aEventStore, A2aPushNotificationStorage,
121 A2aTaskStorage,
122};
123use turul_a2a::streaming::TaskEventBroker;
124
125pub struct LambdaA2aServerBuilder {
136 executor: Option<Arc<dyn AgentExecutor>>,
137 task_storage: Option<Arc<dyn A2aTaskStorage>>,
138 push_storage: Option<Arc<dyn A2aPushNotificationStorage>>,
139 event_store: Option<Arc<dyn A2aEventStore>>,
140 atomic_store: Option<Arc<dyn A2aAtomicStore>>,
141 cancellation_supervisor: Option<Arc<dyn A2aCancellationSupervisor>>,
142 push_delivery_store: Option<Arc<dyn A2aPushDeliveryStore>>,
143 middleware: Vec<Arc<dyn A2aMiddleware>>,
144 runtime_config: RuntimeConfig,
145 durable_executor_queue: Option<Arc<dyn turul_a2a::durable_executor::DurableExecutorQueue>>,
146 path_prefix: Option<String>,
147 outbound_url_validator: Option<turul_a2a::push::ssrf::OutboundUrlValidator>,
148}
149
150impl LambdaA2aServerBuilder {
151 pub fn new() -> Self {
152 Self {
153 executor: None,
154 task_storage: None,
155 push_storage: None,
156 event_store: None,
157 atomic_store: None,
158 cancellation_supervisor: None,
159 push_delivery_store: None,
160 middleware: vec![],
161 runtime_config: RuntimeConfig::default(),
162 durable_executor_queue: None,
163 path_prefix: None,
164 outbound_url_validator: None,
165 }
166 }
167
168 pub fn strip_path_prefix(mut self, prefix: impl Into<String>) -> Self {
185 self.path_prefix = Some(prefix.into());
186 self
187 }
188
189 pub fn with_durable_executor(
203 mut self,
204 queue: Arc<dyn turul_a2a::durable_executor::DurableExecutorQueue>,
205 ) -> Self {
206 self.durable_executor_queue = Some(queue);
207 self.runtime_config.supports_return_immediately = true;
208 self
209 }
210
211 #[cfg(feature = "sqs")]
216 pub fn with_sqs_return_immediately(
217 self,
218 queue_url: impl Into<String>,
219 sqs_client: Arc<aws_sdk_sqs::Client>,
220 ) -> Self {
221 let queue = Arc::new(SqsDurableExecutorQueue::new(queue_url, sqs_client));
222 self.with_durable_executor(queue)
223 }
224
225 pub fn executor(mut self, exec: impl AgentExecutor + 'static) -> Self {
226 self.executor = Some(Arc::new(exec));
227 self
228 }
229
230 pub fn storage<S>(mut self, storage: S) -> Self
246 where
247 S: A2aTaskStorage
248 + A2aPushNotificationStorage
249 + A2aEventStore
250 + A2aAtomicStore
251 + A2aCancellationSupervisor
252 + Clone
253 + 'static,
254 {
255 self.task_storage = Some(Arc::new(storage.clone()));
256 self.push_storage = Some(Arc::new(storage.clone()));
257 self.event_store = Some(Arc::new(storage.clone()));
258 self.atomic_store = Some(Arc::new(storage.clone()));
259 self.cancellation_supervisor = Some(Arc::new(storage));
260 self
261 }
262
263 pub fn task_storage(mut self, s: impl A2aTaskStorage + 'static) -> Self {
266 self.task_storage = Some(Arc::new(s));
267 self
268 }
269
270 pub fn push_storage(mut self, s: impl A2aPushNotificationStorage + 'static) -> Self {
272 self.push_storage = Some(Arc::new(s));
273 self
274 }
275
276 pub fn event_store(mut self, s: impl A2aEventStore + 'static) -> Self {
278 self.event_store = Some(Arc::new(s));
279 self
280 }
281
282 pub fn atomic_store(mut self, s: impl A2aAtomicStore + 'static) -> Self {
284 self.atomic_store = Some(Arc::new(s));
285 self
286 }
287
288 pub fn cancellation_supervisor(mut self, s: impl A2aCancellationSupervisor + 'static) -> Self {
294 self.cancellation_supervisor = Some(Arc::new(s));
295 self
296 }
297
298 pub fn push_delivery_store(mut self, store: impl A2aPushDeliveryStore + 'static) -> Self {
306 self.push_delivery_store = Some(Arc::new(store));
307 self
308 }
309
310 pub fn runtime_config(mut self, cfg: RuntimeConfig) -> Self {
315 self.runtime_config = cfg;
316 self
317 }
318
319 pub fn middleware(mut self, mw: Arc<dyn A2aMiddleware>) -> Self {
320 self.middleware.push(mw);
321 self
322 }
323
324 pub fn outbound_url_validator(
334 mut self,
335 validator: turul_a2a::push::ssrf::OutboundUrlValidator,
336 ) -> Self {
337 self.outbound_url_validator = Some(validator);
338 self
339 }
340
341 pub fn build(self) -> Result<LambdaA2aHandler, A2aError> {
342 let executor = self
343 .executor
344 .ok_or(A2aError::Internal("executor is required".into()))?;
345 let task_storage = self.task_storage.ok_or(A2aError::Internal(
346 "task_storage is required for Lambda".into(),
347 ))?;
348 let push_storage = self.push_storage.ok_or(A2aError::Internal(
349 "push_storage is required for Lambda".into(),
350 ))?;
351 let event_store: Arc<dyn A2aEventStore> = self.event_store.ok_or(A2aError::Internal(
352 "event_store is required for Lambda (use .storage() for unified backend)".into(),
353 ))?;
354 let atomic_store: Arc<dyn A2aAtomicStore> = self.atomic_store.ok_or(A2aError::Internal(
355 "atomic_store is required for Lambda (use .storage() for unified backend)".into(),
356 ))?;
357 let cancellation_supervisor: Arc<dyn A2aCancellationSupervisor> =
362 self.cancellation_supervisor.ok_or(A2aError::Internal(
363 "cancellation_supervisor is required for Lambda. Use .storage() for unified \
364 backend wiring, or .cancellation_supervisor(...) alongside the individual \
365 storage setters. Omitting it silently breaks cross-instance cancellation."
366 .into(),
367 ))?;
368
369 let task_backend = task_storage.backend_name();
372 let push_backend = push_storage.backend_name();
373 let event_backend = event_store.backend_name();
374 let atomic_backend = atomic_store.backend_name();
375 let supervisor_backend = cancellation_supervisor.backend_name();
376 if task_backend != push_backend
377 || task_backend != event_backend
378 || task_backend != atomic_backend
379 || task_backend != supervisor_backend
380 {
381 return Err(A2aError::Internal(format!(
382 "Storage backend mismatch: task={task_backend}, push={push_backend}, \
383 event={event_backend}, atomic={atomic_backend}, \
384 cancellation_supervisor={supervisor_backend}. \
385 All storage traits, the cancellation supervisor included, \
386 must share one backend so cross-instance cancel markers \
387 are observable. Use .storage() for unified backend."
388 )));
389 }
390
391 let push_delivery_store = self.push_delivery_store;
392
393 match (
399 push_delivery_store.is_some(),
400 atomic_store.push_dispatch_enabled(),
401 ) {
402 (true, true) | (false, false) => {}
403 (true, false) => {
404 return Err(A2aError::Internal(
405 "push_delivery_store wired but atomic_store.push_dispatch_enabled() \
406 is false. Call .with_push_dispatch_enabled(true) on the backend \
407 storage before passing it to .storage()."
408 .into(),
409 ));
410 }
411 (false, true) => {
412 return Err(A2aError::Internal(
413 "atomic_store.push_dispatch_enabled() is true but no \
414 push_delivery_store is wired. Pending-dispatch markers would be \
415 written with no consumer, imposing load-bearing infra for no \
416 benefit. If you need to populate markers for an external \
417 consumer, open an issue for a distinctly-named opt-in — for now, \
418 this configuration is rejected."
419 .into(),
420 ));
421 }
422 }
423
424 let mut runtime_config = self.runtime_config;
440 if self.durable_executor_queue.is_none() {
441 runtime_config.supports_return_immediately = false;
442 }
443 runtime_config.executor_lease_ttl = None;
446 runtime_config.executor_stall_timeout = None;
447 if runtime_config
450 .idle_task_timeout
451 .is_some_and(|d| d.is_zero())
452 {
453 return Err(A2aError::Internal(
454 "idle_task_timeout must be greater than zero".into(),
455 ));
456 }
457
458 let push_dispatcher: Option<Arc<turul_a2a::push::PushDispatcher>> = if let Some(delivery) =
465 push_delivery_store.as_ref()
466 {
467 let push_delivery_backend = delivery.backend_name();
468 if task_backend != push_delivery_backend {
469 return Err(A2aError::Internal(format!(
470 "Storage backend mismatch: task={task_backend}, \
471 push_delivery={push_delivery_backend}. \
472 All storage traits must share the same backend."
473 )));
474 }
475
476 let retry_horizon = runtime_config
480 .push_backoff_cap
481 .saturating_mul(runtime_config.push_max_attempts as u32);
482 if runtime_config.push_claim_expiry <= retry_horizon {
483 return Err(A2aError::Internal(format!(
484 "push_claim_expiry ({:?}) must be greater than retry horizon \
485 (push_max_attempts={} * push_backoff_cap={:?} = {:?}). \
486 Raise push_claim_expiry or lower push_max_attempts/push_backoff_cap.",
487 runtime_config.push_claim_expiry,
488 runtime_config.push_max_attempts,
489 runtime_config.push_backoff_cap,
490 retry_horizon
491 )));
492 }
493
494 let delivery_cfg =
495 turul_a2a::push::delivery::PushDeliveryConfig::from_runtime_config(&runtime_config);
496
497 let instance_id = format!("a2a-lambda-{}", uuid::Uuid::now_v7());
498 let worker = turul_a2a::push::delivery::PushDeliveryWorker::new(
499 delivery.clone(),
500 delivery_cfg,
501 self.outbound_url_validator.clone(),
502 instance_id,
503 )
504 .map_err(|e| A2aError::Internal(format!("push worker build failed: {e}")))?;
505
506 Some(Arc::new(turul_a2a::push::PushDispatcher::new(
507 Arc::new(worker),
508 push_storage.clone(),
509 task_storage.clone(),
510 )))
511 } else {
512 None
513 };
514
515 let state = AppState {
516 executor,
517 task_storage,
518 push_storage,
519 event_store,
520 atomic_store,
521 event_broker: TaskEventBroker::new(),
522 middleware_stack: Arc::new(MiddlewareStack::new(self.middleware)),
523 runtime_config,
524 in_flight: Arc::new(turul_a2a::server::in_flight::InFlightRegistry::new()),
525 cancellation_supervisor,
526 push_delivery_store,
527 push_dispatcher,
528 durable_executor_queue: self.durable_executor_queue,
529 };
530
531 let router = build_router(state.clone());
532
533 let path_prefix = match self.path_prefix {
534 None => None,
535 Some(p) if p.is_empty() || p == "/" => None,
536 Some(p) => {
537 if !p.starts_with('/') {
538 return Err(A2aError::InvalidRequest {
539 message: format!(
540 "LambdaA2aServerBuilder::strip_path_prefix: prefix must start with '/'; got {p:?}"
541 ),
542 });
543 }
544 if p.ends_with('/') {
545 return Err(A2aError::InvalidRequest {
546 message: format!(
547 "LambdaA2aServerBuilder::strip_path_prefix: prefix must not end with '/' (except '/'); got {p:?}"
548 ),
549 });
550 }
551 Some(Arc::from(p))
552 }
553 };
554
555 Ok(LambdaA2aHandler {
556 router,
557 state,
558 path_prefix,
559 })
560 }
561}
562
563impl Default for LambdaA2aServerBuilder {
564 fn default() -> Self {
565 Self::new()
566 }
567}
568
569#[derive(Clone)]
571pub struct LambdaA2aHandler {
572 router: axum::Router,
573 state: AppState,
576 path_prefix: Option<Arc<str>>,
580}
581
582impl LambdaA2aHandler {
583 pub fn builder() -> LambdaA2aServerBuilder {
584 LambdaA2aServerBuilder::new()
585 }
586
587 pub async fn cancel_idle_tasks(
593 &self,
594 ) -> Result<turul_a2a::server::IdleSweep, turul_a2a::storage::A2aStorageError> {
595 match self.state.runtime_config.idle_task_timeout {
596 Some(idle_for) => {
597 turul_a2a::server::liveness::cancel_idle_tasks(
598 &self.state,
599 Some(&self.state.in_flight),
600 idle_for,
601 )
602 .await
603 }
604 None => Ok(Default::default()),
605 }
606 }
607
608 pub async fn handle(
610 &self,
611 event: lambda_http::Request,
612 ) -> Result<lambda_http::Response<lambda_http::Body>, lambda_http::Error> {
613 let axum_req = lambda_to_axum_request(event)?;
614 let axum_req = match &self.path_prefix {
615 Some(prefix) => adapter::strip_request_path_prefix(axum_req, prefix),
616 None => axum_req,
617 };
618
619 let axum_resp = tower::ServiceExt::oneshot(self.router.clone(), axum_req)
620 .await
621 .map_err(|e| lambda_http::Error::from(format!("Router error: {e}")))?;
622
623 axum_to_lambda_response(axum_resp).await
624 }
625
626 pub async fn handle_http_event_value(
664 &self,
665 value: serde_json::Value,
666 ) -> Result<serde_json::Value, lambda_runtime::Error> {
667 use base64::Engine;
668 use http_body_util::BodyExt;
669
670 let lambda_req: lambda_http::request::LambdaRequest = serde_json::from_value(value)
671 .map_err(|e| lambda_runtime::Error::from(format!("invalid HTTP event: {e}")))?;
672
673 let shape = match &lambda_req {
676 lambda_http::request::LambdaRequest::ApiGatewayV1(_) => HttpEventShape::ApiGatewayV1,
677 lambda_http::request::LambdaRequest::ApiGatewayV2(_) => HttpEventShape::ApiGatewayV2,
678 lambda_http::request::LambdaRequest::Alb(_) => HttpEventShape::Alb,
679 lambda_http::request::LambdaRequest::WebSocket(_) => {
680 return Err(lambda_runtime::Error::from(
681 "WebSocket Lambda events are not supported by this adapter",
682 ));
683 }
684 _ => {
685 return Err(lambda_runtime::Error::from(
686 "unsupported Lambda HTTP event variant",
687 ));
688 }
689 };
690
691 let req: lambda_http::Request = lambda_req.into();
692
693 let resp = self
694 .handle(req)
695 .await
696 .map_err(|e| lambda_runtime::Error::from(format!("handler error: {e}")))?;
697 let (parts, body) = resp.into_parts();
698
699 let bytes = body
700 .collect()
701 .await
702 .map_err(|e| lambda_runtime::Error::from(format!("body collect: {e}")))?
703 .to_bytes();
704
705 let ct = parts
706 .headers
707 .get(http::header::CONTENT_TYPE)
708 .and_then(|v| v.to_str().ok())
709 .unwrap_or("application/octet-stream")
710 .to_ascii_lowercase();
711 let essence = ct.split(';').next().unwrap_or("").trim();
712 let is_text = essence.starts_with("text/")
713 || matches!(
714 essence,
715 "application/json" | "application/xml" | "application/javascript"
716 )
717 || essence.ends_with("+json")
718 || essence.ends_with("+xml")
719 || ct.contains("charset=");
720
721 let (body_str, is_base64) = if bytes.is_empty() {
722 (None, false)
723 } else if is_text {
724 match std::str::from_utf8(&bytes) {
725 Ok(s) => (Some(s.to_string()), false),
726 Err(_) => (
727 Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
728 true,
729 ),
730 }
731 } else {
732 (
733 Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
734 true,
735 )
736 };
737
738 let status = parts.status.as_u16() as i64;
739 let headers = parts.headers;
740 let body_lambda = body_str.map(aws_lambda_events::encodings::Body::Text);
741
742 match shape {
743 HttpEventShape::ApiGatewayV1 => {
744 let mut api_resp = aws_lambda_events::apigw::ApiGatewayProxyResponse::default();
745 api_resp.status_code = status;
746 api_resp.headers = headers;
747 api_resp.body = body_lambda;
748 api_resp.is_base64_encoded = is_base64;
749 serde_json::to_value(api_resp).map_err(|e| {
750 lambda_runtime::Error::from(format!("serialise APIGW v1 response: {e}"))
751 })
752 }
753 HttpEventShape::ApiGatewayV2 => {
754 let mut api_resp = aws_lambda_events::apigw::ApiGatewayV2httpResponse::default();
755 api_resp.status_code = status;
756 api_resp.headers = headers;
757 api_resp.body = body_lambda;
758 api_resp.is_base64_encoded = is_base64;
759 serde_json::to_value(api_resp).map_err(|e| {
760 lambda_runtime::Error::from(format!("serialise APIGW v2 response: {e}"))
761 })
762 }
763 HttpEventShape::Alb => {
764 let mut api_resp = aws_lambda_events::alb::AlbTargetGroupResponse::default();
765 api_resp.status_code = status;
766 api_resp.headers = headers;
767 api_resp.body = body_lambda;
768 api_resp.is_base64_encoded = is_base64;
769 serde_json::to_value(api_resp).map_err(|e| {
770 lambda_runtime::Error::from(format!("serialise ALB response: {e}"))
771 })
772 }
773 }
774 }
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782enum HttpEventShape {
783 ApiGatewayV1,
784 ApiGatewayV2,
785 Alb,
786}
787
788impl LambdaA2aHandler {
789 #[cfg(feature = "sqs")]
800 pub async fn handle_sqs(
801 &self,
802 event: aws_lambda_events::event::sqs::SqsEvent,
803 ) -> aws_lambda_events::event::sqs::SqsBatchResponse {
804 durable::drive_sqs_batch(&self.state, event).await
805 }
806
807 pub async fn run_http_only(self) -> Result<(), lambda_runtime::Error> {
816 let handler = Arc::new(self);
817 lambda_http::run(lambda_http::service_fn(move |event| {
818 let h = Arc::clone(&handler);
819 async move { h.handle(event).await }
820 }))
821 .await
822 }
823}
824
825#[cfg(not(feature = "sqs"))]
829impl LambdaA2aHandler {
830 pub async fn run(self) -> Result<(), lambda_runtime::Error> {
834 self.run_http_only().await
835 }
836}
837
838impl LambdaA2aServerBuilder {
839 pub async fn run(self) -> Result<(), lambda_runtime::Error> {
845 let handler = self.build().map_err(|e| {
846 lambda_runtime::Error::from(format!("LambdaA2aServerBuilder::run: {e}"))
847 })?;
848 handler.run().await
849 }
850}
851
852#[cfg(test)]
853mod handle_http_event_value_shape_tests {
854 use super::*;
862 use std::sync::Arc;
863 use turul_a2a::executor::{AgentExecutor, ExecutionContext};
864 use turul_a2a::server::RuntimeConfig;
865 use turul_a2a::server::in_flight::InFlightRegistry;
866 use turul_a2a::storage::InMemoryA2aStorage;
867 use turul_a2a::streaming::TaskEventBroker;
868 use turul_a2a_types::{Message, Task};
869
870 struct NoOpExecutor;
871
872 #[async_trait::async_trait]
873 impl AgentExecutor for NoOpExecutor {
874 async fn execute(
875 &self,
876 task: &mut Task,
877 _msg: &Message,
878 _ctx: &ExecutionContext,
879 ) -> Result<(), turul_a2a::error::A2aError> {
880 let mut proto = task.as_proto().clone();
881 proto.status = Some(turul_a2a_proto::TaskStatus {
882 state: turul_a2a_proto::TaskState::Completed.into(),
883 message: None,
884 timestamp: None,
885 });
886 *task = Task::try_from(proto).unwrap();
887 Ok(())
888 }
889 fn agent_card(&self) -> turul_a2a_proto::AgentCard {
890 turul_a2a_proto::AgentCard {
891 name: "test-agent".to_string(),
892 ..Default::default()
893 }
894 }
895 }
896
897 fn build_handler() -> LambdaA2aHandler {
898 let s = Arc::new(InMemoryA2aStorage::new());
899 let state = AppState {
900 executor: Arc::new(NoOpExecutor),
901 task_storage: s.clone(),
902 push_storage: s.clone(),
903 event_store: s.clone(),
904 atomic_store: s.clone(),
905 event_broker: TaskEventBroker::new(),
906 middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
907 runtime_config: RuntimeConfig::default(),
908 in_flight: Arc::new(InFlightRegistry::new()),
909 cancellation_supervisor: s.clone(),
910 push_delivery_store: None,
911 push_dispatcher: None,
912 durable_executor_queue: None,
913 };
914 let router = build_router(state.clone());
915 LambdaA2aHandler {
916 router,
917 state,
918 path_prefix: None,
919 }
920 }
921
922 fn apigw_v2_agent_card_get() -> serde_json::Value {
925 serde_json::json!({
926 "version": "2.0",
927 "routeKey": "GET /.well-known/agent-card.json",
928 "rawPath": "/.well-known/agent-card.json",
929 "rawQueryString": "",
930 "headers": {"accept": "application/json", "a2a-version": "1.0"},
931 "requestContext": {
932 "accountId": "000000000000",
933 "apiId": "api",
934 "domainName": "fake.execute-api",
935 "domainPrefix": "fake",
936 "http": {
937 "method": "GET",
938 "path": "/.well-known/agent-card.json",
939 "protocol": "HTTP/1.1",
940 "sourceIp": "127.0.0.1",
941 "userAgent": "test"
942 },
943 "requestId": "rid",
944 "routeKey": "GET /.well-known/agent-card.json",
945 "stage": "$default",
946 "time": "01/Jan/2026:00:00:00 +0000",
947 "timeEpoch": 1_735_689_600_000i64
948 },
949 "isBase64Encoded": false
950 })
951 }
952
953 fn apigw_v1_agent_card_get_with_stage(stage: &str, path: &str) -> serde_json::Value {
964 let request_context_path = if stage == "$default" {
965 path.to_string()
966 } else {
967 format!("/{stage}{path}")
968 };
969 serde_json::json!({
970 "resource": "/{proxy+}",
971 "path": path,
972 "httpMethod": "GET",
973 "headers": {"accept": "application/json", "a2a-version": "1.0"},
974 "multiValueHeaders": {},
975 "queryStringParameters": null,
976 "multiValueQueryStringParameters": null,
977 "pathParameters": {"proxy": path.trim_start_matches('/').to_string()},
978 "stageVariables": null,
979 "requestContext": {
980 "accountId": "000000000000",
981 "apiId": "abc123",
982 "domainName": "fake.execute-api",
983 "domainPrefix": "fake",
984 "extendedRequestId": "xrid",
985 "httpMethod": "GET",
986 "identity": {
987 "sourceIp": "127.0.0.1",
988 "userAgent": "test"
989 },
990 "path": request_context_path,
991 "protocol": "HTTP/1.1",
992 "requestId": "rid",
993 "requestTime": "01/Jan/2026:00:00:00 +0000",
994 "requestTimeEpoch": 1_735_689_600_000i64,
995 "resourceId": "rsrc",
996 "resourcePath": "/{proxy+}",
997 "stage": stage
998 },
999 "body": null,
1000 "isBase64Encoded": false
1001 })
1002 }
1003
1004 fn apigw_v1_agent_card_get() -> serde_json::Value {
1005 apigw_v1_agent_card_get_with_stage("$default", "/.well-known/agent-card.json")
1006 }
1007
1008 fn alb_agent_card_get() -> serde_json::Value {
1011 serde_json::json!({
1012 "requestContext": {
1013 "elb": {
1014 "targetGroupArn": "arn:aws:elasticloadbalancing:ap-southeast-2:000:targetgroup/fake/00"
1015 }
1016 },
1017 "httpMethod": "GET",
1018 "path": "/.well-known/agent-card.json",
1019 "queryStringParameters": {},
1020 "headers": {"accept": "application/json", "a2a-version": "1.0"},
1021 "body": "",
1022 "isBase64Encoded": false
1023 })
1024 }
1025
1026 #[tokio::test]
1029 async fn v1_inbound_event_emits_v1_response_envelope() {
1030 let handler = build_handler();
1031 let resp_value = handler
1032 .handle_http_event_value(apigw_v1_agent_card_get())
1033 .await
1034 .expect("handle_http_event_value(v1) must succeed");
1035
1036 let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1038 serde_json::from_value(resp_value.clone())
1039 .expect("response must deserialize as ApiGatewayProxyResponse (v1)");
1040 assert_eq!(v1.status_code, 200);
1041 match v1.body {
1042 Some(aws_lambda_events::encodings::Body::Text(s)) => {
1043 assert!(s.contains("\"name\""), "v1 body shape: {s}");
1044 }
1045 other => panic!("expected text body, got {other:?}"),
1046 }
1047
1048 assert!(
1052 resp_value.get("cookies").is_none(),
1053 "v1 response must not carry v2-only `cookies` field; payload: {resp_value}"
1054 );
1055 }
1056
1057 #[tokio::test]
1061 async fn v2_inbound_event_emits_v2_response_envelope() {
1062 let handler = build_handler();
1063 let resp_value = handler
1064 .handle_http_event_value(apigw_v2_agent_card_get())
1065 .await
1066 .expect("handle_http_event_value(v2) must succeed");
1067
1068 let v2: aws_lambda_events::apigw::ApiGatewayV2httpResponse =
1069 serde_json::from_value(resp_value.clone())
1070 .expect("response must deserialize as ApiGatewayV2httpResponse (v2)");
1071 assert_eq!(v2.status_code, 200);
1072 match v2.body {
1073 Some(aws_lambda_events::encodings::Body::Text(s)) => {
1074 assert!(s.contains("\"name\""), "v2 body shape: {s}");
1075 }
1076 other => panic!("expected text body, got {other:?}"),
1077 }
1078 }
1079
1080 #[tokio::test]
1082 async fn alb_inbound_event_emits_alb_response_envelope() {
1083 let handler = build_handler();
1084 let resp_value = handler
1085 .handle_http_event_value(alb_agent_card_get())
1086 .await
1087 .expect("handle_http_event_value(alb) must succeed");
1088
1089 let alb: aws_lambda_events::alb::AlbTargetGroupResponse =
1090 serde_json::from_value(resp_value)
1091 .expect("response must deserialize as AlbTargetGroupResponse");
1092 assert_eq!(alb.status_code, 200);
1093 }
1094
1095 #[tokio::test]
1100 async fn v1_stage_prefix_inbound_with_strip_emits_v1_response() {
1101 let s = Arc::new(InMemoryA2aStorage::new());
1102 let state = AppState {
1103 executor: Arc::new(NoOpExecutor),
1104 task_storage: s.clone(),
1105 push_storage: s.clone(),
1106 event_store: s.clone(),
1107 atomic_store: s.clone(),
1108 event_broker: TaskEventBroker::new(),
1109 middleware_stack: Arc::new(MiddlewareStack::new(vec![])),
1110 runtime_config: RuntimeConfig::default(),
1111 in_flight: Arc::new(InFlightRegistry::new()),
1112 cancellation_supervisor: s.clone(),
1113 push_delivery_store: None,
1114 push_dispatcher: None,
1115 durable_executor_queue: None,
1116 };
1117 let router = build_router(state.clone());
1118 let handler = LambdaA2aHandler {
1119 router,
1120 state,
1121 path_prefix: Some(Arc::from("/dev")),
1122 };
1123
1124 let event = apigw_v1_agent_card_get_with_stage("dev", "/.well-known/agent-card.json");
1125 let resp_value = handler
1126 .handle_http_event_value(event)
1127 .await
1128 .expect("v1 stage-prefixed event must succeed under strip_path_prefix");
1129
1130 let v1: aws_lambda_events::apigw::ApiGatewayProxyResponse =
1131 serde_json::from_value(resp_value)
1132 .expect("response must deserialize as ApiGatewayProxyResponse");
1133 assert_eq!(v1.status_code, 200);
1134 match v1.body {
1135 Some(aws_lambda_events::encodings::Body::Text(s)) => {
1136 assert!(s.contains("\"name\""), "v1 stage-prefix body: {s}");
1137 }
1138 other => panic!("expected text body, got {other:?}"),
1139 }
1140 }
1141
1142 #[tokio::test]
1148 async fn v1_response_does_not_match_v2_envelope_strictly() {
1149 let handler = build_handler();
1150 let v1_value = handler
1151 .handle_http_event_value(apigw_v1_agent_card_get())
1152 .await
1153 .unwrap();
1154
1155 assert!(v1_value.get("cookies").is_none());
1157
1158 let v2_value = handler
1159 .handle_http_event_value(apigw_v2_agent_card_get())
1160 .await
1161 .unwrap();
1162 assert!(
1165 v2_value.get("cookies").is_some(),
1166 "v2 response should carry the v2-only cookies field"
1167 );
1168 }
1169}