use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use a2a_protocol_types::artifact::{Artifact, ArtifactId};
use a2a_protocol_types::events::TaskArtifactUpdateEvent;
use a2a_protocol_types::message::Part;
use a2a_protocol_types::push::TaskPushNotificationConfig;
use a2a_protocol_types::task::ContextId;
use super::*;
use crate::handler::limits::HandlerLimits;
use crate::push::{InMemoryPushConfigStore, PushConfigStore, PushSender};
use crate::store::{InMemoryTaskStore, TaskStore};
#[derive(Default)]
struct Pushes(Mutex<Vec<TaskState>>);
impl PushSender for Pushes {
fn send<'a>(
&'a self,
_url: &'a str,
event: &'a StreamResponse,
_config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
if let StreamResponse::StatusUpdate(e) = event {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(e.status.state);
}
Box::pin(async { Ok(()) })
}
}
fn task(state: TaskState) -> Task {
Task {
id: TaskId::new("t"),
context_id: ContextId::new("c"),
status: TaskStatus::new(state),
history: None,
artifacts: None,
metadata: None,
}
}
fn status(seq: u64, state: TaskState) -> StreamEvent {
StreamEvent::at(
seq,
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: TaskId::new("t"),
context_id: ContextId::new("c"),
status: TaskStatus::new(state),
metadata: None,
}),
)
}
fn artifact(seq: u64) -> StreamEvent {
StreamEvent::at(
seq,
StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
task_id: TaskId::new("t"),
context_id: ContextId::new("c"),
artifact: Artifact::new(ArtifactId::new("late"), vec![Part::text("x")]),
append: None,
last_chunk: Some(true),
metadata: None,
}),
)
}
const fn state_of(event: &StreamResponse) -> Option<TaskState> {
match event {
StreamResponse::StatusUpdate(e) => Some(e.status.state),
_ => None,
}
}
struct Fixture {
store: InMemoryTaskStore,
configs: InMemoryPushConfigStore,
pushes: Pushes,
limits: HandlerLimits,
gate: TerminalGate,
cancel: CancellationToken,
}
impl Fixture {
async fn new(stored: TaskState) -> Self {
let store = InMemoryTaskStore::new();
store.save(&task(stored)).await.expect("seed");
let configs = InMemoryPushConfigStore::new();
configs
.set(TaskPushNotificationConfig {
tenant: None,
id: Some("cfg".into()),
task_id: Some("t".into()),
url: "https://example.com/hook".into(),
token: None,
authentication: None,
})
.await
.expect("config");
Self {
store,
configs,
pushes: Pushes::default(),
limits: HandlerLimits::default(),
gate: TerminalGate::default(),
cancel: CancellationToken::new(),
}
}
fn processor<'a>(&'a self, task_id: &'a TaskId) -> Processor<'a> {
Processor::new(
task_id,
BackgroundDeps {
task_store: &self.store,
push_config_store: &self.configs,
push_sender: Some(&self.pushes),
limits: &self.limits,
metrics: &crate::metrics::NoopMetrics,
},
task(TaskState::Working),
self.cancel.clone(),
Some(&self.gate),
)
}
fn pushed(&self) -> Vec<TaskState> {
self.pushes
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
async fn logged(&self) -> Vec<(u64, Option<TaskState>)> {
self.store
.read_events(&TaskId::new("t"), 0, 100)
.await
.expect("log")
.into_iter()
.map(|e| (e.seq, state_of(&e.event)))
.collect()
}
}
#[tokio::test]
async fn a_refused_write_supersedes_the_run() {
let f = Fixture::new(TaskState::Canceled).await;
let id = TaskId::new("t");
let mut run = f.processor(&id);
let mut stored = task(TaskState::Canceled);
stored.status = TaskStatus::with_timestamp(TaskState::Canceled);
f.store.save(&stored).await.expect("re-seed");
run.handle(Ok(artifact(1))).await;
assert!(f.cancel.is_cancelled(), "the executor is told");
assert_eq!(
run.last_task.status, stored.status,
"the stored task, adopted"
);
assert_eq!(f.pushed(), vec![TaskState::Canceled], "pushed once");
assert_eq!(run.last_task.status.state, TaskState::Canceled, "adopted");
let mut ticket = f.gate.arm(2).expect("gate open");
run.handle(Ok(status(2, TaskState::Completed))).await;
let verdict = ticket.try_recv().expect("answered");
assert_eq!(state_of(&verdict), Some(TaskState::Canceled));
run.handle(Ok(artifact(3))).await;
assert_eq!(f.pushed(), vec![TaskState::Canceled], "not pushed again");
assert_eq!(
f.logged().await,
vec![(1, None)],
"only the event that met the refusal is logged; later ones are \
dropped, the terminal one included"
);
let stored = f.store.get(&id).await.expect("get").expect("stored");
assert_eq!(stored.status.state, TaskState::Canceled);
assert_eq!(
stored.artifacts.unwrap_or_default().len(),
0,
"no artifact survives"
);
}
#[tokio::test]
async fn a_refused_terminal_event_is_answered_and_logged_as_the_stored_state() {
let f = Fixture::new(TaskState::Canceled).await;
let id = TaskId::new("t");
let mut run = f.processor(&id);
let mut ticket = f.gate.arm(4).expect("gate open");
run.handle(Ok(status(4, TaskState::Completed))).await;
assert_eq!(
state_of(&ticket.try_recv().expect("answered")),
Some(TaskState::Canceled)
);
assert_eq!(f.logged().await, vec![(4, Some(TaskState::Canceled))]);
assert!(f.cancel.is_cancelled());
assert_eq!(f.pushed(), vec![TaskState::Canceled]);
}
#[tokio::test]
async fn a_persisted_terminal_event_is_answered_with_itself() {
let f = Fixture::new(TaskState::Working).await;
let id = TaskId::new("t");
let mut run = f.processor(&id);
let mut ticket = f.gate.arm(1).expect("gate open");
run.handle(Ok(status(1, TaskState::Completed))).await;
assert_eq!(
state_of(&ticket.try_recv().expect("answered")),
Some(TaskState::Completed)
);
assert_eq!(f.logged().await, vec![(1, Some(TaskState::Completed))]);
assert_eq!(f.pushed(), vec![TaskState::Completed]);
assert!(!f.cancel.is_cancelled());
run.handle(Ok(status(2, TaskState::Working))).await;
assert!(
f.cancel.is_cancelled(),
"a late event still stops the executor"
);
assert_eq!(
f.pushed(),
vec![TaskState::Completed],
"no second terminal push over our own"
);
}
#[tokio::test]
async fn a_terminal_event_that_was_not_persisted_is_still_answered() {
let f = Fixture::new(TaskState::Canceled).await;
let id = TaskId::new("t");
let mut run = f.processor(&id);
run.last_task.status = TaskStatus::new(TaskState::Canceled);
let mut ticket = f.gate.arm(1).expect("gate open");
run.handle(Ok(status(1, TaskState::Canceled))).await;
assert_eq!(
state_of(&ticket.try_recv().expect("answered")),
Some(TaskState::Canceled)
);
assert!(!f.cancel.is_cancelled(), "a repeat is not a conflict");
assert_eq!(f.pushed(), Vec::<TaskState>::new());
assert_eq!(f.logged().await, vec![(1, Some(TaskState::Canceled))]);
run.handle(Ok(status(2, TaskState::Working))).await;
assert_eq!(f.pushed(), vec![TaskState::Canceled]);
}
#[tokio::test]
async fn a_persisted_running_update_does_not_count_as_the_terminal_push() {
let f = Fixture::new(TaskState::Working).await;
let id = TaskId::new("t");
let mut run = f.processor(&id);
run.handle(Ok(status(1, TaskState::Working))).await;
assert_eq!(f.pushed(), vec![TaskState::Working]);
f.store
.save(&task(TaskState::Canceled))
.await
.expect("cancel elsewhere");
run.handle(Ok(artifact(2))).await;
assert_eq!(
f.pushed(),
vec![TaskState::Working, TaskState::Canceled],
"webhooks hear how the task ended"
);
}
#[tokio::test]
async fn a_panic_fails_a_running_task_but_not_a_finished_one() {
let id = TaskId::new("t");
let f = Fixture::new(TaskState::Canceled).await;
let mut run = f.processor(&id);
run.executor_panicked().await;
let stored = f.store.get(&id).await.expect("get").expect("stored");
assert_eq!(stored.status.state, TaskState::Canceled);
assert!(f.cancel.is_cancelled(), "the refusal supersedes the run");
assert_eq!(f.pushed(), vec![TaskState::Canceled]);
let f = Fixture::new(TaskState::Working).await;
let mut run = f.processor(&id);
run.executor_panicked().await;
let stored = f.store.get(&id).await.expect("get").expect("stored");
assert_eq!(stored.status.state, TaskState::Failed);
assert!(!f.cancel.is_cancelled());
let f = Fixture::new(TaskState::Completed).await;
let mut run = f.processor(&id);
run.last_task.status = TaskStatus::new(TaskState::Completed);
run.executor_panicked().await;
let stored = f.store.get(&id).await.expect("get").expect("stored");
assert_eq!(stored.status.state, TaskState::Completed);
assert!(!f.cancel.is_cancelled());
}
#[tokio::test]
async fn a_refusal_is_trusted_over_a_stale_read() {
let inner = InMemoryTaskStore::new();
inner.save(&task(TaskState::Canceled)).await.expect("seed");
let store = crate::handler::event_processing::stale_reads::StaleReads::always(inner);
let f = Fixture::new(TaskState::Canceled).await;
let id = TaskId::new("t");
let mut run = Processor::new(
&id,
BackgroundDeps {
task_store: &store,
push_config_store: &f.configs,
push_sender: Some(&f.pushes),
limits: &f.limits,
metrics: &crate::metrics::NoopMetrics,
},
task(TaskState::Working),
f.cancel.clone(),
Some(&f.gate),
);
let mut ticket = f.gate.arm(1).expect("gate open");
run.handle(Ok(status(1, TaskState::Completed))).await;
assert_eq!(
state_of(&ticket.try_recv().expect("answered")),
Some(TaskState::Canceled)
);
assert_eq!(run.last_task.status.state, TaskState::Canceled);
assert_eq!(f.pushed(), vec![TaskState::Canceled]);
}