use std::io::Write;
use std::sync::Mutex;
use agent_code_lib::llm::message::Usage;
use agent_code_lib::query::StreamSink;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
other => Err(format!(
"unknown output format: {other} (expected text or json)"
)),
}
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Text => write!(f, "text"),
Self::Json => write!(f, "json"),
}
}
}
#[repr(u8)]
pub enum ExitCode {
Success = 0,
ConfigError = 1,
InputError = 2,
ToolFailure = 3,
LlmError = 4,
CostLimit = 5,
TurnLimit = 6,
PermissionDenied = 7,
}
#[derive(Serialize)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
enum Event<'a> {
SessionStart {
session_id: &'a str,
model: &'a str,
timestamp: &'a str,
},
TextDelta {
content: &'a str,
turn: usize,
},
Thinking {
content: &'a str,
turn: usize,
},
ToolCall {
tool: &'a str,
input: &'a serde_json::Value,
turn: usize,
},
ToolResult {
tool: &'a str,
output: &'a str,
is_error: bool,
turn: usize,
},
TurnComplete {
turn: usize,
input_tokens: u64,
output_tokens: u64,
cost_usd: f64,
},
Error {
message: &'a str,
turn: usize,
},
SessionEnd {
turns: usize,
total_cost_usd: f64,
exit_code: u8,
},
}
struct SinkState {
turn: usize,
last_usage: Usage,
}
pub struct JsonStreamSink {
model: String,
inner: Mutex<SinkState>,
}
impl JsonStreamSink {
pub fn new(model: &str) -> Self {
Self {
model: model.to_string(),
inner: Mutex::new(SinkState {
turn: 0,
last_usage: Usage::default(),
}),
}
}
pub fn emit_session_start(&self, session_id: &str) {
let ts = chrono::Utc::now().to_rfc3339();
emit(&Event::SessionStart {
session_id,
model: &self.model,
timestamp: &ts,
});
}
pub fn emit_session_end(&self, total_cost_usd: f64, exit_code: u8) {
let state = self.inner.lock().unwrap();
emit(&Event::SessionEnd {
turns: state.turn,
total_cost_usd,
exit_code,
});
}
}
impl StreamSink for JsonStreamSink {
fn on_turn_start(&self, turn: usize) {
self.inner.lock().unwrap().turn = turn;
}
fn on_text(&self, text: &str) {
let turn = self.inner.lock().unwrap().turn;
emit(&Event::TextDelta {
content: text,
turn,
});
}
fn on_thinking(&self, text: &str) {
let turn = self.inner.lock().unwrap().turn;
emit(&Event::Thinking {
content: text,
turn,
});
}
fn on_tool_start(&self, tool_name: &str, input: &serde_json::Value) {
let state = self.inner.lock().unwrap();
emit(&Event::ToolCall {
tool: tool_name,
input,
turn: state.turn,
});
}
fn on_tool_result(&self, tool_name: &str, result: &agent_code_lib::tools::ToolResult) {
let state = self.inner.lock().unwrap();
emit(&Event::ToolResult {
tool: tool_name,
output: &result.content,
is_error: result.is_error,
turn: state.turn,
});
}
fn on_usage(&self, usage: &Usage) {
let mut state = self.inner.lock().unwrap();
state.last_usage = usage.clone();
}
fn on_turn_complete(&self, turn: usize) {
let mut state = self.inner.lock().unwrap();
state.turn = turn;
let cost = crate::estimate_model_cost(&state.last_usage, &self.model);
emit(&Event::TurnComplete {
turn,
input_tokens: state.last_usage.input_tokens,
output_tokens: state.last_usage.output_tokens,
cost_usd: cost,
});
}
fn on_error(&self, error: &str) {
let turn = self.inner.lock().unwrap().turn;
emit(&Event::Error {
message: error,
turn,
});
}
fn on_warning(&self, msg: &str) {
let _ = writeln!(std::io::stderr(), "{msg}");
}
fn on_compact(&self, freed_tokens: u64) {
let _ = writeln!(std::io::stderr(), "compacted: freed ~{freed_tokens} tokens");
}
}
fn emit(event: &Event<'_>) {
if let Ok(line) = serde_json::to_string(event) {
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{line}");
let _ = lock.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_format_parse() {
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert!("xml".parse::<OutputFormat>().is_err());
}
#[test]
fn event_serialization_text_delta() {
let event = Event::TextDelta {
content: "hello",
turn: 1,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"text_delta""#));
assert!(json.contains(r#""content":"hello""#));
assert!(json.contains(r#""turn":1"#));
}
#[test]
fn event_serialization_session_start() {
let event = Event::SessionStart {
session_id: "abc-123",
model: "test-model",
timestamp: "2026-04-15T00:00:00Z",
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"session_start""#));
assert!(json.contains(r#""session_id":"abc-123""#));
}
#[test]
fn event_serialization_tool_call() {
let input = serde_json::json!({"file_path": "test.rs", "content": "fn main() {}"});
let event = Event::ToolCall {
tool: "FileWrite",
input: &input,
turn: 2,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"tool_call""#));
assert!(json.contains(r#""tool":"FileWrite""#));
assert!(json.contains(r#""file_path":"test.rs""#));
}
#[test]
fn event_serialization_session_end() {
let event = Event::SessionEnd {
turns: 3,
total_cost_usd: 0.042,
exit_code: 0,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"session_end""#));
assert!(json.contains(r#""exit_code":0"#));
}
#[test]
fn event_serialization_turn_complete() {
let event = Event::TurnComplete {
turn: 1,
input_tokens: 1234,
output_tokens: 567,
cost_usd: 0.003,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"turn_complete""#));
assert!(json.contains(r#""input_tokens":1234"#));
}
#[test]
fn event_serialization_error() {
let event = Event::Error {
message: "rate limited",
turn: 1,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains(r#""type":"error""#));
assert!(json.contains(r#""message":"rate limited""#));
}
#[test]
fn all_events_are_single_line_json() {
let events: Vec<serde_json::Value> = vec![
serde_json::to_value(Event::SessionStart {
session_id: "x",
model: "m",
timestamp: "t",
})
.unwrap(),
serde_json::to_value(Event::TextDelta {
content: "multi\nline\ncontent",
turn: 1,
})
.unwrap(),
serde_json::to_value(Event::SessionEnd {
turns: 1,
total_cost_usd: 0.0,
exit_code: 0,
})
.unwrap(),
];
for val in events {
let line = serde_json::to_string(&val).unwrap();
assert!(!line.contains('\n'), "event must be single-line: {line}",);
}
}
}