use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use nexo_tool_meta::admin::agent_events::AgentEventKind;
use tokio::sync::broadcast;
pub const DEFAULT_BROADCAST_CAPACITY: usize = 256;
#[async_trait]
pub trait AgentEventEmitter: Send + Sync + fmt::Debug {
async fn emit(&self, event: AgentEventKind);
}
#[derive(Debug, Default, Clone)]
pub struct NoopAgentEventEmitter;
#[async_trait]
impl AgentEventEmitter for NoopAgentEventEmitter {
async fn emit(&self, _event: AgentEventKind) {}
}
pub struct BroadcastAgentEventEmitter {
tx: broadcast::Sender<AgentEventKind>,
}
impl fmt::Debug for BroadcastAgentEventEmitter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BroadcastAgentEventEmitter")
.field("subscribers", &self.tx.receiver_count())
.field("capacity", &self.tx.len())
.finish_non_exhaustive()
}
}
impl BroadcastAgentEventEmitter {
pub fn new() -> Self {
Self::with_capacity(DEFAULT_BROADCAST_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> Self {
assert!(capacity > 0, "broadcast capacity must be > 0");
let (tx, _rx) = broadcast::channel(capacity);
Self { tx }
}
pub fn subscribe(&self) -> broadcast::Receiver<AgentEventKind> {
self.tx.subscribe()
}
pub fn subscriber_count(&self) -> usize {
self.tx.receiver_count()
}
}
impl Default for BroadcastAgentEventEmitter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl AgentEventEmitter for BroadcastAgentEventEmitter {
async fn emit(&self, event: AgentEventKind) {
let _ = self.tx.send(event);
}
}
pub type SharedAgentEventEmitter = Arc<dyn AgentEventEmitter>;
pub struct TeeAgentEventEmitter {
sinks: Vec<Arc<dyn AgentEventEmitter>>,
}
impl fmt::Debug for TeeAgentEventEmitter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TeeAgentEventEmitter")
.field("sinks", &self.sinks.len())
.finish()
}
}
impl TeeAgentEventEmitter {
pub fn new() -> Self {
Self { sinks: Vec::new() }
}
pub fn with_sinks(sinks: Vec<Arc<dyn AgentEventEmitter>>) -> Self {
Self { sinks }
}
pub fn push(mut self, sink: Arc<dyn AgentEventEmitter>) -> Self {
self.sinks.push(sink);
self
}
pub fn len(&self) -> usize {
self.sinks.len()
}
pub fn is_empty(&self) -> bool {
self.sinks.is_empty()
}
}
impl Default for TeeAgentEventEmitter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl AgentEventEmitter for TeeAgentEventEmitter {
async fn emit(&self, event: AgentEventKind) {
for sink in &self.sinks {
sink.emit(event.clone()).await;
}
}
}
pub struct NatsAgentEventEmitter {
client: async_nats::Client,
subject_prefix: String,
}
impl fmt::Debug for NatsAgentEventEmitter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NatsAgentEventEmitter")
.field("subject_prefix", &self.subject_prefix)
.finish_non_exhaustive()
}
}
pub const DEFAULT_AGENT_EVENT_SUBJECT_PREFIX: &str = "nexo.agent_events";
impl NatsAgentEventEmitter {
pub fn new(client: async_nats::Client) -> Self {
Self {
client,
subject_prefix: DEFAULT_AGENT_EVENT_SUBJECT_PREFIX.to_string(),
}
}
pub fn with_prefix(client: async_nats::Client, prefix: impl Into<String>) -> Self {
let prefix = prefix.into();
assert!(
!prefix.is_empty() && !prefix.contains(' '),
"agent event subject prefix must be non-empty and contain no spaces, got: {prefix:?}"
);
Self {
client,
subject_prefix: prefix,
}
}
}
pub fn agent_event_subject(prefix: &str, event: &AgentEventKind) -> Option<String> {
let (agent_id, kind): (&str, &str) = match event {
AgentEventKind::TranscriptAppended { agent_id, .. } => (agent_id, "transcript_appended"),
AgentEventKind::PendingInboundsDropped { agent_id, .. } => {
(agent_id, "pending_inbounds_dropped")
}
AgentEventKind::EscalationRequested { agent_id, .. } => (agent_id, "escalation_requested"),
AgentEventKind::EscalationResolved { agent_id, .. } => (agent_id, "escalation_resolved"),
AgentEventKind::ProcessingStateChanged { agent_id, .. } => {
(agent_id, "processing_state_changed")
}
_ => return None,
};
let safe_agent = agent_id.replace([' ', '\t', '\n', '.', '*', '>'], "_");
Some(format!("{prefix}.{safe_agent}.{kind}"))
}
#[async_trait]
impl AgentEventEmitter for NatsAgentEventEmitter {
async fn emit(&self, event: AgentEventKind) {
let Some(subject) = agent_event_subject(&self.subject_prefix, &event) else {
return;
};
let payload = match serde_json::to_vec(&event) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
error = %e,
"nats agent event emitter: serialise failed; frame dropped",
);
return;
}
};
if let Err(e) = self.client.publish(subject.clone(), payload.into()).await {
tracing::warn!(
error = %e,
subject = %subject,
"nats agent event emitter: publish failed; live broadcast continues",
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nexo_tool_meta::admin::agent_events::TranscriptRole;
use tokio::sync::broadcast::error::RecvError;
use uuid::Uuid;
fn sample_event(seq: u64, body: &str) -> AgentEventKind {
AgentEventKind::TranscriptAppended {
agent_id: "ana".into(),
session_id: Uuid::nil(),
seq,
role: TranscriptRole::User,
body: body.into(),
sent_at_ms: 1_700_000_000_000 + seq,
sender_id: None,
source_plugin: "whatsapp".into(),
tenant_id: None,
}
}
#[tokio::test]
async fn broadcast_emit_round_trips_through_subscriber() {
let emitter = BroadcastAgentEventEmitter::new();
let mut rx = emitter.subscribe();
let evt = sample_event(0, "[REDACTED:phone] hola");
emitter.emit(evt.clone()).await;
let recv = rx.recv().await.unwrap();
assert_eq!(recv, evt);
if let AgentEventKind::TranscriptAppended { body, .. } = &recv {
assert!(body.starts_with("[REDACTED:"));
} else {
panic!("expected TranscriptAppended");
}
}
#[tokio::test]
async fn broadcast_supports_multiple_subscribers() {
let emitter = BroadcastAgentEventEmitter::new();
let mut rx_a = emitter.subscribe();
let mut rx_b = emitter.subscribe();
emitter.emit(sample_event(0, "x")).await;
emitter.emit(sample_event(1, "y")).await;
for rx in [&mut rx_a, &mut rx_b] {
let first = rx.recv().await.unwrap();
let second = rx.recv().await.unwrap();
assert!(matches!(
first,
AgentEventKind::TranscriptAppended { seq: 0, .. }
));
assert!(matches!(
second,
AgentEventKind::TranscriptAppended { seq: 1, .. }
));
}
}
#[tokio::test]
async fn broadcast_lag_surfaces_as_lagged_recv_not_panic() {
let emitter = BroadcastAgentEventEmitter::with_capacity(2);
let mut rx = emitter.subscribe();
for i in 0..5 {
emitter.emit(sample_event(i, "fill")).await;
}
let first = rx.recv().await.unwrap_err();
match first {
RecvError::Lagged(n) => assert!(n >= 1, "should report at least 1 lagged frame"),
other => panic!("expected Lagged, got {other:?}"),
}
let resync = rx.recv().await.unwrap();
assert!(matches!(resync, AgentEventKind::TranscriptAppended { .. }));
}
#[tokio::test]
async fn noop_emitter_silently_drops_event() {
let emitter = NoopAgentEventEmitter;
emitter.emit(sample_event(0, "x")).await;
}
#[derive(Debug, Default)]
struct RecordingSink {
seen: tokio::sync::Mutex<Vec<AgentEventKind>>,
}
#[async_trait]
impl AgentEventEmitter for RecordingSink {
async fn emit(&self, event: AgentEventKind) {
self.seen.lock().await.push(event);
}
}
#[tokio::test]
async fn tee_fans_out_each_event_to_every_sink() {
let a = Arc::new(RecordingSink::default());
let b = Arc::new(RecordingSink::default());
let tee = TeeAgentEventEmitter::new()
.push(a.clone() as Arc<dyn AgentEventEmitter>)
.push(b.clone() as Arc<dyn AgentEventEmitter>);
assert_eq!(tee.len(), 2);
tee.emit(sample_event(0, "first")).await;
tee.emit(sample_event(1, "second")).await;
let a_seen = a.seen.lock().await;
let b_seen = b.seen.lock().await;
assert_eq!(a_seen.len(), 2);
assert_eq!(b_seen.len(), 2);
match (&a_seen[0], &b_seen[0]) {
(
AgentEventKind::TranscriptAppended { seq: sa, .. },
AgentEventKind::TranscriptAppended { seq: sb, .. },
) => assert_eq!(sa, sb),
other => panic!("unexpected events: {other:?}"),
}
}
#[tokio::test]
async fn tee_with_zero_sinks_is_noop_safe() {
let tee = TeeAgentEventEmitter::new();
assert!(tee.is_empty());
tee.emit(sample_event(0, "drop")).await;
}
#[tokio::test]
async fn tee_preserves_sink_order() {
let a = Arc::new(RecordingSink::default());
let b = Arc::new(RecordingSink::default());
let tee = TeeAgentEventEmitter::with_sinks(vec![
a.clone() as Arc<dyn AgentEventEmitter>,
Arc::new(NoopAgentEventEmitter) as Arc<dyn AgentEventEmitter>,
b.clone() as Arc<dyn AgentEventEmitter>,
]);
tee.emit(sample_event(7, "ordered")).await;
assert_eq!(a.seen.lock().await.len(), 1);
assert_eq!(b.seen.lock().await.len(), 1);
}
use nexo_tool_meta::admin::escalations::{EscalationReason, EscalationUrgency};
use nexo_tool_meta::admin::processing::{ProcessingControlState, ProcessingScope};
fn convo(agent: &str) -> ProcessingScope {
ProcessingScope::Conversation {
agent_id: agent.into(),
channel: "whatsapp".into(),
account_id: "55-1234".into(),
contact_id: "55-5678".into(),
mcp_channel_source: None,
}
}
#[test]
fn nats_subject_for_transcript_appended() {
let evt = sample_event(0, "x");
let s = agent_event_subject("nexo.agent_events", &evt).unwrap();
assert_eq!(s, "nexo.agent_events.ana.transcript_appended");
}
#[test]
fn nats_subject_for_processing_state_changed() {
let scope = convo("ana");
let evt = AgentEventKind::ProcessingStateChanged {
agent_id: "ana".into(),
scope: scope.clone(),
prev_state: ProcessingControlState::AgentActive,
new_state: ProcessingControlState::PausedByOperator {
scope,
paused_at_ms: 1,
operator_token_hash: "h".into(),
reason: None,
},
at_ms: 1,
tenant_id: None,
};
let s = agent_event_subject("nexo.agent_events", &evt).unwrap();
assert_eq!(s, "nexo.agent_events.ana.processing_state_changed");
}
#[test]
fn nats_subject_for_escalation_kinds() {
let req = AgentEventKind::EscalationRequested {
agent_id: "ana".into(),
scope: convo("ana"),
summary: "x".into(),
reason: EscalationReason::UnknownQuery,
urgency: EscalationUrgency::Normal,
requested_at_ms: 1,
tenant_id: None,
};
assert_eq!(
agent_event_subject("nexo.agent_events", &req).unwrap(),
"nexo.agent_events.ana.escalation_requested"
);
let res = AgentEventKind::EscalationResolved {
agent_id: "ana".into(),
scope: convo("ana"),
resolved_at_ms: 1,
by: nexo_tool_meta::admin::escalations::ResolvedBy::OperatorTakeover,
tenant_id: None,
};
assert_eq!(
agent_event_subject("nexo.agent_events", &res).unwrap(),
"nexo.agent_events.ana.escalation_resolved"
);
}
#[test]
fn nats_subject_sanitises_agent_id_with_separator_chars() {
let evt = AgentEventKind::TranscriptAppended {
agent_id: "ana.bad".into(),
session_id: Uuid::nil(),
seq: 0,
role: TranscriptRole::User,
body: "x".into(),
sent_at_ms: 1,
sender_id: None,
source_plugin: "whatsapp".into(),
tenant_id: None,
};
let s = agent_event_subject("nexo.agent_events", &evt).unwrap();
assert_eq!(s, "nexo.agent_events.ana_bad.transcript_appended");
}
#[test]
fn nats_subject_honours_custom_prefix() {
let evt = sample_event(0, "x");
let s = agent_event_subject("acme.events", &evt).unwrap();
assert_eq!(s, "acme.events.ana.transcript_appended");
}
}