use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use nexo_tool_meta::admin::agent_events::{
AgentEventKind, AgentEventsListFilter, AgentEventsListResponse, AgentEventsReadParams,
AgentEventsReadResponse, AgentEventsSearchParams, AgentEventsSearchResponse, SearchHit,
};
use crate::agent::admin_rpc::agent_events_sqlite::{AgentEventLog, AgentEventLogFilter};
use crate::agent::admin_rpc::dispatcher::{AdminRpcError, AdminRpcResult};
pub const DEFAULT_LIST_WINDOW_MS: u64 = 30 * 24 * 3_600 * 1000;
pub const DEFAULT_LIST_LIMIT: usize = 500;
const MAX_LIST_LIMIT: usize = 1000;
pub const DEFAULT_READ_LIMIT: usize = 200;
const MAX_READ_LIMIT: usize = 1000;
pub const DEFAULT_SEARCH_LIMIT: usize = 50;
const MAX_SEARCH_LIMIT: usize = 500;
#[async_trait]
pub trait TranscriptReader: Send + Sync {
async fn list_recent_events(
&self,
filter: &AgentEventsListFilter,
) -> anyhow::Result<Vec<AgentEventKind>>;
async fn read_session_events(
&self,
params: &AgentEventsReadParams,
) -> anyhow::Result<Vec<AgentEventKind>>;
async fn search_events(
&self,
params: &AgentEventsSearchParams,
) -> anyhow::Result<Vec<SearchHit>>;
}
pub struct MergingAgentEventReader {
transcripts: Arc<dyn TranscriptReader>,
log: Arc<dyn AgentEventLog>,
}
impl std::fmt::Debug for MergingAgentEventReader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MergingAgentEventReader").finish()
}
}
impl MergingAgentEventReader {
pub fn new(transcripts: Arc<dyn TranscriptReader>, log: Arc<dyn AgentEventLog>) -> Self {
Self { transcripts, log }
}
}
fn event_at_ms(event: &AgentEventKind) -> u64 {
match event {
AgentEventKind::TranscriptAppended { sent_at_ms, .. } => *sent_at_ms,
AgentEventKind::PendingInboundsDropped { at_ms, .. } => *at_ms,
AgentEventKind::EscalationRequested {
requested_at_ms, ..
} => *requested_at_ms,
AgentEventKind::EscalationResolved { resolved_at_ms, .. } => *resolved_at_ms,
AgentEventKind::ProcessingStateChanged { at_ms, .. } => *at_ms,
_ => 0,
}
}
#[async_trait]
impl TranscriptReader for MergingAgentEventReader {
async fn list_recent_events(
&self,
filter: &AgentEventsListFilter,
) -> anyhow::Result<Vec<AgentEventKind>> {
let limit = if filter.limit == 0 {
DEFAULT_LIST_LIMIT
} else {
filter.limit.min(MAX_LIST_LIMIT)
};
match filter.kind.as_deref() {
Some("transcript_appended") => self.transcripts.list_recent_events(filter).await,
Some(other) => {
let log_filter = AgentEventLogFilter {
agent_id: filter.agent_id.clone(),
kind: Some(other.to_string()),
since_ms: filter.since_ms,
tenant_id: filter.tenant_id.clone(),
limit,
};
self.log.list_recent(&log_filter).await
}
None => {
let transcripts = self
.transcripts
.list_recent_events(filter)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "merger: transcripts side failed");
Vec::new()
});
let log_filter = AgentEventLogFilter {
agent_id: filter.agent_id.clone(),
kind: None,
since_ms: filter.since_ms,
tenant_id: filter.tenant_id.clone(),
limit,
};
let log_rows = self.log.list_recent(&log_filter).await.unwrap_or_else(|e| {
tracing::warn!(error = %e, "merger: log side failed");
Vec::new()
});
let log_rows: Vec<AgentEventKind> = log_rows
.into_iter()
.filter(|e| !matches!(e, AgentEventKind::TranscriptAppended { .. }))
.collect();
let mut merged: Vec<AgentEventKind> =
Vec::with_capacity(transcripts.len() + log_rows.len());
merged.extend(transcripts);
merged.extend(log_rows);
merged.sort_by_key(|e| std::cmp::Reverse(event_at_ms(e)));
merged.truncate(limit);
Ok(merged)
}
}
}
async fn read_session_events(
&self,
params: &AgentEventsReadParams,
) -> anyhow::Result<Vec<AgentEventKind>> {
self.transcripts.read_session_events(params).await
}
async fn search_events(
&self,
params: &AgentEventsSearchParams,
) -> anyhow::Result<Vec<SearchHit>> {
self.transcripts.search_events(params).await
}
}
pub async fn list(reader: &dyn TranscriptReader, params: Value) -> AdminRpcResult {
let mut filter: AgentEventsListFilter = match serde_json::from_value(params) {
Ok(f) => f,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if filter.agent_id.is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams("agent_id is empty".into()));
}
if filter.limit == 0 {
filter.limit = DEFAULT_LIST_LIMIT;
}
filter.limit = filter.limit.min(MAX_LIST_LIMIT);
let events = match reader.list_recent_events(&filter).await {
Ok(e) => e,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!(
"transcript_reader.list_recent_events: {e}"
)));
}
};
let response = AgentEventsListResponse { events };
AdminRpcResult::ok(serde_json::to_value(response).unwrap_or(Value::Null))
}
pub async fn read(reader: &dyn TranscriptReader, params: Value) -> AdminRpcResult {
let mut p: AgentEventsReadParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if p.agent_id.is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams("agent_id is empty".into()));
}
if p.limit == 0 {
p.limit = DEFAULT_READ_LIMIT;
}
p.limit = p.limit.min(MAX_READ_LIMIT);
let events = match reader.read_session_events(&p).await {
Ok(e) => e,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!(
"transcript_reader.read_session_events: {e}"
)));
}
};
let response = AgentEventsReadResponse { events };
AdminRpcResult::ok(serde_json::to_value(response).unwrap_or(Value::Null))
}
pub async fn search(reader: &dyn TranscriptReader, params: Value) -> AdminRpcResult {
let mut p: AgentEventsSearchParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => return AdminRpcResult::err(AdminRpcError::InvalidParams(e.to_string())),
};
if p.agent_id.is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams("agent_id is empty".into()));
}
if p.query.trim().is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams("query is empty".into()));
}
if p.limit == 0 {
p.limit = DEFAULT_SEARCH_LIMIT;
}
p.limit = p.limit.min(MAX_SEARCH_LIMIT);
let hits = match reader.search_events(&p).await {
Ok(h) => h,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::Internal(format!(
"transcript_reader.search_events: {e}"
)));
}
};
let response = AgentEventsSearchResponse { hits };
AdminRpcResult::ok(serde_json::to_value(response).unwrap_or(Value::Null))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use nexo_tool_meta::admin::agent_events::TranscriptRole;
use uuid::Uuid;
#[derive(Default)]
struct MockReader {
events: Mutex<Vec<AgentEventKind>>,
last_filter: Mutex<Option<AgentEventsListFilter>>,
last_read: Mutex<Option<AgentEventsReadParams>>,
last_search: Mutex<Option<AgentEventsSearchParams>>,
force_error: bool,
}
impl MockReader {
fn new() -> Self {
Self::default()
}
fn with_event(mut self, e: AgentEventKind) -> Self {
self.events.get_mut().unwrap().push(e);
self
}
fn forcing_error() -> Self {
Self {
force_error: true,
..Self::default()
}
}
}
#[async_trait]
impl TranscriptReader for MockReader {
async fn list_recent_events(
&self,
filter: &AgentEventsListFilter,
) -> anyhow::Result<Vec<AgentEventKind>> {
*self.last_filter.lock().unwrap() = Some(filter.clone());
if self.force_error {
anyhow::bail!("forced");
}
let mut out: Vec<AgentEventKind> = self
.events
.lock()
.unwrap()
.iter()
.filter(|e| match e {
AgentEventKind::TranscriptAppended { agent_id, .. } => {
agent_id == &filter.agent_id
}
_ => false,
})
.cloned()
.collect();
out.sort_by_key(|e| match e {
AgentEventKind::TranscriptAppended { sent_at_ms, .. } => {
std::cmp::Reverse(*sent_at_ms)
}
_ => std::cmp::Reverse(0u64),
});
out.truncate(filter.limit);
Ok(out)
}
async fn read_session_events(
&self,
params: &AgentEventsReadParams,
) -> anyhow::Result<Vec<AgentEventKind>> {
*self.last_read.lock().unwrap() = Some(params.clone());
if self.force_error {
anyhow::bail!("forced");
}
let after = params.since_seq.unwrap_or(0);
let mut out: Vec<AgentEventKind> = self
.events
.lock()
.unwrap()
.iter()
.filter(|e| match e {
AgentEventKind::TranscriptAppended {
agent_id,
session_id,
seq,
..
} => {
agent_id == ¶ms.agent_id
&& *session_id == params.session_id
&& (params.since_seq.is_none() || *seq > after)
}
_ => false,
})
.cloned()
.collect();
out.sort_by_key(|e| match e {
AgentEventKind::TranscriptAppended { seq, .. } => *seq,
_ => 0u64,
});
out.truncate(params.limit);
Ok(out)
}
async fn search_events(
&self,
params: &AgentEventsSearchParams,
) -> anyhow::Result<Vec<SearchHit>> {
*self.last_search.lock().unwrap() = Some(params.clone());
if self.force_error {
anyhow::bail!("forced");
}
let mut out: Vec<SearchHit> = Vec::new();
for evt in self.events.lock().unwrap().iter() {
let AgentEventKind::TranscriptAppended {
agent_id,
session_id,
sent_at_ms,
role,
body,
source_plugin,
..
} = evt
else {
continue;
};
if agent_id != ¶ms.agent_id {
continue;
}
if !body.contains(¶ms.query) {
continue;
}
out.push(SearchHit {
session_id: *session_id,
timestamp_ms: *sent_at_ms,
role: *role,
source_plugin: source_plugin.clone(),
snippet: format!("[{}]", params.query),
});
}
out.truncate(params.limit);
Ok(out)
}
}
fn evt(agent: &str, sid: Uuid, seq: u64, body: &str, ts_ms: u64) -> AgentEventKind {
AgentEventKind::TranscriptAppended {
agent_id: agent.into(),
session_id: sid,
seq,
role: TranscriptRole::User,
body: body.into(),
sent_at_ms: ts_ms,
sender_id: None,
source_plugin: "whatsapp".into(),
tenant_id: None,
}
}
#[tokio::test]
async fn list_returns_events_newest_first_capped() {
let s1 = Uuid::nil();
let s2 = Uuid::from_u128(2);
let reader = MockReader::new()
.with_event(evt("ana", s1, 0, "hi", 1_000))
.with_event(evt("ana", s2, 0, "yo", 3_000))
.with_event(evt("ana", s1, 1, "hola", 2_000))
.with_event(evt("bob", s1, 0, "ignored", 4_000));
let result = list(
&reader,
serde_json::json!({ "agent_id": "ana", "limit": 5 }),
)
.await;
let response: AgentEventsListResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(response.events.len(), 3);
match &response.events[0] {
AgentEventKind::TranscriptAppended { sent_at_ms, .. } => {
assert_eq!(*sent_at_ms, 3_000);
}
_ => panic!("expected TranscriptAppended"),
}
}
#[tokio::test]
async fn list_rejects_empty_agent_id() {
let reader = MockReader::new();
let result = list(&reader, serde_json::json!({ "agent_id": "" })).await;
let err = result.error.expect("error");
assert!(matches!(err, AdminRpcError::InvalidParams(_)));
}
#[tokio::test]
async fn read_returns_events_after_since_seq() {
let sid = Uuid::nil();
let reader = MockReader::new()
.with_event(evt("ana", sid, 0, "a", 1_000))
.with_event(evt("ana", sid, 1, "b", 2_000))
.with_event(evt("ana", sid, 2, "c", 3_000));
let result = read(
&reader,
serde_json::json!({
"agent_id": "ana",
"session_id": sid,
"since_seq": 0,
"limit": 50
}),
)
.await;
let response: AgentEventsReadResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(response.events.len(), 2);
match &response.events[0] {
AgentEventKind::TranscriptAppended { seq, .. } => assert_eq!(*seq, 1),
_ => panic!("expected TranscriptAppended"),
}
}
#[tokio::test]
async fn read_unknown_session_returns_empty_not_error() {
let reader = MockReader::new().with_event(evt("ana", Uuid::nil(), 0, "x", 1));
let result = read(
&reader,
serde_json::json!({
"agent_id": "ana",
"session_id": Uuid::from_u128(99),
}),
)
.await;
let response: AgentEventsReadResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert!(response.events.is_empty());
}
#[tokio::test]
async fn search_delegates_and_caps_limit() {
let reader = MockReader::new().with_event(evt(
"ana",
Uuid::nil(),
0,
"[REDACTED:phone] hola",
1_000,
));
let result = search(
&reader,
serde_json::json!({
"agent_id": "ana",
"query": "hola",
"limit": 0,
}),
)
.await;
let response: AgentEventsSearchResponse =
serde_json::from_value(result.result.unwrap()).unwrap();
assert_eq!(response.hits.len(), 1);
let resolved = reader.last_search.lock().unwrap().clone().unwrap();
assert_eq!(resolved.limit, DEFAULT_SEARCH_LIMIT);
}
#[tokio::test]
async fn search_rejects_empty_query() {
let reader = MockReader::new();
let result = search(
&reader,
serde_json::json!({ "agent_id": "ana", "query": " " }),
)
.await;
let err = result.error.expect("error");
match err {
AdminRpcError::InvalidParams(m) => assert!(m.contains("query")),
other => panic!("expected InvalidParams, got {other:?}"),
}
}
#[tokio::test]
async fn list_propagates_reader_error_as_internal() {
let reader = MockReader::forcing_error();
let result = list(&reader, serde_json::json!({ "agent_id": "ana" })).await;
let err = result.error.expect("error");
assert!(matches!(err, AdminRpcError::Internal(_)));
}
use crate::agent::admin_rpc::SqliteAgentEventLog;
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,
}
}
fn transcript(agent: &str, sent_at_ms: u64) -> AgentEventKind {
AgentEventKind::TranscriptAppended {
agent_id: agent.into(),
session_id: Uuid::nil(),
seq: 0,
role: TranscriptRole::User,
body: "hola".into(),
sent_at_ms,
sender_id: None,
source_plugin: "whatsapp".into(),
tenant_id: None,
}
}
fn pause_changed(agent: &str, at_ms: u64) -> AgentEventKind {
AgentEventKind::ProcessingStateChanged {
agent_id: agent.into(),
scope: convo(agent),
prev_state: ProcessingControlState::AgentActive,
new_state: ProcessingControlState::PausedByOperator {
scope: convo(agent),
paused_at_ms: at_ms,
operator_token_hash: "abcdef0123456789".into(),
reason: None,
},
at_ms,
tenant_id: None,
}
}
fn escalation(agent: &str, requested_at_ms: u64) -> AgentEventKind {
AgentEventKind::EscalationRequested {
agent_id: agent.into(),
scope: convo(agent),
summary: "needs review".into(),
reason: EscalationReason::UnknownQuery,
urgency: EscalationUrgency::Normal,
requested_at_ms,
tenant_id: None,
}
}
#[tokio::test]
async fn merger_kind_none_interleaves_both_sources_by_at_ms_desc() {
let transcripts: Arc<dyn TranscriptReader> = Arc::new(
MockReader::default()
.with_event(transcript("ana", 1_000))
.with_event(transcript("ana", 3_000)),
);
let log = Arc::new(SqliteAgentEventLog::open_memory().await.unwrap());
log.append(pause_changed("ana", 2_000)).await.unwrap();
log.append(escalation("ana", 4_000)).await.unwrap();
let merger = MergingAgentEventReader::new(transcripts, log);
let out = merger
.list_recent_events(&AgentEventsListFilter {
agent_id: "ana".into(),
limit: 10,
..Default::default()
})
.await
.unwrap();
assert_eq!(out.len(), 4);
let stamps: Vec<u64> = out.iter().map(event_at_ms).collect();
assert_eq!(stamps, vec![4_000, 3_000, 2_000, 1_000]);
}
#[tokio::test]
async fn merger_kind_transcript_appended_routes_to_transcripts_only() {
let transcripts: Arc<dyn TranscriptReader> =
Arc::new(MockReader::default().with_event(transcript("ana", 1_000)));
let log = Arc::new(SqliteAgentEventLog::open_memory().await.unwrap());
log.append(pause_changed("ana", 2_000)).await.unwrap();
let merger = MergingAgentEventReader::new(transcripts, log);
let out = merger
.list_recent_events(&AgentEventsListFilter {
agent_id: "ana".into(),
kind: Some("transcript_appended".into()),
limit: 10,
..Default::default()
})
.await
.unwrap();
assert_eq!(out.len(), 1);
assert!(matches!(out[0], AgentEventKind::TranscriptAppended { .. }));
}
#[tokio::test]
async fn merger_kind_other_routes_to_log_only() {
let transcripts: Arc<dyn TranscriptReader> =
Arc::new(MockReader::default().with_event(transcript("ana", 1_000)));
let log = Arc::new(SqliteAgentEventLog::open_memory().await.unwrap());
log.append(pause_changed("ana", 2_000)).await.unwrap();
log.append(escalation("ana", 3_000)).await.unwrap();
let merger = MergingAgentEventReader::new(transcripts, log);
let out = merger
.list_recent_events(&AgentEventsListFilter {
agent_id: "ana".into(),
kind: Some("processing_state_changed".into()),
limit: 10,
..Default::default()
})
.await
.unwrap();
assert_eq!(out.len(), 1);
assert!(matches!(
out[0],
AgentEventKind::ProcessingStateChanged { .. }
));
}
#[tokio::test]
async fn merger_skips_log_transcripts_to_avoid_double_count() {
let transcripts: Arc<dyn TranscriptReader> =
Arc::new(MockReader::default().with_event(transcript("ana", 1_000)));
let log = Arc::new(SqliteAgentEventLog::open_memory().await.unwrap());
log.append(transcript("ana", 1_000)).await.unwrap(); log.append(pause_changed("ana", 2_000)).await.unwrap();
let merger = MergingAgentEventReader::new(transcripts, log);
let out = merger
.list_recent_events(&AgentEventsListFilter {
agent_id: "ana".into(),
limit: 10,
..Default::default()
})
.await
.unwrap();
assert_eq!(out.len(), 2);
let n_transcripts = out
.iter()
.filter(|e| matches!(e, AgentEventKind::TranscriptAppended { .. }))
.count();
assert_eq!(n_transcripts, 1, "no duplicate transcripts");
}
#[tokio::test]
async fn merger_truncates_to_limit_after_merging() {
let transcripts: Arc<dyn TranscriptReader> = Arc::new(
MockReader::default()
.with_event(transcript("ana", 1_000))
.with_event(transcript("ana", 2_000)),
);
let log = Arc::new(SqliteAgentEventLog::open_memory().await.unwrap());
log.append(pause_changed("ana", 3_000)).await.unwrap();
log.append(escalation("ana", 4_000)).await.unwrap();
let merger = MergingAgentEventReader::new(transcripts, log);
let out = merger
.list_recent_events(&AgentEventsListFilter {
agent_id: "ana".into(),
limit: 2,
..Default::default()
})
.await
.unwrap();
assert_eq!(out.len(), 2);
let stamps: Vec<u64> = out.iter().map(event_at_ms).collect();
assert_eq!(stamps, vec![4_000, 3_000]);
}
#[tokio::test]
async fn merger_passes_through_read_session_to_transcripts() {
let transcripts: Arc<dyn TranscriptReader> =
Arc::new(MockReader::default().with_event(transcript("ana", 1_000)));
let log = Arc::new(SqliteAgentEventLog::open_memory().await.unwrap());
let merger = MergingAgentEventReader::new(transcripts, log);
let out = merger
.read_session_events(&AgentEventsReadParams {
agent_id: "ana".into(),
session_id: Uuid::nil(),
limit: 10,
since_seq: None,
})
.await
.unwrap();
assert_eq!(out.len(), 1);
}
}