Skip to main content

atomr_agents_coding_cli_vendor_codex/
parser.rs

1//! Codex stdout is plain text interspersed with optional JSON
2//! envelopes. The parser is intentionally lenient: anything that
3//! parses as JSON with a known `type` is normalized; everything else
4//! is forwarded as an `AssistantTextDelta`. Unknown JSON envelopes are
5//! forwarded as `RawVendorEvent` so we never silently drop data.
6
7use atomr_agents_coding_cli_core::{
8    CliEventParser, CliVendorKind, CodingCliEvent, FinishReason, ParseError,
9};
10use serde_json::Value;
11
12#[derive(Default)]
13pub struct CodexParser;
14
15impl CodexParser {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl CliEventParser for CodexParser {
22    fn parse_line(&mut self, line: &str) -> Result<Vec<CodingCliEvent>, ParseError> {
23        let trimmed = line.trim_end_matches('\n');
24        if trimmed.is_empty() {
25            return Ok(Vec::new());
26        }
27        if let Some(stripped) = strip_braces(trimmed) {
28            if let Ok(v) = serde_json::from_str::<Value>(stripped) {
29                return Ok(normalize(&v));
30            }
31        }
32        // Plain text — emit as a delta.
33        Ok(vec![CodingCliEvent::AssistantTextDelta {
34            text: format!("{}\n", trimmed),
35        }])
36    }
37
38    fn flush(&mut self) -> Result<Vec<CodingCliEvent>, ParseError> {
39        Ok(Vec::new())
40    }
41}
42
43fn strip_braces(s: &str) -> Option<&str> {
44    let s = s.trim();
45    if (s.starts_with('{') && s.ends_with('}')) || (s.starts_with('[') && s.ends_with(']')) {
46        Some(s)
47    } else {
48        None
49    }
50}
51
52fn normalize(v: &Value) -> Vec<CodingCliEvent> {
53    match v.get("type").and_then(Value::as_str).unwrap_or("") {
54        "assistant" | "message" => {
55            if let Some(text) = v
56                .get("content")
57                .and_then(Value::as_str)
58                .or_else(|| v.get("text").and_then(Value::as_str))
59            {
60                return vec![CodingCliEvent::AssistantTextDelta {
61                    text: text.to_string(),
62                }];
63            }
64            vec![CodingCliEvent::RawVendorEvent {
65                vendor: CliVendorKind::Codex,
66                payload: v.clone(),
67            }]
68        }
69        "tool_call" => {
70            let id = v.get("id").and_then(Value::as_str).unwrap_or("").to_string();
71            let name = v.get("name").and_then(Value::as_str).unwrap_or("").to_string();
72            let input = v.get("arguments").or_else(|| v.get("input")).cloned().unwrap_or(Value::Null);
73            vec![CodingCliEvent::ToolCallStarted {
74                tool_call_id: id,
75                name,
76                input,
77            }]
78        }
79        "tool_result" => {
80            let id = v
81                .get("tool_call_id")
82                .or_else(|| v.get("id"))
83                .and_then(Value::as_str)
84                .unwrap_or("")
85                .to_string();
86            let output = v.get("output").or_else(|| v.get("content")).cloned();
87            let error = v.get("error").and_then(Value::as_str).map(|s| s.to_string());
88            vec![CodingCliEvent::ToolCallFinished {
89                tool_call_id: id,
90                output,
91                error,
92            }]
93        }
94        "usage" => {
95            let input_tokens = v.get("input_tokens").and_then(Value::as_u64).unwrap_or(0);
96            let output_tokens = v.get("output_tokens").and_then(Value::as_u64).unwrap_or(0);
97            let cost_usd = v.get("cost_usd").and_then(Value::as_f64);
98            vec![CodingCliEvent::Usage {
99                input_tokens,
100                output_tokens,
101                cost_usd,
102            }]
103        }
104        "done" | "result" => {
105            let text = v
106                .get("result")
107                .and_then(Value::as_str)
108                .map(|s| s.to_string());
109            vec![CodingCliEvent::RunFinished {
110                reason: FinishReason::Completed,
111                result_text: text,
112            }]
113        }
114        _ => vec![CodingCliEvent::RawVendorEvent {
115            vendor: CliVendorKind::Codex,
116            payload: v.clone(),
117        }],
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn plain_text_becomes_assistant_delta() {
127        let mut p = CodexParser::new();
128        let ev = p.parse_line("Hello world").unwrap();
129        assert!(matches!(&ev[0], CodingCliEvent::AssistantTextDelta { text } if text.contains("Hello world")));
130    }
131
132    #[test]
133    fn json_assistant_envelope() {
134        let mut p = CodexParser::new();
135        let ev = p.parse_line(r#"{"type":"assistant","content":"hi"}"#).unwrap();
136        assert!(matches!(&ev[0], CodingCliEvent::AssistantTextDelta { text } if text == "hi"));
137    }
138
139    #[test]
140    fn tool_call_and_result() {
141        let mut p = CodexParser::new();
142        let a = p.parse_line(r#"{"type":"tool_call","id":"c1","name":"read","arguments":{"path":"a.rs"}}"#).unwrap();
143        let b = p.parse_line(r#"{"type":"tool_result","tool_call_id":"c1","output":"file"}"#).unwrap();
144        assert!(matches!(&a[0], CodingCliEvent::ToolCallStarted { name, .. } if name == "read"));
145        assert!(matches!(&b[0], CodingCliEvent::ToolCallFinished { error: None, .. }));
146    }
147
148    #[test]
149    fn done_envelope() {
150        let mut p = CodexParser::new();
151        let ev = p.parse_line(r#"{"type":"done","result":"finished"}"#).unwrap();
152        assert!(matches!(
153            &ev[0],
154            CodingCliEvent::RunFinished { reason: FinishReason::Completed, result_text: Some(t) } if t == "finished"
155        ));
156    }
157
158    #[test]
159    fn empty_line() {
160        let mut p = CodexParser::new();
161        assert!(p.parse_line("").unwrap().is_empty());
162    }
163}