Skip to main content

harness/codex/
parser.rs

1//! Codex's `codex exec --json` parser — its JSONL wire format → the neutral
2//! [`crate::RunEvent`] vocabulary.
3//!
4//! Two layers: the stateless [`parse_codex_line`] decodes one line into a
5//! [`ParsedLine`] (tool cards, session, usage), and the stateful
6//! [`CodexStreamParser`] wraps it per-run to resolve codex's
7//! preamble-vs-answer ambiguity and drop its stderr noise.
8//!
9//! Wire format reference (verified against the official docs,
10//! <https://developers.openai.com/codex/noninteractive>): `--json` emits one
11//! JSON object per line. The assistant's reply is an `item.completed` event
12//! whose `item.type == "agent_message"` with the full text in `item.text` —
13//! Codex sends the whole message at once, not token deltas. Command
14//! executions arrive as `command_execution` items; `thread.started` carries
15//! the session (thread id) and `turn.completed` the token usage.
16
17use 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
25/// A stable tool *identifier* for a codex item — the card's `name`, which the
26/// consumer humanizes (see Compose's `toolLabels`). Returning an identifier
27/// here rather than a display phrase keeps codex consistent with the other
28/// adapters (bob's `read_file`, …) and keeps the raw command — which lives in
29/// `input` — out of the `name`, so a live status never echoes a shell command.
30/// `None` for non-tool items.
31fn 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
41/// The neutral [`ToolKind`] for a codex tool identifier (the `name` produced
42/// by [`codex_tool_kind`]): codex edits files via `file_change`, runs shells
43/// via `command_execution`, searches via `web_search`.
44fn 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, // mcp_tool_call + any future identifier
50    }
51}
52
53/// A human-readable fallback label for a codex tool item, used only when it
54/// carries no `id` to key a card (rare — codex 0.125.0 always sends one). The
55/// normal path emits [`codex_tool_kind`] as the `name` and lets the consumer
56/// phrase it; this stays command-free so even the fallback can't leak a shell
57/// command into the status line.
58fn 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
71/// The tool call's input, lifted inline. Only `command_execution`
72/// carries a literal we can ground against (`command`); other item
73/// types stream/structure their args differently, so leave them `None`
74/// rather than guess.
75fn 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
86/// The tool call's output, lifted inline. `command_execution` reports
87/// `aggregated_output`; other item types are left `None`.
88fn 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
99/// Did a codex tool item succeed? `command_execution` reports
100/// `exit_code` (0 = ok); otherwise fall back to `status` (anything but
101/// an explicit failure is treated as ok, since not every tool type
102/// carries an exit code).
103fn 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/// Stateful per-run wrapper over [`parse_codex_line`] that resolves codex's
114/// preamble-vs-answer ambiguity and drops its stderr noise. One per run.
115///
116/// Codex emits several *complete* `agent_message` items in a turn: short
117/// preambles before tool calls ("I'll read the file first") and a final
118/// answer. Nothing on the item distinguishes them — the only signal is
119/// position: the last `agent_message` before `turn.completed` is the answer;
120/// every earlier one is a preamble. So we hold the latest `agent_message`
121/// and classify it by what follows — another item means it was a preamble
122/// (→ [`RunEvent::Activity`], transient narration), `turn.completed` (or
123/// stream end) means it was the answer (→ [`RunEvent::Text`]). Without this
124/// the preambles concatenate onto the answer in the bubble.
125///
126/// It also drops codex's stderr: in `--json` mode codex writes only tracing
127/// logs there ("Reading additional input…", internal `ERROR codex_core::…`
128/// lines) and reports real failures as stdout `error` items — so stderr is
129/// pure noise, not status.
130#[derive(Debug, Default)]
131pub struct CodexStreamParser {
132    /// The most recent `agent_message` text, not yet known to be a preamble
133    /// (→ Activity) or the final answer (→ Text).
134    pending_message: Option<String>,
135}
136
137impl CodexStreamParser {
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Normalize one raw process event, applying the agent_message state
143    /// machine to stdout and dropping stderr noise.
144    pub fn on_process_event(&mut self, event: ProcessEvent) -> Vec<RunEvent> {
145        match event {
146            // codex's stderr is tracing noise in `--json` mode; real errors
147            // arrive as stdout `error` items. Don't surface it as status.
148            ProcessEvent::Stderr { .. } => Vec::new(),
149            ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
150            ProcessEvent::Error { run_id, message } => {
151                // Flush a held message as the answer before the terminal error.
152                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                // Defensive: a turn normally ends with `turn.completed` (which
162                // flushes the answer); if the stream ended without it, don't
163                // lose a held final message.
164                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            // `ProcessEvent` is #[non_exhaustive]; ignore any future variant.
174            _ => 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        // A new assistant message arrived: whatever we held is now known to
187        // be a preamble (it was superseded). Hold the new one.
188        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        // Any other line: a held message is a preamble — unless the turn just
197        // ended, when it's the answer.
198        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        // Non-`agent_message` lines still decode normally (tool cards,
204        // session, usage, error).
205        out.extend(run_events_from_parsed(run_id, parse_codex_line(line)));
206        out
207    }
208
209    /// Emit a held message as transient narration (it was a preamble).
210    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    /// Emit a held message as the answer (the final assistant message).
221    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
232/// The text of an `agent_message` `item.completed` line, else `None`. Returns
233/// `Some("")` for an empty/absent `text` so the caller still treats the line
234/// as a (superseding) message.
235fn 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
252/// Parse one line of `codex exec --json` JSONL into the shared
253/// [`ParsedLine`]. Assistant text is the full `agent_message` on
254/// `item.completed`; tool items (`command_execution`, `file_change`,
255/// `web_search`, `mcp_tool_call`) become structured tool cards
256/// (`ToolStart`/`ToolEnd`). Codex edits files directly via tools
257/// (reflected on disk by the file watcher), so it never emits
258/// suggested-edit previews — `edits` stays empty.
259pub 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            // The assistant's reply: full text in one shot.
277            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            // A tool item finished. Grounded in codex 0.125.0's
288            // `--json` schema: command_execution / web_search /
289            // file_change / mcp_tool_call items arrive on
290            // `item.completed` carrying `id` + `status` (+ `exit_code`
291            // for commands). It does NOT emit an `item.started` for
292            // these, so emit BOTH start and end from this one event —
293            // that's what makes a card appear. The frontend dedups a
294            // repeated start by id, so a future codex that *does* send
295            // `item.started` still renders correctly.
296            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            // A tool announced before it finishes → a "running" card; the
328            // matching `item.completed` flips it to done/error. If the
329            // item has no `id` to key the card, degrade to a plain
330            // activity line rather than drop it.
331            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        // A terminal in-band failure codex reports on stdout. It is NOT
351        // transient narration — downgrading it to `activity` (as before) meant
352        // a failed turn surfaced no error at all. Map it to `error` so it
353        // becomes a `RunEvent::Error` the consumer can render.
354        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        // thread.started → session. Codex gives a `thread_id` (its session)
366        // but no model in this event, so `model` stays None.
367        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        // turn.completed → token usage (codex reports input/output tokens).
379        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            // Codex reports cache reads as `cached_input_tokens`; it has no
387            // separate cache-write counter (read-through cache), so
388            // `cache_write_tokens` stays None. Absent → None, unchanged.
389            let cache_read_tokens = usage
390                .and_then(|u| u.get("cached_input_tokens"))
391                .and_then(Value::as_u64);
392            let total_tokens = match (input_tokens, output_tokens) {
393                (Some(i), Some(o)) => Some(i + o),
394                _ => None,
395            };
396            ParsedLine {
397                usage: Some(UsageInfo {
398                    input_tokens,
399                    output_tokens,
400                    total_tokens,
401                    cache_read_tokens,
402                    cache_write_tokens: None,
403                }),
404                ..ParsedLine::default()
405            }
406        }
407        // turn.failed → a terminal in-band failure (quota mid-turn, context
408        // overflow, model error). codex nests the reason at `error.message`
409        // (verified against codex-rs `exec/src/exec_events.rs`); surface it as
410        // a real error rather than silently ignoring it.
411        Some("turn.failed") => {
412            let message = obj
413                .get("error")
414                .and_then(Value::as_object)
415                .and_then(|e| e.get("message"))
416                .and_then(Value::as_str)
417                .filter(|s| !s.is_empty())
418                .unwrap_or("Codex turn failed");
419            ParsedLine {
420                error: Some(truncate(message, 240)),
421                ..ParsedLine::default()
422            }
423        }
424        // turn.started / item.updated: lifecycle / partials — ignored.
425        _ => ParsedLine::default(),
426    }
427}
428
429fn truncate(s: &str, max_chars: usize) -> String {
430    s.chars().take(max_chars).collect()
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn agent_message_completed_becomes_text() {
439        let line = serde_json::json!({
440            "type": "item.completed",
441            "item": { "id": "item_3", "type": "agent_message", "text": "Repo has docs and sdk." }
442        })
443        .to_string();
444        let parsed = parse_codex_line(&line);
445        assert_eq!(parsed.text.as_deref(), Some("Repo has docs and sdk."));
446        assert!(parsed.edits.is_empty());
447        assert!(parsed.activity.is_none());
448    }
449
450    // Tool-card tests grounded in codex-cli 0.125.0's `--json` schema:
451    // tool items (command_execution / web_search / file_change /
452    // mcp_tool_call) arrive on `item.completed` carrying `id`, `status`
453    // (in_progress|completed|failed) and, for commands, `exit_code`.
454    // Example (verbatim from the documented schema):
455    //   {"type":"item.completed","item":{"id":"item_2","type":
456    //    "command_execution","command":"bash -lc false",
457    //    "aggregated_output":"","exit_code":1,"status":"failed"}}
458
459    #[test]
460    fn command_execution_completed_becomes_finished_tool_card() {
461        let line = serde_json::json!({
462            "type": "item.completed",
463            "item": {
464                "id": "item_2",
465                "type": "command_execution",
466                "command": "bash -lc 'echo hi'",
467                "aggregated_output": "hi\n",
468                "exit_code": 0,
469                "status": "completed"
470            }
471        })
472        .to_string();
473        let parsed = parse_codex_line(&line);
474        let start = parsed.tool_start.expect("tool_start");
475        let end = parsed.tool_end.expect("tool_end");
476        assert_eq!(start.tool_call_id, "item_2");
477        assert_eq!(end.tool_call_id, "item_2");
478        // `name` is the tool *identifier* (the UI humanizes it); the command
479        // rides in `input`, never in the name — so it can't leak into a
480        // status line.
481        assert_eq!(start.name, "command_execution");
482        assert_eq!(start.input.as_deref(), Some("bash -lc 'echo hi'"));
483        assert_eq!(end.output.as_deref(), Some("hi\n"));
484        assert!(end.ok, "exit_code 0 → ok");
485        assert!(parsed.activity.is_none());
486        assert!(parsed.text.is_none());
487    }
488
489    #[test]
490    fn command_execution_nonzero_exit_is_error_card() {
491        let line = r#"{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"bash -lc false","aggregated_output":"","exit_code":1,"status":"failed"}}"#;
492        let end = parse_codex_line(line).tool_end.expect("tool_end");
493        assert!(!end.ok, "exit_code 1 / status failed → error");
494    }
495
496    #[test]
497    fn web_search_completed_becomes_tool_card() {
498        let line = serde_json::json!({
499            "type": "item.completed",
500            "item": { "id": "item_5", "type": "web_search", "status": "completed" }
501        })
502        .to_string();
503        let parsed = parse_codex_line(&line);
504        assert_eq!(parsed.tool_start.expect("start").name, "web_search");
505        assert!(parsed.tool_end.expect("end").ok, "no exit_code, status completed → ok");
506    }
507
508    #[test]
509    fn started_tool_with_id_becomes_running_card() {
510        let line = serde_json::json!({
511            "type": "item.started",
512            "item": { "id": "item_1", "type": "command_execution", "command": "bash -lc ls", "status": "in_progress" }
513        })
514        .to_string();
515        let parsed = parse_codex_line(&line);
516        let start = parsed.tool_start.expect("start");
517        assert_eq!(start.name, "command_execution");
518        assert_eq!(start.input.as_deref(), Some("bash -lc ls")); // input known at start
519        assert!(parsed.tool_end.is_none(), "started → running (no end yet)");
520        assert!(parsed.activity.is_none());
521    }
522
523    #[test]
524    fn tool_without_id_degrades_to_activity() {
525        // Defensive: an item lacking `id` can't key a card, so it falls
526        // back to a plain activity line rather than vanishing.
527        let line = serde_json::json!({
528            "type": "item.completed",
529            "item": { "type": "command_execution", "command": "ls -la", "exit_code": 0 }
530        })
531        .to_string();
532        let parsed = parse_codex_line(&line);
533        // No id to key a card → a command-free human fallback (never the
534        // raw command).
535        assert_eq!(parsed.activity.as_deref(), Some("Running a command"));
536        assert!(parsed.tool_start.is_none() && parsed.tool_end.is_none());
537    }
538
539    #[test]
540    fn thread_started_yields_session_and_turn_completed_yields_usage() {
541        // thread.started → Session (thread id; codex reports no model here).
542        let session = parse_codex_line(r#"{"type":"thread.started","thread_id":"abc"}"#)
543            .session
544            .expect("session");
545        assert_eq!(session.session_id.as_deref(), Some("abc"));
546        assert_eq!(session.model, None);
547
548        // turn.completed → Usage.
549        let usage =
550            parse_codex_line(r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":40}}"#)
551                .usage
552                .expect("usage");
553        assert_eq!(usage.input_tokens, Some(100));
554        assert_eq!(usage.output_tokens, Some(40));
555        assert_eq!(usage.total_tokens, Some(140));
556
557        // turn.started remains pure lifecycle.
558        assert!(parse_codex_line(r#"{"type":"turn.started"}"#).is_empty());
559    }
560
561    #[test]
562    fn error_event_becomes_error_not_activity() {
563        // A codex `error` line is a real failure, not transient narration: it
564        // must decode to `error` (→ RunEvent::Error), never to `activity`.
565        let line = r#"{"type":"error","message":"rate limited"}"#;
566        let parsed = parse_codex_line(line);
567        assert_eq!(parsed.error.as_deref(), Some("rate limited"));
568        assert!(parsed.activity.is_none());
569    }
570
571    #[test]
572    fn turn_failed_becomes_error() {
573        // codex reports a mid-turn failure as `turn.failed` with the reason
574        // nested at `error.message`. Previously ignored (→ no answer AND no
575        // error); now it surfaces as `error` → RunEvent::Error.
576        let line = r#"{"type":"turn.failed","error":{"message":"context window exceeded"}}"#;
577        let parsed = parse_codex_line(line);
578        assert_eq!(parsed.error.as_deref(), Some("context window exceeded"));
579        assert!(parsed.activity.is_none() && parsed.text.is_none());
580
581        // Defensive: a turn.failed without a usable message still surfaces an
582        // error (never silence).
583        let bare = parse_codex_line(r#"{"type":"turn.failed"}"#);
584        assert_eq!(bare.error.as_deref(), Some("Codex turn failed"));
585    }
586
587    #[test]
588    fn non_json_is_ignored() {
589        assert!(parse_codex_line("plain text").text.is_none());
590    }
591
592    // --- CodexStreamParser: preamble-vs-answer + stderr drop ----------------
593
594    fn stdout(p: &mut CodexStreamParser, line: &str) -> Vec<RunEvent> {
595        p.on_process_event(ProcessEvent::Stdout {
596            run_id: "r".to_owned(),
597            line: line.to_owned(),
598        })
599    }
600
601    #[test]
602    fn codex_preambles_are_narration_and_only_final_message_is_the_answer() {
603        // The grounded multi-message turn (codex-cli 0.125.0): two preambles
604        // before tool calls, then the final answer — nothing on the items
605        // distinguishes them, only position does.
606        let mut p = CodexStreamParser::new();
607        let mut events = Vec::new();
608        for line in [
609            r#"{"type":"thread.started","thread_id":"t"}"#,
610            r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"I’m going to read a.txt first."}}"#,
611            r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cat a.txt","aggregated_output":"alpha\n","exit_code":0,"status":"completed"}}"#,
612            r#"{"type":"item.completed","item":{"id":"m2","type":"agent_message","text":"I’m going to read b.txt next."}}"#,
613            r#"{"type":"item.completed","item":{"id":"c2","type":"command_execution","command":"cat b.txt","aggregated_output":"one\n","exit_code":0,"status":"completed"}}"#,
614            r#"{"type":"item.completed","item":{"id":"m3","type":"agent_message","text":"a.txt has more lines."}}"#,
615            r#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"#,
616        ] {
617            events.extend(stdout(&mut p, line));
618        }
619
620        // Exactly one answer (Text) — the FINAL message; preambles are not in it.
621        let texts: Vec<&str> = events
622            .iter()
623            .filter_map(|e| match e {
624                RunEvent::Text { delta, .. } => Some(delta.as_str()),
625                _ => None,
626            })
627            .collect();
628        assert_eq!(texts, vec!["a.txt has more lines."]);
629
630        // The two preambles surface as transient Activity (narration), in order.
631        let activity: Vec<&str> = events
632            .iter()
633            .filter_map(|e| match e {
634                RunEvent::Activity { message, .. } => Some(message.as_str()),
635                _ => None,
636            })
637            .collect();
638        assert_eq!(
639            activity,
640            vec![
641                "I’m going to read a.txt first.",
642                "I’m going to read b.txt next."
643            ]
644        );
645
646        // Tool cards, session, and usage still flow through unchanged.
647        assert_eq!(
648            events
649                .iter()
650                .filter(|e| matches!(e, RunEvent::ToolStart { .. }))
651                .count(),
652            2
653        );
654        assert!(events.iter().any(|e| matches!(e, RunEvent::Session { .. })));
655        assert!(events.iter().any(|e| matches!(e, RunEvent::Usage { .. })));
656    }
657
658    #[test]
659    fn codex_single_message_turn_is_the_answer() {
660        // No preamble: one agent_message → the answer, no spurious narration.
661        let mut p = CodexStreamParser::new();
662        let mut events = Vec::new();
663        events.extend(stdout(
664            &mut p,
665            r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"Done."}}"#,
666        ));
667        events.extend(stdout(
668            &mut p,
669            r#"{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#,
670        ));
671        let texts: Vec<&str> = events
672            .iter()
673            .filter_map(|e| match e {
674                RunEvent::Text { delta, .. } => Some(delta.as_str()),
675                _ => None,
676            })
677            .collect();
678        assert_eq!(texts, vec!["Done."]);
679        assert!(!events.iter().any(|e| matches!(e, RunEvent::Activity { .. })));
680    }
681
682    #[test]
683    fn codex_stderr_is_dropped_as_noise() {
684        let mut p = CodexStreamParser::new();
685        let out = p.on_process_event(ProcessEvent::Stderr {
686            run_id: "r".to_owned(),
687            line: "2026-05-31T05:20:28Z ERROR codex_core::memories::phase2::job: failed to claim job"
688                .to_owned(),
689        });
690        assert!(out.is_empty(), "codex stderr is tracing noise → dropped, got {out:?}");
691    }
692
693    #[test]
694    fn codex_turn_failed_surfaces_as_error_through_stream_parser() {
695        // End-to-end on the production codex path (CodexStreamParser → not
696        // normalize_process_event): a `turn.failed` stdout line must yield a
697        // RunEvent::Error so the failure isn't silently swallowed.
698        let mut p = CodexStreamParser::new();
699        let out = stdout(
700            &mut p,
701            r#"{"type":"turn.failed","error":{"message":"quota exceeded"}}"#,
702        );
703        assert!(
704            out.iter().any(
705                |e| matches!(e, RunEvent::Error { message, .. } if message == "quota exceeded")
706            ),
707            "turn.failed must surface as RunEvent::Error, got {out:?}"
708        );
709    }
710
711    #[test]
712    fn codex_error_line_surfaces_as_error_through_stream_parser() {
713        // The other in-band failure shape: a standalone `error` line.
714        let mut p = CodexStreamParser::new();
715        let out = stdout(&mut p, r#"{"type":"error","message":"rate limited"}"#);
716        assert!(
717            out.iter().any(
718                |e| matches!(e, RunEvent::Error { message, .. } if message == "rate limited")
719            ),
720            "error line must surface as RunEvent::Error, got {out:?}"
721        );
722        // And it's a real error, not transient narration.
723        assert!(!out.iter().any(|e| matches!(e, RunEvent::Activity { .. })));
724    }
725
726    #[test]
727    fn codex_held_answer_is_flushed_if_stream_ends_without_turn_completed() {
728        // Defensive: final message then the process exits with no
729        // `turn.completed` — the answer must not be lost.
730        let mut p = CodexStreamParser::new();
731        let _ = stdout(
732            &mut p,
733            r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"Final."}}"#,
734        );
735        let out = p.on_process_event(ProcessEvent::Exited {
736            run_id: "r".to_owned(),
737            exit_code: Some(0),
738            cancelled: false,
739        });
740        assert!(
741            matches!(out.first(), Some(RunEvent::Text { delta, .. }) if delta == "Final."),
742            "held answer flushed as Text before Exited, got {out:?}"
743        );
744        assert!(matches!(out.last(), Some(RunEvent::Exited { .. })));
745    }
746}