use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use nexo_broker::{BrokerHandle, Event};
use nexo_driver_types::GoalId;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use super::inbox::{InboxMessage, INBOX_SUBJECT_PREFIX};
pub const MAX_QUEUE: usize = 64;
pub struct InboxBuffer {
queue: Mutex<VecDeque<InboxMessage>>,
}
impl InboxBuffer {
fn new() -> Self {
Self {
queue: Mutex::new(VecDeque::with_capacity(8)),
}
}
pub fn push(&self, msg: InboxMessage) -> bool {
let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner());
let evicted = if q.len() >= MAX_QUEUE {
q.pop_front().is_some()
} else {
false
};
q.push_back(msg);
if evicted {
tracing::warn!(
target: "agent::inbox_router",
cap = MAX_QUEUE,
"inbox buffer at cap; evicted oldest message"
);
}
evicted
}
pub fn drain(&self) -> Vec<InboxMessage> {
let mut q = self.queue.lock().unwrap_or_else(|p| p.into_inner());
q.drain(..).collect()
}
pub fn len(&self) -> usize {
self.queue.lock().unwrap_or_else(|p| p.into_inner()).len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
pub struct InboxRouter<B: BrokerHandle + ?Sized> {
broker: Arc<B>,
buffers: DashMap<GoalId, Arc<InboxBuffer>>,
}
impl<B: BrokerHandle + ?Sized + 'static> InboxRouter<B> {
pub fn new(broker: Arc<B>) -> Arc<Self> {
Arc::new(Self {
broker,
buffers: DashMap::new(),
})
}
pub fn spawn(self: &Arc<Self>, cancel: CancellationToken) -> tokio::task::JoinHandle<()> {
let me = Arc::clone(self);
tokio::spawn(async move {
let pattern = format!("{}.>", INBOX_SUBJECT_PREFIX);
let mut sub = match me.broker.subscribe(&pattern).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(
target: "agent::inbox_router",
error = %e,
"could not subscribe to {pattern} — peer messages offline"
);
return;
}
};
loop {
tokio::select! {
_ = cancel.cancelled() => break,
next = sub.next() => {
match next {
Some(ev) => me.dispatch_inbound(ev),
None => break,
}
}
}
}
})
}
pub fn register(&self, goal_id: GoalId) -> Arc<InboxBuffer> {
Arc::clone(
self.buffers
.entry(goal_id)
.or_insert_with(|| Arc::new(InboxBuffer::new()))
.value(),
)
}
pub fn forget(&self, goal_id: GoalId) {
self.buffers.remove(&goal_id);
}
pub fn buffer_count(&self) -> usize {
self.buffers.len()
}
fn dispatch_inbound(&self, ev: Event) {
let goal_id = match parse_goal_from_subject(&ev.topic) {
Some(g) => g,
None => {
tracing::debug!(
target: "agent::inbox_router",
topic = %ev.topic,
"[inbox] dropping subject without parsable goal_id"
);
return;
}
};
let msg: InboxMessage = match serde_json::from_value(ev.payload) {
Ok(m) => m,
Err(e) => {
tracing::debug!(
target: "agent::inbox_router",
topic = %ev.topic,
error = %e,
"[inbox] dropping malformed payload"
);
return;
}
};
let buf = Arc::clone(
self.buffers
.entry(goal_id)
.or_insert_with(|| Arc::new(InboxBuffer::new()))
.value(),
);
buf.push(msg);
}
}
fn parse_goal_from_subject(subject: &str) -> Option<GoalId> {
let prefix = format!("{}.", INBOX_SUBJECT_PREFIX);
let suffix = subject.strip_prefix(&prefix)?;
Uuid::parse_str(suffix).ok().map(GoalId)
}
pub fn render_peer_messages_block(messages: &[InboxMessage]) -> Option<String> {
if messages.is_empty() {
return None;
}
let mut out = String::from("# PEER MESSAGES\n\n");
for m in messages {
let corr = match m.correlation_id {
Some(c) => format!(r#" correlation_id="{c}""#),
None => String::new(),
};
out.push_str(&format!(
"<peer-message from=\"{}\" sent_at=\"{}\"{}>\n{}\n</peer-message>\n",
m.from_agent_id,
m.sent_at.to_rfc3339(),
corr,
m.body,
));
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use nexo_broker::AnyBroker;
use nexo_broker::BrokerHandle;
fn mk_msg(from: &str, body: &str) -> InboxMessage {
InboxMessage {
from_agent_id: from.into(),
from_goal_id: GoalId(Uuid::new_v4()),
to_agent_id: "kate".into(),
body: body.into(),
sent_at: Utc::now(),
correlation_id: None,
}
}
#[test]
fn buffer_push_drain_round_trip() {
let buf = InboxBuffer::new();
assert!(buf.is_empty());
buf.push(mk_msg("a", "first"));
buf.push(mk_msg("b", "second"));
assert_eq!(buf.len(), 2);
let drained = buf.drain();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].body, "first");
assert_eq!(drained[1].body, "second");
assert!(buf.is_empty());
}
#[test]
fn buffer_drain_empty_returns_empty_vec() {
let buf = InboxBuffer::new();
let drained = buf.drain();
assert!(drained.is_empty());
}
#[test]
fn buffer_evicts_oldest_at_cap() {
let buf = InboxBuffer::new();
for i in 0..MAX_QUEUE {
buf.push(mk_msg(&format!("a{i}"), "msg"));
}
assert_eq!(buf.len(), MAX_QUEUE);
let evicted = buf.push(mk_msg("over", "msg"));
assert!(evicted);
assert_eq!(buf.len(), MAX_QUEUE);
let drained = buf.drain();
assert_eq!(drained.len(), MAX_QUEUE);
assert_ne!(drained[0].from_agent_id, "a0");
assert_eq!(drained.last().unwrap().from_agent_id, "over");
}
#[test]
fn parse_goal_from_subject_valid() {
let goal = GoalId(Uuid::new_v4());
let subj = format!("{}.{}", INBOX_SUBJECT_PREFIX, goal.0);
assert_eq!(parse_goal_from_subject(&subj), Some(goal));
}
#[test]
fn parse_goal_from_subject_rejects_unknown_prefix() {
let subj = "team.broadcast.foo";
assert_eq!(parse_goal_from_subject(subj), None);
}
#[test]
fn parse_goal_from_subject_rejects_non_uuid_suffix() {
let subj = format!("{}.not-a-uuid", INBOX_SUBJECT_PREFIX);
assert_eq!(parse_goal_from_subject(&subj), None);
}
#[test]
fn render_empty_returns_none() {
assert!(render_peer_messages_block(&[]).is_none());
}
#[test]
fn render_single_message_includes_from_and_body() {
let msgs = vec![mk_msg("researcher", "task #1 ready")];
let block = render_peer_messages_block(&msgs).unwrap();
assert!(block.starts_with("# PEER MESSAGES"));
assert!(block.contains(r#"from="researcher""#));
assert!(block.contains("task #1 ready"));
assert!(!block.contains("correlation_id"));
}
#[test]
fn render_with_correlation_id_includes_attribute() {
let mut m = mk_msg("a", "b");
m.correlation_id = Some(Uuid::new_v4());
let corr = m.correlation_id.unwrap();
let block = render_peer_messages_block(&[m]).unwrap();
assert!(block.contains(&format!(r#"correlation_id="{corr}""#)));
}
#[test]
fn render_preserves_chronological_order() {
let msgs = vec![
mk_msg("a", "first"),
mk_msg("b", "second"),
mk_msg("c", "third"),
];
let block = render_peer_messages_block(&msgs).unwrap();
let pos_first = block.find("first").unwrap();
let pos_second = block.find("second").unwrap();
let pos_third = block.find("third").unwrap();
assert!(pos_first < pos_second);
assert!(pos_second < pos_third);
}
#[tokio::test]
async fn router_register_idempotent_returns_same_buffer() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker);
let goal = GoalId(Uuid::new_v4());
let buf1 = router.register(goal);
let buf2 = router.register(goal);
buf1.push(mk_msg("a", "ping"));
let drained = buf2.drain();
assert_eq!(drained.len(), 1);
}
#[tokio::test]
async fn router_dispatch_inbound_pushes_to_buffer() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker);
let goal = GoalId(Uuid::new_v4());
let buf = router.register(goal);
let msg = mk_msg("researcher", "task #1");
let payload = serde_json::to_value(&msg).unwrap();
let topic = format!("{}.{}", INBOX_SUBJECT_PREFIX, goal.0);
let event = Event::new(&topic, "test", payload);
router.dispatch_inbound(event);
let drained = buf.drain();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].body, "task #1");
}
#[tokio::test]
async fn router_buffer_on_demand_for_unregistered_goal() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker);
let goal = GoalId(Uuid::new_v4());
let msg = mk_msg("researcher", "early bird");
let payload = serde_json::to_value(&msg).unwrap();
let topic = format!("{}.{}", INBOX_SUBJECT_PREFIX, goal.0);
router.dispatch_inbound(Event::new(&topic, "test", payload));
assert_eq!(router.buffer_count(), 1);
let buf = router.register(goal);
let drained = buf.drain();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].body, "early bird");
}
#[tokio::test]
async fn router_drops_malformed_subject() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker);
let event = Event::new("agent.inbox.not-a-uuid", "test", serde_json::json!({}));
router.dispatch_inbound(event); assert_eq!(router.buffer_count(), 0);
}
#[tokio::test]
async fn router_drops_malformed_payload() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker);
let goal = GoalId(Uuid::new_v4());
let topic = format!("{}.{}", INBOX_SUBJECT_PREFIX, goal.0);
let event = Event::new(&topic, "test", serde_json::json!({"garbage": true}));
router.dispatch_inbound(event);
assert_eq!(router.buffer_count(), 0);
}
#[tokio::test]
async fn router_forget_drops_buffer() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker);
let goal = GoalId(Uuid::new_v4());
let _ = router.register(goal);
assert_eq!(router.buffer_count(), 1);
router.forget(goal);
assert_eq!(router.buffer_count(), 0);
}
#[tokio::test]
async fn router_spawn_subscribes_and_routes_end_to_end() {
let broker = Arc::new(AnyBroker::local());
let router = InboxRouter::new(broker.clone());
let cancel = CancellationToken::new();
let _handle = router.spawn(cancel.clone());
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let goal = GoalId(Uuid::new_v4());
let buf = router.register(goal);
let msg = mk_msg("researcher", "via broker");
let payload = serde_json::to_value(&msg).unwrap();
let topic = format!("{}.{}", INBOX_SUBJECT_PREFIX, goal.0);
broker
.publish(&topic, Event::new(&topic, "test", payload))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let drained = buf.drain();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].body, "via broker");
cancel.cancel();
}
}