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 let inbound = TraceContext::parse(headers.get(TRACEPARENT_HEADER)?).ok()?;
198 if policy == InboundTracePolicy::Restart {
199 return TraceContext::from_bytes(
205 *uuid::Uuid::new_v4().as_bytes(),
206 fresh_span_id(),
207 a2a_protocol_types::trace_context::FLAG_SAMPLED,
208 )
209 .ok();
210 }
211 let inbound = match headers.get(TRACESTATE_HEADER) {
212 Some(state) => inbound.clone().with_tracestate(state).unwrap_or(inbound),
217 None => inbound,
218 };
219 inbound.child_bytes(fresh_span_id()).ok()
220}
221
222pub(super) fn parse_extensions_header(headers: &HashMap<String, String>) -> Vec<String> {
227 headers
228 .get("a2a-extensions")
229 .map(|v| {
230 v.split(',')
231 .map(str::trim)
232 .filter(|s| !s.is_empty())
233 .map(str::to_owned)
234 .collect()
235 })
236 .unwrap_or_default()
237}
238
239pub(super) fn truncate_history(history: Option<Vec<Message>>, n: u32) -> Option<Vec<Message>> {
260 if n == 0 {
261 return None;
262 }
263 let mut msgs = history?;
264 let excess = msgs.len().saturating_sub(n as usize);
265 msgs.drain(..excess);
266 Some(msgs)
267}
268
269impl RequestHandler {
270 const CONTEXT_LOOKUP_PAGE_SIZE: u32 = 10;
278
279 pub(crate) async fn find_task_by_context(
280 &self,
281 context_id: &str,
282 ) -> ServerResult<Option<Task>> {
283 if context_id.len() > self.limits.max_id_length {
284 return Ok(None);
285 }
286 let tenant = crate::store::tenant::TenantContext::current();
288 let tenant_param = if tenant.is_empty() {
289 None
290 } else {
291 Some(tenant)
292 };
293 let params = ListTasksParams {
294 tenant: tenant_param,
295 context_id: Some(context_id.to_owned()),
296 status: None,
297 page_size: Some(Self::CONTEXT_LOOKUP_PAGE_SIZE),
298 page_token: None,
299 status_timestamp_after: None,
300 include_artifacts: None,
301 history_length: None,
302 };
303 let resp = self.task_store.list(¶ms).await?;
304
305 let mut terminal_fallback: Option<Task> = None;
308 for task in resp.tasks {
309 if !task.status.state.is_terminal() {
310 return Ok(Some(task));
311 }
312 if terminal_fallback.is_none() {
313 terminal_fallback = Some(task);
314 }
315 }
316 Ok(terminal_fallback)
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn history(len: usize) -> Vec<Message> {
330 use a2a_protocol_types::message::{MessageId, MessageRole, Part};
331 (0..len)
332 .map(|i| Message {
333 id: MessageId::new(format!("h{i}")),
334 role: MessageRole::User,
335 parts: vec![Part::text("x")],
336 context_id: None,
337 task_id: None,
338 reference_task_ids: None,
339 extensions: None,
340 metadata: None,
341 })
342 .collect()
343 }
344
345 fn kept(len: usize, n: u32) -> Option<Vec<String>> {
348 truncate_history(Some(history(len)), n)
349 .map(|msgs| msgs.into_iter().map(|m| m.id.0).collect())
350 }
351
352 #[test]
359 fn truncate_history_zero_omits_rather_than_emptying() {
360 assert_eq!(kept(3, 0), None, "n=0 must omit history entirely");
361 assert_eq!(kept(0, 0), None);
362 }
363
364 #[test]
370 fn truncate_history_keeps_the_most_recent() {
371 assert_eq!(kept(6, 2), Some(vec!["h4".into(), "h5".into()]));
372 assert_eq!(kept(4, 1), Some(vec!["h3".into()]));
373 }
374
375 #[test]
382 fn truncate_history_at_or_below_the_limit_keeps_everything() {
383 assert_eq!(
384 kept(3, 3),
385 Some(vec!["h0".into(), "h1".into(), "h2".into()])
386 );
387 assert_eq!(kept(2, 5), Some(vec!["h0".into(), "h1".into()]));
388 }
389
390 #[test]
396 fn truncate_history_distinguishes_absent_from_empty() {
397 assert_eq!(truncate_history(None, 3), None);
398 assert_eq!(kept(0, 3), Some(vec![]));
399 }
400
401 #[test]
404 fn validate_id_accepts_normal_id() {
405 assert!(
406 validate_id("task-123", "task_id", 1024).is_ok(),
407 "a normal short ID should be accepted"
408 );
409 }
410
411 #[test]
412 fn validate_id_rejects_empty_string() {
413 let err = validate_id("", "task_id", 1024).unwrap_err();
414 assert!(
415 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("empty")),
416 "empty string should be rejected with InvalidParams: {err:?}"
417 );
418 }
419
420 #[test]
421 fn validate_id_rejects_whitespace_only() {
422 let err = validate_id(" \t\n ", "context_id", 1024).unwrap_err();
423 assert!(
424 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("empty")),
425 "whitespace-only string should be rejected: {err:?}"
426 );
427 }
428
429 #[test]
430 fn validate_id_rejects_exceeding_max_length() {
431 let long_id = "a".repeat(2000);
432 let err = validate_id(&long_id, "task_id", 1024).unwrap_err();
433 assert!(
434 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("maximum length")),
435 "overly long ID should be rejected: {err:?}"
436 );
437 }
438
439 #[test]
440 fn validate_id_accepts_exactly_max_length() {
441 let exact = "b".repeat(128);
442 assert!(
443 validate_id(&exact, "task_id", 128).is_ok(),
444 "ID at exactly max length should be accepted"
445 );
446 }
447
448 #[test]
449 fn validate_id_trims_before_length_check() {
450 assert!(
452 validate_id(" abc ", "id", 3).is_ok(),
453 "trimmed length (3) should pass a max of 3"
454 );
455 }
456
457 #[test]
458 fn validate_id_includes_field_name_in_error() {
459 let err = validate_id("", "my_field", 1024).unwrap_err();
460 assert!(
461 matches!(err, ServerError::InvalidParams(ref msg) if msg.contains("my_field")),
462 "error message should contain the field name: {err:?}"
463 );
464 }
465
466 #[test]
474 fn a_traceparent_header_reaches_the_call_context() {
475 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
476
477 let mut headers = HashMap::new();
478 headers.insert("traceparent".to_owned(), PARENT.to_owned());
479 headers.insert("tracestate".to_owned(), "vendor=value".to_owned());
480
481 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
482 let trace = ctx
483 .trace_context()
484 .expect("a valid traceparent must reach the context");
485
486 assert_eq!(
487 trace.trace_id(),
488 "4bf92f3577b34da6a3ce929d0e0e4736",
489 "same trace as the caller — that is the whole point"
490 );
491 assert_eq!(trace.flags(), 1, "the sampling decision carries through");
492 assert_eq!(trace.tracestate(), Some("vendor=value"));
493 assert_ne!(
494 trace.span_id(),
495 "00f067aa0ba902b7",
496 "this hop is a child, not the caller's own span"
497 );
498 }
499
500 #[test]
504 fn a_malformed_traceparent_is_dropped_not_fatal() {
505 let mut headers = HashMap::new();
506 headers.insert("traceparent".to_owned(), "not-a-traceparent".to_owned());
507
508 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
509 assert!(ctx.trace_context().is_none());
510 assert_eq!(ctx.method(), "message/send", "the call still proceeds");
511 }
512
513 #[test]
521 fn the_inbound_trace_policy_decides_whether_a_peer_chooses_the_trace_id() {
522 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
523 let mut headers = HashMap::new();
524 headers.insert("traceparent".to_owned(), PARENT.to_owned());
525 headers.insert("tracestate".to_owned(), "vendor=value".to_owned());
526
527 assert_eq!(
530 InboundTracePolicy::default(),
531 InboundTracePolicy::Continue,
532 "a mesh of trusted agents needs one trace id across every hop"
533 );
534 let joined =
535 build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue)
536 .trace_context()
537 .cloned()
538 .expect("the default still joins the caller's trace");
539 assert_eq!(joined.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
540
541 let restarted =
542 build_call_context("message/send", Some(&headers), InboundTracePolicy::Restart)
543 .trace_context()
544 .cloned()
545 .expect("a restart still traces the request, with our own ids");
546 assert_ne!(
547 restarted.trace_id(),
548 "4bf92f3577b34da6a3ce929d0e0e4736",
549 "the peer must not choose the trace id on a front gate"
550 );
551 assert_ne!(restarted.span_id(), "00f067aa0ba902b7");
552 assert_eq!(
553 restarted.tracestate(),
554 None,
555 "§3.4: vendors SHOULD clean up tracestate on restart"
556 );
557 assert!(
558 restarted.is_sampled(),
559 "a trace this hop starts on purpose is one it means to be recorded"
560 );
561
562 assert!(
563 build_call_context("message/send", Some(&headers), InboundTracePolicy::Drop)
564 .trace_context()
565 .is_none(),
566 "Drop refuses to trace an untrusted request at all"
567 );
568 }
569
570 #[test]
582 fn each_call_mints_its_own_span_id() {
583 const PARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
584 let mut headers = HashMap::new();
585 headers.insert("traceparent".to_owned(), PARENT.to_owned());
586
587 let span_of = |policy| {
588 build_call_context("message/send", Some(&headers), policy)
589 .trace_context()
590 .expect("this policy traces the request")
591 .span_id()
592 .to_owned()
593 };
594
595 assert_ne!(
596 span_of(InboundTracePolicy::Continue),
597 span_of(InboundTracePolicy::Continue),
598 "two requests joining one caller trace are two spans, not one"
599 );
600 assert_ne!(
601 span_of(InboundTracePolicy::Restart),
602 span_of(InboundTracePolicy::Restart),
603 "a restarted trace mints a fresh span id on every request too"
604 );
605 }
606
607 #[test]
608 fn build_call_context_without_headers() {
609 let ctx = build_call_context("message/send", None, InboundTracePolicy::Continue);
610 assert_eq!(ctx.method(), "message/send", "method should be set");
611 assert!(
612 ctx.http_headers().is_empty(),
613 "headers should be empty when None is passed"
614 );
615 }
616
617 #[test]
618 fn build_call_context_with_headers() {
619 let mut headers = HashMap::new();
620 headers.insert("authorization".to_owned(), "Bearer tok".to_owned());
621 headers.insert("x-request-id".to_owned(), "req-99".to_owned());
622
623 let ctx = build_call_context("tasks/get", Some(&headers), InboundTracePolicy::Continue);
624 assert_eq!(ctx.method(), "tasks/get");
625 assert_eq!(
626 ctx.http_headers().get("authorization").map(String::as_str),
627 Some("Bearer tok"),
628 "headers should be cloned into the context"
629 );
630 assert_eq!(
631 ctx.http_headers().get("x-request-id").map(String::as_str),
632 Some("req-99"),
633 );
634 }
635
636 #[test]
637 fn build_call_context_with_empty_headers_map() {
638 let headers = HashMap::new();
639 let ctx = build_call_context("test", Some(&headers), InboundTracePolicy::Continue);
640 assert!(
641 ctx.http_headers().is_empty(),
642 "an empty map should result in empty headers"
643 );
644 }
645
646 #[test]
649 fn build_call_context_parses_extensions_header() {
650 let mut headers = HashMap::new();
651 headers.insert(
652 "a2a-extensions".to_owned(),
653 "https://example.com/ext/geo/v1, https://standards.org/ext/cite/v1".to_owned(),
654 );
655
656 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
657 assert_eq!(
658 ctx.extensions(),
659 &[
660 "https://example.com/ext/geo/v1".to_owned(),
661 "https://standards.org/ext/cite/v1".to_owned(),
662 ],
663 "comma-separated extension URIs must be parsed and trimmed"
664 );
665 }
666
667 #[test]
668 fn build_call_context_no_extensions_header_is_empty() {
669 let mut headers = HashMap::new();
670 headers.insert("authorization".to_owned(), "Bearer tok".to_owned());
671 let ctx = build_call_context("message/send", Some(&headers), InboundTracePolicy::Continue);
672 assert_eq!(ctx.extensions(), [] as [String; 0]);
673 }
674
675 #[test]
676 fn parse_extensions_header_drops_empty_segments() {
677 let mut headers = HashMap::new();
678 headers.insert(
679 "a2a-extensions".to_owned(),
680 " ,https://example.com/ext/v1,, ".to_owned(),
681 );
682 assert_eq!(
683 parse_extensions_header(&headers),
684 vec!["https://example.com/ext/v1".to_owned()],
685 "whitespace-only and empty segments must be dropped"
686 );
687
688 headers.insert("a2a-extensions".to_owned(), " ".to_owned());
689 assert!(
690 parse_extensions_header(&headers).is_empty(),
691 "a blank header value yields no extensions"
692 );
693 }
694
695 mod find_task_by_context_tests {
698 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
699
700 use crate::agent_executor;
701 use crate::builder::RequestHandlerBuilder;
702 use crate::handler::limits::HandlerLimits;
703
704 struct DummyExecutor;
705 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
706
707 #[tokio::test]
716 async fn context_id_too_long_returns_none_without_querying_the_store() {
717 let handler = RequestHandlerBuilder::new(DummyExecutor)
718 .with_handler_limits(HandlerLimits::default().with_max_id_length(10))
719 .build()
720 .unwrap();
721
722 let long_id = "a".repeat(11);
723 handler
724 .task_store
725 .save(&make_task("t-long", &long_id, TaskState::Working))
726 .await
727 .unwrap();
728
729 let result = handler.find_task_by_context(&long_id).await.unwrap();
730 assert!(
731 result.is_none(),
732 "context_id longer than max_id_length must be rejected, but the \
733 store's matching task came back: {result:?}"
734 );
735 }
736
737 #[tokio::test]
743 async fn context_id_at_exactly_max_length_is_still_looked_up() {
744 let handler = RequestHandlerBuilder::new(DummyExecutor)
745 .with_handler_limits(HandlerLimits::default().with_max_id_length(10))
746 .build()
747 .unwrap();
748
749 let exact_id = "a".repeat(10);
750 handler
751 .task_store
752 .save(&make_task("t-exact", &exact_id, TaskState::Working))
753 .await
754 .unwrap();
755
756 let found = handler
757 .find_task_by_context(&exact_id)
758 .await
759 .unwrap()
760 .expect("a context_id of exactly max_id_length is within the limit");
761 assert_eq!(found.id.0, "t-exact");
762 }
763
764 #[tokio::test]
765 async fn context_id_within_limit_returns_none_for_missing() {
766 let handler = RequestHandlerBuilder::new(DummyExecutor)
767 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
768 .build()
769 .unwrap();
770
771 let result = handler
772 .find_task_by_context("no-such-context")
773 .await
774 .unwrap();
775 assert!(
776 result.is_none(),
777 "find_task_by_context should return None when no task matches the context"
778 );
779 }
780
781 fn make_task(id: &str, context_id: &str, state: TaskState) -> Task {
783 Task {
784 id: TaskId::new(id.to_owned()),
785 context_id: ContextId::new(context_id),
786 status: TaskStatus::new(state),
787 history: None,
788 artifacts: None,
789 metadata: None,
790 }
791 }
792
793 #[tokio::test]
794 async fn prefers_non_terminal_over_terminal_task() {
795 let handler = RequestHandlerBuilder::new(DummyExecutor)
796 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
797 .build()
798 .unwrap();
799
800 handler
802 .task_store
803 .save(&make_task("aaa-completed", "ctx-1", TaskState::Completed))
804 .await
805 .unwrap();
806 handler
808 .task_store
809 .save(&make_task("bbb-working", "ctx-1", TaskState::Working))
810 .await
811 .unwrap();
812
813 let result = handler.find_task_by_context("ctx-1").await.unwrap();
814 assert!(result.is_some(), "should find a task");
815 let task = result.unwrap();
816 assert_eq!(
817 task.id.0, "bbb-working",
818 "should prefer the non-terminal (Working) task over the terminal (Completed) one"
819 );
820 }
821
822 #[tokio::test]
823 async fn returns_terminal_task_when_no_non_terminal_exists() {
824 let handler = RequestHandlerBuilder::new(DummyExecutor)
825 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
826 .build()
827 .unwrap();
828
829 handler
830 .task_store
831 .save(&make_task("task-done", "ctx-2", TaskState::Completed))
832 .await
833 .unwrap();
834
835 let result = handler.find_task_by_context("ctx-2").await.unwrap();
836 assert!(result.is_some(), "should still return a terminal task");
837 assert_eq!(result.unwrap().id.0, "task-done");
838 }
839
840 #[tokio::test]
841 async fn returns_first_non_terminal_when_multiple_exist() {
842 let handler = RequestHandlerBuilder::new(DummyExecutor)
843 .with_handler_limits(HandlerLimits::default().with_max_id_length(100))
844 .build()
845 .unwrap();
846
847 handler
848 .task_store
849 .save(&make_task("aaa-failed", "ctx-3", TaskState::Failed))
850 .await
851 .unwrap();
852 handler
853 .task_store
854 .save(&make_task("bbb-submitted", "ctx-3", TaskState::Submitted))
855 .await
856 .unwrap();
857 handler
858 .task_store
859 .save(&make_task("ccc-working", "ctx-3", TaskState::Working))
860 .await
861 .unwrap();
862
863 let result = handler.find_task_by_context("ctx-3").await.unwrap();
864 let task = result.unwrap();
865 assert!(
866 !task.status.state.is_terminal(),
867 "should return a non-terminal task, got {:?}",
868 task.status.state
869 );
870 assert_eq!(
874 task.id.0, "ccc-working",
875 "should return the most-recently-updated non-terminal task"
876 );
877 }
878 }
879}