use std::collections::HashSet;
use serde_json::Value;
#[derive(Default)]
pub(super) struct EventLog {
started_commands: HashSet<String>,
streamed_items: HashSet<String>,
line_open: bool,
}
impl EventLog {
pub(super) fn start_turn(&mut self) {
self.started_commands.clear();
self.streamed_items.clear();
}
pub(super) fn event(&mut self, method: &str, params: &Value) -> String {
let mut text = String::new();
let delta = method.ends_with("/outputDelta");
match method {
"item/started" if params["item"]["type"] == "commandExecution" => {
let item = ¶ms["item"];
if item["id"]
.as_str()
.is_none_or(|id| self.started_commands.insert(id.to_string()))
{
command_header(&mut text, item);
}
}
"item/completed" => {
let item = ¶ms["item"];
let id = item["id"].as_str().unwrap_or_default();
let started = self.started_commands.remove(id);
let streamed = self.streamed_items.remove(id);
completed_item(&mut text, item, started, streamed);
}
"error" => {
line(&mut text, "Codex error");
line(&mut text, error_message(¶ms["error"]));
}
_ if delta => {
if let Some(output) = params["delta"].as_str().filter(|s| !s.is_empty()) {
if let Some(id) = params["itemId"].as_str() {
self.streamed_items.insert(id.to_string());
}
text.push_str(output);
}
}
_ => {}
}
if !text.is_empty() {
if self.line_open && !delta {
text.insert(0, '\n');
}
self.line_open = !text.ends_with('\n');
}
text
}
}
fn line(output: &mut String, text: &str) {
if !text.is_empty() {
output.push_str(text);
if !text.ends_with('\n') {
output.push('\n');
}
}
}
fn command_header(output: &mut String, item: &Value) {
line(output, "exec");
if let Some(cwd) = item["cwd"].as_str() {
line(output, &format!("in {cwd}"));
}
line(output, item["command"].as_str().unwrap_or_default());
}
fn status(output: &mut String, label: &str, item: &Value) {
let state = if item["success"] == false {
"failed"
} else {
item["status"].as_str().unwrap_or("completed")
};
let mut text = format!("{label}: {state}");
if let Some(code) = item["exitCode"].as_i64() {
text.push_str(&format!(" (exit code {code})"));
}
if let Some(ms) = item["durationMs"].as_u64() {
text.push_str(&format!(" in {:.2}s", ms as f64 / 1000.0));
}
line(output, &text);
}
fn error_message(error: &Value) -> &str {
error["message"]
.as_str()
.or_else(|| error.as_str())
.unwrap_or_default()
}
fn content_text(output: &mut String, content: &Value) {
if let Some(text) = content.as_str() {
line(output, text);
} else if let Some(parts) = content.as_array() {
for part in parts {
line(output, part["text"].as_str().unwrap_or_default());
}
}
}
fn completed_item(output: &mut String, item: &Value, started: bool, streamed: bool) {
let kind = item["type"].as_str().unwrap_or("item");
match kind {
"agentMessage" | "plan" => {
if let Some(text) = item["text"].as_str().filter(|s| !s.is_empty()) {
line(
output,
if kind == "agentMessage" {
"codex"
} else {
"plan"
},
);
line(output, text);
}
}
"reasoning" => {
let mut text = String::new();
for field in ["summary", "content"] {
if let Some(parts) = item[field].as_array() {
for part in parts {
line(&mut text, part.as_str().unwrap_or_default());
}
}
if !text.is_empty() {
break;
}
}
if !text.is_empty() {
line(output, "thinking");
line(output, &text);
}
}
"commandExecution" => {
if !started {
command_header(output, item);
}
if !streamed {
line(
output,
item["aggregatedOutput"].as_str().unwrap_or_default(),
);
}
status(output, "exec", item);
}
"fileChange" => {
status(output, "patch", item);
if let Some(changes) = item["changes"].as_array() {
for change in changes {
let path = change["path"].as_str().unwrap_or("unknown path");
let kind = change["kind"]["type"].as_str().unwrap_or("update");
line(output, &format!("{kind} {path}"));
line(output, change["diff"].as_str().unwrap_or_default());
}
}
}
"mcpToolCall" | "dynamicToolCall" => {
let tool = item["tool"].as_str().unwrap_or("tool");
let server = item["server"]
.as_str()
.or_else(|| item["namespace"].as_str());
let label =
server.map_or_else(|| tool.to_string(), |server| format!("{server}.{tool}"));
status(output, &label, item);
content_text(output, &item["result"]["content"]);
content_text(output, &item["contentItems"]);
line(output, error_message(&item["error"]));
}
"functionCallOutput" => {
line(output, item["name"].as_str().unwrap_or("tool"));
content_text(output, &item["output"]);
}
"userMessage" => {
line(output, "user");
content_text(output, &item["content"]);
}
"webSearch" => {
line(output, "web search");
line(output, item["query"].as_str().unwrap_or_default());
}
"contextCompaction" => line(output, "Context compacted"),
_ => status(output, kind, item),
}
}
#[cfg(test)]
mod tests;