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,
#[serde(default)]
is_error: bool,
},
Usage {
input_tokens: u32,
output_tokens: u32,
cache_hit_tokens: u32,
cache_miss_tokens: u32,
step: usize,
},
ContextBreakdown {
breakdown: crate::llm::ContextBreakdown,
step: usize,
},
PartialToken { text: String, step: usize },
PartialReasoning { text: String, step: usize },
Reasoning { text: String, step: usize },
Microcompact { step: usize, pruned: usize },
Compacted {
removed: usize,
kept: usize,
summary_chars: usize,
step: usize,
},
CompactionSkipped {
step: usize,
reason: CompactionSkipReason,
},
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,
usage: Option<crate::session::UsageMeta>,
},
MessageAppendedWithAudit {
message: crate::message::Message,
audit: crate::tools::AuditMeta,
},
CompactionBoundary {
turn: u32,
compacted_count: usize,
summary_uuid: Option<String>,
cache_hit_tokens: u32,
cache_miss_tokens: u32,
is_recompaction_in_chain: bool,
turns_since_previous_compact: u32,
previous_compact_turn: Option<u32>,
},
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 },
LlmRetry {
step: usize,
attempt: u32,
wait_ms: u64,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompactionSkipReason {
CircuitBreaker,
Error,
}
#[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,
is_error: false,
},
AgentEvent::Usage {
input_tokens: 10,
output_tokens: 20,
cache_hit_tokens: 5,
cache_miss_tokens: 15,
step: 4,
},
AgentEvent::PartialToken {
text: "hel".into(),
step: 5,
},
AgentEvent::Microcompact { step: 5, pruned: 3 },
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".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()),
is_compaction_summary: false,
};
let event = AgentEvent::MessageAppended {
message: msg.clone(),
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");
} 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(),
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);
}
#[test]
fn tool_result_is_error_defaults_to_false_when_absent() {
let json = r#"{"type":"tool_result","id":"x","name":"y","output":"ok","step":0}"#;
let event: AgentEvent = serde_json::from_str(json).expect("must deserialize");
match event {
AgentEvent::ToolResult { is_error, .. } => {
assert!(!is_error, "is_error must default to false when absent");
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn tool_result_is_error_true_round_trips() {
let event = AgentEvent::ToolResult {
id: "c1".into(),
name: "Bash".into(),
output: "ERROR: oops".into(),
step: 3,
is_error: true,
};
let json = serde_json::to_string(&event).expect("serialize");
assert!(
json.contains("\"is_error\":true"),
"is_error=true must be in JSON; got: {json}"
);
let restored: AgentEvent = serde_json::from_str(&json).expect("round-trip");
assert_eq!(restored, event);
}
#[test]
fn additional_event_variants_round_trip() {
let events: Vec<AgentEvent> = vec![
AgentEvent::PartialReasoning {
text: "thinking...".into(),
step: 0,
},
AgentEvent::Reasoning {
text: "full reasoning".into(),
step: 1,
},
AgentEvent::PlanModeRequested {
reason: "complex task".into(),
},
AgentEvent::PlanModeApproved,
AgentEvent::PlanModeRejected {
reason: "too risky".into(),
},
AgentEvent::CompactionBoundary {
turn: 5,
compacted_count: 10,
summary_uuid: Some("abc-123".into()),
cache_hit_tokens: 42,
cache_miss_tokens: 158,
is_recompaction_in_chain: true,
turns_since_previous_compact: 3,
previous_compact_turn: Some(2),
},
AgentEvent::GoalSet {
condition: "all tests pass".into(),
max_turns: 20,
},
AgentEvent::GoalContinuing {
reason: "not yet".into(),
turns: 3,
},
AgentEvent::GoalAchieved {
condition: "all tests pass".into(),
turns: 5,
},
AgentEvent::GoalCleared,
AgentEvent::HookStarted {
hook_event: "pre_tool".into(),
hook_name: "safety-check".into(),
status_message: Some("checking...".into()),
},
AgentEvent::HookProgress {
hook_event: "pre_tool".into(),
hook_name: "safety-check".into(),
last_line: "progress line".into(),
},
AgentEvent::HookFinished {
hook_event: "pre_tool".into(),
hook_name: "safety-check".into(),
outcome: "continue".into(),
duration_ms: 42,
},
AgentEvent::HookSystemMessage {
text: "hook message".into(),
},
AgentEvent::Microcompact { step: 3, pruned: 2 },
AgentEvent::LlmRetry {
step: 2,
attempt: 1,
wait_ms: 5000,
reason: "rate_limited".into(),
},
];
for event in &events {
let json = serde_json::to_string(event)
.unwrap_or_else(|e| panic!("serialization failed for {event:?}: {e}"));
let back: AgentEvent = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialization failed for {json}: {e}"));
assert_eq!(*event, back, "round-trip failed for event type");
}
}
#[test]
fn todo_updated_and_message_appended_with_audit_round_trip() {
use crate::tools::todo::{TodoItem, TodoStatus};
let todos = vec![TodoItem {
content: "do something".into(),
status: TodoStatus::InProgress,
active_form: Some("doing something".into()),
}];
let event = AgentEvent::TodoUpdated { todos };
let json = serde_json::to_string(&event).unwrap();
let back: AgentEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, back, "TodoUpdated must round-trip");
let msg = crate::message::Message::user("hello");
let audit = crate::tools::AuditMeta::synthetic_unknown_tool("Read");
let event2 = AgentEvent::MessageAppendedWithAudit {
message: msg,
audit,
};
let json2 = serde_json::to_string(&event2).unwrap();
let back2: AgentEvent = serde_json::from_str(&json2).unwrap();
assert_eq!(event2, back2, "MessageAppendedWithAudit must round-trip");
}
#[tokio::test]
async fn broadcast_sink_no_subscriber_does_not_panic() {
let (sink, rx) = BroadcastSink::new(16);
drop(rx); sink.emit(AgentEvent::PlanConfirmed).await;
}
#[tokio::test]
async fn channel_sink_delivers_in_order() {
let (sink, mut rx) = ChannelSink::new();
sink.emit(AgentEvent::PlanConfirmed).await;
sink.emit(AgentEvent::PlanModeApproved).await;
assert_eq!(rx.recv().await.unwrap(), AgentEvent::PlanConfirmed);
assert_eq!(rx.recv().await.unwrap(), AgentEvent::PlanModeApproved);
}
#[test]
fn message_appended_no_longer_has_parent_uuid_field() {
let src = include_str!("event.rs");
let arm = src
.split("MessageAppended {")
.nth(1)
.expect("MessageAppended arm must exist")
.split("}")
.next()
.expect("MessageAppended arm must close");
assert!(
!arm.contains("parent_uuid"),
"MessageAppended arm must not reference parent_uuid: {arm}"
);
}
}