atomr_agents_coding_cli_vendor_codex/
parser.rs1use 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 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}