//! Codex app-server notifications into the common event model. //! //! The CLI promises JSONL but deliberately leaves room for new item kinds. We //! therefore match only the records that have a useful common equivalent and //! ignore the rest; an added Codex item must not make a live session go deaf. use std::collections::HashSet; use serde_json::{Value, json}; use super::super::driver::{Event, SessionStatus}; #[derive(Default)] pub(super) struct Translator { pub(super) thread_id: Option, completed: bool, limited: bool, streamed_messages: HashSet, pending_usage: Option, } impl Translator { pub(super) fn translate(&mut self, line: &Value) -> Vec { let method = line.get("method").and_then(Value::as_str); let body = method.and_then(|_| line.get("params")).unwrap_or(line); let kind = method.or_else(|| line.get("type").and_then(Value::as_str)); match kind { Some("thread.started") => { self.thread_id = line .get("thread_id") .and_then(Value::as_str) .map(str::to_string); Vec::new() } Some("thread/started") => { self.thread_id = body .pointer("/thread/id") .and_then(Value::as_str) .map(str::to_string); Vec::new() } Some("turn.started") | Some("turn/started") => { self.completed = false; self.limited = false; self.pending_usage = None; self.streamed_messages.clear(); vec![Event::Status { state: SessionStatus::Running, }] } Some("item.started") | Some("item/started") => start_item(&body["item"]), Some("item.updated") => update_item(&line["item"]), Some("item.completed") | Some("item/completed") => { complete_item(&body["item"], &self.streamed_messages) } Some("item/agentMessage/delta") => { let Some(delta) = body.get("delta").and_then(Value::as_str) else { return Vec::new(); }; if let Some(id) = body.get("itemId").and_then(Value::as_str) { self.streamed_messages.insert(id.to_string()); } vec![Event::AssistantText { delta: delta.to_string(), }] } Some("item/commandExecution/outputDelta") => { let (Some(id), Some(output)) = ( body.get("itemId").and_then(Value::as_str), body.get("delta").and_then(Value::as_str), ) else { return Vec::new(); }; vec![Event::ToolUpdate { id: id.to_string(), output: output.to_string(), }] } Some("thread/tokenUsage/updated") => { self.pending_usage = body .pointer("/tokenUsage/last/totalTokens") .and_then(Value::as_u64); Vec::new() } Some("turn.completed") | Some("turn/completed") => { self.completed = true; let mut events = Vec::new(); if let Some(tokens) = self.pending_usage.take() { events.push(Event::UsageDelta { tokens, context: None, }); } else if let Some(usage) = line.get("usage") { let input = number(usage, "input_tokens"); let output = number(usage, "output_tokens"); if input.is_some() || output.is_some() { events.push(Event::UsageDelta { tokens: input.unwrap_or(0) + output.unwrap_or(0), // `exec` reports the sum across every model call in // a turn, not the final call's context. context: None, }); } } if let Some(error) = body.pointer("/turn/error") && !error.is_null() { events.extend(self.failure(error)); } events.push(Event::Status { state: SessionStatus::Idle, }); events } Some("turn.failed") | Some("error") => self.failure(body), _ => Vec::new(), } } #[cfg(test)] pub(super) fn completed(&self) -> bool { self.completed } #[cfg(test)] pub(super) fn limited(&self) -> bool { self.limited } fn failure(&mut self, line: &Value) -> Vec { let detail = error_text(line); if is_limit(&detail) { if self.limited { return Vec::new(); } self.limited = true; vec![Event::LimitReached { resets_at: find_reset(line), }] } else { vec![Event::Error { message: detail }] } } } fn start_item(item: &Value) -> Vec { let Some((id, tool, input)) = tool(item) else { return Vec::new(); }; vec![Event::ToolStart { id, tool, input }] } fn update_item(item: &Value) -> Vec { let Some(id) = item.get("id").and_then(Value::as_str) else { return Vec::new(); }; let output = item .get("aggregated_output") .or_else(|| item.get("output")) .and_then(value_text); output .filter(|text| !text.is_empty()) .map(|output| { vec![Event::ToolUpdate { id: id.to_string(), output, }] }) .unwrap_or_default() } fn complete_item(item: &Value, streamed_messages: &HashSet) -> Vec { match item.get("type").and_then(Value::as_str) { Some("agent_message" | "agentMessage") => item .get("text") .and_then(Value::as_str) .filter(|text| !text.is_empty()) .filter(|_| { item.get("id") .and_then(Value::as_str) .is_none_or(|id| !streamed_messages.contains(id)) }) .map(|delta| { vec![Event::AssistantText { delta: delta.to_string(), }] }) .unwrap_or_default(), Some("reasoning" | "userMessage") => Vec::new(), _ => { let Some((id, _, _)) = tool(item) else { return Vec::new(); }; let output = tool_output(item); vec![Event::ToolEnd { id, output }] } } } fn tool(item: &Value) -> Option<(String, String, Value)> { let id = item.get("id")?.as_str()?.to_string(); let kind = item.get("type")?.as_str()?; let (name, input) = match kind { "command_execution" | "commandExecution" => ( "exec_command".to_string(), json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}), ), "file_change" | "fileChange" => ( "apply_patch".to_string(), item.get("changes").cloned().unwrap_or(Value::Null), ), "mcp_tool_call" | "mcpToolCall" => ( item.get("tool") .or_else(|| item.get("name")) .and_then(Value::as_str) .map(|tool| format!("mcp:{tool}")) .unwrap_or_else(|| "mcp".to_string()), item.get("arguments").cloned().unwrap_or(Value::Null), ), "web_search" | "webSearch" => ( "web_search".to_string(), json!({"query": item.get("query").cloned().unwrap_or(Value::Null)}), ), "todo_list" | "todoList" | "plan" => ("update_plan".to_string(), item.clone()), _ => return None, }; Some((id, name, input)) } fn tool_output(item: &Value) -> String { for key in [ "aggregated_output", "aggregatedOutput", "output", "result", "error", ] { if let Some(text) = item.get(key).and_then(value_text) && !text.is_empty() { return text; } } match item.get("status").and_then(Value::as_str) { Some(status) => status.to_string(), None => String::new(), } } fn value_text(value: &Value) -> Option { value .as_str() .map(str::to_string) .or_else(|| (!value.is_null()).then(|| value.to_string())) } fn number(value: &Value, key: &str) -> Option { value.get(key).and_then(Value::as_u64) } fn error_text(line: &Value) -> String { line.get("message") .and_then(Value::as_str) .or_else(|| line.pointer("/error/message").and_then(Value::as_str)) .or_else(|| line.get("error").and_then(Value::as_str)) .unwrap_or("Codex ended the turn with an unknown error") .to_string() } fn is_limit(detail: &str) -> bool { let lower = detail.to_ascii_lowercase(); lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("quota exceeded") || lower.contains("credits depleted") } fn find_reset(value: &Value) -> Option { for key in ["resets_at", "resetsAt", "reset_at", "resetAt"] { if let Some(at) = value.get(key).and_then(Value::as_f64) { return Some(at); } } value.as_object()?.values().find_map(find_reset) } #[cfg(test)] mod tests { use super::*; fn line(text: &str) -> Value { serde_json::from_str(text).expect("fixture") } #[test] fn translates_the_observed_minimal_stream() { let mut translator = Translator::default(); assert!( translator .translate(&line(r#"{"type":"thread.started","thread_id":"thread-1"}"#)) .is_empty() ); assert_eq!(translator.thread_id.as_deref(), Some("thread-1")); assert_eq!( translator.translate(&line( r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}"# )), vec![Event::AssistantText { delta: "hello".to_string() }] ); let events = translator.translate(&line( r#"{"type":"turn.completed","usage":{"input_tokens":13,"cached_input_tokens":8,"output_tokens":5}}"#, )); assert_eq!( events[0], Event::UsageDelta { tokens: 18, context: None } ); assert!(translator.completed()); } #[test] fn translates_tools_and_limits_without_matching_whole_records() { let mut translator = Translator::default(); let started = translator.translate(&line( r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pwd","status":"in_progress"}}"#, )); assert!(matches!(&started[0], Event::ToolStart { tool, .. } if tool == "exec_command")); let ended = translator.translate(&line( r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pwd","aggregated_output":"/tmp\n","exit_code":0,"status":"completed"}}"#, )); assert_eq!( ended, vec![Event::ToolEnd { id: "item_1".to_string(), output: "/tmp\n".to_string() }] ); let limit = translator.translate(&line( r#"{"type":"turn.failed","error":{"message":"usage limit reached","resetsAt":1234}}"#, )); assert_eq!( limit, vec![Event::LimitReached { resets_at: Some(1234.0) }] ); assert!(translator.limited()); } #[test] fn translates_native_app_server_streaming_without_repeating_the_final_item() { let mut translator = Translator::default(); assert_eq!( translator.translate(&line( r#"{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1"}}}"# )), vec![Event::Status { state: SessionStatus::Running }] ); assert_eq!( translator.translate(&line( r#"{"method":"item/agentMessage/delta","params":{"itemId":"message-1","delta":"hello"}}"# )), vec![Event::AssistantText { delta: "hello".to_string() }] ); assert!( translator .translate(&line( r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello"}}}"# )) .is_empty() ); assert!( translator .translate(&line( r#"{"method":"thread/tokenUsage/updated","params":{"tokenUsage":{"last":{"totalTokens":42}}}}"# )) .is_empty() ); assert_eq!( translator.translate(&line( r#"{"method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed","error":null}}}"# )), vec![ Event::UsageDelta { tokens: 42, context: None }, Event::Status { state: SessionStatus::Idle } ] ); } }