use super::*;
fn req(id: &str) -> InteractionRequest {
InteractionRequest::free_text(id, "prompt?", "stage", true)
}
async fn settle() {
for _ in 0..8 {
tokio::task::yield_now().await;
}
}
#[test]
fn a_poisoned_registry_still_serves_every_other_agent() {
let hub = InteractionHub::new();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = hub.pending.lock().expect("fresh lock");
panic!("a panic while holding the interaction registry");
}));
std::panic::set_hook(prev);
assert!(poisoned.is_err());
assert!(hub.pending.is_poisoned(), "the lock really is poisoned");
assert!(hub.pending().is_empty());
assert!(!hub.cancel("nope"));
assert!(!hub.answer(InteractionResponse::text("nope", "x")));
}
#[tokio::test]
async fn ask_is_answered_through_the_hub() {
let hub = InteractionHub::new();
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
settle().await;
let pending = hub.pending();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].0, "agent-a");
assert_eq!(pending[0].1.id, "q1");
assert!(hub.answer(InteractionResponse::text("q1", "hello")));
let response = asking.await.unwrap();
assert_eq!(response.value.as_deref(), Some("hello"));
assert!(hub.pending().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_batch_waiting_on_a_prompt_does_not_hold_the_tool_lane() {
use crate::tool_bridge::{ToolJob, ToolLane, ToolLaneStats};
use bevy_ecs::entity::Entity;
let hub = InteractionHub::new();
let (job_tx, job_rx) = tokio::sync::mpsc::unbounded_channel();
let (result_tx, mut results) = tokio::sync::mpsc::unbounded_channel();
let stats = Arc::new(ToolLaneStats::new(1));
let lane = ToolLane::new(
tokio::runtime::Handle::current(),
result_tx,
Arc::new(Notify::new()),
1,
stats.clone(),
);
let serving = lane.serve(job_rx);
let submit = |entity: u32, exec: crate::tool_bridge::BoxedToolExec| {
stats.enqueued();
job_tx
.send(ToolJob {
entity: Entity::from_raw_u32(entity).expect("a small index is a valid id"),
exec,
cancel: crate::cancel::CancelToken::new(),
})
.expect("the lane is serving");
};
let asking = hub.backend_for("agent-a");
submit(
1,
Box::new(move || {
Box::pin(async move {
let response = asking.ask(req("q1")).await;
vec![("q1".to_string(), response.value.unwrap_or_default())]
})
}),
);
wait_for_prompt(&hub).await;
assert_eq!(stats.parked(), 1, "the asker stepped off the lane");
let answering = hub.clone();
submit(
2,
Box::new(move || {
Box::pin(async move {
answering.answer(InteractionResponse::text("q1", "hello"));
vec![("answered".to_string(), "ok".to_string())]
})
}),
);
let mut answers = Vec::new();
for _ in 0..2 {
let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
.await
.expect("both batches finished")
.expect("an outcome arrived");
answers.extend(outcome.results);
}
answers.sort();
assert_eq!(
answers,
vec![
("answered".to_string(), "ok".to_string()),
("q1".to_string(), "hello".to_string()),
],
"the asker got its answer from the batch behind it"
);
drop(job_tx);
tokio::time::timeout(std::time::Duration::from_secs(30), serving)
.await
.expect("the lane drained")
.expect("the lane task ended");
}
async fn wait_for_prompt(hub: &InteractionHub) {
tokio::time::timeout(std::time::Duration::from_secs(30), async {
while hub.pending().is_empty() {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
.await
.expect("the prompt was raised");
}
#[tokio::test]
async fn answer_unknown_request_is_false() {
let hub = InteractionHub::new();
assert!(!hub.answer(InteractionResponse::text("nope", "x")));
}
#[tokio::test]
async fn submit_and_answer_nudge_the_attached_wake() {
let hub = InteractionHub::new();
let wake = Arc::new(Notify::new());
hub.attach_wake(wake.clone());
hub.attach_wake(Arc::new(Notify::new()));
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
settle().await;
wake.notified().await;
assert!(hub.answer(InteractionResponse::text("q1", "hi")));
wake.notified().await;
assert_eq!(asking.await.unwrap().value.as_deref(), Some("hi"));
}
#[tokio::test]
async fn cancel_nudges_the_attached_wake() {
let hub = InteractionHub::new();
let wake = Arc::new(Notify::new());
hub.attach_wake(wake.clone());
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move { backend.ask(req("q2")).await });
settle().await;
wake.notified().await;
assert!(hub.cancel("q2"));
wake.notified().await; let _ = asking.await.unwrap();
}
#[tokio::test]
async fn cancel_wakes_submit_with_neutral_response() {
let hub = InteractionHub::new();
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move { backend.ask(req("q2")).await });
settle().await;
assert!(hub.cancel("q2"));
let response = asking.await.unwrap();
assert_eq!(response.request_id, "q2");
assert_eq!(response.value.as_deref(), Some(""));
assert!(!hub.cancel("q2"));
}
#[tokio::test(start_paused = true)]
async fn a_prompt_nobody_answers_is_released_when_the_deadline_passes() {
let hub = InteractionHub::new();
hub.set_timeout_secs(60);
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
settle().await;
assert_eq!(hub.pending().len(), 1, "the prompt is open while it waits");
let response = asking.await.unwrap();
assert_eq!(response.request_id, "q1");
assert_eq!(response.value.as_deref(), Some(""));
assert_eq!(response.approved, None);
assert!(
hub.pending().is_empty(),
"the expired request is off the open list, so the agent leaves Waiting"
);
}
#[tokio::test(start_paused = true)]
async fn a_deadline_changes_nothing_for_a_prompt_that_is_answered() {
let hub = InteractionHub::new();
hub.set_timeout_secs(3600);
let answered_backend = hub.backend_for("agent-a");
let answered = tokio::spawn(async move { answered_backend.ask(req("q1")).await });
let cancelled_backend = hub.backend_for("agent-b");
let cancelled = tokio::spawn(async move { cancelled_backend.ask(req("q2")).await });
settle().await;
assert!(hub.answer(InteractionResponse::text("q1", "yes, go on")));
assert_eq!(answered.await.unwrap().value.as_deref(), Some("yes, go on"));
assert!(hub.cancel("q2"));
assert_eq!(cancelled.await.unwrap().value.as_deref(), Some(""));
}
#[tokio::test(start_paused = true)]
async fn a_zero_deadline_waits_for_a_person_however_long_it_takes() {
let hub = InteractionHub::new();
hub.set_timeout_secs(0);
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
settle().await;
tokio::time::advance(Duration::from_secs(86_400)).await;
assert_eq!(hub.pending().len(), 1, "a day later, still waiting");
assert!(hub.answer(InteractionResponse::text("q1", "here I am")));
assert_eq!(asking.await.unwrap().value.as_deref(), Some("here I am"));
}
#[tokio::test(start_paused = true)]
async fn the_deadline_denies_rather_than_approves() {
let hub = InteractionHub::new();
hub.set_timeout_secs(30);
let backend = hub.backend_for("agent-a");
let asking = tokio::spawn(async move {
backend
.ask(InteractionRequest::tool_approval(
"t1",
"shell",
serde_json::json!({"command": "rm -rf /"}),
"implement",
&[],
))
.await
});
let response = asking.await.unwrap();
assert!(!leviath_core::interaction::response_approved(&response));
}
#[tokio::test]
async fn an_answer_that_lands_as_the_deadline_passes_still_wins() {
let hub = InteractionHub::new();
let (responder, mut rx) = oneshot::channel();
responder
.send(InteractionResponse::text("q1", "approved by hand"))
.expect("the receiver is still alive");
let response = hub.expire("agent-a", "q1", &mut rx);
assert_eq!(response.value.as_deref(), Some("approved by hand"));
}