use pretty_assertions::assert_eq;
use rho_sdk::SessionId;
use super::{
MessageValidationError, NoticePostError, SubagentNotice, SubagentNoticeBridge,
ValidatedMessage, MAX_MESSAGE_BYTES, NOTICE_QUEUE_CAPACITY,
};
#[test]
fn validated_message_rejects_empty_and_oversized() {
assert_eq!(
ValidatedMessage::parse(" "),
Err(MessageValidationError::Empty)
);
let too_big = "x".repeat(MAX_MESSAGE_BYTES + 1);
assert_eq!(
ValidatedMessage::parse(&too_big),
Err(MessageValidationError::TooLarge {
bytes: MAX_MESSAGE_BYTES + 1,
max_bytes: MAX_MESSAGE_BYTES,
})
);
assert_eq!(
ValidatedMessage::parse(" hello ").unwrap().into_string(),
"hello"
);
}
#[test]
fn notice_bridge_fails_closed_when_unbound_or_full() {
let bridge = SubagentNoticeBridge::new();
let notice = sample_notice("a1");
assert_eq!(bridge.post(notice.clone()), Err(NoticePostError::Unbound));
let (mut receiver, permits) = bridge.bind_parent();
for index in 0..NOTICE_QUEUE_CAPACITY {
bridge
.post(sample_notice(&format!("n{index}")))
.expect("queue should accept up to capacity");
}
assert_eq!(
bridge.post(sample_notice("overflow")),
Err(NoticePostError::QueueFull {
capacity: NOTICE_QUEUE_CAPACITY,
})
);
assert_eq!(receiver.try_recv().unwrap().run_id, "n0");
assert_eq!(permits.outstanding(), NOTICE_QUEUE_CAPACITY);
permits.release(NOTICE_QUEUE_CAPACITY);
bridge
.post(sample_notice("after-release"))
.expect("release frees a slot");
}
#[test]
fn notice_permit_release_is_scoped_to_binding_generation() {
let bridge = SubagentNoticeBridge::new();
let (_old_receiver, old_permits) = bridge.bind_parent();
bridge
.post(sample_notice("old"))
.expect("first generation accepts a notice");
assert_eq!(old_permits.outstanding(), 1);
let (_new_receiver, new_permits) = bridge.bind_parent();
assert_eq!(new_permits.outstanding(), 0);
bridge
.post(sample_notice("new"))
.expect("replacement generation starts with a fresh budget");
assert_eq!(new_permits.outstanding(), 1);
old_permits.release(1);
assert_eq!(old_permits.outstanding(), 0);
assert_eq!(new_permits.outstanding(), 1);
assert_eq!(
bridge.post(sample_notice("still-counts")),
Ok(()),
"active generation still tracks its own accepted notice"
);
assert_eq!(new_permits.outstanding(), 2);
}
#[test]
fn notice_post_after_rebind_uses_only_the_active_generation() {
let bridge = SubagentNoticeBridge::new();
let (_old_receiver, old_permits) = bridge.bind_parent();
let (mut new_receiver, new_permits) = bridge.bind_parent();
bridge
.post(sample_notice("active"))
.expect("active binding accepts posts");
assert_eq!(new_receiver.try_recv().unwrap().run_id, "active");
assert_eq!(new_permits.outstanding(), 1);
assert_eq!(old_permits.outstanding(), 0);
for index in 1..NOTICE_QUEUE_CAPACITY {
bridge
.post(sample_notice(&format!("n{index}")))
.expect("active generation fills to capacity");
}
assert_eq!(
bridge.post(sample_notice("overflow")),
Err(NoticePostError::QueueFull {
capacity: NOTICE_QUEUE_CAPACITY,
})
);
assert_eq!(new_permits.outstanding(), NOTICE_QUEUE_CAPACITY);
}
#[test]
fn notice_rebind_retains_in_flight_channel_notices_on_retired_generation() {
let bridge = SubagentNoticeBridge::new();
let (receiver, old_permits) = bridge.bind_parent();
bridge
.post(sample_notice("queued-a"))
.expect("first notice accepted");
bridge
.post(sample_notice("queued-b"))
.expect("second notice accepted");
assert_eq!(old_permits.outstanding(), 2);
let rebind = bridge.rebind_parent(Some(receiver));
assert_eq!(
rebind
.retained
.iter()
.map(|notice| notice.run_id.as_str())
.collect::<Vec<_>>(),
vec!["queued-a", "queued-b"]
);
let retired = rebind
.retired_permits
.expect("prior binding exposes retired permits");
assert_eq!(retired.outstanding(), 2);
assert_eq!(rebind.permits.outstanding(), 0);
bridge
.post(sample_notice("new-gen"))
.expect("replacement generation accepts posts");
assert_eq!(rebind.permits.outstanding(), 1);
retired.release(2);
assert_eq!(old_permits.outstanding(), 0);
assert_eq!(rebind.permits.outstanding(), 1);
}
#[test]
fn notice_post_holds_binding_lock_from_reserve_through_enqueue() {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Barrier,
};
use std::thread;
use std::time::{Duration, Instant};
let bridge = SubagentNoticeBridge::new();
let (mut receiver, permits) = bridge.bind_parent();
let in_gap = Arc::new(AtomicBool::new(false));
let leave_gap = Arc::new(Barrier::new(2));
let post_bridge = bridge.clone();
let post_in_gap = Arc::clone(&in_gap);
let post_leave_gap = Arc::clone(&leave_gap);
let poster = thread::spawn(move || {
post_bridge.post_with_enqueue_gap(sample_notice("locked"), &|| {
post_in_gap.store(true, Ordering::SeqCst);
post_leave_gap.wait();
})
});
let entered = Instant::now();
while !in_gap.load(Ordering::SeqCst) {
assert!(
entered.elapsed() < Duration::from_secs(10),
"post should enter the reserve→enqueue gap"
);
thread::sleep(Duration::from_millis(1));
}
assert!(
bridge.binding_lock_held(),
"binding lock must stay held from reserve through enqueue"
);
let rebind_bridge = bridge.clone();
let rebind_started = Arc::new(AtomicBool::new(false));
let rebind_flag = Arc::clone(&rebind_started);
let rebind = thread::spawn(move || {
rebind_flag.store(true, Ordering::SeqCst);
rebind_bridge.bind_parent()
});
let rebind_seen = Instant::now();
while !rebind_started.load(Ordering::SeqCst) {
assert!(
rebind_seen.elapsed() < Duration::from_secs(10),
"rebind thread should start"
);
thread::sleep(Duration::from_millis(1));
}
let blocked_until = Instant::now() + Duration::from_millis(50);
while Instant::now() < blocked_until {
assert!(
!rebind.is_finished(),
"rebind must wait for post to finish enqueue before replacing the binding"
);
thread::yield_now();
}
leave_gap.wait();
let post_result = poster.join().expect("post thread");
let (mut new_receiver, new_permits) = rebind.join().expect("rebind thread");
assert_eq!(post_result, Ok(()));
assert_eq!(receiver.try_recv().unwrap().run_id, "locked");
assert_eq!(permits.outstanding(), 1);
assert!(new_receiver.try_recv().is_err());
assert_eq!(new_permits.outstanding(), 0);
}
#[test]
fn steering_slot_is_closed_outside_the_live_window() {
let slot = super::SteeringSlot::new();
assert!(slot.handle().is_none());
slot.clear();
assert!(slot.handle().is_none());
}
fn sample_notice(run_id: &str) -> SubagentNotice {
SubagentNotice {
run_id: run_id.into(),
agent_id: "worker".into(),
parent_session_id: SessionId::from_string("session-1").unwrap(),
message: "blocked on schema".into(),
}
}