use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AgentEvent {
SessionStarted {
session_id: String,
},
Message {
text: String,
},
TokenUsage {
input: u64,
output: u64,
cache_read: u64,
},
RateLimit {
scope: String,
remaining: u64,
reset_at: DateTime<Utc>,
observed_at: DateTime<Utc>,
},
ToolCall {
call_id: Option<String>,
name: Option<String>,
phase: ToolCallPhase,
input: Option<Value>,
output: Option<Value>,
raw: Value,
},
Subagent {
call_id: Option<String>,
action: String,
status: Option<String>,
target_ids: Vec<String>,
raw: Value,
},
Unknown {
event_type: Option<String>,
raw: Value,
},
Completed,
Error {
detail: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallPhase {
Request,
Result,
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn utc(value: &str) -> DateTime<Utc> {
value.parse().expect("test timestamp parses")
}
#[test]
fn serializes_provider_neutral_event_vocabulary() {
let cases = [
(
AgentEvent::SessionStarted {
session_id: "session-1".into(),
},
json!({
"kind": "session_started",
"session_id": "session-1"
}),
),
(
AgentEvent::Message {
text: "stage output".into(),
},
json!({
"kind": "message",
"text": "stage output"
}),
),
(
AgentEvent::TokenUsage {
input: 11,
output: 7,
cache_read: 3,
},
json!({
"kind": "token_usage",
"input": 11,
"output": 7,
"cache_read": 3
}),
),
(
AgentEvent::RateLimit {
scope: "provider:tokens_per_min".into(),
remaining: 42,
reset_at: utc("2026-05-16T10:15:30Z"),
observed_at: utc("2026-05-16T10:00:00Z"),
},
json!({
"kind": "rate_limit",
"scope": "provider:tokens_per_min",
"remaining": 42,
"reset_at": "2026-05-16T10:15:30Z",
"observed_at": "2026-05-16T10:00:00Z"
}),
),
(
AgentEvent::ToolCall {
call_id: Some("tool-1".into()),
name: Some("Bash".into()),
phase: ToolCallPhase::Request,
input: Some(json!({"command": "cargo test"})),
output: None,
raw: json!({"type": "assistant"}),
},
json!({
"kind": "tool_call",
"call_id": "tool-1",
"name": "Bash",
"phase": "request",
"input": {"command": "cargo test"},
"output": null,
"raw": {"type": "assistant"}
}),
),
(
AgentEvent::Subagent {
call_id: Some("collab-1".into()),
action: "spawnAgent".into(),
status: Some("completed".into()),
target_ids: vec!["thread-2".into()],
raw: json!({"type": "collabAgentToolCall"}),
},
json!({
"kind": "subagent",
"call_id": "collab-1",
"action": "spawnAgent",
"status": "completed",
"target_ids": ["thread-2"],
"raw": {"type": "collabAgentToolCall"}
}),
),
(
AgentEvent::Unknown {
event_type: Some("future_event_kind".into()),
raw: json!({"type": "future_event_kind"}),
},
json!({
"kind": "unknown",
"event_type": "future_event_kind",
"raw": {"type": "future_event_kind"}
}),
),
(
AgentEvent::Completed,
json!({
"kind": "completed"
}),
),
(
AgentEvent::Error {
detail: "provider line failed to decode".into(),
},
json!({
"kind": "error",
"detail": "provider line failed to decode"
}),
),
];
for (event, expected) in cases {
let value = serde_json::to_value(event).expect("event serializes");
assert_eq!(value, expected);
assert!(value.get("provider").is_none());
assert!(value.get("runtime").is_none());
}
}
#[test]
fn deserializes_provider_neutral_events_and_roundtrips() {
let cases = [
(
json!({
"kind": "session_started",
"session_id": "session-1"
}),
AgentEvent::SessionStarted {
session_id: "session-1".into(),
},
),
(
json!({
"kind": "message",
"text": "stage output"
}),
AgentEvent::Message {
text: "stage output".into(),
},
),
(
json!({
"kind": "token_usage",
"input": 11,
"output": 7,
"cache_read": 3
}),
AgentEvent::TokenUsage {
input: 11,
output: 7,
cache_read: 3,
},
),
(
json!({
"kind": "rate_limit",
"scope": "provider:tokens_per_min",
"remaining": 42,
"reset_at": "2026-05-16T10:15:30Z",
"observed_at": "2026-05-16T10:00:00Z"
}),
AgentEvent::RateLimit {
scope: "provider:tokens_per_min".into(),
remaining: 42,
reset_at: utc("2026-05-16T10:15:30Z"),
observed_at: utc("2026-05-16T10:00:00Z"),
},
),
(
json!({
"kind": "tool_call",
"call_id": "tool-1",
"name": "Bash",
"phase": "request",
"input": {"command": "cargo test"},
"output": null,
"raw": {"type": "assistant"}
}),
AgentEvent::ToolCall {
call_id: Some("tool-1".into()),
name: Some("Bash".into()),
phase: ToolCallPhase::Request,
input: Some(json!({"command": "cargo test"})),
output: None,
raw: json!({"type": "assistant"}),
},
),
(
json!({
"kind": "subagent",
"call_id": "collab-1",
"action": "spawnAgent",
"status": "completed",
"target_ids": ["thread-2"],
"raw": {"type": "collabAgentToolCall"}
}),
AgentEvent::Subagent {
call_id: Some("collab-1".into()),
action: "spawnAgent".into(),
status: Some("completed".into()),
target_ids: vec!["thread-2".into()],
raw: json!({"type": "collabAgentToolCall"}),
},
),
(
json!({
"kind": "unknown",
"event_type": "future_event_kind",
"raw": {"type": "future_event_kind"}
}),
AgentEvent::Unknown {
event_type: Some("future_event_kind".into()),
raw: json!({"type": "future_event_kind"}),
},
),
(
json!({
"kind": "completed"
}),
AgentEvent::Completed,
),
(
json!({
"kind": "error",
"detail": "provider line failed to decode"
}),
AgentEvent::Error {
detail: "provider line failed to decode".into(),
},
),
];
for (value, expected) in cases {
let event: AgentEvent = serde_json::from_value(value.clone()).expect("event deserializes");
assert_eq!(event, expected);
assert_eq!(serde_json::to_value(event).expect("event serializes"), value);
}
}
}