use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tokio::sync::mpsc;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AgentEvent {
AssistantText { text: String, step: usize },
ToolCall {
name: String,
id: String,
arguments: String,
step: usize,
},
Latency { step: usize, llm_ms: u64 },
ToolResult {
id: String,
name: String,
output: String,
step: usize,
},
Usage {
input_tokens: u32,
output_tokens: u32,
step: usize,
},
PartialToken { text: String, step: usize },
Reasoning { text: String, step: usize },
Compacted {
removed: usize,
kept: usize,
summary_chars: usize,
step: usize,
},
TurnFinished {
reason: String,
steps: usize,
},
PlanModeRequested { reason: String },
PlanModeApproved,
PlanModeRejected { reason: String },
PlanProposed {
plan_text: String,
tool_calls: Vec<serde_json::Value>,
},
PlanConfirmed,
PlanRejected { reason: String },
MessageAppended {
message: crate::message::Message,
parent_uuid: Option<String>,
usage: Option<crate::session::UsageMeta>,
},
MessageAppendedWithAudit {
message: crate::message::Message,
audit: crate::tools::AuditMeta,
},
CompactionBoundary {
turn: u32,
compacted_count: usize,
summary_uuid: Option<String>,
},
TodoUpdated {
todos: Vec<crate::tools::todo::TodoItem>,
},
GoalSet {
condition: String,
max_turns: u32,
},
GoalContinuing {
reason: String,
turns: u32,
},
GoalAchieved {
condition: String,
turns: u32,
},
GoalCleared,
HookStarted {
hook_event: String,
hook_name: String,
status_message: Option<String>,
},
HookProgress {
hook_event: String,
hook_name: String,
last_line: String,
},
HookFinished {
hook_event: String,
hook_name: String,
outcome: String,
duration_ms: u64,
},
HookSystemMessage { text: String },
}
#[async_trait::async_trait]
pub trait EventSink: Send + Sync + 'static {
async fn emit(&self, event: AgentEvent);
}
pub struct ChannelSink {
tx: mpsc::UnboundedSender<AgentEvent>,
}
impl ChannelSink {
pub fn new() -> (Self, mpsc::UnboundedReceiver<AgentEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
(Self { tx }, rx)
}
}
#[async_trait::async_trait]
impl EventSink for ChannelSink {
async fn emit(&self, event: AgentEvent) {
let _ = self.tx.send(event);
}
}
pub struct BroadcastSink {
tx: broadcast::Sender<AgentEvent>,
}
impl BroadcastSink {
pub fn new(capacity: usize) -> (Self, broadcast::Receiver<AgentEvent>) {
let (tx, rx) = broadcast::channel(capacity);
(Self { tx }, rx)
}
}
#[async_trait::async_trait]
impl EventSink for BroadcastSink {
async fn emit(&self, event: AgentEvent) {
let _ = self.tx.send(event);
}
}
pub struct NullSink;
#[async_trait::async_trait]
impl EventSink for NullSink {
async fn emit(&self, _event: AgentEvent) {
}
}
pub struct CompositeSink {
sinks: Vec<Box<dyn EventSink>>,
}
impl CompositeSink {
pub fn new(sinks: impl IntoIterator<Item = Box<dyn EventSink>>) -> Self {
Self {
sinks: sinks.into_iter().collect(),
}
}
}
#[async_trait::async_trait]
impl EventSink for CompositeSink {
async fn emit(&self, event: AgentEvent) {
for sink in &self.sinks {
sink.emit(event.clone()).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn channel_sink_delivers_events() {
let (sink, mut rx) = ChannelSink::new();
let event = AgentEvent::AssistantText {
text: "hello".into(),
step: 0,
};
sink.emit(event.clone()).await;
let received = rx.recv().await.unwrap();
assert_eq!(received, event);
}
#[tokio::test]
async fn broadcast_sink_delivers_to_multiple() {
let (sink, mut rx1) = BroadcastSink::new(16);
let mut rx2 = sink.tx.subscribe();
let event = AgentEvent::PlanConfirmed;
sink.emit(event.clone()).await;
assert_eq!(rx1.recv().await.unwrap(), event);
assert_eq!(rx2.recv().await.unwrap(), event);
}
#[tokio::test]
async fn null_sink_does_not_panic() {
let sink = NullSink;
sink.emit(AgentEvent::PlanConfirmed).await;
}
#[tokio::test]
async fn composite_sink_fans_out() {
let (sink1, mut rx1) = ChannelSink::new();
let (sink2, mut rx2) = ChannelSink::new();
let composite = CompositeSink::new(vec![
Box::new(sink1) as Box<dyn EventSink>,
Box::new(sink2) as Box<dyn EventSink>,
]);
let event = AgentEvent::PlanConfirmed;
composite.emit(event.clone()).await;
assert_eq!(rx1.recv().await.unwrap(), event);
assert_eq!(rx2.recv().await.unwrap(), event);
}
#[test]
fn agent_event_serialization_round_trip() {
let events = vec![
AgentEvent::AssistantText {
text: "hello".into(),
step: 0,
},
AgentEvent::ToolCall {
name: "foo".into(),
id: "1".into(),
arguments: "{}".into(),
step: 1,
},
AgentEvent::Latency {
step: 2,
llm_ms: 100,
},
AgentEvent::ToolResult {
id: "1".into(),
name: "foo".into(),
output: "ok".into(),
step: 3,
},
AgentEvent::Usage {
input_tokens: 10,
output_tokens: 20,
step: 4,
},
AgentEvent::PartialToken {
text: "hel".into(),
step: 5,
},
AgentEvent::Compacted {
removed: 5,
kept: 3,
summary_chars: 200,
step: 6,
},
AgentEvent::TurnFinished {
reason: "done".into(),
steps: 7,
},
AgentEvent::PlanProposed {
plan_text: "plan".into(),
tool_calls: vec![],
},
AgentEvent::PlanConfirmed,
AgentEvent::PlanRejected {
reason: "nope".into(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
assert_eq!(*event, deserialized, "round-trip failed for {json}");
}
}
#[test]
fn message_appended_round_trips_through_event() {
use crate::llm::ToolCall as LlmToolCall;
use crate::message::Message;
let tc = LlmToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: serde_json::json!({"path": "/tmp/foo"}),
};
let msg = Message {
role: crate::message::Role::Assistant,
content: "some text".into(),
tool_calls: vec![tc.clone()],
tool_call_id: None,
reasoning_content: Some("my reasoning".into()),
};
let event = AgentEvent::MessageAppended {
message: msg.clone(),
parent_uuid: None,
usage: None,
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, deserialized);
if let AgentEvent::MessageAppended { message: m, .. } = deserialized {
assert_eq!(m.content, "some text");
assert_eq!(m.reasoning_content.as_deref(), Some("my reasoning"));
assert_eq!(m.tool_calls.len(), 1);
assert_eq!(m.tool_calls[0].name, "read_file");
} else {
panic!("deserialized to wrong variant");
}
}
#[tokio::test]
async fn composite_sink_preserves_message_appended() {
use crate::message::Message;
let (sink1, mut rx1) = ChannelSink::new();
let (sink2, mut rx2) = ChannelSink::new();
let composite = CompositeSink::new(vec![
Box::new(sink1) as Box<dyn EventSink>,
Box::new(sink2) as Box<dyn EventSink>,
]);
let msg = Message::user("hello");
let event = AgentEvent::MessageAppended {
message: msg.clone(),
parent_uuid: None,
usage: None,
};
composite.emit(event.clone()).await;
let got1 = rx1.recv().await.unwrap();
let got2 = rx2.recv().await.unwrap();
assert_eq!(got1, event);
assert_eq!(got2, event);
composite.emit(AgentEvent::PlanConfirmed).await;
assert_eq!(rx1.recv().await.unwrap(), AgentEvent::PlanConfirmed);
assert_eq!(rx2.recv().await.unwrap(), AgentEvent::PlanConfirmed);
}
}