use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Event {
#[serde(rename = "thread.started")]
ThreadStarted {
thread_id: String,
},
#[serde(rename = "turn.started")]
TurnStarted,
#[serde(rename = "turn.completed")]
TurnCompleted {
#[serde(default)]
usage: Option<Usage>,
},
#[serde(rename = "turn.failed")]
TurnFailed {
#[serde(default)]
usage: Option<Usage>,
#[serde(default)]
error: Option<ThreadError>,
},
#[serde(rename = "item.started")]
ItemStarted {
item: Item,
},
#[serde(rename = "item.updated")]
ItemUpdated {
item: Item,
},
#[serde(rename = "item.completed")]
ItemCompleted {
item: Item,
},
#[serde(rename = "token_count")]
TokenCount {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
cached_input_tokens: u64,
#[serde(default)]
output_tokens: u64,
},
#[serde(rename = "error")]
Error {
#[serde(default)]
message: Option<String>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Item {
AgentMessage {
#[serde(default)]
id: Option<String>,
#[serde(default)]
text: Option<String>,
},
Reasoning {
#[serde(default)]
id: Option<String>,
#[serde(default)]
text: Option<String>,
},
CommandExecution {
#[serde(default)]
id: Option<String>,
#[serde(default)]
command: Option<String>,
#[serde(default)]
aggregated_output: Option<String>,
#[serde(default)]
exit_code: Option<i32>,
#[serde(default)]
status: Option<String>,
},
FileChange {
#[serde(default)]
id: Option<String>,
#[serde(default)]
changes: Vec<FileUpdateChange>,
#[serde(default)]
status: Option<String>,
},
McpToolCall {
#[serde(default)]
id: Option<String>,
#[serde(default)]
server: Option<String>,
#[serde(default)]
tool: Option<String>,
#[serde(default)]
status: Option<String>,
},
WebSearch {
#[serde(default)]
id: Option<String>,
#[serde(default)]
query: Option<String>,
},
TodoList {
#[serde(default)]
id: Option<String>,
#[serde(default)]
items: Vec<TodoItem>,
},
Error {
#[serde(default)]
id: Option<String>,
#[serde(default)]
message: Option<String>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cached_input_tokens: u64,
#[serde(default)]
pub total_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadError {
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileUpdateChange {
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub old_content: Option<String>,
#[serde(default)]
pub new_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub completed: bool,
}
impl Event {
pub fn is_thread_started(&self) -> bool {
matches!(self, Event::ThreadStarted { .. })
}
pub fn is_turn_completed(&self) -> bool {
matches!(self, Event::TurnCompleted { .. })
}
pub fn is_turn_failed(&self) -> bool {
matches!(self, Event::TurnFailed { .. })
}
pub fn is_error(&self) -> bool {
matches!(self, Event::Error { .. })
}
pub fn is_item_completed(&self) -> bool {
matches!(self, Event::ItemCompleted { .. })
}
pub fn item(&self) -> Option<&Item> {
match self {
Event::ItemStarted { item }
| Event::ItemUpdated { item }
| Event::ItemCompleted { item } => Some(item),
_ => None,
}
}
}
impl Item {
pub fn id(&self) -> Option<&str> {
match self {
Item::AgentMessage { id, .. }
| Item::Reasoning { id, .. }
| Item::CommandExecution { id, .. }
| Item::FileChange { id, .. }
| Item::McpToolCall { id, .. }
| Item::WebSearch { id, .. }
| Item::TodoList { id, .. }
| Item::Error { id, .. } => id.as_deref(),
Item::Unknown => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
Item::AgentMessage { text, .. } | Item::Reasoning { text, .. } => text.as_deref(),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_thread_started() {
let json = r#"{"type":"thread.started","thread_id":"thread_abc123"}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::ThreadStarted { thread_id } => assert_eq!(thread_id, "thread_abc123"),
other => panic!("expected ThreadStarted, got {other:?}"),
}
}
#[test]
fn deserialize_turn_started() {
let json = r#"{"type":"turn.started"}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert!(matches!(event, Event::TurnStarted));
}
#[test]
fn deserialize_turn_started_with_extra_fields() {
let json = r#"{"type":"turn.started","future_field":"hello"}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert!(matches!(event, Event::TurnStarted));
}
#[test]
fn deserialize_turn_completed_with_usage() {
let json =
r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::TurnCompleted {
usage: Some(usage), ..
} => {
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
}
other => panic!("expected TurnCompleted with usage, got {other:?}"),
}
}
#[test]
fn deserialize_turn_completed_without_usage() {
let json = r#"{"type":"turn.completed"}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::TurnCompleted { usage: None } => {}
other => panic!("expected TurnCompleted without usage, got {other:?}"),
}
}
#[test]
fn deserialize_turn_failed() {
let json = r#"{"type":"turn.failed","error":{"message":"rate limited","code":"rate_limit"}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::TurnFailed {
error: Some(ref err),
..
} => {
assert_eq!(err.message.as_deref(), Some("rate limited"));
assert_eq!(err.code.as_deref(), Some("rate_limit"));
}
other => panic!("expected TurnFailed, got {other:?}"),
}
}
#[test]
fn deserialize_item_completed_agent_message() {
let json = r#"{"type":"item.completed","item":{"type":"agent_message","id":"msg_1","text":"Hello!"}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::ItemCompleted {
item: Item::AgentMessage { id, text },
} => {
assert_eq!(id.as_deref(), Some("msg_1"));
assert_eq!(text.as_deref(), Some("Hello!"));
}
other => panic!("expected ItemCompleted with AgentMessage, got {other:?}"),
}
}
#[test]
fn deserialize_item_reasoning() {
let json =
r#"{"type":"item.started","item":{"type":"reasoning","id":"r_1","text":"Let me think..."}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::ItemStarted {
item: Item::Reasoning { id, text },
} => {
assert_eq!(id.as_deref(), Some("r_1"));
assert_eq!(text.as_deref(), Some("Let me think..."));
}
other => panic!("expected ItemStarted with Reasoning, got {other:?}"),
}
}
#[test]
fn deserialize_item_command_execution() {
let json = r#"{"type":"item.completed","item":{"type":"command_execution","id":"cmd_1","command":"ls -la","aggregated_output":"total 42\n","exit_code":0,"status":"completed"}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::ItemCompleted {
item:
Item::CommandExecution {
id,
command,
exit_code,
status,
..
},
} => {
assert_eq!(id.as_deref(), Some("cmd_1"));
assert_eq!(command.as_deref(), Some("ls -la"));
assert_eq!(exit_code, Some(0));
assert_eq!(status.as_deref(), Some("completed"));
}
other => panic!("expected CommandExecution, got {other:?}"),
}
}
#[test]
fn deserialize_item_file_change() {
let json = r#"{"type":"item.completed","item":{"type":"file_change","id":"fc_1","changes":[{"file_path":"src/main.rs","new_content":"fn main() {}"}],"status":"completed"}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::ItemCompleted {
item: Item::FileChange {
id, changes, status,
},
} => {
assert_eq!(id.as_deref(), Some("fc_1"));
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].file_path.as_deref(), Some("src/main.rs"));
assert_eq!(status.as_deref(), Some("completed"));
}
other => panic!("expected FileChange, got {other:?}"),
}
}
#[test]
fn deserialize_token_count() {
let json = r#"{"type":"token_count","input_tokens":200,"cached_input_tokens":50,"output_tokens":100}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::TokenCount {
input_tokens,
cached_input_tokens,
output_tokens,
} => {
assert_eq!(input_tokens, 200);
assert_eq!(cached_input_tokens, 50);
assert_eq!(output_tokens, 100);
}
other => panic!("expected TokenCount, got {other:?}"),
}
}
#[test]
fn deserialize_error_event() {
let json = r#"{"type":"error","message":"something went wrong"}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::Error { message } => {
assert_eq!(message.as_deref(), Some("something went wrong"))
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn deserialize_unknown_event_type() {
let json = r#"{"type":"future.event","some_field":"value"}"#;
let event: Event = serde_json::from_str(json).unwrap();
assert!(matches!(event, Event::Unknown));
}
#[test]
fn deserialize_unknown_item_type() {
let json = r#"{"type":"item.completed","item":{"type":"future_item","id":"x"}}"#;
let event: Event = serde_json::from_str(json).unwrap();
match event {
Event::ItemCompleted {
item: Item::Unknown,
} => {}
other => panic!("expected ItemCompleted with Unknown item, got {other:?}"),
}
}
#[test]
fn item_id_helper() {
let item = Item::AgentMessage {
id: Some("msg_1".into()),
text: Some("hi".into()),
};
assert_eq!(item.id(), Some("msg_1"));
assert_eq!(Item::Unknown.id(), None);
}
#[test]
fn item_text_helper() {
let item = Item::Reasoning {
id: None,
text: Some("thinking...".into()),
};
assert_eq!(item.text(), Some("thinking..."));
let cmd = Item::CommandExecution {
id: None,
command: None,
aggregated_output: None,
exit_code: None,
status: None,
};
assert_eq!(cmd.text(), None);
}
#[test]
fn event_item_helper() {
let event = Event::ItemCompleted {
item: Item::AgentMessage {
id: Some("m1".into()),
text: Some("hello".into()),
},
};
assert!(event.item().is_some());
assert_eq!(event.item().unwrap().id(), Some("m1"));
assert!(Event::TurnStarted.item().is_none());
}
}