use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Flow {
#[default]
Continue,
Cancel,
}
impl Flow {
pub fn is_cancel(self) -> bool {
self == Flow::Cancel
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunEvent {
pub run_id: i64,
pub step: u32,
pub depth: u32,
#[serde(flatten)]
pub kind: EventKind,
}
impl RunEvent {
pub fn new(run_id: i64, step: u32, kind: EventKind) -> Self {
Self {
run_id,
step,
depth: 0,
kind,
}
}
pub fn at_depth(run_id: i64, step: u32, depth: u32, kind: EventKind) -> Self {
Self {
run_id,
step,
depth,
kind,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum EventKind {
Started {
goal: String,
provider: String,
},
Step {
decision: String,
tool_call: String,
tokens: u64,
changed: bool,
},
ToolCall {
name: String,
target: String,
},
Refused {
act: String,
target: String,
rule: Option<String>,
layer: Option<String>,
},
ApprovalRequested {
act: String,
target: String,
},
ApprovalDecided {
act: String,
target: String,
decision: String,
},
SpendDraw {
tokens: u64,
remaining: Option<u64>,
},
Retry {
kind: String,
attempt: u32,
delay_ms: u64,
},
FellBackTo {
provider: String,
},
Replan {
window: u32,
},
Stalled,
Spawned {
child_run_id: i64,
goal: String,
},
SpawnRefused {
cap: String,
},
MemoryWrote {
key: String,
},
Sandbox {
kind: String,
backend: Option<String>,
},
Mcp {
server: String,
tool: Option<String>,
ok: Option<bool>,
millis: Option<u64>,
},
Finished {
outcome: String,
steps: u32,
tokens: u64,
},
}
pub trait Observer: Send + Sync {
fn event(&self, event: &RunEvent) -> Flow;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Ignore;
impl Observer for Ignore {
fn event(&self, _event: &RunEvent) -> Flow {
Flow::Continue
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wire_shape_is_flat_and_tagged() {
let e = RunEvent::new(
7,
3,
EventKind::Step {
decision: "wrote src/a.rs".into(),
tool_call: "write_file:{}".into(),
tokens: 412,
changed: true,
},
);
let v: serde_json::Value = serde_json::to_value(&e).unwrap();
assert_eq!(v["run_id"], 7);
assert_eq!(v["step"], 3);
assert_eq!(v["depth"], 0);
assert_eq!(v["event"], "step");
assert_eq!(v["decision"], "wrote src/a.rs");
assert_eq!(v["tokens"], 412);
assert_eq!(v["changed"], true);
assert!(v.get("kind").is_none(), "the payload must not be nested");
}
#[test]
fn a_unit_variant_still_carries_its_tag() {
let v = serde_json::to_value(RunEvent::new(1, 9, EventKind::Stalled)).unwrap();
assert_eq!(v["event"], "stalled");
assert_eq!(v["step"], 9);
}
#[test]
fn every_variant_round_trips() {
for kind in every_kind() {
let e = RunEvent::at_depth(1, 2, 3, kind);
let json = serde_json::to_string(&e).unwrap();
let back: RunEvent = serde_json::from_str(&json).unwrap_or_else(|err| {
panic!("{json} did not round-trip: {err}");
});
assert_eq!(e, back, "round-trip changed the event: {json}");
}
}
#[test]
fn each_variant_has_a_distinct_tag() {
let mut tags = Vec::new();
for kind in every_kind() {
let v = serde_json::to_value(RunEvent::new(1, 1, kind)).unwrap();
tags.push(v["event"].as_str().unwrap().to_string());
}
let mut unique = tags.clone();
unique.sort();
unique.dedup();
assert_eq!(
tags.len(),
unique.len(),
"two variants share a wire tag, so a consumer cannot tell them apart: {tags:?}"
);
}
#[test]
fn ignore_never_cancels() {
assert_eq!(
Ignore.event(&RunEvent::new(1, 1, EventKind::Stalled)),
Flow::Continue
);
assert!(!Flow::Continue.is_cancel());
assert!(Flow::Cancel.is_cancel());
assert_eq!(Flow::default(), Flow::Continue);
}
fn every_kind() -> Vec<EventKind> {
let all = vec![
EventKind::Started {
goal: "g".into(),
provider: "p".into(),
},
EventKind::Step {
decision: "d".into(),
tool_call: "t".into(),
tokens: 1,
changed: true,
},
EventKind::ToolCall {
name: "n".into(),
target: "t".into(),
},
EventKind::Refused {
act: "read".into(),
target: "t".into(),
rule: Some("r".into()),
layer: None,
},
EventKind::ApprovalRequested {
act: "write".into(),
target: "t".into(),
},
EventKind::ApprovalDecided {
act: "write".into(),
target: "t".into(),
decision: "approve".into(),
},
EventKind::SpendDraw {
tokens: 1,
remaining: Some(2),
},
EventKind::Retry {
kind: "rate_limited".into(),
attempt: 1,
delay_ms: 40,
},
EventKind::FellBackTo {
provider: "p".into(),
},
EventKind::Replan { window: 3 },
EventKind::Stalled,
EventKind::Spawned {
child_run_id: 2,
goal: "g".into(),
},
EventKind::SpawnRefused {
cap: "depth".into(),
},
EventKind::MemoryWrote { key: "k".into() },
EventKind::Sandbox {
kind: "create".into(),
backend: Some("b".into()),
},
EventKind::Mcp {
server: "s".into(),
tool: Some("t".into()),
ok: Some(true),
millis: Some(5),
},
EventKind::Finished {
outcome: "success".into(),
steps: 4,
tokens: 9,
},
];
for k in &all {
match k {
EventKind::Started { .. }
| EventKind::Step { .. }
| EventKind::ToolCall { .. }
| EventKind::Refused { .. }
| EventKind::ApprovalRequested { .. }
| EventKind::ApprovalDecided { .. }
| EventKind::SpendDraw { .. }
| EventKind::Retry { .. }
| EventKind::FellBackTo { .. }
| EventKind::Replan { .. }
| EventKind::Stalled
| EventKind::Spawned { .. }
| EventKind::SpawnRefused { .. }
| EventKind::MemoryWrote { .. }
| EventKind::Sandbox { .. }
| EventKind::Mcp { .. }
| EventKind::Finished { .. } => {}
}
}
all
}
}