use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const AGENT_EVENT_NOTIFY_METHOD: &str = "nexo/notify/agent_event";
#[non_exhaustive]
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AgentEventKind {
TranscriptAppended {
agent_id: String,
session_id: Uuid,
seq: u64,
role: TranscriptRole,
body: String,
sent_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
sender_id: Option<String>,
source_plugin: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tenant_id: Option<String>,
},
PendingInboundsDropped {
agent_id: String,
scope: crate::admin::processing::ProcessingScope,
dropped: u32,
at_ms: u64,
},
EscalationRequested {
agent_id: String,
scope: crate::admin::processing::ProcessingScope,
summary: String,
reason: crate::admin::escalations::EscalationReason,
urgency: crate::admin::escalations::EscalationUrgency,
requested_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
tenant_id: Option<String>,
},
EscalationResolved {
agent_id: String,
scope: crate::admin::processing::ProcessingScope,
resolved_at_ms: u64,
by: crate::admin::escalations::ResolvedBy,
#[serde(default, skip_serializing_if = "Option::is_none")]
tenant_id: Option<String>,
},
ProcessingStateChanged {
agent_id: String,
scope: crate::admin::processing::ProcessingScope,
prev_state: crate::admin::processing::ProcessingControlState,
new_state: crate::admin::processing::ProcessingControlState,
at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
tenant_id: Option<String>,
},
SecurityEvent {
#[serde(flatten)]
event: SecurityEventKind,
},
PeerTyping {
channel: String,
#[serde(default)]
account_id: String,
sender_id: String,
composing: bool,
at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tenant_id: Option<String>,
},
WhatsappBotMessage {
instance: String,
bot_jid: String,
msg_id: String,
target_id: String,
edit: String,
text: String,
at_ms: u64,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "security_kind", rename_all = "snake_case")]
pub enum SecurityEventKind {
TokenRotated {
at_ms: u64,
prev_hash: String,
new_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRole {
User,
Assistant,
Tool,
System,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AgentEventsListFilter {
pub agent_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_ms: Option<u64>,
#[serde(default)]
pub limit: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AgentEventsListResponse {
pub events: Vec<AgentEventKind>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentEventsReadParams {
pub agent_id: String,
pub session_id: Uuid,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_seq: Option<u64>,
#[serde(default)]
pub limit: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AgentEventsReadResponse {
pub events: Vec<AgentEventKind>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentEventsSearchParams {
pub agent_id: String,
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default)]
pub limit: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AgentEventsSearchResponse {
pub hits: Vec<SearchHit>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SearchHit {
pub session_id: Uuid,
pub timestamp_ms: u64,
pub role: TranscriptRole,
pub source_plugin: String,
pub snippet: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transcript_appended_round_trip_includes_optional_sender() {
let evt = AgentEventKind::TranscriptAppended {
agent_id: "ana".into(),
session_id: Uuid::nil(),
seq: 7,
role: TranscriptRole::User,
body: "[REDACTED:phone] hola".into(),
sent_at_ms: 1_700_000_000_000,
sender_id: Some("wa.55123".into()),
source_plugin: "whatsapp".into(),
tenant_id: None,
};
let v = serde_json::to_value(&evt).unwrap();
assert_eq!(v["kind"], "transcript_appended");
assert_eq!(v["seq"], 7);
assert_eq!(v["role"], "user");
let back: AgentEventKind = serde_json::from_value(v).unwrap();
assert_eq!(back, evt);
}
#[test]
fn transcript_appended_omits_unset_sender() {
let evt = AgentEventKind::TranscriptAppended {
agent_id: "ana".into(),
session_id: Uuid::nil(),
seq: 0,
role: TranscriptRole::Assistant,
body: "ok".into(),
sent_at_ms: 1,
sender_id: None,
source_plugin: "internal".into(),
tenant_id: None,
};
let s = serde_json::to_string(&evt).unwrap();
assert!(!s.contains("sender_id"), "absent sender skipped on wire");
}
#[test]
fn list_filter_round_trip_with_defaults() {
let f = AgentEventsListFilter {
agent_id: "ana".into(),
kind: None,
since_ms: None,
limit: 0,
tenant_id: None,
};
let s = serde_json::to_string(&f).unwrap();
assert!(!s.contains("kind"));
assert!(!s.contains("since_ms"));
let back: AgentEventsListFilter = serde_json::from_str(&s).unwrap();
assert_eq!(back, f);
}
#[test]
fn search_params_round_trip_and_notify_method_constant() {
let p = AgentEventsSearchParams {
agent_id: "ana".into(),
query: "hola \"phone\"".into(),
kind: Some("transcript_appended".into()),
limit: 25,
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["query"], "hola \"phone\"");
let back: AgentEventsSearchParams = serde_json::from_value(v).unwrap();
assert_eq!(back, p);
assert_eq!(AGENT_EVENT_NOTIFY_METHOD, "nexo/notify/agent_event");
}
#[test]
fn processing_state_changed_round_trip_carries_prev_and_new() {
use crate::admin::processing::{ProcessingControlState, ProcessingScope};
let scope = ProcessingScope::Conversation {
agent_id: "ana".into(),
channel: "whatsapp".into(),
account_id: "55-1234".into(),
contact_id: "55-5678".into(),
mcp_channel_source: None,
};
let evt = AgentEventKind::ProcessingStateChanged {
agent_id: "ana".into(),
scope: scope.clone(),
prev_state: ProcessingControlState::AgentActive,
new_state: ProcessingControlState::PausedByOperator {
scope: scope.clone(),
paused_at_ms: 1_700_000_000_000,
operator_token_hash: "abcdef0123456789".into(),
reason: Some("escalated".into()),
},
at_ms: 1_700_000_000_000,
tenant_id: None,
};
let v = serde_json::to_value(&evt).unwrap();
assert_eq!(v["kind"], "processing_state_changed");
assert_eq!(v["agent_id"], "ana");
assert_eq!(v["prev_state"]["state"], "agent_active");
assert_eq!(v["new_state"]["state"], "paused_by_operator");
assert!(
v.get("tenant_id").is_none(),
"tenant_id absent must be skipped"
);
let back: AgentEventKind = serde_json::from_value(v).unwrap();
assert_eq!(back, evt);
}
#[test]
fn security_event_token_rotated_round_trip_with_reason() {
let evt = AgentEventKind::SecurityEvent {
event: SecurityEventKind::TokenRotated {
at_ms: 1_700_000_000_123,
prev_hash: "cafebabedeadbeef".into(),
new_hash: "1234567890abcdef".into(),
reason: Some("scheduled rotation".into()),
},
};
let v = serde_json::to_value(&evt).unwrap();
assert_eq!(v["kind"], "security_event");
assert_eq!(v["security_kind"], "token_rotated");
assert_eq!(v["at_ms"], 1_700_000_000_123u64);
assert_eq!(v["prev_hash"], "cafebabedeadbeef");
assert_eq!(v["new_hash"], "1234567890abcdef");
assert_eq!(v["reason"], "scheduled rotation");
let back: AgentEventKind = serde_json::from_value(v).unwrap();
assert_eq!(back, evt);
}
#[test]
fn security_event_token_rotated_omits_unset_reason() {
let evt = AgentEventKind::SecurityEvent {
event: SecurityEventKind::TokenRotated {
at_ms: 1_700_000_000_000,
prev_hash: String::new(),
new_hash: "deadbeefcafebabe".into(),
reason: None,
},
};
let s = serde_json::to_string(&evt).unwrap();
assert!(!s.contains("reason"), "absent reason skipped on wire");
let back: AgentEventKind =
serde_json::from_value(serde_json::from_str(&s).unwrap()).unwrap();
assert_eq!(back, evt);
}
}