use std::io::{self, Write};
use std::sync::Mutex;
use serde::Serialize;
#[derive(Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum JsonEvent<'a> {
Boot {
version: &'a str,
bare: bool,
auto: bool,
},
Query { text: &'a str, queue_len: usize },
ResponseChunk { text: &'a str },
ResponseEnd,
ToolCall {
tool: &'a str,
args: &'a serde_json::Value,
id: &'a str,
},
ToolResult {
tool: &'a str,
id: &'a str,
output: &'a str,
is_error: bool,
},
Cost {
input_tokens: u64,
output_tokens: u64,
total_usd: f64,
},
LoopTick {
iteration: u64,
total_ticks: u64,
prompt_preview: &'a str,
},
CommandAck { command: &'a str, text: &'a str },
Status { message: &'a str },
Error { message: &'a str },
}
pub struct JsonEventSink {
writer: Mutex<Box<dyn Write + Send>>,
}
impl std::fmt::Debug for JsonEventSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonEventSink").finish_non_exhaustive()
}
}
impl JsonEventSink {
#[must_use]
pub fn new() -> Self {
Self {
writer: Mutex::new(Box::new(io::stdout())),
}
}
#[must_use]
pub fn with_writer(w: impl Write + Send + 'static) -> Self {
Self {
writer: Mutex::new(Box::new(w)),
}
}
pub fn emit(&self, event: &JsonEvent<'_>) {
let Ok(mut w) = self.writer.lock() else {
return;
};
if let Ok(line) = serde_json::to_string(event) {
let _ = writeln!(w, "{line}");
let _ = w.flush();
}
}
}
impl Default for JsonEventSink {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boot_event_serializes_correctly() {
let event = JsonEvent::Boot {
version: "0.1.0",
bare: true,
auto: false,
};
let s = serde_json::to_string(&event).unwrap();
assert!(s.contains("\"event\":\"boot\""));
assert!(s.contains("\"version\":\"0.1.0\""));
assert!(s.contains("\"bare\":true"));
}
#[test]
fn response_end_serializes_without_fields() {
let event = JsonEvent::ResponseEnd;
let s = serde_json::to_string(&event).unwrap();
assert_eq!(s, r#"{"event":"response_end"}"#);
}
#[test]
fn emit_does_not_panic_on_concurrent_use() {
use std::sync::Arc;
use std::thread;
let sink = Arc::new(JsonEventSink::new());
let handles: Vec<_> = (0..4)
.map(|i| {
let s = Arc::clone(&sink);
thread::spawn(move || {
for _ in 0..10 {
s.emit(&JsonEvent::Status {
message: &format!("thread {i}"),
});
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
}