1use std::collections::HashMap;
9
10use a2a_protocol_types::message::Message;
11use a2a_protocol_types::params::ListTasksParams;
12use a2a_protocol_types::task::Task;
13use a2a_protocol_types::trace_context::{TRACEPARENT_HEADER, TRACESTATE_HEADER, TraceContext};
14
15use crate::call_context::CallContext;
16use crate::error::{ServerError, ServerResult};
17
18use super::RequestHandler;
19
20pub(super) fn validate_id(raw: &str, name: &str, max_length: usize) -> ServerResult<()> {
22 let trimmed = raw.trim();
23 if trimmed.is_empty() {
24 return Err(ServerError::InvalidParams(format!(
25 "{name} must not be empty or whitespace-only"
26 )));
27 }
28 if trimmed.len() > max_length {
29 return Err(ServerError::InvalidParams(format!(
30 "{name} exceeds maximum length (got {}, max {max_length})",
31 trimmed.len()
32 )));
33 }
34 Ok(())
35}
36
37pub(super) fn validate_metadata_object(
47 metadata: Option<&serde_json::Value>,
48 field: &str,
49) -> ServerResult<()> {
50 if let Some(value) = metadata
51 && !value.is_object()
52 {
53 return Err(ServerError::InvalidParams(format!(
54 "{field} metadata must be a JSON object (got {}); non-object metadata \
55 is not representable across all A2A transports (gRPC google.protobuf.Struct)",
56 json_kind(value)
57 )));
58 }
59 Ok(())
60}
61
62const fn json_kind(value: &serde_json::Value) -> &'static str {
64 match value {
65 serde_json::Value::Null => "null",
66 serde_json::Value::Bool(_) => "boolean",
67 serde_json::Value::Number(_) => "number",
68 serde_json::Value::String(_) => "string",
69 serde_json::Value::Array(_) => "array",
70 serde_json::Value::Object(_) => "object",
71 }
72}
73
74pub(super) fn build_call_context(
76 method: &str,
77 headers: Option<&HashMap<String, String>>,
78 inbound_trace_policy: InboundTracePolicy,
79) -> CallContext {
80 let mut ctx = CallContext::new(method);
81 if let Some(h) = headers {
82 let extensions = parse_extensions_header(h);
88 if !extensions.is_empty() {
89 ctx = ctx.with_extensions(extensions);
90 }
91 if let Some(trace) = parse_trace_context(h, inbound_trace_policy) {
92 ctx = ctx.with_trace_context(trace);
93 }
94 ctx = ctx.with_http_headers(h.clone());
95 }
96 let tenant = crate::store::tenant::TenantContext::current();
103 if !tenant.is_empty() {
104 ctx = ctx.with_tenant(tenant);
105 }
106 ctx
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148#[non_exhaustive]
149pub enum InboundTracePolicy {
150 #[default]
153 Continue,
154 Restart,
160 Drop,
165}
166
167fn fresh_span_id() -> [u8; 8] {
170 let uuid = uuid::Uuid::new_v4();
171 let mut span_id = [0_u8; 8];
172 span_id.copy_from_slice(&uuid.as_bytes()[..8]);
173 span_id
174}
175
176fn parse_trace_context(
191 headers: &HashMap<String, String>,
192 policy: InboundTracePolicy,
193) -> Option<TraceContext> {
194 if policy == InboundTracePolicy::Drop {
195 return None;
196 }
197 #[cfg(feature = "otel")]
204 if let Some((trace_id, span_id, flags)) = crate::rpc_span::current_recorded_span() {
205 let ours = TraceContext::from_bytes(trace_id, span_id, flags).ok()?;
206 return Some(match (policy, headers.get(TRACESTATE_HEADER)) {
207 (InboundTracePolicy::Continue, Some(state)) => {
208 ours.clone().with_tracestate(state).unwrap_or(ours)
209 }
210 _ => ours,
211 });
212 }
213 let inbound = TraceContext::parse(headers.get(TRACEPARENT_HEADER)?).ok()?;
214 if policy == InboundTracePolicy::Restart {
215 return TraceContext::from_bytes(
221 *uuid::Uuid::new_v4().as_bytes(),
222 fresh_span_id(),
223 a2a_protocol_types::trace_context::FLAG_SAMPLED,
224 )
225 .ok();
226 }
227 let inbound = match headers.get(TRACESTATE_HEADER) {
228 Some(state) => inbound.clone().with_tracestate(state).unwrap_or(inbound),
233 None => inbound,
234 };
235 inbound.child_bytes(fresh_span_id()).ok()
236}
237
238pub fn parse_extensions_header(headers: &HashMap<String, String>) -> Vec<String> {
243 headers
244 .get("a2a-extensions")
245 .map(|v| {
246 v.split(',')
247 .map(str::trim)
248 .filter(|s| !s.is_empty())
249 .map(str::to_owned)
250 .collect()
251 })
252 .unwrap_or_default()
253}
254
255pub(super) fn truncate_history(history: Option<Vec<Message>>, n: u32) -> Option<Vec<Message>> {
276 if n == 0 {
277 return None;
278 }
279 let mut msgs = history?;
280 let excess = msgs.len().saturating_sub(n as usize);
281 msgs.drain(..excess);
282 Some(msgs)
283}
284
285impl RequestHandler {
286 const CONTEXT_LOOKUP_PAGE_SIZE: u32 = 10;
294
295 pub(crate) async fn find_task_by_context(
296 &self,
297 context_id: &str,
298 ) -> ServerResult<Option<Task>> {
299 if context_id.len() > self.limits.max_id_length {
300 return Ok(None);
301 }
302 let tenant = crate::store::tenant::TenantContext::current();
304 let tenant_param = if tenant.is_empty() {
305 None
306 } else {
307 Some(tenant)
308 };
309 let params = ListTasksParams {
310 tenant: tenant_param,
311 context_id: Some(context_id.to_owned()),
312 status: None,
313 page_size: Some(Self::CONTEXT_LOOKUP_PAGE_SIZE),
314 page_token: None,
315 status_timestamp_after: None,
316 include_artifacts: None,
317 history_length: None,
318 };
319 let resp = self.task_store.list(¶ms).await?;
320
321 let mut terminal_fallback: Option<Task> = None;
324 for task in resp.tasks {
325 if !task.status.state.is_terminal() {
326 return Ok(Some(task));
327 }
328 if terminal_fallback.is_none() {
329 terminal_fallback = Some(task);
330 }
331 }
332 Ok(terminal_fallback)
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn history(len: usize) -> Vec<Message> {
346 use a2a_protocol_types::message::{MessageId, MessageRole, Part};
347 (0..len)
348 .map(|i| Message {
349 id: MessageId::new(format!("h{i}")),
350 role: MessageRole::User,
351 parts: vec![Part::text("x")],
352 context_id: None,
353 task_id: None,
354 reference_task_ids: None,
355 extensions: None,
356 metadata: None,
357 })
358 .collect()
359 }
360
361 fn kept(len: usize, n: u32) -> Option<Vec<String>> {
364 truncate_history(Some(history(len)), n)
365 .map(|msgs| msgs.into_iter().map(|m| m.id.0).collect())
366 }
367
368 #[test]
375 fn truncate_history_zero_omits_rather_than_emptying() {
376 assert_eq!(kept(3, 0), None, "n=0 must omit history entirely");
377 assert_eq!(kept(0, 0), None);
378 }
379
380 #[test]
386 fn truncate_history_keeps_the_most_recent() {
387 assert_eq!(kept(6, 2), Some(vec!["h4".into(), "h5".into()]));
388 assert_eq!(kept(4, 1), Some(vec!["h3".into()]));
389 }
390
391 #[test]
398 fn truncate_history_at_or_below_the_limit_keeps_everything() {
399 assert_eq!(
400 kept(3, 3),
401 Some(vec!["h0".into(), "h1".into(), "h2".into()])
402 );
403 assert_eq!(kept(2, 5), Some(vec!["h0".into(), "h1".into()]));
404 }
405
406 #[test]
412 fn truncate_history_distinguishes_absent_from_empty() {
413 assert_eq!(truncate_history(None, 3), None);
414 assert_eq!(kept(0, 3), Some(vec![]));
415 }
416
417 #[test]
420 fn validate_id_accepts_normal_id() {
421 assert!(
422 validate_id("task-123", "task_id", 1024).is_ok(),
423 "a normal short ID should be accepted"
424 );
425 }
426
427 #[test]
428 fn validate_id_rejects_empty_string() {
429 let err = validate_id("", "task_id", 1024).unwrap_err();
430 assert!(
431 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("empty")),
432 "empty string should be rejected with InvalidParams: {err:?}"
433 );
434 }
435
436 #[test]
437 fn validate_id_rejects_whitespace_only() {
438 let err = validate_id(" \t\n ", "context_id", 1024).unwrap_err();
439 assert!(
440 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("empty")),
441 "whitespace-only string should be rejected: {err:?}"
442 );
443 }
444
445 #[test]
446 fn validate_id_rejects_exceeding_max_length() {
447 let long_id = "a".repeat(2000);
448 let err = validate_id(&long_id, "task_id", 1024).unwrap_err();
449 assert!(
450 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("maximum length")),
451 "overly long ID should be rejected: {err:?}"
452 );
453 }
454
455 #[test]
456 fn validate_id_accepts_exactly_max_length() {
457 let exact = "b".repeat(128);
458 assert!(
459 validate_id(&exact, "task_id", 128).is_ok(),
460 "ID at exactly max length should be accepted"
461 );
462 }
463
464 #[test]
465 fn validate_id_trims_before_length_check() {
466 assert!(
468 validate_id(" abc ", "id", 3).is_ok(),
469 "trimmed length (3) should pass a max of 3"
470 );
471 }
472
473 #[test]
474 fn validate_id_includes_field_name_in_error() {
475 let err = validate_id("", "my_field", 1024).unwrap_err();
476 assert!(
477 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("my_field")),
478 "error message should contain the field name: {err:?}"
479 );
480 }
481
482 #[test]
490 fn a_traceparent_header_reaches_the_call_context() {
491 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
492
493 let mut headers = HashMap::new();
494 headers.insert("traceparent".to_owned(), PARENT.to_owned());
495 headers.insert("tracestate".to_owned(), "vendor=value".to_owned());
496
497 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
498 let trace = ctx
499 .trace_context()
500 .expect("a valid traceparent must reach the context");
501
502 assert_eq!(
503 trace.trace_id(),
504 "4bf92f3577b34da6a3ce929d0e0e4736",
505 "same trace as the caller — that is the whole point"
506 );
507 assert_eq!(trace.flags(), 1, "the sampling decision carries through");
508 assert_eq!(trace.tracestate(), Some("vendor=value"));
509 assert_ne!(
510 trace.span_id(),
511 "00f067aa0ba902b7",
512 "this hop is a child, not the caller's own span"
513 );
514 }
515
516 #[test]
520 fn a_malformed_traceparent_is_dropped_not_fatal() {
521 let mut headers = HashMap::new();
522 headers.insert("traceparent".to_owned(), "not-a-traceparent".to_owned());
523
524 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
525 assert!(ctx.trace_context().is_none());
526 assert_eq!(ctx.method(), "message/send", "the call still proceeds");
527 }
528
529 #[test]
537 fn the_inbound_trace_policy_decides_whether_a_peer_chooses_the_trace_id() {
538 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
539 let mut headers = HashMap::new();
540 headers.insert("traceparent".to_owned(), PARENT.to_owned());
541 headers.insert("tracestate".to_owned(), "vendor=value".to_owned());
542
543 assert_eq!(
546 InboundTracePolicy::default(),
547 InboundTracePolicy::Continue,
548 "a mesh of trusted agents needs one trace id across every hop"
549 );
550 let joined =
551 build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue)
552 .trace_context()
553 .cloned()
554 .expect("the default still joins the caller's trace");
555 assert_eq!(joined.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
556
557 let restarted =
558 build_call_context("message/send", Some(&headers), InboundTracePolicy::Restart)
559 .trace_context()
560 .cloned()
561 .expect("a restart still traces the request, with our own ids");
562 assert_ne!(
563 restarted.trace_id(),
564 "4bf92f3577b34da6a3ce929d0e0e4736",
565 "the peer must not choose the trace id on a front gate"
566 );
567 assert_ne!(restarted.span_id(), "00f067aa0ba902b7");
568 assert_eq!(
569 restarted.tracestate(),
570 None,
571 "§3.4: vendors SHOULD clean up tracestate on restart"
572 );
573 assert!(
574 restarted.is_sampled(),
575 "a trace this hop starts on purpose is one it means to be recorded"
576 );
577
578 assert!(
579 build_call_context("message/send", Some(&headers), InboundTracePolicy::Drop)
580 .trace_context()
581 .is_none(),
582 "Drop refuses to trace an untrusted request at all"
583 );
584 }
585
586 #[cfg(feature = "otel")]
593 #[test]
594 fn a_recorded_span_carries_the_callers_tracestate_only_under_continue() {
595 use opentelemetry::trace::TracerProvider as _;
596 use tracing_subscriber::layer::SubscriberExt as _;
597 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
598 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
599 let subscriber = tracing_subscriber::registry()
600 .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("helpers")));
601 let mut headers = HashMap::new();
602 headers.insert("traceparent".to_owned(), PARENT.to_owned());
603 headers.insert("tracestate".to_owned(), "vendor=value".to_owned());
604 tracing::subscriber::with_default(subscriber, || {
605 let span = tracing::info_span!("call");
606 let _entered = span.enter();
607 let continued = parse_trace_context(&headers, InboundTracePolicy::Continue)
608 .expect("the recorded span is the trace");
609 assert_eq!(continued.tracestate(), Some("vendor=value"));
610 let restarted = parse_trace_context(&headers, InboundTracePolicy::Restart)
611 .expect("the recorded span is the trace");
612 assert_eq!(restarted.tracestate(), None);
613 });
614 }
615
616 #[test]
628 fn each_call_mints_its_own_span_id() {
629 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
630 let mut headers = HashMap::new();
631 headers.insert("traceparent".to_owned(), PARENT.to_owned());
632
633 let span_of = |policy| {
634 build_call_context("message/send", Some(&headers), policy)
635 .trace_context()
636 .expect("this policy traces the request")
637 .span_id()
638 .to_owned()
639 };
640
641 assert_ne!(
642 span_of(InboundTracePolicy::Continue),
643 span_of(InboundTracePolicy::Continue),
644 "two requests joining one caller trace are two spans, not one"
645 );
646 assert_ne!(
647 span_of(InboundTracePolicy::Restart),
648 span_of(InboundTracePolicy::Restart),
649 "a restarted trace mints a fresh span id on every request too"
650 );
651 }
652
653 #[test]
654 fn build_call_context_without_headers() {
655 let ctx = build_call_context("message/send", None, InboundTracePolicy::Continue);
656 assert_eq!(ctx.method(), "message/send", "method should be set");
657 assert!(
658 ctx.http_headers().is_empty(),
659 "headers should be empty when None is passed"
660 );
661 }
662
663 #[test]
664 fn build_call_context_with_headers() {
665 let mut headers = HashMap::new();
666 headers.insert("authorization".to_owned(), "Bearer tok".to_owned());
667 headers.insert("x-request-id".to_owned(), "req-99".to_owned());
668
669 let ctx = build_call_context("tasks/get", Some(&headers), InboundTracePolicy::Continue);
670 assert_eq!(ctx.method(), "tasks/get");
671 assert_eq!(
672 ctx.http_headers().get("authorization").map(String::as_str),
673 Some("Bearer tok"),
674 "headers should be cloned into the context"
675 );
676 assert_eq!(
677 ctx.http_headers().get("x-request-id").map(String::as_str),
678 Some("req-99"),
679 );
680 }
681
682 #[test]
683 fn build_call_context_with_empty_headers_map() {
684 let headers = HashMap::new();
685 let ctx = build_call_context("test", Some(&headers), InboundTracePolicy::Continue);
686 assert!(
687 ctx.http_headers().is_empty(),
688 "an empty map should result in empty headers"
689 );
690 }
691
692 #[test]
695 fn build_call_context_parses_extensions_header() {
696 let mut headers = HashMap::new();
697 headers.insert(
698 "a2a-extensions".to_owned(),
699 "https://example.com/ext/geo/v1, https://standards.org/ext/cite/v1".to_owned(),
700 );
701
702 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
703 assert_eq!(
704 ctx.extensions(),
705 &[
706 "https://example.com/ext/geo/v1".to_owned(),
707 "https://standards.org/ext/cite/v1".to_owned(),
708 ],
709 "comma-separated extension URIs must be parsed and trimmed"
710 );
711 }
712
713 #[test]
714 fn build_call_context_no_extensions_header_is_empty() {
715 let mut headers = HashMap::new();
716 headers.insert("authorization".to_owned(), "Bearer tok".to_owned());
717 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
718 assert_eq!(ctx.extensions(), [] as [String; 0]);
719 }
720
721 #[test]
722 fn parse_extensions_header_drops_empty_segments() {
723 let mut headers = HashMap::new();
724 headers.insert(
725 "a2a-extensions".to_owned(),
726 " ,https://example.com/ext/v1,, ".to_owned(),
727 );
728 assert_eq!(
729 parse_extensions_header(&headers),
730 vec!["https://example.com/ext/v1".to_owned()],
731 "whitespace-only and empty segments must be dropped"
732 );
733
734 headers.insert("a2a-extensions".to_owned(), " ".to_owned());
735 assert!(
736 parse_extensions_header(&headers).is_empty(),
737 "a blank header value yields no extensions"
738 );
739 }
740
741 mod find_task_by_context_tests {
744 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
745
746 use crate::agent_executor;
747 use crate::builder::RequestHandlerBuilder;
748 use crate::handler::limits::HandlerLimits;
749
750 struct DummyExecutor;
751 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
752
753 #[tokio::test]
762 async fn context_id_too_long_returns_none_without_querying_the_store() {
763 let handler = RequestHandlerBuilder::new(DummyExecutor)
764 .with_handler_limits(HandlerLimits::default().with_max_id_length(10))
765 .build()
766 .unwrap();
767
768 let long_id = "a".repeat(11);
769 handler
770 .task_store
771 .save(&make_task("t-long", &long_id, TaskState::Working))
772 .await
773 .unwrap();
774
775 let result = handler.find_task_by_context(&long_id).await.unwrap();
776 assert!(
777 result.is_none(),
778 "context_id longer than max_id_length must be rejected, but the \
779 store's matching task came back: {result:?}"
780 );
781 }
782
783 #[tokio::test]
789 async fn context_id_at_exactly_max_length_is_still_looked_up() {
790 let handler = RequestHandlerBuilder::new(DummyExecutor)
791 .with_handler_limits(HandlerLimits::default().with_max_id_length(10))
792 .build()
793 .unwrap();
794
795 let exact_id = "a".repeat(10);
796 handler
797 .task_store
798 .save(&make_task("t-exact", &exact_id, TaskState::Working))
799 .await
800 .unwrap();
801
802 let found = handler
803 .find_task_by_context(&exact_id)
804 .await
805 .unwrap()
806 .expect("a context_id of exactly max_id_length is within the limit");
807 assert_eq!(found.id.0, "t-exact");
808 }
809
810 #[tokio::test]
811 async fn context_id_within_limit_returns_none_for_missing() {
812 let handler = RequestHandlerBuilder::new(DummyExecutor)
813 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
814 .build()
815 .unwrap();
816
817 let result = handler
818 .find_task_by_context("no-such-context")
819 .await
820 .unwrap();
821 assert!(
822 result.is_none(),
823 "find_task_by_context should return None when no task matches the context"
824 );
825 }
826
827 fn make_task(id: &str, context_id: &str, state: TaskState) -> Task {
829 Task {
830 id: TaskId::new(id.to_owned()),
831 context_id: ContextId::new(context_id),
832 status: TaskStatus::new(state),
833 history: None,
834 artifacts: None,
835 metadata: None,
836 }
837 }
838
839 #[tokio::test]
840 async fn prefers_non_terminal_over_terminal_task() {
841 let handler = RequestHandlerBuilder::new(DummyExecutor)
842 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
843 .build()
844 .unwrap();
845
846 handler
848 .task_store
849 .save(&make_task("aaa-completed", "ctx-1", TaskState::Completed))
850 .await
851 .unwrap();
852 handler
854 .task_store
855 .save(&make_task("bbb-working", "ctx-1", TaskState::Working))
856 .await
857 .unwrap();
858
859 let result = handler.find_task_by_context("ctx-1").await.unwrap();
860 assert!(result.is_some(), "should find a task");
861 let task = result.unwrap();
862 assert_eq!(
863 task.id.0, "bbb-working",
864 "should prefer the non-terminal (Working) task over the terminal (Completed) one"
865 );
866 }
867
868 #[tokio::test]
869 async fn returns_terminal_task_when_no_non_terminal_exists() {
870 let handler = RequestHandlerBuilder::new(DummyExecutor)
871 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
872 .build()
873 .unwrap();
874
875 handler
876 .task_store
877 .save(&make_task("task-done", "ctx-2", TaskState::Completed))
878 .await
879 .unwrap();
880
881 let result = handler.find_task_by_context("ctx-2").await.unwrap();
882 assert!(result.is_some(), "should still return a terminal task");
883 assert_eq!(result.unwrap().id.0, "task-done");
884 }
885
886 #[tokio::test]
887 async fn returns_first_non_terminal_when_multiple_exist() {
888 let handler = RequestHandlerBuilder::new(DummyExecutor)
889 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
890 .build()
891 .unwrap();
892
893 handler
894 .task_store
895 .save(&make_task("aaa-failed", "ctx-3", TaskState::Failed))
896 .await
897 .unwrap();
898 handler
899 .task_store
900 .save(&make_task("bbb-submitted", "ctx-3", TaskState::Submitted))
901 .await
902 .unwrap();
903 handler
904 .task_store
905 .save(&make_task("ccc-working", "ctx-3", TaskState::Working))
906 .await
907 .unwrap();
908
909 let result = handler.find_task_by_context("ctx-3").await.unwrap();
910 let task = result.unwrap();
911 assert!(
912 !task.status.state.is_terminal(),
913 "should return a non-terminal task, got {:?}",
914 task.status.state
915 );
916 assert_eq!(
920 task.id.0, "ccc-working",
921 "should return the most-recently-updated non-terminal task"
922 );
923 }
924 }
925}