use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use a2a_protocol_types::error::A2aResult;
use a2a_protocol_types::message::Message;
use a2a_protocol_types::params::MessageSendParams;
use a2a_protocol_server::builder::RequestHandlerBuilder;
use a2a_protocol_server::store::tenant::TenantContext;
use a2a_protocol_server::tenant_resolver::HeaderTenantResolver;
use a2a_protocol_server::{
AgentExecutor, CallContext, EventQueueWriter, RequestContext, RequestHandler, ServerInterceptor,
};
#[derive(Debug, Default, Clone)]
struct Seen {
caller_identity: Option<String>,
tenant: Option<String>,
authorization: Option<String>,
extensions: Vec<String>,
request_id: Option<String>,
task_local_tenant: String,
}
struct RecordingExecutor(Arc<Mutex<Seen>>);
impl AgentExecutor for RecordingExecutor {
fn execute<'a>(
&'a self,
ctx: &'a RequestContext,
_queue: &'a dyn EventQueueWriter,
) -> std::pin::Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async move {
*self.0.lock().expect("uncontended") = Seen {
caller_identity: ctx.caller_identity().map(ToOwned::to_owned),
tenant: ctx.tenant().map(ToOwned::to_owned),
authorization: ctx.http_header("AuThOrIzAtIoN").map(ToOwned::to_owned),
extensions: ctx.activated_extensions().to_vec(),
request_id: ctx.request_id().map(ToOwned::to_owned),
task_local_tenant: TenantContext::current(),
};
Ok(())
})
}
}
struct IdentifyingInterceptor;
impl ServerInterceptor for IdentifyingInterceptor {
fn before<'a>(
&'a self,
ctx: &'a CallContext,
) -> std::pin::Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
ctx.set_caller_identity("ada@example.com");
Box::pin(async { Ok(()) })
}
fn after<'a>(
&'a self,
_ctx: &'a CallContext,
) -> std::pin::Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
fn authenticates(&self) -> bool {
true
}
}
fn handler(seen: &Arc<Mutex<Seen>>) -> RequestHandler {
RequestHandlerBuilder::new(RecordingExecutor(Arc::clone(seen)))
.with_interceptor(IdentifyingInterceptor)
.with_tenant_resolver(HeaderTenantResolver::new("x-tenant-id"))
.build()
.expect("handler")
}
fn headers() -> HashMap<String, String> {
HashMap::from([
("authorization".to_owned(), "Bearer tok".to_owned()),
("x-tenant-id".to_owned(), "acme".to_owned()),
("x-request-id".to_owned(), "req-42".to_owned()),
(
"a2a-extensions".to_owned(),
"https://example.com/ext/v1".to_owned(),
),
])
}
async fn send_and_observe(seen: &Arc<Mutex<Seen>>, with_headers: bool) -> Seen {
let handler = handler(seen);
let hs = headers();
handler
.on_send_message(
MessageSendParams::new(Message::user_text("m1", "hello")),
false,
if with_headers { Some(&hs) } else { None },
)
.await
.expect("send");
seen.lock().expect("uncontended").clone()
}
#[tokio::test]
async fn an_executor_sees_the_caller_identity_the_interceptor_established() {
let seen = Arc::new(Mutex::new(Seen::default()));
let observed = send_and_observe(&seen, true).await;
assert_eq!(
observed.caller_identity.as_deref(),
Some("ada@example.com"),
"the identity an authenticating interceptor set must reach the executor"
);
}
#[tokio::test]
async fn an_executor_sees_the_resolved_tenant_not_the_client_supplied_one() {
let seen = Arc::new(Mutex::new(Seen::default()));
let observed = send_and_observe(&seen, true).await;
assert_eq!(
observed.tenant.as_deref(),
Some("acme"),
"the tenant the resolver derived from a trusted header must reach the executor"
);
}
#[tokio::test]
async fn the_spawned_executor_runs_inside_the_tenant_scope() {
let seen = Arc::new(Mutex::new(Seen::default()));
let observed = send_and_observe(&seen, true).await;
assert_eq!(
observed.task_local_tenant, "acme",
"TenantContext::current() inside the executor must be the request's tenant, \
not the empty default a bare tokio::spawn leaves behind"
);
}
#[tokio::test]
async fn an_executor_sees_headers_case_insensitively_and_the_request_id() {
let seen = Arc::new(Mutex::new(Seen::default()));
let observed = send_and_observe(&seen, true).await;
assert_eq!(observed.authorization.as_deref(), Some("Bearer tok"));
assert_eq!(observed.request_id.as_deref(), Some("req-42"));
}
#[tokio::test]
async fn an_executor_sees_the_extensions_the_caller_activated() {
let seen = Arc::new(Mutex::new(Seen::default()));
let observed = send_and_observe(&seen, true).await;
assert_eq!(
observed.extensions,
vec!["https://example.com/ext/v1".to_owned()],
"the A2A-Extensions header (spec 14.2.2) must reach the executor"
);
}
#[tokio::test]
async fn a_headerless_call_reports_nothing_rather_than_a_default() {
let seen = Arc::new(Mutex::new(Seen::default()));
let observed = send_and_observe(&seen, false).await;
assert_eq!(observed.tenant, None);
assert_eq!(observed.authorization, None);
assert_eq!(observed.request_id, None);
assert!(observed.extensions.is_empty());
}
#[test]
fn a_directly_built_context_has_no_call_context() {
let ctx = RequestContext::new(
Message::user_text("m1", "hi"),
"t-1".into(),
"c-1".to_owned(),
);
assert!(ctx.call_context.is_none());
assert_eq!(ctx.caller_identity(), None);
assert_eq!(ctx.tenant(), None);
assert_eq!(ctx.http_header("authorization"), None);
assert!(ctx.activated_extensions().is_empty());
}