use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard, AgentInterface};
use a2a_protocol_types::error::{A2aError, A2aResult};
use a2a_protocol_types::message::{Message, MessageId, MessageRole, Part};
use a2a_protocol_types::params::{
ListTasksParams, MessageSendParams, SendMessageConfiguration, TaskQueryParams,
};
use a2a_protocol_types::push::TaskPushNotificationConfig;
use a2a_protocol_types::responses::TaskListResponse;
use a2a_protocol_types::task::{ContextId, Task, TaskId};
use a2a_protocol_server::agent_executor;
use a2a_protocol_server::builder::RequestHandlerBuilder;
use a2a_protocol_server::handler::SendMessageResult;
use a2a_protocol_server::push::{HttpPushSender, PushConfigStore};
use a2a_protocol_server::store::{InMemoryTaskStore, TaskStore};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use tokio::sync::oneshot;
const LOOKUP_WINDOW: Duration = Duration::from_secs(5);
struct NoopExecutor;
agent_executor!(NoopExecutor, |_ctx, _queue| async { Ok(()) });
struct WatchingTaskStore {
inner: InMemoryTaskStore,
watched_context: String,
lookups: UnboundedSender<()>,
}
#[allow(clippy::manual_async_fn)]
impl TaskStore for WatchingTaskStore {
fn save<'a>(&'a self, t: &'a Task) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
self.inner.save(t)
}
fn get<'a>(
&'a self,
id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
self.inner.get(id)
}
fn list<'a>(
&'a self,
p: &'a ListTasksParams,
) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
if p.context_id.as_deref() == Some(self.watched_context.as_str()) {
let _ = self.lookups.send(());
}
self.inner.list(p)
}
fn insert_if_absent<'a>(
&'a self,
t: &'a Task,
) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
self.inner.insert_if_absent(t)
}
fn delete<'a>(
&'a self,
id: &'a TaskId,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
self.inner.delete(id)
}
}
struct GatedPushConfigStore {
entered: Mutex<Option<oneshot::Sender<String>>>,
release: Mutex<Option<oneshot::Receiver<()>>>,
}
#[allow(clippy::manual_async_fn)]
impl PushConfigStore for GatedPushConfigStore {
fn set<'a>(
&'a self,
config: TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
Box::pin(async move {
let entered = self.entered.lock().expect("test mutex").take();
let release = self.release.lock().expect("test mutex").take();
if let Some(entered) = entered {
let _ = entered.send(config.task_id.clone().unwrap_or_default());
}
if let Some(release) = release {
let _ = release.await;
}
Err(A2aError::internal(
"injected push-config rejection, after the task row is written",
))
})
}
fn get<'a>(
&'a self,
_task_id: &'a str,
_id: &'a str,
) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
{
Box::pin(async { Ok(None) })
}
fn list<'a>(
&'a self,
_task_id: &'a str,
) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
Box::pin(async { Ok(Vec::new()) })
}
fn delete<'a>(
&'a self,
_task_id: &'a str,
_id: &'a str,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
fn push_card() -> AgentCard {
AgentCard {
url: None,
name: "Push Guard Agent".into(),
description: "Advertises push notifications".into(),
version: "1.0.0".into(),
supported_interfaces: vec![AgentInterface {
url: "https://agent.example.com/rpc".into(),
protocol_binding: "JSONRPC".into(),
protocol_version: "1.0.0".into(),
tenant: None,
}],
default_input_modes: vec!["text/plain".into()],
default_output_modes: vec!["text/plain".into()],
skills: vec![],
capabilities: AgentCapabilities::none().with_push_notifications(true),
provider: None,
icon_url: None,
documentation_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
fn send(msg_id: &str, ctx: &str, with_config: bool) -> MessageSendParams {
MessageSendParams {
tenant: None,
message: Message {
id: MessageId::new(msg_id),
role: MessageRole::User,
parts: vec![Part::text("hello")],
context_id: Some(ContextId::new(ctx)),
task_id: None,
reference_task_ids: None,
extensions: None,
metadata: None,
},
configuration: Some(SendMessageConfiguration {
accepted_output_modes: vec!["text/plain".to_owned()],
task_push_notification_config: with_config.then(|| TaskPushNotificationConfig {
tenant: None,
task_id: None,
id: None,
url: "http://127.0.0.1:9/hook".to_owned(),
token: None,
authentication: None,
}),
history_length: None,
return_immediately: Some(true),
}),
metadata: None,
}
}
fn task_id_of(result: SendMessageResult) -> String {
match result {
SendMessageResult::Response(a2a_protocol_types::responses::SendMessageResponse::Task(
t,
)) => t.id.0,
other => panic!("expected a Task response, got: {other:?}"),
}
}
fn drain(rx: &mut UnboundedReceiver<()>) {
while rx.try_recv().is_ok() {}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_concurrent_send_cannot_see_a_task_the_push_config_is_about_to_withdraw() {
const CTX: &str = "ctx-guard";
let (lookups_tx, mut lookups) = unbounded_channel();
let (entered_tx, entered) = oneshot::channel();
let (release_tx, release_rx) = oneshot::channel();
let handler = Arc::new(
RequestHandlerBuilder::new(NoopExecutor)
.with_agent_card(push_card())
.with_push_sender(HttpPushSender::new().allow_private_urls())
.with_task_store(WatchingTaskStore {
inner: InMemoryTaskStore::new(),
watched_context: CTX.to_owned(),
lookups: lookups_tx,
})
.with_push_config_store(GatedPushConfigStore {
entered: Mutex::new(Some(entered_tx)),
release: Mutex::new(Some(release_rx)),
})
.build()
.expect("handler must build"),
);
let first = tokio::spawn({
let handler = Arc::clone(&handler);
async move {
handler
.on_send_message(send("msg-a", CTX, true), false, None)
.await
}
});
let doomed_id = tokio::time::timeout(LOOKUP_WINDOW, entered)
.await
.expect("the first send must reach the push-config step")
.expect("the gate must report the task id it parked on");
assert!(
!doomed_id.is_empty(),
"the server must fill in taskId before storing the inline config"
);
drain(&mut lookups);
let second = tokio::spawn({
let handler = Arc::clone(&handler);
async move {
handler
.on_send_message(send("msg-b", CTX, false), false, None)
.await
}
});
let looked_up = tokio::time::timeout(LOOKUP_WINDOW, lookups.recv()).await;
assert!(
looked_up.is_err(),
"the second send reached find_task_by_context while the first was still \
inside the push-config step — so it can adopt a task row that is about \
to be rolled back. The per-context guard must be held across that step."
);
let _ = release_tx.send(());
let first = first
.await
.expect("the first send task must not panic")
.expect_err("the gated push-config store refuses, so the send must fail");
let _ = first;
let survivor = task_id_of(
second
.await
.expect("the second send task must not panic")
.expect("the second send carries no push config, so it must succeed"),
);
assert_ne!(
survivor, doomed_id,
"the second send must create its own task, not continue the withdrawn one"
);
let err = handler
.on_get_task(
TaskQueryParams {
tenant: None,
id: doomed_id.clone(),
history_length: None,
},
None::<&HashMap<String, String>>,
)
.await;
assert!(
err.is_err(),
"the refused send's task must have been rolled back, but {doomed_id} is \
still readable"
);
}