#[derive(Debug, Default)]
pub(crate) struct CodexStreamSummary {
pub result_text: String,
pub thread_id: Option<String>,
pub tool_names: Vec<String>,
pub tokens: Option<(u64, u64)>,
pub failed: Option<String>,
}
pub(crate) fn parse_stream(stdout: &str) -> CodexStreamSummary {
let mut summary = CodexStreamSummary::default();
for raw in stdout.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
tracing::debug!(
"codex-json: skipping malformed line ({} bytes): {}",
line.len(),
e
);
continue;
}
};
match value.get("type").and_then(|v| v.as_str()) {
Some("thread.started") => {
if let Some(id) = value.get("thread_id").and_then(|v| v.as_str()) {
summary.thread_id = Some(id.to_string());
}
}
Some("item.completed") => {
let Some(item) = value.get("item") else {
continue;
};
match item.get("type").and_then(|v| v.as_str()) {
Some("agent_message") => {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
if !summary.result_text.is_empty() {
summary.result_text.push('\n');
}
summary.result_text.push_str(text);
}
}
Some("reasoning") | None => {}
Some(other) => summary.tool_names.push(other.to_string()),
}
}
Some("turn.completed") => {
if let Some(usage) = value.get("usage") {
let input = usage
.get("input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output = usage
.get("output_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
summary.tokens = Some((input, output));
}
}
Some("turn.failed") | Some("error") => {
let message = value
.get("error")
.and_then(|e| e.get("message"))
.or_else(|| value.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("codex turn failed")
.to_string();
summary.failed = Some(message);
}
_ => {} }
}
summary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_text_thread_id_and_usage() {
let stdout = r#"{"type":"thread.started","thread_id":"abc-123"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"ok"}}
{"type":"turn.completed","usage":{"input_tokens":13648,"cached_input_tokens":4992,"output_tokens":17}}
"#;
let s = parse_stream(stdout);
assert_eq!(s.result_text, "ok");
assert_eq!(s.thread_id.as_deref(), Some("abc-123"));
assert_eq!(s.tokens, Some((13648, 17)));
assert!(s.failed.is_none());
}
#[test]
fn collects_non_message_items_as_tool_names() {
let stdout = r#"{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"ls"}}
{"type":"item.completed","item":{"id":"i2","type":"reasoning"}}
{"type":"item.completed","item":{"id":"i3","type":"agent_message","text":"done"}}
"#;
let s = parse_stream(stdout);
assert_eq!(s.tool_names, vec!["command_execution"]);
assert_eq!(s.result_text, "done");
}
#[test]
fn captures_turn_failure() {
let stdout = r#"{"type":"thread.started","thread_id":"t1"}
{"type":"turn.failed","error":{"message":"model not supported"}}
"#;
let s = parse_stream(stdout);
assert_eq!(s.failed.as_deref(), Some("model not supported"));
assert_eq!(s.thread_id.as_deref(), Some("t1"));
}
#[test]
fn skips_malformed_lines_without_aborting() {
let stdout = "{\"type\":\"thread.started\",\"thread_id\":\"t2\"}\nnot json at all\n{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"fine\"}}\n";
let s = parse_stream(stdout);
assert_eq!(s.thread_id.as_deref(), Some("t2"));
assert_eq!(s.result_text, "fine");
}
#[test]
fn handles_empty_stdout() {
let s = parse_stream("");
assert_eq!(s.result_text, "");
assert!(s.thread_id.is_none());
assert!(s.tokens.is_none());
}
}