use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, warn};
use crate::capability::CapabilityChecker;
use crate::error::{KernelError, KernelResult};
use crate::ipc::{KernelMessage, MessageTarget};
use crate::process::{Pid, ProcessState, ProcessTable};
use crate::service::ServiceRegistry;
use crate::topic::TopicRouter;
#[cfg(feature = "exochain")]
use crate::chain::ChainManager;
#[cfg(feature = "mesh")]
use crate::mesh_ipc::MeshIpcEnvelope;
#[cfg(feature = "mesh")]
use crate::mesh_runtime::MeshRuntime;
const DEFAULT_INBOX_CAPACITY: usize = 1024;
#[allow(dead_code)]
const MAX_A2A_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
struct PendingRequest {
response_tx: oneshot::Sender<KernelMessage>,
#[allow(dead_code)]
sent_at: Instant,
}
pub struct A2ARouter {
process_table: Arc<ProcessTable>,
capability_checker: Arc<CapabilityChecker>,
topic_router: Arc<TopicRouter>,
service_registry: Option<Arc<ServiceRegistry>>,
inboxes: DashMap<Pid, mpsc::Sender<KernelMessage>>,
pending_requests: DashMap<String, PendingRequest>,
#[cfg(feature = "exochain")]
gate: std::sync::OnceLock<Arc<dyn crate::gate::GateBackend>>,
#[cfg(feature = "os-patterns")]
dead_letter_queue: std::sync::OnceLock<Arc<crate::dead_letter::DeadLetterQueue>>,
#[cfg(feature = "mesh")]
mesh_runtime: std::sync::OnceLock<Arc<MeshRuntime>>,
}
impl A2ARouter {
pub fn new(
process_table: Arc<ProcessTable>,
capability_checker: Arc<CapabilityChecker>,
topic_router: Arc<TopicRouter>,
) -> Self {
Self {
process_table,
capability_checker,
topic_router,
service_registry: None,
inboxes: DashMap::new(),
pending_requests: DashMap::new(),
#[cfg(feature = "exochain")]
gate: std::sync::OnceLock::new(),
#[cfg(feature = "os-patterns")]
dead_letter_queue: std::sync::OnceLock::new(),
#[cfg(feature = "mesh")]
mesh_runtime: std::sync::OnceLock::new(),
}
}
pub fn with_service_registry(mut self, registry: Arc<ServiceRegistry>) -> Self {
self.service_registry = Some(registry);
self
}
#[cfg(feature = "exochain")]
pub fn with_gate(self, gate: Arc<dyn crate::gate::GateBackend>) -> Self {
let _ = self.gate.set(gate);
self
}
#[cfg(feature = "exochain")]
pub fn set_gate(&self, gate: Arc<dyn crate::gate::GateBackend>) {
let _ = self.gate.set(gate);
}
#[cfg(feature = "os-patterns")]
pub fn set_dead_letter_queue(&self, dlq: Arc<crate::dead_letter::DeadLetterQueue>) {
let _ = self.dead_letter_queue.set(dlq);
}
#[cfg(feature = "os-patterns")]
pub fn dead_letter_queue(&self) -> Option<&Arc<crate::dead_letter::DeadLetterQueue>> {
self.dead_letter_queue.get()
}
#[cfg(feature = "mesh")]
pub fn set_mesh_runtime(&self, runtime: Arc<MeshRuntime>) {
let _ = self.mesh_runtime.set(runtime);
}
#[cfg(feature = "mesh")]
pub fn mesh_runtime(&self) -> Option<&Arc<MeshRuntime>> {
self.mesh_runtime.get()
}
pub fn service_registry(&self) -> Option<&Arc<ServiceRegistry>> {
self.service_registry.as_ref()
}
pub fn create_inbox(&self, pid: Pid) -> mpsc::Receiver<KernelMessage> {
let (tx, rx) = mpsc::channel(DEFAULT_INBOX_CAPACITY);
self.inboxes.insert(pid, tx);
debug!(pid, "created inbox");
rx
}
pub fn remove_inbox(&self, pid: Pid) {
self.inboxes.remove(&pid);
debug!(pid, "removed inbox");
}
pub async fn send(&self, msg: KernelMessage) -> KernelResult<()> {
let from = msg.from;
let sender = self
.process_table
.get(from)
.ok_or(KernelError::ProcessNotFound { pid: from })?;
if !matches!(sender.state, ProcessState::Running | ProcessState::Suspended) {
return Err(KernelError::Ipc(format!(
"sender PID {from} is not running (state: {})",
sender.state
)));
}
#[cfg(feature = "exochain")]
if let Some(gate) = self.gate.get() {
let action = match &msg.payload {
crate::ipc::MessagePayload::ToolCall { name, .. } => format!("tool.{name}"),
crate::ipc::MessagePayload::Signal(_) => "ipc.signal".to_string(),
_ => "ipc.send".to_string(),
};
let context = serde_json::json!({
"from": from,
"target": format!("{:?}", msg.target),
"layer": "routing",
});
match gate.check(&from.to_string(), &action, &context) {
crate::gate::GateDecision::Deny { reason, .. } => {
return Err(KernelError::CapabilityDenied {
pid: from,
action,
reason: format!("routing gate denied: {reason}"),
});
}
crate::gate::GateDecision::Defer { .. }
| crate::gate::GateDecision::Permit { .. } => {
}
}
}
match &msg.target {
MessageTarget::Process(target_pid) => {
self.capability_checker
.check_ipc_target(from, *target_pid)?;
self.deliver_to_inbox(*target_pid, msg).await
}
MessageTarget::Topic(topic) => {
let subscribers = self.topic_router.live_subscribers(topic);
let mut delivered = 0u32;
for &sub_pid in &subscribers {
if sub_pid != from {
let msg_clone = msg.clone();
if self.deliver_to_inbox(sub_pid, msg_clone).await.is_ok() {
delivered += 1;
}
}
}
debug!(from, topic, delivered, "published to topic");
Ok(())
}
MessageTarget::Broadcast => {
let mut delivered = 0u32;
let pids: Vec<Pid> = self.inboxes.iter().map(|entry| *entry.key()).collect();
for pid in pids {
if pid != from {
if self.capability_checker.check_ipc_target(from, pid).is_ok() {
let msg_clone = msg.clone();
if self.deliver_to_inbox(pid, msg_clone).await.is_ok() {
delivered += 1;
}
}
}
}
debug!(from, delivered, "broadcast sent");
Ok(())
}
MessageTarget::Service(name) => {
let name = name.clone();
self.route_to_service(from, &name, msg).await
}
MessageTarget::ServiceMethod { service, .. } => {
let service_name = service.clone();
self.route_to_service(from, &service_name, msg).await
}
MessageTarget::Kernel => {
debug!(from, "kernel message routing not yet implemented");
Ok(())
}
MessageTarget::RemoteNode { node_id, .. } => {
#[cfg(feature = "mesh")]
{
if let Some(runtime) = self.mesh_runtime.get() {
let node_id = node_id.clone();
debug!(from, %node_id, "routing message to remote node via mesh");
let envelope = MeshIpcEnvelope::new(
runtime.node_id().to_string(),
node_id.clone(),
msg,
);
runtime
.send_to_peer(&node_id, envelope)
.await
.map_err(|e| KernelError::Mesh(format!(
"failed to send to remote node '{node_id}': {e}"
)))
} else {
debug!(from, %node_id, "remote node routing: no mesh runtime attached");
Err(KernelError::Mesh(format!(
"remote routing to node '{node_id}' not yet implemented"
)))
}
}
#[cfg(not(feature = "mesh"))]
{
debug!(from, %node_id, "remote node routing not available (mesh feature disabled)");
Err(KernelError::Mesh(format!(
"remote routing to node '{node_id}' not yet implemented"
)))
}
}
}
}
async fn deliver_to_inbox(&self, pid: Pid, msg: KernelMessage) -> KernelResult<()> {
let tx = match self.inboxes.get(&pid) {
Some(tx) => tx.clone(),
None => {
warn!(pid, "no inbox for PID, dead-lettering");
#[cfg(feature = "os-patterns")]
if let Some(dlq) = self.dead_letter_queue.get() {
dlq.intake(
msg,
crate::dead_letter::DeadLetterReason::TargetNotFound { pid },
);
}
return Err(KernelError::Ipc(format!("no inbox for PID {pid}")));
}
};
match tx.try_send(msg) {
Ok(()) => {
debug!(pid, "message delivered to inbox");
Ok(())
}
Err(mpsc::error::TrySendError::Full(rejected_msg)) => {
warn!(pid, "inbox full, dead-lettering");
#[cfg(feature = "os-patterns")]
if let Some(dlq) = self.dead_letter_queue.get() {
dlq.intake(
rejected_msg,
crate::dead_letter::DeadLetterReason::InboxFull { pid },
);
}
Err(KernelError::Ipc(format!("inbox full for PID {pid}")))
}
Err(mpsc::error::TrySendError::Closed(rejected_msg)) => {
warn!(pid, "inbox closed, removing and dead-lettering");
self.inboxes.remove(&pid);
#[cfg(feature = "os-patterns")]
if let Some(dlq) = self.dead_letter_queue.get() {
dlq.intake(
rejected_msg,
crate::dead_letter::DeadLetterReason::AgentExited { pid },
);
}
Err(KernelError::Ipc(format!("inbox closed for PID {pid}")))
}
}
}
async fn route_to_service(
&self,
from: Pid,
service_name: &str,
msg: KernelMessage,
) -> KernelResult<()> {
let registry = self.service_registry.as_ref().ok_or_else(|| {
KernelError::Ipc(format!(
"no service registry configured; cannot route to service '{service_name}'"
))
})?;
let target_pid = registry.resolve_target(service_name).ok_or_else(|| {
KernelError::Ipc(format!("service not found: '{service_name}'"))
})?;
self.capability_checker
.check_ipc_target(from, target_pid)?;
self.deliver_to_inbox(target_pid, msg).await
}
#[cfg(feature = "exochain")]
pub async fn send_checked(
&self,
msg: KernelMessage,
chain: Option<&ChainManager>,
) -> KernelResult<()> {
if let Some(cm) = chain {
cm.append(
"ipc",
"ipc.send",
Some(serde_json::json!({
"from": msg.from,
"target": format!("{:?}", msg.target),
"payload_type": msg.payload.type_name(),
"msg_id": msg.id,
})),
);
}
self.send(msg).await
}
pub fn topic_router(&self) -> &Arc<TopicRouter> {
&self.topic_router
}
pub fn inbox_count(&self) -> usize {
self.inboxes.len()
}
pub fn has_inbox(&self, pid: Pid) -> bool {
self.inboxes.contains_key(&pid)
}
pub async fn request(
&self,
msg: KernelMessage,
timeout: Duration,
) -> KernelResult<KernelMessage> {
let request_id = msg.id.clone();
let (tx, rx) = oneshot::channel();
self.pending_requests.insert(
request_id.clone(),
PendingRequest {
response_tx: tx,
sent_at: Instant::now(),
},
);
if let Err(e) = self.send(msg).await {
self.pending_requests.remove(&request_id);
return Err(e);
}
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => {
self.pending_requests.remove(&request_id);
Err(KernelError::Ipc("response channel closed".into()))
}
Err(_) => {
self.pending_requests.remove(&request_id);
Err(KernelError::Timeout {
operation: format!("request {request_id}"),
duration_ms: timeout.as_millis() as u64,
})
}
}
}
pub fn try_complete_request(&self, msg: KernelMessage) -> bool {
if let Some(ref corr_id) = msg.correlation_id
&& let Some((_, pending)) = self.pending_requests.remove(corr_id)
{
let _ = pending.response_tx.send(msg);
return true;
}
false
}
pub fn pending_request_count(&self) -> usize {
self.pending_requests.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::AgentCapabilities;
use crate::ipc::MessagePayload;
use crate::process::{ProcessEntry, ResourceUsage};
use tokio_util::sync::CancellationToken;
fn setup_router(
agent_count: usize,
) -> (A2ARouter, Vec<Pid>, Vec<mpsc::Receiver<KernelMessage>>) {
let table = Arc::new(ProcessTable::new(64));
let mut pids = Vec::new();
for i in 0..agent_count {
let entry = ProcessEntry {
pid: 0,
agent_id: format!("agent-{i}"),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let pid = table.insert(entry).unwrap();
pids.push(pid);
}
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let mut receivers = Vec::new();
for &pid in &pids {
let rx = router.create_inbox(pid);
receivers.push(rx);
}
(router, pids, receivers)
}
#[tokio::test]
async fn direct_message_delivery() {
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "hello");
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert_eq!(received.from, pids[0]);
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "hello"
));
}
#[tokio::test]
async fn message_to_self_works() {
let (router, pids, mut receivers) = setup_router(1);
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[0]), "self-msg");
router.send(msg).await.unwrap();
let received = receivers[0].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "self-msg"
));
}
#[tokio::test]
async fn broadcast_delivers_to_all_except_sender() {
let (router, pids, mut receivers) = setup_router(3);
let msg = KernelMessage::text(pids[0], MessageTarget::Broadcast, "broadcast");
router.send(msg).await.unwrap();
assert!(receivers[0].try_recv().is_err());
let r1 = receivers[1].try_recv().unwrap();
assert!(matches!(
r1.payload,
MessagePayload::Text(ref t) if t == "broadcast"
));
let r2 = receivers[2].try_recv().unwrap();
assert!(matches!(
r2.payload,
MessagePayload::Text(ref t) if t == "broadcast"
));
}
#[tokio::test]
async fn topic_publish_delivers_to_subscribers() {
let (router, pids, mut receivers) = setup_router(3);
router.topic_router().subscribe(pids[1], "build");
router.topic_router().subscribe(pids[2], "build");
let msg = KernelMessage::text(pids[0], MessageTarget::Topic("build".into()), "build done");
router.send(msg).await.unwrap();
assert!(receivers[0].try_recv().is_err());
assert!(receivers[1].try_recv().is_ok());
assert!(receivers[2].try_recv().is_ok());
}
#[tokio::test]
async fn topic_publish_excludes_sender_if_subscribed() {
let (router, pids, mut receivers) = setup_router(2);
router.topic_router().subscribe(pids[0], "build");
router.topic_router().subscribe(pids[1], "build");
let msg = KernelMessage::text(pids[0], MessageTarget::Topic("build".into()), "done");
router.send(msg).await.unwrap();
assert!(receivers[0].try_recv().is_err());
assert!(receivers[1].try_recv().is_ok());
}
#[tokio::test]
async fn send_from_nonexistent_pid_fails() {
let (router, _pids, _receivers) = setup_router(1);
let msg = KernelMessage::text(999, MessageTarget::Process(1), "hello");
let result = router.send(msg).await;
assert!(result.is_err());
}
#[tokio::test]
async fn send_to_pid_without_inbox_fails() {
let table = Arc::new(ProcessTable::new(64));
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "sender".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx = router.create_inbox(sender_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "hello");
let result = router.send(msg).await;
assert!(result.is_err());
}
#[tokio::test]
async fn ipc_scope_restricts_messaging() {
let table = Arc::new(ProcessTable::new(64));
use crate::capability::IpcScope;
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "restricted".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![]), ..Default::default()
},
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx1 = router.create_inbox(sender_pid);
let _rx2 = router.create_inbox(target_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "blocked");
let result = router.send(msg).await;
assert!(result.is_err());
}
#[tokio::test]
async fn ipc_scope_none_blocks_all() {
let table = Arc::new(ProcessTable::new(64));
use crate::capability::IpcScope;
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "no-ipc".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities {
can_ipc: false,
ipc_scope: IpcScope::None,
..Default::default()
},
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx1 = router.create_inbox(sender_pid);
let _rx2 = router.create_inbox(target_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "blocked");
let result = router.send(msg).await;
assert!(result.is_err());
}
#[test]
fn create_and_remove_inbox() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx = router.create_inbox(42);
assert!(router.has_inbox(42));
assert_eq!(router.inbox_count(), 1);
router.remove_inbox(42);
assert!(!router.has_inbox(42));
assert_eq!(router.inbox_count(), 0);
}
#[tokio::test]
async fn tool_call_message_routes() {
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::tool_call(
pids[0],
MessageTarget::Process(pids[1]),
"read_file",
serde_json::json!({"path": "/test"}),
);
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::ToolCall { ref name, .. } if name == "read_file"
));
}
#[tokio::test]
async fn tool_result_message_routes() {
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::tool_result(
pids[1],
MessageTarget::Process(pids[0]),
"call-1",
serde_json::json!({"content": "data"}),
);
router.send(msg).await.unwrap();
let received = receivers[0].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::ToolResult { ref call_id, .. } if call_id == "call-1"
));
}
#[cfg(feature = "exochain")]
#[tokio::test]
async fn send_checked_logs_chain_event() {
let (router, pids, mut receivers) = setup_router(2);
let chain = crate::chain::ChainManager::new(0, 1000);
let initial_seq = chain.sequence();
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "audited");
router.send_checked(msg, Some(&chain)).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "audited"
));
assert_eq!(chain.sequence(), initial_seq + 1);
let events = chain.tail(1);
assert_eq!(events[0].kind, "ipc.send");
assert_eq!(events[0].source, "ipc");
let payload = events[0].payload.as_ref().unwrap();
assert_eq!(payload["from"], pids[0]);
assert_eq!(payload["payload_type"], "text");
}
#[cfg(feature = "exochain")]
#[tokio::test]
async fn send_checked_without_chain_still_delivers() {
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "no-chain");
router.send_checked(msg, None).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "no-chain"
));
}
#[tokio::test]
async fn rvf_payload_routes() {
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::new(
pids[0],
MessageTarget::Process(pids[1]),
MessagePayload::Rvf {
segment_type: 0x40,
data: vec![0xCA, 0xFE],
},
);
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Rvf { segment_type: 0x40, .. }
));
}
#[tokio::test]
async fn closed_inbox_auto_removed() {
let (router, pids, receivers) = setup_router(2);
drop(receivers);
assert!(router.has_inbox(pids[1]));
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "gone");
let result = router.send(msg).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("inbox closed"), "expected 'inbox closed', got: {err_msg}");
assert!(!router.has_inbox(pids[1]));
}
#[tokio::test]
async fn inbox_overflow_returns_error() {
let table = Arc::new(ProcessTable::new(64));
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "sender".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx_sender = router.create_inbox(sender_pid);
let (tx, _rx_target) = mpsc::channel(2);
router.inboxes.insert(target_pid, tx);
let m1 = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "msg1");
let m2 = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "msg2");
router.send(m1).await.unwrap();
router.send(m2).await.unwrap();
let m3 = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "overflow");
let result = router.send(m3).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("inbox full"), "expected 'inbox full', got: {err_msg}");
assert!(router.has_inbox(target_pid));
}
#[tokio::test]
async fn concurrent_sends_to_same_pid() {
let (router, pids, mut receivers) = setup_router(4);
let router = Arc::new(router);
let target = pids[0];
let mut handles = Vec::new();
for &sender_pid in &pids[1..] {
let r = Arc::clone(&router);
let msg = KernelMessage::text(
sender_pid,
MessageTarget::Process(target),
&format!("from-{sender_pid}"),
);
handles.push(tokio::spawn(async move { r.send(msg).await }));
}
for h in handles {
h.await.unwrap().unwrap();
}
let mut received = Vec::new();
while let Ok(msg) = receivers[0].try_recv() {
if let MessagePayload::Text(t) = &msg.payload {
received.push(t.clone());
}
}
assert_eq!(received.len(), 3);
for &sender_pid in &pids[1..] {
assert!(
received.iter().any(|t| t == &format!("from-{sender_pid}")),
"missing message from PID {sender_pid}"
);
}
}
#[tokio::test]
async fn send_from_non_running_process_fails() {
let table = Arc::new(ProcessTable::new(64));
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "exited-sender".to_owned(),
state: ProcessState::Exited(0),
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx1 = router.create_inbox(sender_pid);
let _rx2 = router.create_inbox(target_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "from-dead");
let result = router.send(msg).await;
assert!(result.is_err(), "send from non-Running process should fail");
}
fn setup_router_with_registry(
agent_count: usize,
) -> (
A2ARouter,
Vec<Pid>,
Vec<mpsc::Receiver<KernelMessage>>,
Arc<crate::service::ServiceRegistry>,
) {
let table = Arc::new(ProcessTable::new(64));
let mut pids = Vec::new();
for i in 0..agent_count {
let entry = ProcessEntry {
pid: 0,
agent_id: format!("agent-{i}"),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let pid = table.insert(entry).unwrap();
pids.push(pid);
}
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let registry = Arc::new(crate::service::ServiceRegistry::new());
let router = A2ARouter::new(table, checker, topic_router)
.with_service_registry(registry.clone());
let mut receivers = Vec::new();
for &pid in &pids {
let rx = router.create_inbox(pid);
receivers.push(rx);
}
(router, pids, receivers, registry)
}
#[tokio::test]
async fn route_to_service_by_name() {
let (router, pids, mut receivers, registry) = setup_router_with_registry(2);
registry
.register_entry(crate::service::ServiceEntry {
name: "auth".into(),
owner_pid: Some(pids[1]),
endpoint: crate::service::ServiceEndpoint::AgentInbox(pids[1]),
audit_level: crate::service::ServiceAuditLevel::Full,
registered_at: chrono::Utc::now(),
})
.unwrap();
let msg = KernelMessage::text(pids[0], MessageTarget::Service("auth".into()), "validate");
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert_eq!(received.from, pids[0]);
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "validate"
));
}
#[tokio::test]
async fn route_service_method() {
let (router, pids, mut receivers, registry) = setup_router_with_registry(2);
registry
.register_entry(crate::service::ServiceEntry {
name: "auth".into(),
owner_pid: Some(pids[1]),
endpoint: crate::service::ServiceEndpoint::AgentInbox(pids[1]),
audit_level: crate::service::ServiceAuditLevel::Full,
registered_at: chrono::Utc::now(),
})
.unwrap();
let msg = KernelMessage::text(
pids[0],
MessageTarget::ServiceMethod {
service: "auth".into(),
method: "validate_token".into(),
},
"token-123",
);
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert_eq!(received.from, pids[0]);
assert!(matches!(
received.target,
MessageTarget::ServiceMethod { ref service, ref method }
if service == "auth" && method == "validate_token"
));
}
#[tokio::test]
async fn service_not_found_returns_error() {
let (router, pids, _receivers, _registry) = setup_router_with_registry(2);
let msg = KernelMessage::text(
pids[0],
MessageTarget::Service("nonexistent".into()),
"hello",
);
let result = router.send(msg).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("service not found"),
"expected 'service not found', got: {err_msg}"
);
}
#[tokio::test]
async fn service_entry_registration() {
let registry = crate::service::ServiceRegistry::new();
let entry = crate::service::ServiceEntry {
name: "cache".into(),
owner_pid: Some(42),
endpoint: crate::service::ServiceEndpoint::AgentInbox(42),
audit_level: crate::service::ServiceAuditLevel::Full,
registered_at: chrono::Utc::now(),
};
registry.register_entry(entry).unwrap();
let retrieved = registry.get_entry("cache").unwrap();
assert_eq!(retrieved.name, "cache");
assert_eq!(retrieved.owner_pid, Some(42));
}
#[tokio::test]
async fn service_entry_with_audit_level() {
let registry = crate::service::ServiceRegistry::new();
let entry = crate::service::ServiceEntry {
name: "metrics".into(),
owner_pid: Some(10),
endpoint: crate::service::ServiceEndpoint::AgentInbox(10),
audit_level: crate::service::ServiceAuditLevel::GateOnly,
registered_at: chrono::Utc::now(),
};
registry.register_entry(entry).unwrap();
let retrieved = registry.get_entry("metrics").unwrap();
assert_eq!(retrieved.audit_level, crate::service::ServiceAuditLevel::GateOnly);
}
#[tokio::test]
async fn resolve_target_finds_owner_pid() {
let registry = crate::service::ServiceRegistry::new();
let entry = crate::service::ServiceEntry {
name: "search".into(),
owner_pid: Some(77),
endpoint: crate::service::ServiceEndpoint::AgentInbox(77),
audit_level: crate::service::ServiceAuditLevel::Full,
registered_at: chrono::Utc::now(),
};
registry.register_entry(entry).unwrap();
assert_eq!(registry.resolve_target("search"), Some(77));
assert_eq!(registry.resolve_target("nonexistent"), None);
}
#[tokio::test]
async fn service_without_registry_returns_error() {
let (router_no_reg, pids, _receivers) = setup_router(2);
let msg = KernelMessage::text(
pids[0],
MessageTarget::Service("missing".into()),
"hello",
);
let result = router_no_reg.send(msg).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("no service registry"),
"expected 'no service registry', got: {err_msg}"
);
}
#[tokio::test]
async fn external_service_no_pid_returns_error() {
let (router, pids, _receivers, registry) = setup_router_with_registry(2);
registry
.register_entry(crate::service::ServiceEntry {
name: "redis".into(),
owner_pid: None,
endpoint: crate::service::ServiceEndpoint::External {
url: "redis://localhost:6379".into(),
},
audit_level: crate::service::ServiceAuditLevel::GateOnly,
registered_at: chrono::Utc::now(),
})
.unwrap();
let msg = KernelMessage::text(
pids[0],
MessageTarget::Service("redis".into()),
"ping",
);
let result = router.send(msg).await;
assert!(result.is_err(), "external service with no PID should fail routing");
}
#[tokio::test]
async fn request_response_completes() {
let (router, pids, mut receivers) = setup_router(2);
let router = Arc::new(router);
let router2 = Arc::clone(&router);
let from_pid = pids[0];
let to_pid = pids[1];
tokio::spawn(async move {
let msg = receivers[1].recv().await.unwrap();
let reply = KernelMessage::with_correlation(
to_pid,
MessageTarget::Process(from_pid),
MessagePayload::Text("pong".into()),
msg.id.clone(),
);
router2.try_complete_request(reply);
});
let request = KernelMessage::text(from_pid, MessageTarget::Process(to_pid), "ping");
let response = router
.request(request, Duration::from_secs(5))
.await
.unwrap();
assert!(matches!(
response.payload,
MessagePayload::Text(ref t) if t == "pong"
));
assert_eq!(router.pending_request_count(), 0);
}
#[tokio::test]
async fn request_response_timeout() {
let (router, pids, _receivers) = setup_router(2);
let request =
KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "no-reply");
let result = router.request(request, Duration::from_millis(50)).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("timeout"),
"expected timeout error, got: {err_msg}"
);
assert_eq!(router.pending_request_count(), 0);
}
#[tokio::test]
async fn try_complete_request_matching() {
let (router, pids, _receivers) = setup_router(2);
let (tx, rx) = oneshot::channel();
let request_id = "req-42".to_string();
router.pending_requests.insert(
request_id.clone(),
PendingRequest {
response_tx: tx,
sent_at: Instant::now(),
},
);
let reply = KernelMessage::with_correlation(
pids[1],
MessageTarget::Process(pids[0]),
MessagePayload::Text("reply".into()),
request_id,
);
let completed = router.try_complete_request(reply);
assert!(completed, "try_complete_request should return true for matching id");
let response = rx.await.unwrap();
assert!(matches!(
response.payload,
MessagePayload::Text(ref t) if t == "reply"
));
}
#[tokio::test]
async fn try_complete_request_no_match() {
let (router, pids, _receivers) = setup_router(2);
let reply = KernelMessage::with_correlation(
pids[1],
MessageTarget::Process(pids[0]),
MessagePayload::Text("orphan".into()),
"nonexistent-id".into(),
);
let completed = router.try_complete_request(reply);
assert!(!completed, "try_complete_request should return false for non-matching id");
let plain = KernelMessage::text(pids[1], MessageTarget::Process(pids[0]), "plain");
assert!(!router.try_complete_request(plain));
}
#[tokio::test]
async fn pending_request_count_tracks_correctly() {
let (router, _pids, _receivers) = setup_router(2);
assert_eq!(router.pending_request_count(), 0);
let (tx1, _rx1) = oneshot::channel();
let (tx2, _rx2) = oneshot::channel();
router.pending_requests.insert(
"req-a".into(),
PendingRequest {
response_tx: tx1,
sent_at: Instant::now(),
},
);
assert_eq!(router.pending_request_count(), 1);
router.pending_requests.insert(
"req-b".into(),
PendingRequest {
response_tx: tx2,
sent_at: Instant::now(),
},
);
assert_eq!(router.pending_request_count(), 2);
let reply = KernelMessage::with_correlation(
1,
MessageTarget::Process(2),
MessagePayload::Text("done".into()),
"req-a".into(),
);
router.try_complete_request(reply);
assert_eq!(router.pending_request_count(), 1);
router.pending_requests.remove("req-b");
assert_eq!(router.pending_request_count(), 0);
}
#[cfg(feature = "exochain")]
#[tokio::test]
async fn routing_gate_denies_message() {
struct DenyGate;
impl crate::gate::GateBackend for DenyGate {
fn check(
&self,
_agent: &str,
_action: &str,
_ctx: &serde_json::Value,
) -> crate::gate::GateDecision {
crate::gate::GateDecision::Deny {
reason: "blocked by policy".into(),
receipt: None,
}
}
}
let (router, pids, _receivers) = setup_router(2);
let router = router.with_gate(Arc::new(DenyGate));
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "hello");
let result = router.send(msg).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("routing gate denied"),
"expected 'routing gate denied', got: {err_msg}"
);
}
#[cfg(feature = "exochain")]
#[tokio::test]
async fn routing_gate_permits_message() {
struct PermitGate;
impl crate::gate::GateBackend for PermitGate {
fn check(
&self,
_agent: &str,
_action: &str,
_ctx: &serde_json::Value,
) -> crate::gate::GateDecision {
crate::gate::GateDecision::Permit { token: None }
}
}
let (router, pids, mut receivers) = setup_router(2);
let router = router.with_gate(Arc::new(PermitGate));
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "hello");
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert_eq!(received.from, pids[0]);
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "hello"
));
}
#[cfg(feature = "exochain")]
#[tokio::test]
async fn routing_gate_defer_still_delivers() {
struct DeferGate;
impl crate::gate::GateBackend for DeferGate {
fn check(
&self,
_agent: &str,
_action: &str,
_ctx: &serde_json::Value,
) -> crate::gate::GateDecision {
crate::gate::GateDecision::Defer {
reason: "pending review".into(),
}
}
}
let (router, pids, mut receivers) = setup_router(2);
let router = router.with_gate(Arc::new(DeferGate));
let msg = KernelMessage::text(pids[0], MessageTarget::Process(pids[1]), "deferred");
router.send(msg).await.unwrap(); assert!(receivers[1].try_recv().is_ok());
}
#[tokio::test]
async fn multiple_messages_queued_in_order() {
let (router, pids, mut receivers) = setup_router(2);
for i in 0..5 {
let msg = KernelMessage::text(
pids[0],
MessageTarget::Process(pids[1]),
&format!("msg-{i}"),
);
router.send(msg).await.unwrap();
}
for i in 0..5 {
let received = receivers[1].try_recv().unwrap();
let expected = format!("msg-{i}");
assert!(
matches!(received.payload, MessagePayload::Text(ref t) if t == &expected),
"expected '{expected}' but got {:?}",
received.payload,
);
}
}
#[tokio::test]
async fn unsubscribe_stops_topic_delivery() {
let (router, pids, mut receivers) = setup_router(3);
router.topic_router().subscribe(pids[1], "events");
router.topic_router().subscribe(pids[2], "events");
router.topic_router().unsubscribe(pids[1], "events");
let msg = KernelMessage::text(
pids[0],
MessageTarget::Topic("events".into()),
"after-unsub",
);
router.send(msg).await.unwrap();
assert!(receivers[1].try_recv().is_err(), "unsubscribed agent should not receive");
let received = receivers[2].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "after-unsub"
));
}
#[tokio::test]
async fn publish_to_empty_topic_succeeds() {
let (router, pids, _receivers) = setup_router(2);
let msg = KernelMessage::text(
pids[0],
MessageTarget::Topic("empty-topic".into()),
"nobody-listening",
);
let result = router.send(msg).await;
assert!(result.is_ok(), "publish to empty topic should succeed");
}
#[tokio::test]
async fn broadcast_with_zero_other_agents() {
let (router, pids, mut receivers) = setup_router(1);
let msg = KernelMessage::text(pids[0], MessageTarget::Broadcast, "alone");
let result = router.send(msg).await;
assert!(result.is_ok(), "broadcast with only sender should succeed");
assert!(receivers[0].try_recv().is_err());
}
#[tokio::test]
async fn ipc_scope_restricted_allows_listed_pids() {
let table = Arc::new(ProcessTable::new(64));
use crate::capability::IpcScope;
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "restricted-sender".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![target_pid]),
..Default::default()
},
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx_sender = router.create_inbox(sender_pid);
let mut rx_target = router.create_inbox(target_pid);
let msg = KernelMessage::text(
sender_pid,
MessageTarget::Process(target_pid),
"allowed",
);
let result = router.send(msg).await;
assert!(result.is_ok(), "restricted sender should reach allowed PID");
let received = rx_target.try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "allowed"
));
}
#[tokio::test]
async fn ipc_scope_restricted_blocks_unlisted_pids() {
let table = Arc::new(ProcessTable::new(64));
use crate::capability::IpcScope;
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "restricted-sender".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![999]),
..Default::default()
},
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx_sender = router.create_inbox(sender_pid);
let _rx_target = router.create_inbox(target_pid);
let msg = KernelMessage::text(
sender_pid,
MessageTarget::Process(target_pid),
"blocked",
);
let result = router.send(msg).await;
assert!(result.is_err(), "restricted sender should be blocked from unlisted PID");
}
#[test]
fn create_inbox_returns_receiver() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let rx = router.create_inbox(10);
assert!(router.has_inbox(10));
assert_eq!(router.inbox_count(), 1);
drop(rx);
}
#[test]
fn create_inbox_replaces_existing() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx1 = router.create_inbox(10);
assert_eq!(router.inbox_count(), 1);
let _rx2 = router.create_inbox(10);
assert_eq!(router.inbox_count(), 1);
assert!(router.has_inbox(10));
}
#[test]
fn remove_nonexistent_inbox_is_noop() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
router.remove_inbox(999);
assert_eq!(router.inbox_count(), 0);
}
#[test]
fn inbox_count_tracks_multiple_inboxes() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx1 = router.create_inbox(1);
let _rx2 = router.create_inbox(2);
let _rx3 = router.create_inbox(3);
assert_eq!(router.inbox_count(), 3);
router.remove_inbox(2);
assert_eq!(router.inbox_count(), 2);
assert!(!router.has_inbox(2));
assert!(router.has_inbox(1));
assert!(router.has_inbox(3));
}
#[tokio::test]
async fn send_to_kernel_target_succeeds() {
let (router, pids, _receivers) = setup_router(1);
let msg = KernelMessage::text(pids[0], MessageTarget::Kernel, "kernel-msg");
let result = router.send(msg).await;
assert!(result.is_ok(), "kernel target routing should succeed (even if no-op)");
}
#[tokio::test]
async fn send_to_remote_node_fails() {
let (router, pids, _receivers) = setup_router(1);
let msg = KernelMessage::text(
pids[0],
MessageTarget::RemoteNode {
node_id: "remote-1".into(),
target: Box::new(MessageTarget::Process(42)),
},
"remote-msg",
);
let result = router.send(msg).await;
assert!(result.is_err(), "remote node routing should fail (not yet implemented)");
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("remote routing"), "expected remote routing error, got: {err_msg}");
}
#[tokio::test]
async fn suspended_process_can_still_send() {
let table = Arc::new(ProcessTable::new(64));
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "suspended-sender".to_owned(),
state: ProcessState::Suspended,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx_sender = router.create_inbox(sender_pid);
let mut rx_target = router.create_inbox(target_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "from-suspended");
let result = router.send(msg).await;
assert!(result.is_ok(), "suspended process should be allowed to send");
let received = rx_target.try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "from-suspended"
));
}
#[tokio::test]
async fn topic_multiple_subscribers_receive_same_message() {
let (router, pids, mut receivers) = setup_router(5);
for &pid in &pids[1..] {
router.topic_router().subscribe(pid, "news");
}
let msg = KernelMessage::text(
pids[0],
MessageTarget::Topic("news".into()),
"breaking",
);
router.send(msg).await.unwrap();
for (i, rx) in receivers[1..].iter_mut().enumerate() {
let received = rx.try_recv().unwrap_or_else(|_| {
panic!("subscriber {} (pid {}) should have received message", i + 1, pids[i + 1])
});
assert!(matches!(
received.payload,
MessagePayload::Text(ref t) if t == "breaking"
));
assert_eq!(received.from, pids[0]);
}
}
#[tokio::test]
async fn broadcast_skips_scope_restricted_targets() {
let table = Arc::new(ProcessTable::new(64));
use crate::capability::IpcScope;
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "restricted-broadcaster".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities {
ipc_scope: IpcScope::Restricted(vec![]),
..Default::default()
},
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx_sender = router.create_inbox(sender_pid);
let mut rx_target = router.create_inbox(target_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Broadcast, "restricted-broadcast");
let result = router.send(msg).await;
assert!(result.is_ok(), "broadcast itself should succeed even if all targets are blocked");
assert!(rx_target.try_recv().is_err(), "restricted broadcast should not reach any target");
}
#[test]
fn service_registry_accessor() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table.clone(), checker.clone(), topic_router.clone());
assert!(router.service_registry().is_none(), "no registry by default");
let registry = Arc::new(crate::service::ServiceRegistry::new());
let router = router.with_service_registry(registry);
assert!(router.service_registry().is_some(), "registry should be present after with_service_registry");
}
#[test]
fn topic_router_accessor() {
let table = Arc::new(ProcessTable::new(64));
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let expected = Arc::clone(&topic_router);
let router = A2ARouter::new(table, checker, topic_router);
assert!(Arc::ptr_eq(router.topic_router(), &expected));
}
#[tokio::test]
async fn json_payload_routes() {
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::new(
pids[0],
MessageTarget::Process(pids[1]),
MessagePayload::Json(serde_json::json!({"key": "value"})),
);
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Json(ref v) if v["key"] == "value"
));
}
#[tokio::test]
async fn signal_payload_routes() {
use crate::ipc::KernelSignal;
let (router, pids, mut receivers) = setup_router(2);
let msg = KernelMessage::signal(
pids[0],
MessageTarget::Process(pids[1]),
KernelSignal::Shutdown,
);
router.send(msg).await.unwrap();
let received = receivers[1].try_recv().unwrap();
assert!(matches!(
received.payload,
MessagePayload::Signal(KernelSignal::Shutdown)
));
}
#[tokio::test]
async fn request_to_nonexistent_target_cleans_up_pending() {
let table = Arc::new(ProcessTable::new(64));
let sender_entry = ProcessEntry {
pid: 0,
agent_id: "sender".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let sender_pid = table.insert(sender_entry).unwrap();
let target_entry = ProcessEntry {
pid: 0,
agent_id: "no-inbox-target".to_owned(),
state: ProcessState::Running,
capabilities: AgentCapabilities::default(),
resource_usage: ResourceUsage::default(),
cancel_token: CancellationToken::new(),
parent_pid: None,
};
let target_pid = table.insert(target_entry).unwrap();
let checker = Arc::new(CapabilityChecker::new(table.clone()));
let topic_router = Arc::new(TopicRouter::new(table.clone()));
let router = A2ARouter::new(table, checker, topic_router);
let _rx_sender = router.create_inbox(sender_pid);
let msg = KernelMessage::text(sender_pid, MessageTarget::Process(target_pid), "request");
let result = router.request(msg, Duration::from_millis(100)).await;
assert!(result.is_err(), "request to PID without inbox should fail");
assert_eq!(router.pending_request_count(), 0, "pending request should be cleaned up on send failure");
}
#[tokio::test]
async fn service_method_not_found_returns_error() {
let (router, pids, _receivers, _registry) = setup_router_with_registry(2);
let msg = KernelMessage::text(
pids[0],
MessageTarget::ServiceMethod {
service: "nonexistent-svc".into(),
method: "do_thing".into(),
},
"call",
);
let result = router.send(msg).await;
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("service not found"),
"expected 'service not found' for missing ServiceMethod target, got: {err_msg}"
);
}
}