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        Some("error") => {
351            let message = obj
352                .get("message")
353                .and_then(Value::as_str)
354                .unwrap_or("Codex error");
355            ParsedLine {
356                activity: Some(truncate(message, 240)),
357                ..ParsedLine::default()
358            }
359        }
360        // thread.started → session. Codex gives a `thread_id` (its session)
361        // but no model in this event, so `model` stays None.
362        Some("thread.started") => ParsedLine {
363            session: Some(SessionInfo {
364                session_id: obj
365                    .get("thread_id")
366                    .and_then(Value::as_str)
367                    .filter(|s| !s.is_empty())
368                    .map(str::to_owned),
369                model: None,
370            }),
371            ..ParsedLine::default()
372        },
373        // turn.completed → token usage (codex reports input/output tokens).
374        Some("turn.completed") => {
375            let usage = obj.get("usage").and_then(Value::as_object);
376            let input_tokens = usage.and_then(|u| u.get("input_tokens")).and_then(Value::as_u64);
377            let output_tokens = usage.and_then(|u| u.get("output_tokens")).and_then(Value::as_u64);
378            if input_tokens.is_none() && output_tokens.is_none() {
379                return ParsedLine::default();
380            }
381            let total_tokens = match (input_tokens, output_tokens) {
382                (Some(i), Some(o)) => Some(i + o),
383                _ => None,
384            };
385            ParsedLine {
386                usage: Some(UsageInfo {
387                    input_tokens,
388                    output_tokens,
389                    total_tokens,
390                }),
391                ..ParsedLine::default()
392            }
393        }
394        // turn.started / turn.failed / item.updated: lifecycle / partials — ignored.
395        _ => ParsedLine::default(),
396    }
397}
398
399fn truncate(s: &str, max_chars: usize) -> String {
400    s.chars().take(max_chars).collect()
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn agent_message_completed_becomes_text() {
409        let line = serde_json::json!({
410            "type": "item.completed",
411            "item": { "id": "item_3", "type": "agent_message", "text": "Repo has docs and sdk." }
412        })
413        .to_string();
414        let parsed = parse_codex_line(&line);
415        assert_eq!(parsed.text.as_deref(), Some("Repo has docs and sdk."));
416        assert!(parsed.edits.is_empty());
417        assert!(parsed.activity.is_none());
418    }
419
420    // Tool-card tests grounded in codex-cli 0.125.0's `--json` schema:
421    // tool items (command_execution / web_search / file_change /
422    // mcp_tool_call) arrive on `item.completed` carrying `id`, `status`
423    // (in_progress|completed|failed) and, for commands, `exit_code`.
424    // Example (verbatim from the documented schema):
425    //   {"type":"item.completed","item":{"id":"item_2","type":
426    //    "command_execution","command":"bash -lc false",
427    //    "aggregated_output":"","exit_code":1,"status":"failed"}}
428
429    #[test]
430    fn command_execution_completed_becomes_finished_tool_card() {
431        let line = serde_json::json!({
432            "type": "item.completed",
433            "item": {
434                "id": "item_2",
435                "type": "command_execution",
436                "command": "bash -lc 'echo hi'",
437                "aggregated_output": "hi\n",
438                "exit_code": 0,
439                "status": "completed"
440            }
441        })
442        .to_string();
443        let parsed = parse_codex_line(&line);
444        let start = parsed.tool_start.expect("tool_start");
445        let end = parsed.tool_end.expect("tool_end");
446        assert_eq!(start.tool_call_id, "item_2");
447        assert_eq!(end.tool_call_id, "item_2");
448        // `name` is the tool *identifier* (the UI humanizes it); the command
449        // rides in `input`, never in the name — so it can't leak into a
450        // status line.
451        assert_eq!(start.name, "command_execution");
452        assert_eq!(start.input.as_deref(), Some("bash -lc 'echo hi'"));
453        assert_eq!(end.output.as_deref(), Some("hi\n"));
454        assert!(end.ok, "exit_code 0 → ok");
455        assert!(parsed.activity.is_none());
456        assert!(parsed.text.is_none());
457    }
458
459    #[test]
460    fn command_execution_nonzero_exit_is_error_card() {
461        let line = r#"{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"bash -lc false","aggregated_output":"","exit_code":1,"status":"failed"}}"#;
462        let end = parse_codex_line(line).tool_end.expect("tool_end");
463        assert!(!end.ok, "exit_code 1 / status failed → error");
464    }
465
466    #[test]
467    fn web_search_completed_becomes_tool_card() {
468        let line = serde_json::json!({
469            "type": "item.completed",
470            "item": { "id": "item_5", "type": "web_search", "status": "completed" }
471        })
472        .to_string();
473        let parsed = parse_codex_line(&line);
474        assert_eq!(parsed.tool_start.expect("start").name, "web_search");
475        assert!(parsed.tool_end.expect("end").ok, "no exit_code, status completed → ok");
476    }
477
478    #[test]
479    fn started_tool_with_id_becomes_running_card() {
480        let line = serde_json::json!({
481            "type": "item.started",
482            "item": { "id": "item_1", "type": "command_execution", "command": "bash -lc ls", "status": "in_progress" }
483        })
484        .to_string();
485        let parsed = parse_codex_line(&line);
486        let start = parsed.tool_start.expect("start");
487        assert_eq!(start.name, "command_execution");
488        assert_eq!(start.input.as_deref(), Some("bash -lc ls")); // input known at start
489        assert!(parsed.tool_end.is_none(), "started → running (no end yet)");
490        assert!(parsed.activity.is_none());
491    }
492
493    #[test]
494    fn tool_without_id_degrades_to_activity() {
495        // Defensive: an item lacking `id` can't key a card, so it falls
496        // back to a plain activity line rather than vanishing.
497        let line = serde_json::json!({
498            "type": "item.completed",
499            "item": { "type": "command_execution", "command": "ls -la", "exit_code": 0 }
500        })
501        .to_string();
502        let parsed = parse_codex_line(&line);
503        // No id to key a card → a command-free human fallback (never the
504        // raw command).
505        assert_eq!(parsed.activity.as_deref(), Some("Running a command"));
506        assert!(parsed.tool_start.is_none() && parsed.tool_end.is_none());
507    }
508
509    #[test]
510    fn thread_started_yields_session_and_turn_completed_yields_usage() {
511        // thread.started → Session (thread id; codex reports no model here).
512        let session = parse_codex_line(r#"{"type":"thread.started","thread_id":"abc"}"#)
513            .session
514            .expect("session");
515        assert_eq!(session.session_id.as_deref(), Some("abc"));
516        assert_eq!(session.model, None);
517
518        // turn.completed → Usage.
519        let usage =
520            parse_codex_line(r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":40}}"#)
521                .usage
522                .expect("usage");
523        assert_eq!(usage.input_tokens, Some(100));
524        assert_eq!(usage.output_tokens, Some(40));
525        assert_eq!(usage.total_tokens, Some(140));
526
527        // turn.started remains pure lifecycle.
528        assert!(parse_codex_line(r#"{"type":"turn.started"}"#).is_empty());
529    }
530
531    #[test]
532    fn error_event_becomes_activity() {
533        let line = r#"{"type":"error","message":"rate limited"}"#;
534        assert_eq!(parse_codex_line(line).activity.as_deref(), Some("rate limited"));
535    }
536
537    #[test]
538    fn non_json_is_ignored() {
539        assert!(parse_codex_line("plain text").text.is_none());
540    }
541
542    // --- CodexStreamParser: preamble-vs-answer + stderr drop ----------------
543
544    fn stdout(p: &mut CodexStreamParser, line: &str) -> Vec<RunEvent> {
545        p.on_process_event(ProcessEvent::Stdout {
546            run_id: "r".to_owned(),
547            line: line.to_owned(),
548        })
549    }
550
551    #[test]
552    fn codex_preambles_are_narration_and_only_final_message_is_the_answer() {
553        // The grounded multi-message turn (codex-cli 0.125.0): two preambles
554        // before tool calls, then the final answer — nothing on the items
555        // distinguishes them, only position does.
556        let mut p = CodexStreamParser::new();
557        let mut events = Vec::new();
558        for line in [
559            r#"{"type":"thread.started","thread_id":"t"}"#,
560            r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"I’m going to read a.txt first."}}"#,
561            r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cat a.txt","aggregated_output":"alpha\n","exit_code":0,"status":"completed"}}"#,
562            r#"{"type":"item.completed","item":{"id":"m2","type":"agent_message","text":"I’m going to read b.txt next."}}"#,
563            r#"{"type":"item.completed","item":{"id":"c2","type":"command_execution","command":"cat b.txt","aggregated_output":"one\n","exit_code":0,"status":"completed"}}"#,
564            r#"{"type":"item.completed","item":{"id":"m3","type":"agent_message","text":"a.txt has more lines."}}"#,
565            r#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"#,
566        ] {
567            events.extend(stdout(&mut p, line));
568        }
569
570        // Exactly one answer (Text) — the FINAL message; preambles are not in it.
571        let texts: Vec<&str> = events
572            .iter()
573            .filter_map(|e| match e {
574                RunEvent::Text { delta, .. } => Some(delta.as_str()),
575                _ => None,
576            })
577            .collect();
578        assert_eq!(texts, vec!["a.txt has more lines."]);
579
580        // The two preambles surface as transient Activity (narration), in order.
581        let activity: Vec<&str> = events
582            .iter()
583            .filter_map(|e| match e {
584                RunEvent::Activity { message, .. } => Some(message.as_str()),
585                _ => None,
586            })
587            .collect();
588        assert_eq!(
589            activity,
590            vec![
591                "I’m going to read a.txt first.",
592                "I’m going to read b.txt next."
593            ]
594        );
595
596        // Tool cards, session, and usage still flow through unchanged.
597        assert_eq!(
598            events
599                .iter()
600                .filter(|e| matches!(e, RunEvent::ToolStart { .. }))
601                .count(),
602            2
603        );
604        assert!(events.iter().any(|e| matches!(e, RunEvent::Session { .. })));
605        assert!(events.iter().any(|e| matches!(e, RunEvent::Usage { .. })));
606    }
607
608    #[test]
609    fn codex_single_message_turn_is_the_answer() {
610        // No preamble: one agent_message → the answer, no spurious narration.
611        let mut p = CodexStreamParser::new();
612        let mut events = Vec::new();
613        events.extend(stdout(
614            &mut p,
615            r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"Done."}}"#,
616        ));
617        events.extend(stdout(
618            &mut p,
619            r#"{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}"#,
620        ));
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!["Done."]);
629        assert!(!events.iter().any(|e| matches!(e, RunEvent::Activity { .. })));
630    }
631
632    #[test]
633    fn codex_stderr_is_dropped_as_noise() {
634        let mut p = CodexStreamParser::new();
635        let out = p.on_process_event(ProcessEvent::Stderr {
636            run_id: "r".to_owned(),
637            line: "2026-05-31T05:20:28Z ERROR codex_core::memories::phase2::job: failed to claim job"
638                .to_owned(),
639        });
640        assert!(out.is_empty(), "codex stderr is tracing noise → dropped, got {out:?}");
641    }
642
643    #[test]
644    fn codex_held_answer_is_flushed_if_stream_ends_without_turn_completed() {
645        // Defensive: final message then the process exits with no
646        // `turn.completed` — the answer must not be lost.
647        let mut p = CodexStreamParser::new();
648        let _ = stdout(
649            &mut p,
650            r#"{"type":"item.completed","item":{"id":"m1","type":"agent_message","text":"Final."}}"#,
651        );
652        let out = p.on_process_event(ProcessEvent::Exited {
653            run_id: "r".to_owned(),
654            exit_code: Some(0),
655            cancelled: false,
656        });
657        assert!(
658            matches!(out.first(), Some(RunEvent::Text { delta, .. }) if delta == "Final."),
659            "held answer flushed as Text before Exited, got {out:?}"
660        );
661        assert!(matches!(out.last(), Some(RunEvent::Exited { .. })));
662    }
663}