1use serde_json::{Map, Value};
18
19use crate::events::run_events_from_parsed;
20use crate::{
21 ParsedLine, ProcessEvent, RunEvent, SessionInfo, ToolCallEnd, ToolCallStart, ToolKind,
22 UsageInfo,
23};
24
25fn codex_tool_kind(item: &Map<String, Value>) -> Option<&'static str> {
32 match item.get("type").and_then(Value::as_str)? {
33 "command_execution" => Some("command_execution"),
34 "file_change" => Some("file_change"),
35 "web_search" => Some("web_search"),
36 "mcp_tool_call" => Some("mcp_tool_call"),
37 _ => None,
38 }
39}
40
41fn codex_tool_kind_class(identifier: &str) -> ToolKind {
45 match identifier {
46 "file_change" => ToolKind::Edit,
47 "command_execution" => ToolKind::Execute,
48 "web_search" => ToolKind::Search,
49 _ => ToolKind::Other, }
51}
52
53fn codex_tool_label(item: &Map<String, Value>) -> Option<String> {
59 Some(
60 match item.get("type").and_then(Value::as_str)? {
61 "command_execution" => "Running a command",
62 "file_change" => "Editing files",
63 "web_search" => "Searching the web",
64 "mcp_tool_call" => "Running a tool",
65 _ => return None,
66 }
67 .to_owned(),
68 )
69}
70
71fn codex_tool_input(item: &Map<String, Value>) -> Option<String> {
76 if item.get("type").and_then(Value::as_str) == Some("command_execution") {
77 return item
78 .get("command")
79 .and_then(Value::as_str)
80 .filter(|s| !s.is_empty())
81 .map(str::to_owned);
82 }
83 None
84}
85
86fn codex_tool_output(item: &Map<String, Value>) -> Option<String> {
89 if item.get("type").and_then(Value::as_str) == Some("command_execution") {
90 return item
91 .get("aggregated_output")
92 .and_then(Value::as_str)
93 .filter(|s| !s.is_empty())
94 .map(str::to_owned);
95 }
96 None
97}
98
99fn codex_tool_ok(item: &Map<String, Value>) -> bool {
104 if let Some(code) = item.get("exit_code").and_then(Value::as_i64) {
105 return code == 0;
106 }
107 !matches!(
108 item.get("status").and_then(Value::as_str),
109 Some("failed") | Some("error")
110 )
111}
112
113#[derive(Debug, Default)]
131pub struct CodexStreamParser {
132 pending_message: Option<String>,
135}
136
137impl CodexStreamParser {
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn on_process_event(&mut self, event: ProcessEvent) -> Vec<RunEvent> {
145 match event {
146 ProcessEvent::Stderr { .. } => Vec::new(),
149 ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
150 ProcessEvent::Error { run_id, message } => {
151 let mut out = self.take_pending_as_answer(&run_id);
153 out.push(RunEvent::Error { run_id, message });
154 out
155 }
156 ProcessEvent::Exited {
157 run_id,
158 exit_code,
159 cancelled,
160 } => {
161 let mut out = self.take_pending_as_answer(&run_id);
165 out.push(RunEvent::Exited {
166 run_id,
167 exit_code,
168 cancelled,
169 });
170 out
171 }
172 ProcessEvent::Stdout { run_id, line } => self.on_stdout(&run_id, &line),
173 _ => Vec::new(),
175 }
176 }
177
178 fn on_stdout(&mut self, run_id: &str, line: &str) -> Vec<RunEvent> {
179 let value = serde_json::from_str::<Value>(line.trim()).ok();
180 let typ = value
181 .as_ref()
182 .and_then(Value::as_object)
183 .and_then(|o| o.get("type"))
184 .and_then(Value::as_str);
185
186 if let Some(text) = value.as_ref().and_then(codex_agent_message_text) {
189 let out = self.take_pending_as_preamble(run_id);
190 if !text.is_empty() {
191 self.pending_message = Some(text);
192 }
193 return out;
194 }
195
196 let mut out = if typ == Some("turn.completed") {
199 self.take_pending_as_answer(run_id)
200 } else {
201 self.take_pending_as_preamble(run_id)
202 };
203 out.extend(run_events_from_parsed(run_id, parse_codex_line(line)));
206 out
207 }
208
209 fn take_pending_as_preamble(&mut self, run_id: &str) -> Vec<RunEvent> {
211 match self.pending_message.take() {
212 Some(text) if !text.is_empty() => vec![RunEvent::Activity {
213 run_id: run_id.to_owned(),
214 message: text,
215 }],
216 _ => Vec::new(),
217 }
218 }
219
220 fn take_pending_as_answer(&mut self, run_id: &str) -> Vec<RunEvent> {
222 match self.pending_message.take() {
223 Some(text) if !text.is_empty() => vec![RunEvent::Text {
224 run_id: run_id.to_owned(),
225 delta: text,
226 }],
227 _ => Vec::new(),
228 }
229 }
230}
231
232fn codex_agent_message_text(value: &Value) -> Option<String> {
236 let obj = value.as_object()?;
237 if obj.get("type").and_then(Value::as_str) != Some("item.completed") {
238 return None;
239 }
240 let item = obj.get("item").and_then(Value::as_object)?;
241 if item.get("type").and_then(Value::as_str) != Some("agent_message") {
242 return None;
243 }
244 Some(
245 item.get("text")
246 .and_then(Value::as_str)
247 .unwrap_or_default()
248 .to_owned(),
249 )
250}
251
252pub fn parse_codex_line(line: &str) -> ParsedLine {
260 let trimmed = line.trim();
261 if trimmed.is_empty() {
262 return ParsedLine::default();
263 }
264 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
265 return ParsedLine::default();
266 };
267 let Some(obj) = value.as_object() else {
268 return ParsedLine::default();
269 };
270
271 match obj.get("type").and_then(Value::as_str) {
272 Some("item.completed") => {
273 let Some(item) = obj.get("item").and_then(Value::as_object) else {
274 return ParsedLine::default();
275 };
276 if item.get("type").and_then(Value::as_str) == Some("agent_message") {
278 if let Some(text) = item.get("text").and_then(Value::as_str) {
279 if !text.is_empty() {
280 return ParsedLine {
281 text: Some(text.to_owned()),
282 ..ParsedLine::default()
283 };
284 }
285 }
286 }
287 if let Some(kind) = codex_tool_kind(item) {
297 return match item.get("id").and_then(Value::as_str) {
298 Some(id) => {
299 let id = id.to_owned();
300 ParsedLine {
301 tool_start: Some(ToolCallStart {
302 tool_call_id: id.clone(),
303 name: kind.to_owned(),
304 input: codex_tool_input(item),
305 tool_kind: codex_tool_kind_class(kind),
306 }),
307 tool_end: Some(ToolCallEnd {
308 tool_call_id: id,
309 ok: codex_tool_ok(item),
310 output: codex_tool_output(item),
311 }),
312 ..ParsedLine::default()
313 }
314 }
315 None => ParsedLine {
316 activity: codex_tool_label(item),
317 ..ParsedLine::default()
318 },
319 };
320 }
321 ParsedLine::default()
322 }
323 Some("item.started") => {
324 let Some(item) = obj.get("item").and_then(Value::as_object) else {
325 return ParsedLine::default();
326 };
327 if let Some(kind) = codex_tool_kind(item) {
332 return match item.get("id").and_then(Value::as_str) {
333 Some(id) => ParsedLine {
334 tool_start: Some(ToolCallStart {
335 tool_call_id: id.to_owned(),
336 name: kind.to_owned(),
337 input: codex_tool_input(item),
338 tool_kind: codex_tool_kind_class(kind),
339 }),
340 ..ParsedLine::default()
341 },
342 None => ParsedLine {
343 activity: codex_tool_label(item),
344 ..ParsedLine::default()
345 },
346 };
347 }
348 ParsedLine::default()
349 }
350 Some("error") => {
355 let message = obj
356 .get("message")
357 .and_then(Value::as_str)
358 .filter(|s| !s.is_empty())
359 .unwrap_or("Codex error");
360 ParsedLine {
361 error: Some(truncate(message, 240)),
362 ..ParsedLine::default()
363 }
364 }
365 Some("thread.started") => ParsedLine {
368 session: Some(SessionInfo {
369 session_id: obj
370 .get("thread_id")
371 .and_then(Value::as_str)
372 .filter(|s| !s.is_empty())
373 .map(str::to_owned),
374 model: None,
375 }),
376 ..ParsedLine::default()
377 },
378 Some("turn.completed") => {
380 let usage = obj.get("usage").and_then(Value::as_object);
381 let input_tokens = usage.and_then(|u| u.get("input_tokens")).and_then(Value::as_u64);
382 let output_tokens = usage.and_then(|u| u.get("output_tokens")).and_then(Value::as_u64);
383 if input_tokens.is_none() && output_tokens.is_none() {
384 return ParsedLine::default();
385 }
386 let total_tokens = match (input_tokens, output_tokens) {
387 (Some(i), Some(o)) => Some(i + o),
388 _ => None,
389 };
390 ParsedLine {
391 usage: Some(UsageInfo {
392 input_tokens,
393 output_tokens,
394 total_tokens,
395 }),
396 ..ParsedLine::default()
397 }
398 }
399 Some("turn.failed") => {
404 let message = obj
405 .get("error")
406 .and_then(Value::as_object)
407 .and_then(|e| e.get("message"))
408 .and_then(Value::as_str)
409 .filter(|s| !s.is_empty())
410 .unwrap_or("Codex turn failed");
411 ParsedLine {
412 error: Some(truncate(message, 240)),
413 ..ParsedLine::default()
414 }
415 }
416 _ => ParsedLine::default(),
418 }
419}
420
421fn truncate(s: &str, max_chars: usize) -> String {
422 s.chars().take(max_chars).collect()
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn agent_message_completed_becomes_text() {
431 let line = serde_json::json!({
432 "type": "item.completed",
433 "item": { "id": "item_3", "type": "agent_message", "text": "Repo has docs and sdk." }
434 })
435 .to_string();
436 let parsed = parse_codex_line(&line);
437 assert_eq!(parsed.text.as_deref(), Some("Repo has docs and sdk."));
438 assert!(parsed.edits.is_empty());
439 assert!(parsed.activity.is_none());
440 }
441
442 #[test]
452 fn command_execution_completed_becomes_finished_tool_card() {
453 let line = serde_json::json!({
454 "type": "item.completed",
455 "item": {
456 "id": "item_2",
457 "type": "command_execution",
458 "command": "bash -lc 'echo hi'",
459 "aggregated_output": "hi\n",
460 "exit_code": 0,
461 "status": "completed"
462 }
463 })
464 .to_string();
465 let parsed = parse_codex_line(&line);
466 let start = parsed.tool_start.expect("tool_start");
467 let end = parsed.tool_end.expect("tool_end");
468 assert_eq!(start.tool_call_id, "item_2");
469 assert_eq!(end.tool_call_id, "item_2");
470 assert_eq!(start.name, "command_execution");
474 assert_eq!(start.input.as_deref(), Some("bash -lc 'echo hi'"));
475 assert_eq!(end.output.as_deref(), Some("hi\n"));
476 assert!(end.ok, "exit_code 0 → ok");
477 assert!(parsed.activity.is_none());
478 assert!(parsed.text.is_none());
479 }
480
481 #[test]
482 fn command_execution_nonzero_exit_is_error_card() {
483 let line = r#"{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"bash -lc false","aggregated_output":"","exit_code":1,"status":"failed"}}"#;
484 let end = parse_codex_line(line).tool_end.expect("tool_end");
485 assert!(!end.ok, "exit_code 1 / status failed → error");
486 }
487
488 #[test]
489 fn web_search_completed_becomes_tool_card() {
490 let line = serde_json::json!({
491 "type": "item.completed",
492 "item": { "id": "item_5", "type": "web_search", "status": "completed" }
493 })
494 .to_string();
495 let parsed = parse_codex_line(&line);
496 assert_eq!(parsed.tool_start.expect("start").name, "web_search");
497 assert!(parsed.tool_end.expect("end").ok, "no exit_code, status completed → ok");
498 }
499
500 #[test]
501 fn started_tool_with_id_becomes_running_card() {
502 let line = serde_json::json!({
503 "type": "item.started",
504 "item": { "id": "item_1", "type": "command_execution", "command": "bash -lc ls", "status": "in_progress" }
505 })
506 .to_string();
507 let parsed = parse_codex_line(&line);
508 let start = parsed.tool_start.expect("start");
509 assert_eq!(start.name, "command_execution");
510 assert_eq!(start.input.as_deref(), Some("bash -lc ls")); assert!(parsed.tool_end.is_none(), "started → running (no end yet)");
512 assert!(parsed.activity.is_none());
513 }
514
515 #[test]
516 fn tool_without_id_degrades_to_activity() {
517 let line = serde_json::json!({
520 "type": "item.completed",
521 "item": { "type": "command_execution", "command": "ls -la", "exit_code": 0 }
522 })
523 .to_string();
524 let parsed = parse_codex_line(&line);
525 assert_eq!(parsed.activity.as_deref(), Some("Running a command"));
528 assert!(parsed.tool_start.is_none() && parsed.tool_end.is_none());
529 }
530
531 #[test]
532 fn thread_started_yields_session_and_turn_completed_yields_usage() {
533 let session = parse_codex_line(r#"{"type":"thread.started","thread_id":"abc"}"#)
535 .session
536 .expect("session");
537 assert_eq!(session.session_id.as_deref(), Some("abc"));
538 assert_eq!(session.model, None);
539
540 let usage =
542 parse_codex_line(r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":40}}"#)
543 .usage
544 .expect("usage");
545 assert_eq!(usage.input_tokens, Some(100));
546 assert_eq!(usage.output_tokens, Some(40));
547 assert_eq!(usage.total_tokens, Some(140));
548
549 assert!(parse_codex_line(r#"{"type":"turn.started"}"#).is_empty());
551 }
552
553 #[test]
554 fn error_event_becomes_error_not_activity() {
555 let line = r#"{"type":"error","message":"rate limited"}"#;
558 let parsed = parse_codex_line(line);
559 assert_eq!(parsed.error.as_deref(), Some("rate limited"));
560 assert!(parsed.activity.is_none());
561 }
562
563 #[test]
564 fn turn_failed_becomes_error() {
565 let line = r#"{"type":"turn.failed","error":{"message":"context window exceeded"}}"#;
569 let parsed = parse_codex_line(line);
570 assert_eq!(parsed.error.as_deref(), Some("context window exceeded"));
571 assert!(parsed.activity.is_none() && parsed.text.is_none());
572
573 let bare = parse_codex_line(r#"{"type":"turn.failed"}"#);
576 assert_eq!(bare.error.as_deref(), Some("Codex turn failed"));
577 }
578
579 #[test]
580 fn non_json_is_ignored() {
581 assert!(parse_codex_line("plain text").text.is_none());
582 }
583
584 fn stdout(p: &mut CodexStreamParser, line: &str) -> Vec<RunEvent> {
587 p.on_process_event(ProcessEvent::Stdout {
588 run_id: "r".to_owned(),
589 line: line.to_owned(),
590 })
591 }
592
593 #[test]
594 fn codex_preambles_are_narration_and_only_final_message_is_the_answer() {
595 let mut p = CodexStreamParser::new();
599 let mut events = Vec::new();
600 for line in [
601 r#"{"type":"thread.started","thread_id":"t"}"#,
602 r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"I’m going to read a.txt first."}}"#,
603 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cat a.txt","aggregated_output":"alpha\n","exit_code":0,"status":"completed"}}"#,
604 r#"{"type":"item.completed","item":{"id":"m2","type":"agent_message","text":"I’m going to read b.txt next."}}"#,
605 r#"{"type":"item.completed","item":{"id":"c2","type":"command_execution","command":"cat b.txt","aggregated_output":"one\n","exit_code":0,"status":"completed"}}"#,
606 r#"{"type":"item.completed","item":{"id":"m3","type":"agent_message","text":"a.txt has more lines."}}"#,
607 r#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"#,
608 ] {
609 events.extend(stdout(&mut p, line));
610 }
611
612 let texts: Vec<&str> = events
614 .iter()
615 .filter_map(|e| match e {
616 RunEvent::Text { delta, .. } => Some(delta.as_str()),
617 _ => None,
618 })
619 .collect();
620 assert_eq!(texts, vec!["a.txt has more lines."]);
621
622 let activity: Vec<&str> = events
624 .iter()
625 .filter_map(|e| match e {
626 RunEvent::Activity { message, .. } => Some(message.as_str()),
627 _ => None,
628 })
629 .collect();
630 assert_eq!(
631 activity,
632 vec![
633 "I’m going to read a.txt first.",
634 "I’m going to read b.txt next."
635 ]
636 );
637
638 assert_eq!(
640 events
641 .iter()
642 .filter(|e| matches!(e, RunEvent::ToolStart { .. }))
643 .count(),
644 2
645 );
646 assert!(events.iter().any(|e| matches!(e, RunEvent::Session { .. })));
647 assert!(events.iter().any(|e| matches!(e, RunEvent::Usage { .. })));
648 }
649
650 #[test]
651 fn codex_single_message_turn_is_the_answer() {
652 let mut p = CodexStreamParser::new();
654 let mut events = Vec::new();
655 events.extend(stdout(
656 &mut p,
657 r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"Done."}}"#,
658 ));
659 events.extend(stdout(
660 &mut p,
661 r#"{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#,
662 ));
663 let texts: Vec<&str> = events
664 .iter()
665 .filter_map(|e| match e {
666 RunEvent::Text { delta, .. } => Some(delta.as_str()),
667 _ => None,
668 })
669 .collect();
670 assert_eq!(texts, vec!["Done."]);
671 assert!(!events.iter().any(|e| matches!(e, RunEvent::Activity { .. })));
672 }
673
674 #[test]
675 fn codex_stderr_is_dropped_as_noise() {
676 let mut p = CodexStreamParser::new();
677 let out = p.on_process_event(ProcessEvent::Stderr {
678 run_id: "r".to_owned(),
679 line: "2026-05-31T05:20:28Z ERROR codex_core::memories::phase2::job: failed to claim job"
680 .to_owned(),
681 });
682 assert!(out.is_empty(), "codex stderr is tracing noise → dropped, got {out:?}");
683 }
684
685 #[test]
686 fn codex_turn_failed_surfaces_as_error_through_stream_parser() {
687 let mut p = CodexStreamParser::new();
691 let out = stdout(
692 &mut p,
693 r#"{"type":"turn.failed","error":{"message":"quota exceeded"}}"#,
694 );
695 assert!(
696 out.iter().any(
697 |e| matches!(e, RunEvent::Error { message, .. } if message == "quota exceeded")
698 ),
699 "turn.failed must surface as RunEvent::Error, got {out:?}"
700 );
701 }
702
703 #[test]
704 fn codex_error_line_surfaces_as_error_through_stream_parser() {
705 let mut p = CodexStreamParser::new();
707 let out = stdout(&mut p, r#"{"type":"error","message":"rate limited"}"#);
708 assert!(
709 out.iter().any(
710 |e| matches!(e, RunEvent::Error { message, .. } if message == "rate limited")
711 ),
712 "error line must surface as RunEvent::Error, got {out:?}"
713 );
714 assert!(!out.iter().any(|e| matches!(e, RunEvent::Activity { .. })));
716 }
717
718 #[test]
719 fn codex_held_answer_is_flushed_if_stream_ends_without_turn_completed() {
720 let mut p = CodexStreamParser::new();
723 let _ = stdout(
724 &mut p,
725 r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"Final."}}"#,
726 );
727 let out = p.on_process_event(ProcessEvent::Exited {
728 run_id: "r".to_owned(),
729 exit_code: Some(0),
730 cancelled: false,
731 });
732 assert!(
733 matches!(out.first(), Some(RunEvent::Text { delta, .. }) if delta == "Final."),
734 "held answer flushed as Text before Exited, got {out:?}"
735 );
736 assert!(matches!(out.last(), Some(RunEvent::Exited { .. })));
737 }
738}