use std::future::pending;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use agentcat_api::models::PublishEventRequest;
use agentcat_api::models::publish_event_request::EventType;
use rmcp::ServerHandler;
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, ClientCapabilities, Implementation};
use rmcp::model::{InitializeRequestParams, ProtocolVersion};
use serde_json::json;
use tokio::runtime::{Builder, Handle, Runtime};
use tokio::sync::{Semaphore, oneshot};
use super::queue::{Kind, Queue, Reply, Request, Worker};
use super::{Delivery, DeliveryConfig, LineWriter, Observer, Publisher};
use crate::client::AgentCat;
use crate::config::{HookError, LogDestination, Options, UserIdentity};
use crate::event::assemble::EventJob;
use crate::hooks::PendingHooks;
use crate::logging;
use crate::pipeline::EventObject;
use crate::sink::{BoxFuture, Sink};
use crate::test_support::json::object;
use crate::test_support::queue::{
self, CapturedLines, DropSignal, MOCK_FAILURE, RecordingPublisher,
};
use crate::test_support::rmcp::{Sample, connect};
use crate::test_support::{TempHome, vectors};
const PROJECT: &str = "proj_delivery";
const WAIT: Duration = Duration::from_secs(2);
const WORKER_THREAD: &str = "agentcat-delivery";
const SUCCESS: &str = "Successfully sent event ";
const fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
fn fast() -> DeliveryConfig {
DeliveryConfig {
max_pending: 10_000,
concurrency: 5,
retry_delays: [ms(10), ms(20), ms(40)],
hook_budget: ms(50),
drop_drain: ms(1_000),
}
}
fn job(project: &str, name: &str) -> EventJob {
EventJob {
event: object(json!({
"id": "",
"sessionId": "ses_delivery000000000000000000",
"projectId": project,
"eventType": "mcp:tools/call",
"timestamp": "2026-10-23T12:00:00.000Z",
"duration": 5,
"resourceName": name,
"tags": {},
})),
hooks: PendingHooks::default(),
hook_mode: false,
runtime: None,
string_redaction: false,
event_redaction: false,
backtrace: None,
}
}
fn handle(
project: &str,
options: Options,
publisher: &RecordingPublisher,
observer: Option<Arc<dyn Observer>>,
config: DeliveryConfig,
) -> (AgentCat, CapturedLines) {
let lines = CapturedLines::default();
let delivery = Delivery::with_writer(
project.to_owned(),
&options,
Arc::new(publisher.clone()),
observer,
config,
lines.writer(),
);
(
AgentCat::with_sink(project, options, Arc::new(delivery)),
lines,
)
}
fn push(agentcat: &AgentCat, job: EventJob) {
agentcat.inner().sink.event(job);
}
fn wait_starts(starts: &mpsc::Receiver<()>, count: usize) {
for _ in 0..count {
starts.recv_timeout(WAIT).expect("an attempt starts");
}
}
fn hooks_runtime() -> Runtime {
Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap()
}
fn hanging<T: Send + 'static>(
runtime: &Runtime,
signal: Option<DropSignal>,
) -> tokio::task::JoinHandle<Result<Option<T>, HookError>> {
runtime.spawn(async move {
let _signal = signal;
pending().await
})
}
#[derive(Default)]
struct RecordingObserver(Mutex<Vec<EventObject>>);
impl Observer for RecordingObserver {
fn finished(&self, event: &EventObject) {
self.0.lock().unwrap().push(event.clone());
}
}
struct Empty;
impl ServerHandler for Empty {}
#[test]
fn default_config_is_the_queue_limits() {
let config = DeliveryConfig::default();
assert_eq!(config.max_pending, 10_000);
assert_eq!(config.concurrency, 5);
assert_eq!(config.retry_delays, [ms(1_000), ms(2_000), ms(4_000)]);
assert_eq!(config.hook_budget, Duration::from_secs(30));
assert_eq!(config.drop_drain, Duration::from_secs(5));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn call_record_is_published() {
let publisher = RecordingPublisher::new();
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
let client = connect(agentcat.track(Sample), "delivery-client").await;
let arguments = object(json!({"msg": "hi"}));
client
.call_tool(CallToolRequestParams::new("echo").with_arguments(arguments))
.await
.unwrap();
client.cancel().await.unwrap();
assert!(agentcat.flush(WAIT).await);
let requests = publisher.requests();
assert_eq!(requests.len(), 1, "{requests:?}");
let request = &requests[0];
assert_eq!(request.project_id, PROJECT);
assert_eq!(request.event_type, Some(EventType::McpColonToolsSlashCall));
assert_eq!(request.resource_name.as_deref(), Some("echo"));
assert_eq!(request.client_name.as_deref(), Some("delivery-client"));
assert!(request.server_name.is_some(), "{request:?}");
assert!(matches!(&request.session_id, Some(Some(session)) if session.starts_with("ses_")));
let id = request.id.clone().unwrap();
assert!(id.starts_with("evt_"), "{id}");
let lines = lines.lines();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with(&format!("{SUCCESS}{id} | mcp:tools/call | session ses_")));
}
#[test]
fn telemetry_only_is_not_posted_but_observed() {
let publisher = RecordingPublisher::new();
let observer = Arc::new(RecordingObserver::default());
let (agentcat, lines) = handle(
"",
Options::default(),
&publisher,
Some(observer.clone()),
fast(),
);
push(&agentcat, job("", "telemetry"));
push(&agentcat, job(PROJECT, "posted"));
assert!(agentcat.flush_blocking(WAIT));
assert_eq!(publisher.resource_names(), ["posted"]);
let seen = observer.0.lock().unwrap().clone();
let mut names: Vec<&str> = seen
.iter()
.map(|event| event["resourceName"].as_str().unwrap())
.collect();
names.sort_unstable();
assert_eq!(names, ["posted", "telemetry"]);
for event in &seen {
assert!(
event["id"].as_str().unwrap().starts_with("evt_"),
"{event:?}"
);
}
let lines = lines.lines();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with(SUCCESS) && lines[0].contains(PROJECT));
}
#[test]
fn retries_three_times_then_logs_failure() {
let publisher = RecordingPublisher::failing(usize::MAX);
let config = DeliveryConfig {
retry_delays: [ms(30), ms(60), ms(120)],
..fast()
};
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, config);
push(&agentcat, job(PROJECT, "failing"));
assert!(agentcat.flush_blocking(WAIT));
let attempts = publisher.attempts();
assert_eq!(attempts.len(), 4, "{attempts:?}");
for (pair, delay) in attempts.windows(2).zip(config.retry_delays) {
let gap = pair[1].at - pair[0].at;
assert!(gap >= delay, "{gap:?} < {delay:?}");
}
let id = attempts[0].request.id.clone().unwrap();
let mut expected =
vec![format!("Failed to send event {id}, retrying... [Error: {MOCK_FAILURE}]"); 4];
expected.push(format!(
"Failed to deliver AgentCat event after retries: {MOCK_FAILURE}"
));
assert_eq!(lines.lines(), expected);
for (line, thread) in lines.entries() {
assert_eq!(thread.as_deref(), Some(WORKER_THREAD), "{line}");
}
}
struct Unusable(Arc<std::sync::atomic::AtomicUsize>);
impl Publisher for Unusable {
fn publish(&self, _request: PublishEventRequest) -> BoxFuture<'static, Result<(), String>> {
self.0.fetch_add(1, Ordering::SeqCst);
Box::pin(std::future::ready(Ok(())))
}
fn prepare(&self, _log: &mut Vec<String>) -> Result<(), String> {
Err("the HTTP client could not be built".to_owned())
}
}
#[test]
fn unusable_publisher_drops_the_event_at_once() {
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let lines = CapturedLines::default();
let delivery = Delivery::with_writer(
PROJECT.to_owned(),
&Options::default(),
Arc::new(Unusable(Arc::clone(&attempts))),
None,
DeliveryConfig {
retry_delays: [ms(1_000), ms(2_000), ms(4_000)],
..fast()
},
lines.writer(),
);
let agentcat = AgentCat::with_sink(PROJECT, Options::default(), Arc::new(delivery));
push(&agentcat, job(PROJECT, "unsendable"));
let started = Instant::now();
assert!(agentcat.flush_blocking(WAIT));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
assert_eq!(attempts.load(Ordering::SeqCst), 0);
assert_eq!(
lines.lines(),
["Failed to deliver AgentCat event after retries: the HTTP client could not be built"]
);
}
#[test]
fn failed_client_build_is_logged_once_by_the_worker() {
let publisher = super::http::ApiPublisher::with_build("http://127.0.0.1:9".to_owned(), |_| {
let thread = std::thread::current();
Err(format!(
"no TLS backend on {}",
thread.name().unwrap_or("?")
))
});
let lines = CapturedLines::default();
let delivery = Delivery::with_writer(
PROJECT.to_owned(),
&Options::default(),
Arc::new(publisher),
None,
DeliveryConfig {
retry_delays: [ms(1_000), ms(2_000), ms(4_000)],
..fast()
},
lines.writer(),
);
let agentcat = AgentCat::with_sink(PROJECT, Options::default(), Arc::new(delivery));
assert!(agentcat.flush_blocking(WAIT));
assert!(lines.lines().is_empty(), "{:?}", lines.lines());
push(&agentcat, job(PROJECT, "first"));
push(&agentcat, job(PROJECT, "second"));
let started = Instant::now();
assert!(agentcat.flush_blocking(WAIT));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
let failed =
"Failed to deliver AgentCat event after retries: the HTTP client could not be built";
assert_eq!(
lines.lines(),
[
"Failed to build the HTTP client, events will not be sent: no TLS backend on \
agentcat-delivery",
failed,
failed,
]
);
for (line, thread) in lines.entries() {
assert_eq!(thread.as_deref(), Some(WORKER_THREAD), "{line}");
}
}
#[test]
fn retry_attempts_share_one_event_id() {
let publisher = RecordingPublisher::failing(2);
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "retried"));
assert!(agentcat.flush_blocking(WAIT));
let ids: Vec<String> = publisher
.requests()
.into_iter()
.map(|request| request.id.unwrap())
.collect();
assert_eq!(ids.len(), 3, "{ids:?}");
assert!(ids[0].starts_with("evt_"), "{ids:?}");
assert!(ids.iter().all(|id| *id == ids[0]), "{ids:?}");
let lines = lines.lines();
assert_eq!(lines.len(), 3, "{lines:?}");
for line in &lines {
assert!(line.contains(&ids[0]), "{line}");
assert_eq!(
queue::mask_event_ids(line).matches("<evt>").count(),
1,
"{line}"
);
}
}
#[test]
fn success_line_matches_the_fixture() {
let runtime = hooks_runtime();
let mut ran = 0;
for case in queue::cases() {
let name = vectors::name(&case).to_owned();
if name.starts_with("retry:") {
continue;
}
let publisher = RecordingPublisher::new();
let lines = CapturedLines::default();
let delivery = Delivery::with_writer(
String::new(),
&queue::options_of(&case),
Arc::new(publisher.clone()),
None,
fast(),
lines.writer(),
);
delivery.event(queue::job_of(&case, Some(runtime.handle())));
assert!(delivery.flush_blocking(WAIT), "{name}");
drop(delivery);
let minted = publisher.requests().first().and_then(minted_session);
let logs: Vec<String> = lines
.lines()
.iter()
.map(|line| {
let line = queue::mask_event_ids(line);
match &minted {
Some(session) => line.replace(session.as_str(), "<minted>"),
None => line,
}
})
.collect();
assert_eq!(logs, queue::worker_logs(&case), "{name}");
ran += 1;
}
assert_eq!(ran, 57, "every non-retry case ran");
}
fn minted_session(request: &PublishEventRequest) -> Option<String> {
let source = request.tags.as_ref()?.get("agentcat_session_id_source")?;
if source != "minted" {
return None;
}
request.session_id.clone().flatten()
}
#[test]
fn success_line_names_an_empty_actor_anonymous() {
let publisher = RecordingPublisher::new();
let (delivery, lines) = bare_delivery(&publisher, fast());
let mut empty_actor = job(PROJECT, "empty-actor");
empty_actor
.event
.insert("identifyActorGivenId".to_owned(), json!(""));
delivery.event(empty_actor);
assert!(delivery.flush_blocking(WAIT));
let success: Vec<String> = lines
.lines()
.into_iter()
.filter(|line| line.starts_with(SUCCESS))
.collect();
assert_eq!(success.len(), 1, "{success:?}");
assert!(success[0].ends_with(" ms | anonymous"), "{}", success[0]);
}
#[test]
fn overflow_drops_oldest_pending() {
let (publisher, gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let config = DeliveryConfig {
max_pending: 3,
concurrency: 1,
..fast()
};
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, config);
push(&agentcat, job(PROJECT, "a"));
wait_starts(&starts, 1);
for name in ["b", "c", "d", "e"] {
push(&agentcat, job(PROJECT, name));
}
gate.open();
assert!(agentcat.flush_blocking(WAIT));
assert_eq!(publisher.resource_names(), ["a", "c", "d", "e"]);
let other: Vec<String> = lines
.lines()
.into_iter()
.filter(|line| !line.starts_with(SUCCESS))
.collect();
assert_eq!(other, ["Event queue full, dropping oldest event"]);
}
#[test]
fn overflow_drop_aborts_hooks() {
let runtime = hooks_runtime();
let (publisher, gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let config = DeliveryConfig {
max_pending: 1,
concurrency: 1,
..fast()
};
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, config);
push(&agentcat, job(PROJECT, "a"));
wait_starts(&starts, 1);
let (signal, dropped) = DropSignal::new();
let mut hooked = job(PROJECT, "b");
hooked.hooks.identify = Some(hanging::<UserIdentity>(&runtime, Some(signal)));
push(&agentcat, hooked);
push(&agentcat, job(PROJECT, "c"));
dropped
.recv_timeout(WAIT)
.expect("the dropped job's hook is aborted");
gate.open();
assert!(agentcat.flush_blocking(WAIT));
assert_eq!(publisher.resource_names(), ["a", "c"]);
}
#[test]
fn enqueue_never_blocks() {
let (publisher, _gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
for _ in 0..5 {
push(&agentcat, job(PROJECT, "held"));
}
wait_starts(&starts, 5);
let jobs: Vec<EventJob> = (0..10_000).map(|_| job(PROJECT, "queued")).collect();
let started = Instant::now();
for job in jobs {
push(&agentcat, job);
}
let elapsed = started.elapsed();
assert!(elapsed < Duration::from_secs(1), "{elapsed:?}");
assert!(!agentcat.shutdown_blocking(ms(50)));
}
#[test]
fn at_most_five_in_flight() {
let (publisher, gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
for index in 0..12 {
push(&agentcat, job(PROJECT, &format!("event-{index}")));
}
wait_starts(&starts, 5);
assert!(!agentcat.flush_blocking(ms(100)));
assert_eq!(publisher.attempts().len(), 5);
gate.open();
assert!(agentcat.flush_blocking(WAIT));
assert_eq!(publisher.attempts().len(), 12);
assert_eq!(publisher.most_running(), 5);
}
#[test]
fn flush_waits_for_in_flight() {
let (publisher, gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "held"));
wait_starts(&starts, 1);
let (answer_tx, answer) = mpsc::channel();
let flusher = agentcat.clone();
let thread = std::thread::spawn(move || {
answer_tx.send(flusher.flush_blocking(WAIT)).unwrap();
});
assert_eq!(
answer.recv_timeout(ms(100)),
Err(mpsc::RecvTimeoutError::Timeout),
"flush waits while the attempt is held"
);
gate.open();
assert_eq!(answer.recv_timeout(WAIT), Ok(true));
assert_eq!(publisher.finished(), 1);
thread.join().unwrap();
}
#[test]
fn unbounded_timeout_waits_for_the_queue() {
const YEAR: u64 = 365 * 24 * 60 * 60;
let before = Instant::now();
let request = Request::new(Kind::Flush, Duration::MAX, Reply::Nobody);
let ahead = request.deadline.duration_since(before);
assert!(
Duration::from_secs(99 * YEAR) <= ahead && ahead <= Duration::from_secs(101 * YEAR),
"{ahead:?}"
);
let publisher = RecordingPublisher::slow(ms(20));
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "a"));
assert!(agentcat.flush_blocking(Duration::MAX));
assert_eq!(publisher.finished(), 1);
}
#[test]
fn flush_returns_false_at_deadline() {
let hooks = hooks_runtime();
let publisher = RecordingPublisher::new();
let config = DeliveryConfig {
hook_budget: Duration::from_secs(10),
..fast()
};
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, config);
let mut held = job(PROJECT, "held");
held.hooks.identify = Some(hanging::<UserIdentity>(&hooks, None));
push(&agentcat, held);
let started = Instant::now();
assert!(!agentcat.flush_blocking(ms(150)));
let elapsed = started.elapsed();
assert!(
ms(150) <= elapsed && elapsed < Duration::from_secs(1),
"{elapsed:?}"
);
let no_timer = Builder::new_current_thread().build().unwrap();
let started = Instant::now();
assert!(!no_timer.block_on(agentcat.flush(ms(150))));
let elapsed = started.elapsed();
assert!(
ms(150) <= elapsed && elapsed < Duration::from_secs(1),
"{elapsed:?}"
);
assert!(publisher.attempts().is_empty());
assert!(!agentcat.shutdown_blocking(ms(50)));
}
#[tokio::test]
async fn shutdown_rejects_later_events() {
let publisher = RecordingPublisher::new();
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "before"));
assert!(agentcat.shutdown(WAIT).await);
push(&agentcat, job(PROJECT, "after"));
assert!(agentcat.flush(WAIT).await);
assert_eq!(publisher.resource_names(), ["before"]);
let other: Vec<(String, Option<String>)> = lines
.entries()
.into_iter()
.filter(|(line, _)| !line.starts_with(SUCCESS))
.collect();
assert_eq!(
other,
[(
"Queue is shutting down, event dropped".to_owned(),
Some(WORKER_THREAD.to_owned())
)]
);
}
#[test]
fn shutdown_logs_events_left() {
let (publisher, _gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let config = DeliveryConfig {
concurrency: 1,
..fast()
};
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, config);
for name in ["a", "b", "c"] {
push(&agentcat, job(PROJECT, name));
}
wait_starts(&starts, 1);
let started = Instant::now();
assert!(!agentcat.shutdown_blocking(ms(100)));
assert!(started.elapsed() < Duration::from_secs(1));
assert_eq!(
lines.lines(),
["Shutting down with 2 events still in queue"]
);
assert_eq!(publisher.resource_names(), ["a"]);
assert_eq!(
publisher.finished(),
0,
"the attempt in flight is abandoned"
);
}
#[test]
fn blocking_twins_work_outside_a_runtime() {
assert!(Handle::try_current().is_err());
let publisher = RecordingPublisher::slow(ms(20));
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "a"));
assert!(agentcat.flush_blocking(WAIT));
assert_eq!(publisher.finished(), 1);
push(&agentcat, job(PROJECT, "b"));
assert!(agentcat.shutdown_blocking(WAIT));
assert_eq!(publisher.finished(), 2);
}
const LONG: Duration = Duration::from_secs(60);
fn bare_delivery(
publisher: &RecordingPublisher,
config: DeliveryConfig,
) -> (Delivery, CapturedLines) {
let lines = CapturedLines::default();
let delivery = Delivery::with_writer(
PROJECT.to_owned(),
&Options::default(),
Arc::new(publisher.clone()),
None,
config,
lines.writer(),
);
(delivery, lines)
}
fn worker_of(delivery: &Delivery) -> Worker {
delivery.shared.lock().worker
}
fn cancel_after_20_ms(request: impl Future<Output = bool>) -> bool {
let runtime = Builder::new_current_thread().enable_time().build().unwrap();
runtime.block_on(async { tokio::time::timeout(ms(20), request).await.is_err() })
}
fn other_lines(lines: &CapturedLines) -> Vec<String> {
lines
.lines()
.into_iter()
.filter(|line| !line.starts_with(SUCCESS))
.collect()
}
#[test]
fn flush_after_timed_out_shutdown_returns_true() {
let (publisher, _gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (agentcat, _lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "held"));
wait_starts(&starts, 1);
assert!(!agentcat.shutdown_blocking(ms(50)));
let started = Instant::now();
assert!(agentcat.flush_blocking(WAIT));
let no_timer = Builder::new_current_thread().build().unwrap();
assert!(no_timer.block_on(agentcat.flush(WAIT)));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
assert_eq!(
publisher.finished(),
0,
"the attempt in flight was abandoned"
);
}
#[test]
fn cancelled_flush_leaves_waiting_list_when_worker_idles() {
let (publisher, gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (delivery, lines) = bare_delivery(&publisher, fast());
let delivery = Arc::new(delivery);
let agentcat = AgentCat::with_sink(PROJECT, Options::default(), delivery.clone());
push(&agentcat, job(PROJECT, "a"));
wait_starts(&starts, 1);
assert!(
cancel_after_20_ms(agentcat.flush(LONG)),
"the customer's timeout cancels the flush"
);
let (probe, answer) = mpsc::channel();
assert_eq!(
delivery.request(Kind::Flush, LONG, Reply::Blocking(probe)),
None
);
push(&agentcat, job(PROJECT, "b"));
assert_eq!(
answer.recv_timeout(ms(100)),
Err(mpsc::RecvTimeoutError::Timeout),
"the request waits while the events are in flight"
);
gate.open();
assert_eq!(answer.recv_timeout(WAIT), Ok(true), "the worker is idle");
assert_eq!(
answer.recv_timeout(WAIT),
Err(mpsc::RecvTimeoutError::Disconnected),
"the worker let the request go"
);
let mut names = publisher.resource_names();
names.sort();
assert_eq!(names, ["a", "b"]);
assert_eq!(other_lines(&lines), Vec::<String>::new());
}
fn handle_with_held_attempt() -> (
AgentCat,
Arc<Delivery>,
RecordingPublisher,
queue::Gate,
CapturedLines,
) {
let (publisher, gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (delivery, lines) = bare_delivery(&publisher, fast());
let delivery = Arc::new(delivery);
let agentcat = AgentCat::with_sink(PROJECT, Options::default(), delivery.clone());
push(&agentcat, job(PROJECT, "a"));
wait_starts(&starts, 1);
(agentcat, delivery, publisher, gate, lines)
}
#[test]
fn cancelled_shutdown_leaves_waiting_list_when_worker_idles() {
let (agentcat, delivery, publisher, gate, lines) = handle_with_held_attempt();
assert!(
cancel_after_20_ms(agentcat.shutdown(LONG)),
"the customer's timeout cancels the shutdown"
);
push(&agentcat, job(PROJECT, "b"));
{
let queue = delivery.shared.lock();
assert!(
queue.shutting_down,
"the cancelled shutdown marked the queue"
);
assert_eq!(
queue.pending.len() + queue.in_flight,
1,
"only `a` is queued: `b` was dropped"
);
}
let (probe, answer) = mpsc::channel();
assert_eq!(
delivery.request(Kind::Flush, LONG, Reply::Blocking(probe)),
None
);
assert_eq!(
answer.recv_timeout(ms(100)),
Err(mpsc::RecvTimeoutError::Timeout),
"the probe waits while the event is in flight"
);
gate.open();
assert_eq!(answer.recv_timeout(WAIT), Ok(true), "the worker drained");
let started = Instant::now();
assert!(agentcat.flush_blocking(WAIT));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
assert_eq!(publisher.resource_names(), ["a"]);
assert_eq!(publisher.finished(), 1, "the event in flight was delivered");
assert_eq!(
other_lines(&lines),
["Queue is shutting down, event dropped"]
);
let (agentcat, delivery, publisher, gate, lines) = handle_with_held_attempt();
let (probe, answer) = mpsc::channel();
assert_eq!(
delivery.request(Kind::Flush, LONG, Reply::Blocking(probe)),
None
);
assert!(
cancel_after_20_ms(agentcat.shutdown(ms(1_000))),
"the customer's timeout cancels the shutdown"
);
assert_eq!(
answer.try_recv(),
Err(mpsc::TryRecvError::Empty),
"the probe waits for the cancelled shutdown's deadline"
);
assert_eq!(
answer.recv_timeout(WAIT),
Ok(false),
"the worker stopped at the cancelled shutdown's deadline"
);
gate.open();
assert!(agentcat.flush_blocking(WAIT));
assert_eq!(publisher.finished(), 0, "the event in flight was abandoned");
assert_eq!(other_lines(&lines), Vec::<String>::new());
}
#[test]
fn shutdown_twice_second_returns_true() {
let no_timer = Builder::new_current_thread().build().unwrap();
let publisher = RecordingPublisher::slow(ms(20));
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "drained"));
assert!(agentcat.shutdown_blocking(WAIT));
assert_eq!(publisher.finished(), 1);
let started = Instant::now();
assert!(agentcat.shutdown_blocking(WAIT));
assert!(no_timer.block_on(agentcat.shutdown(WAIT)));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
let lines = lines.lines();
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].starts_with(SUCCESS), "{lines:?}");
let (publisher, _gate) = RecordingPublisher::gated();
let starts = publisher.starts();
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
push(&agentcat, job(PROJECT, "held"));
wait_starts(&starts, 1);
assert!(!agentcat.shutdown_blocking(ms(50)));
let started = Instant::now();
assert!(agentcat.shutdown_blocking(WAIT));
assert!(no_timer.block_on(agentcat.shutdown(WAIT)));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
assert_eq!(publisher.finished(), 0);
assert_eq!(lines.lines(), Vec::<String>::new());
}
#[test]
fn flush_before_first_event_returns_true_without_thread() {
let publisher = RecordingPublisher::new();
let (delivery, lines) = bare_delivery(&publisher, fast());
let started = Instant::now();
assert!(delivery.flush_blocking(WAIT));
let no_timer = Builder::new_current_thread().build().unwrap();
assert!(no_timer.block_on(delivery.flush(WAIT)));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
assert_eq!(worker_of(&delivery), Worker::Idle);
assert_eq!(lines.lines(), Vec::<String>::new());
delivery.event(job(PROJECT, "first"));
assert!(delivery.flush_blocking(WAIT));
assert!(matches!(worker_of(&delivery), Worker::Running(_)));
assert_eq!(publisher.resource_names(), ["first"]);
}
#[test]
fn shutdown_before_first_event_returns_true_without_thread() {
let publisher = RecordingPublisher::new();
let (delivery, _lines) = bare_delivery(&publisher, fast());
let started = Instant::now();
assert!(delivery.shutdown_blocking(WAIT));
let no_timer = Builder::new_current_thread().build().unwrap();
assert!(no_timer.block_on(delivery.shutdown(WAIT)));
assert_eq!(worker_of(&delivery), Worker::Idle);
delivery.event(job(PROJECT, "after"));
assert!(delivery.flush_blocking(WAIT));
assert!(started.elapsed() < ms(500), "{:?}", started.elapsed());
assert_eq!(worker_of(&delivery), Worker::Idle);
assert!(publisher.attempts().is_empty());
}
#[test]
fn shutting_down_line_dropped_when_worker_never_started() {
let publisher = RecordingPublisher::new();
let (delivery, lines) = bare_delivery(&publisher, fast());
assert!(delivery.shutdown_blocking(WAIT));
delivery.event(job(PROJECT, "after"));
delivery.log("a call-path line".to_owned());
{
let queue = delivery.shared.lock();
assert_eq!(queue.lines, Vec::<String>::new());
assert!(queue.pending.is_empty());
assert_eq!(queue.worker, Worker::Idle);
}
drop(delivery);
assert_eq!(lines.lines(), Vec::<String>::new());
assert!(publisher.attempts().is_empty());
}
struct HeldDrop {
release: mpsc::Receiver<()>,
dropped: Arc<AtomicBool>,
}
impl Drop for HeldDrop {
fn drop(&mut self) {
let _ = self.release.recv();
self.dropped.store(true, Ordering::SeqCst);
}
}
struct HangsHolding {
held: Mutex<Option<HeldDrop>>,
starts: mpsc::Sender<()>,
}
impl Publisher for HangsHolding {
fn publish(&self, _request: PublishEventRequest) -> BoxFuture<'static, Result<(), String>> {
let held = self.held.lock().unwrap().take();
let starts = self.starts.clone();
Box::pin(async move {
let _held = held;
let _ = starts.send(());
pending().await
})
}
}
#[test]
fn stop_awaits_aborted_tasks_before_answering() {
let dropped = Arc::new(AtomicBool::new(false));
let (release, released) = mpsc::channel();
let (starts_tx, starts) = mpsc::channel();
let publisher = HangsHolding {
held: Mutex::new(Some(HeldDrop {
release: released,
dropped: Arc::clone(&dropped),
})),
starts: starts_tx,
};
let delivery = Delivery::with_writer(
PROJECT.to_owned(),
&Options::default(),
Arc::new(publisher),
None,
fast(),
CapturedLines::default().writer(),
);
delivery.event(job(PROJECT, "held"));
wait_starts(&starts, 1);
let (reply, answer) = mpsc::channel();
assert_eq!(
delivery.request(Kind::Shutdown, ms(50), Reply::Blocking(reply)),
None
);
assert_eq!(
answer.recv_timeout(ms(300)),
Err(mpsc::RecvTimeoutError::Timeout),
"no answer while the aborted attempt's drop is held"
);
assert!(!dropped.load(Ordering::SeqCst));
release.send(()).unwrap();
assert_eq!(answer.recv_timeout(WAIT), Ok(false));
assert!(
dropped.load(Ordering::SeqCst),
"the aborted attempt was dropped before the answer"
);
}
#[test]
fn worker_that_panics_is_gone() {
let (entered, writing) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let released = Mutex::new(released);
let writer: LineWriter = Arc::new(move |line: &str| {
let _ = entered.send(());
let _ = released.lock().unwrap().recv_timeout(WAIT);
panic!("the line writer fails on {line:?}");
});
let publisher = RecordingPublisher::new();
let delivery = Delivery::with_writer(
PROJECT.to_owned(),
&Options::default(),
Arc::new(publisher.clone()),
None,
fast(),
writer,
);
delivery.log("first line".to_owned());
writing
.recv_timeout(WAIT)
.expect("the worker writes the line");
delivery.event(job(PROJECT, "held"));
let (reply, answer) = mpsc::channel();
assert_eq!(
delivery.request(Kind::Flush, LONG, Reply::Blocking(reply)),
None
);
release.send(()).unwrap();
assert_eq!(answer.recv_timeout(WAIT), Ok(false));
assert_eq!(worker_of(&delivery), Worker::Gone);
assert!(
delivery.shared.lock().pending.is_empty(),
"the event is dropped"
);
let started = Instant::now();
assert!(!delivery.flush_blocking(LONG));
assert!(started.elapsed() < WAIT, "{:?}", started.elapsed());
assert!(publisher.attempts().is_empty());
}
#[test]
fn close_waits_only_for_a_worker_on_another_thread() {
let here = std::thread::current().id();
let other = std::thread::spawn(|| std::thread::current().id())
.join()
.unwrap();
let cases = [
(Worker::Idle, None),
(Worker::Gone, None),
(Worker::Running(other), Some(true)),
(Worker::Running(here), Some(false)),
];
for (worker, queued) in cases {
let mut queue = Queue::new();
queue.worker = worker;
let (answer, _receiver) = mpsc::channel();
let before = Instant::now();
let waits = queue.close(ms(300), answer);
assert_eq!(waits, queued == Some(true), "{worker:?}");
assert!(queue.closed && queue.shutting_down, "{worker:?}");
match (queued, queue.requests.as_slice()) {
(None, []) => {}
(Some(waits), [request]) => {
assert_eq!(request.kind, Kind::Shutdown, "{worker:?}");
assert!(request.deadline >= before + ms(300), "{worker:?}");
assert_eq!(
matches!(request.reply, Reply::Blocking(_)),
waits,
"{worker:?}"
);
assert_eq!(matches!(request.reply, Reply::Nobody), !waits, "{worker:?}");
}
(_, requests) => panic!("{worker:?}: {requests:?}"),
}
}
}
#[test]
fn last_drop_drains_queue() {
let publisher = RecordingPublisher::slow(ms(20));
let (agentcat, lines) = handle(PROJECT, Options::default(), &publisher, None, fast());
for name in ["a", "b", "c"] {
push(&agentcat, job(PROJECT, name));
}
drop(agentcat);
let mut names = publisher.resource_names();
names.sort();
assert_eq!(names, ["a", "b", "c"]);
assert_eq!(publisher.finished(), 3);
assert_eq!(lines.lines().len(), 3);
}
struct OnDrop(Option<oneshot::Sender<()>>);
impl Drop for OnDrop {
fn drop(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
#[test]
fn last_drop_on_current_thread_runtime_returns_within_budget() {
let runtime = Builder::new_current_thread().enable_all().build().unwrap();
let publisher = RecordingPublisher::new();
let completed = Arc::new(AtomicBool::new(false));
let (release, released) = oneshot::channel::<()>();
let (dropped_tx, dropped) = oneshot::channel::<()>();
let hook_state = Arc::new(Mutex::new(Some((released, dropped_tx))));
let done = Arc::clone(&completed);
let options = Options::default().identify(move |_call| {
let state = hook_state.lock().unwrap().take();
let done = Arc::clone(&done);
async move {
let (released, dropped) = state.expect("one call");
let _on_drop = OnDrop(Some(dropped));
let _ = released.await;
done.store(true, Ordering::SeqCst);
Ok(Some(UserIdentity::new("user-1")))
}
});
let config = DeliveryConfig {
hook_budget: Duration::from_secs(10),
drop_drain: ms(300),
..fast()
};
let recording = publisher.clone();
let elapsed = runtime.block_on(async move {
let (agentcat, _lines) = handle(PROJECT, options, &recording, None, config);
let (server_end, client_end) = tokio::io::duplex(64 * 1024);
let client = InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new("current-thread", "1.0.0"),
)
.with_protocol_version(ProtocolVersion::V_2025_11_25);
let (server, client) = tokio::join!(
agentcat.track(Sample).serve(server_end),
client.serve(client_end)
);
let (server, client) = (server.unwrap(), client.unwrap());
let arguments = object(json!({"msg": "hi"}));
client
.call_tool(CallToolRequestParams::new("echo").with_arguments(arguments))
.await
.unwrap();
client.cancel().await.unwrap();
server.cancel().await.unwrap();
release.send(()).unwrap();
let started = Instant::now();
drop(agentcat);
let elapsed = started.elapsed();
tokio::time::timeout(WAIT, dropped)
.await
.expect("the hook's future is dropped")
.unwrap();
elapsed
});
assert!(
ms(300) <= elapsed && elapsed < Duration::from_secs(1),
"{elapsed:?}"
);
assert!(
!completed.load(Ordering::SeqCst),
"the abandoned event's hook is aborted"
);
assert!(publisher.attempts().is_empty());
}
struct DropsLastHandle {
slot: Arc<Mutex<Option<AgentCat>>>,
release: Arc<Semaphore>,
report: mpsc::Sender<(Duration, Option<String>)>,
_exit: DropSignal,
}
impl Publisher for DropsLastHandle {
fn publish(&self, _request: PublishEventRequest) -> BoxFuture<'static, Result<(), String>> {
let slot = Arc::clone(&self.slot);
let release = Arc::clone(&self.release);
let report = self.report.clone();
Box::pin(async move {
let _ = release.acquire().await;
let last = slot.lock().unwrap().take();
let started = Instant::now();
drop(last);
let thread = std::thread::current().name().map(str::to_owned);
let _ = report.send((started.elapsed(), thread));
Ok(())
})
}
}
#[test]
fn drop_on_worker_thread_does_not_wait() {
let slot = Arc::new(Mutex::new(None));
let release = Arc::new(Semaphore::new(0));
let (report, reports) = mpsc::channel();
let (exit, exited) = DropSignal::new();
let publisher = DropsLastHandle {
slot: Arc::clone(&slot),
release: Arc::clone(&release),
report,
_exit: exit,
};
let delivery = Delivery::with_writer(
PROJECT.to_owned(),
&Options::default(),
Arc::new(publisher),
None,
fast(),
CapturedLines::default().writer(),
);
let agentcat = AgentCat::with_sink(PROJECT, Options::default(), Arc::new(delivery));
push(&agentcat, job(PROJECT, "a"));
*slot.lock().unwrap() = Some(agentcat);
release.close();
let (elapsed, thread) = reports
.recv_timeout(WAIT)
.expect("the attempt drops the last handle");
assert_eq!(thread.as_deref(), Some(WORKER_THREAD));
assert!(
elapsed < ms(200),
"{elapsed:?}: a drain wait takes drop_drain (1 s)"
);
exited
.recv_timeout(WAIT)
.expect("the worker exits and drops its publisher");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn log_lines_are_written_by_the_worker() {
let home = TempHome::new("delivery-log");
logging::configure(LogDestination::File, Some(home.path().to_path_buf()));
let publisher: Arc<dyn Publisher> = Arc::new(RecordingPublisher::new());
let agentcat = AgentCat::with_delivery(PROJECT, Options::default(), publisher, fast());
let client = connect(agentcat.track(Empty), "delivery-client").await;
client.list_all_tools().await.unwrap();
client.cancel().await.unwrap();
assert!(agentcat.flush(WAIT).await);
let text = std::fs::read_to_string(home.path().join("agentcat.log")).unwrap();
let warning = "Warning: No tools found in the original list. This is likely due to the tools not being registered before `track`.";
let expected = format!("] {} {warning}", logging::prefix());
assert!(text.lines().any(|line| line.ends_with(&expected)), "{text}");
}
#[test]
fn json_features_empty_in_this_build() {
assert_eq!(super::worker::json_features(), Vec::<&str>::new());
}
#[test]
fn feature_warning_has_the_fixed_text() {
assert_eq!(
super::worker::feature_warning("float_roundtrip"),
"Warning: serde_json feature \"float_roundtrip\" is enabled in this build; AgentCat token estimates and size checks may differ from builds without it"
);
}