From dee958069ddfd0eaeef49e455d407077585a4b45 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 15:29:09 -0700 Subject: [PATCH 01/10] test(corpus): pin codex 0.147.0 chrome the adapter does not yet match --- tests/corpus/README.md | 10 ++++++ tests/corpus/preview_codex_queued.bin | 40 ++++++++++++++++++++++++ tests/corpus/preview_codex_reasoning.bin | 40 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 tests/corpus/preview_codex_queued.bin create mode 100644 tests/corpus/preview_codex_reasoning.bin diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 73e8616..353dfbf 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -37,6 +37,14 @@ CRLF. Claude and Grok use the alternate screen; Codex is inline. Identifying and user-configured text is replaced with alignment-preserving synthetic values. Geometry is 40×120 unless noted. +Most Codex fixtures were sanitized from a live 0.144.6 session. Two were built +instead from codex 0.147.0's own snapshot tests, whose expectations are the +rendered rows themselves: `preview_codex_reasoning.bin` from a full-screen +vt100 snapshot at 40×80, and `preview_codex_queued.bin` at 40×36. Both are +bottom-anchored on a 40-row screen the way an inline TUI paints. Sourcing rows +from the CLI's own test suite pins them to a named upstream version, which a +sanitized capture cannot do. + The Codex hint-row and approval fixtures use approximate indentation, so their tests match trimmed heads and column-0 structure. The Claude waiting fixture omits the welcome box and includes agent-roster rows below the input box. The @@ -63,6 +71,8 @@ roster below it. | `preview_codex_ran.bin` | codex transient completion row | `codex:ran` extraction through the `└` attachment row | | `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no token bar | `codex:working` through the composer pin; no model prefix without the bar | | `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | +| `preview_codex_reasoning.bin` | codex 0.147.0 status row headed by the model's own reasoning phrase, over an `• Explored` group and a reply bullet, composer carrying text, no status line | `codex:working` keeps the CLI's header verbatim; the `•`-headed rows above it never surface, and no status line means no model prefix | +| `preview_codex_queued.bin` | codex 0.147.0 `• Working` row separated from the composer by a `• Queued follow-up inputs` block, default `status_line` below | `codex:working` survives the queued-message heads; the model label reads the default `model-with-reasoning · current-dir` shape | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | | `preview_claude_waiting.bin` | claude waiting on a backgrounded subagent, `⏺` prose and agent roster around the box | `claude:waiting` extracts the ellipsis-less row verbatim; no model label mid-session | | `preview_claude_workflow_wait.bin` | claude waiting on a dynamic workflow, with 19 blank rows before the input box and a workflow roster below it | `claude:waiting` matches across the blank rows; the roster is excluded | diff --git a/tests/corpus/preview_codex_queued.bin b/tests/corpus/preview_codex_queued.bin new file mode 100644 index 0000000..99cbfb9 --- /dev/null +++ b/tests/corpus/preview_codex_queued.bin @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + +• Working (0s • esc to interrupt) + +• Queued follow-up inputs + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + +› Ask Codex to do anything + + gpt-5.6-sol default · /tmp/project \ No newline at end of file diff --git a/tests/corpus/preview_codex_reasoning.bin b/tests/corpus/preview_codex_reasoning.bin new file mode 100644 index 0000000..3229ce4 --- /dev/null +++ b/tests/corpus/preview_codex_reasoning.bin @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +• I’m going to search the repo for where “Change Approved” is rendered to update + that view. + +• Explored + └ Search Change Approved + Read diff_render.rs + +• Investigating rendering code (0s • esc to interrupt) + + +› Summarize recent commits + + tab to queue message 100% context left \ No newline at end of file From 63b066b04725174cdcaa167d942aa027bf2cd0ed Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 15:45:18 -0700 Subject: [PATCH 02/10] fix(harness): read codex's live status row as 0.147.0 paints it --- src/harness/summary.rs | 169 +++++++++++++++++++++----- src/harness/summary_tests.rs | 228 +++++++++++++++++++++++++++++++++-- 2 files changed, 354 insertions(+), 43 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 6e0dd2f..fe2e011 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -298,10 +298,10 @@ fn claude_welcome_label(rows: &[String]) -> Option { // ----------------------------------------------------------------- codex -- /// codex (inline UI, primary screen). The pin is its composer: the -/// bottom-most column-0 `›` row that is not a modal selector; status rows -/// sit above it, and scrollback beyond the first foreign row is out of -/// bounds. The approval modal removes the composer and is checked first. -/// A token bar or indented hint rows may appear below the composer. +/// bottom-most column-0 prompt-glyph row that is not a modal selector; +/// status rows sit above it, and scrollback beyond the first foreign row is +/// out of bounds. The approval modal removes the composer and is checked +/// first. A token bar or indented hint rows may appear below the composer. pub struct CodexSummary; impl SummaryAdapter for CodexSummary { @@ -320,6 +320,25 @@ impl SummaryAdapter for CodexSummary { } } +/// Composer prompt glyphs: `!` in bash mode, `»` at `ultra` reasoning +/// effort, `›` otherwise. All three render at column 0 and are dim while +/// input is disabled, which costs the row no text. +const CODEX_PROMPT: &[char] = &['›', '»', '!']; + +/// Queued-message group heads codex paints between the status row and the +/// composer, each over its own ` ↳ `-indented item rows. The heads sit at +/// column 0 and are chrome, not status. +const CODEX_QUEUED_HEADS: &[&str] = &[ + "• Messages to be submitted after next tool call", + "• Messages to be submitted at end of turn", + "• Queued follow-up inputs", +]; + +/// Maximum indented rows crossed between the composer and the status row. +/// Queued-message blocks are exempt: their height is the user's queue +/// depth, so counting them would push the status row out of reach. +const CODEX_STATUS_WINDOW: usize = 10; + /// `› 1. Yes, proceed (y)`: the modal's selected option row (column-0 `›`, /// one digit, `. `). fn codex_menu_head(row: &str) -> bool { @@ -339,7 +358,10 @@ fn codex_numbered_option(row: &str) -> bool { /// below it, pinned to the last nine painted rows. The modal removes the /// composer and token bar; that absence is the disambiguator (a menu quoted /// in the conversation always has the live composer below it, so any -/// non-selector `›` row under the selector suppresses the match). +/// non-selector [`CODEX_PROMPT`] row under the selector suppresses the +/// match). Suppression tests the glyph alone, without the composer's +/// trailing-space rule: over-suppressing costs one preview, while +/// under-suppressing reports a modal the user is not looking at. fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { let last = rows.iter().rposition(|r| !r.is_empty())?; let i = (last.saturating_sub(8)..=last).find(|&i| codex_menu_head(&rows[i]))?; @@ -349,7 +371,7 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { } rows[i + 1..] .iter() - .all(|r| !r.starts_with('›') || codex_menu_head(r)) + .all(|r| !r.starts_with(CODEX_PROMPT) || codex_menu_head(r)) .then(|| ("awaiting approval".to_string(), "codex:approval-menu")) } @@ -368,52 +390,135 @@ fn codex_token_line(rows: &[String]) -> Option { }) } -/// The composer: the bottom-most column-0 `›` row that is not a modal -/// selector. Rows below it are tolerated, never required: blank rows, -/// indented affordance hints (`tab to queue message`), or the token bar. -/// The working layout can paint hints below the composer with no bar at -/// all. Prompt echoes in scrollback share the `›` head but sit above the -/// composer, so the bottom-most wins. +/// The composer: the bottom-most column-0 [`CODEX_PROMPT`] row — the glyph +/// alone or the glyph and a space — that is not a modal selector. Rows +/// below it are tolerated, never required: blank rows, indented affordance +/// hints (`tab to queue message`), or the token bar. The working layout can +/// paint hints below the composer with no bar at all. Prompt echoes in +/// scrollback share the glyph but sit above the composer, so the +/// bottom-most wins. fn codex_composer(rows: &[String]) -> Option { - rows.iter() - .rposition(|r| (r.as_str() == "›" || r.starts_with("› ")) && !codex_menu_head(r)) + rows.iter().rposition(|r| { + let mut chars = r.chars(); + chars.next().is_some_and(|c| CODEX_PROMPT.contains(&c)) + && matches!(chars.next(), None | Some(' ')) + && !codex_menu_head(r) + }) } /// Walk up from the composer through the status region: blanks and indented /// rows (tool-output attachments like `└ ok`, wrapped continuations) are -/// skipped, and the first column-0 row decides. Only two heads extract -/// (`• Working (` and `• Ran `); any other column-0 row (a reply bullet, -/// a `⚠` notice, a turn separator) stops the scan: scrollback holds `• Ran` -/// rows from every prior turn, and skipping an unknown row to reach one -/// would resurface stale work as live status. +/// skipped, [`CODEX_QUEUED_HEADS`] are walked past, and the first other +/// column-0 row decides. Only two shapes extract ([`codex_status_head`] and +/// `• Ran `); any other column-0 row (a reply bullet, a `⚠` notice, a turn +/// separator) stops the scan: scrollback holds `• Ran` rows from every +/// prior turn, and skipping an unknown row to reach one would resurface +/// stale work as live status. `• Ran ` is tested first because the status +/// head matches on structure, not on a literal verb. fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static str)> { - for row in rows[composer.saturating_sub(10)..composer].iter().rev() { - if row.is_empty() || row.starts_with(' ') { + // Indented rows crossed since the last column-0 row. A queued head + // claims the ones below it, so a deep queue never exhausts the window. + let mut indented = 0usize; + for row in rows[..composer].iter().rev() { + if row.is_empty() { + continue; + } + if row.starts_with(' ') { + indented += 1; + continue; + } + if CODEX_QUEUED_HEADS.contains(&row.as_str()) { + indented = 0; continue; } - if let Some(after_paren) = row.strip_prefix("• Working (") { - return Some((codex_working(after_paren), "codex:working")); + if indented > CODEX_STATUS_WINDOW { + return None; } if let Some(cmd) = row.strip_prefix("• Ran ") && !cmd.is_empty() { return Some((format!("Ran {cmd}"), "codex:ran")); } + if let Some((header, after_paren)) = codex_status_head(row) { + return Some((codex_working(header, after_paren), "codex:working")); + } return None; } None } -/// `7s • esc to interrupt) · 1 background terminal running · /ps to view · -/// /stop to close` → `Working · 1 background terminal running`. The -/// parenthetical is the elapsed counter plus interrupt affordance, dropped -/// whole: an unclosed paren is CLI-side truncation mid-affordance and drops -/// to the end. Of the ` · ` suffixes, `/`-headed segments are key hints; -/// everything else is slow-moving state and is kept, with its own ellipsis -/// when the CLI truncated it. -fn codex_working(after_paren: &str) -> String { +/// The live status row, `[{glyph} ]{header} ({elapsed} • {key} to +/// interrupt)`, split into its header and the text after the opening paren. +/// The glyph is codex's activity indicator: a shimmered `•` on truecolor +/// stdout, `•`/`◦` alternating at 600 ms otherwise, and — with animations +/// disabled — absent along with its space, so it is optional. The header is +/// a free-form `String` (`Working` is only the default; a reasoning phrase, +/// `Booting MCP server: {name}`, and verbatim stream errors all land there), +/// which leaves the parenthetical as the only fixed structure. Anchoring on +/// [`codex_elapsed`] rather than the closing `)` keeps rows truncated at the +/// terminal's width matchable. +fn codex_status_head(row: &str) -> Option<(&str, &str)> { + let rest = row + .strip_prefix("• ") + .or_else(|| row.strip_prefix("◦ ")) + .unwrap_or(row); + if !rest.starts_with(char::is_alphanumeric) { + return None; + } + // The header can carry its own parentheses (`Starting MCP servers + // (1/3): a, b, c`), so the first ` (` opening a counter wins. + rest.match_indices(" (").find_map(|(i, _)| { + let after = &rest[i + " (".len()..]; + codex_elapsed(after).then(|| (&rest[..i], after)) + }) +} + +/// Whether `s` opens with codex's compact elapsed counter: space-separated +/// `{digits}{unit}` fields in strictly descending `h`, `m`, `s` order, +/// ending at the seconds field — `0s`, `1m 00s`, `25h 02m 03s`. The counter +/// must close the row or be followed by a space or `)`; a field that is not +/// digits plus a unit (`1/3`, `9.9s`) fails. This token carries the whole +/// anchor, since the header left of it is free-form. +fn codex_elapsed(s: &str) -> bool { + let mut rest = s; + let mut units = "hms"; + loop { + let digits = rest.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 { + return false; + } + let tail = &rest[digits..]; + let Some(unit) = tail.chars().next() else { + return false; + }; + let Some(at) = units.find(unit) else { + return false; + }; + units = &units[at + 1..]; + let after = &tail[unit.len_utf8()..]; + if unit == 's' { + return after.is_empty() || after.starts_with([' ', ')']); + } + let Some(next) = after.strip_prefix(' ') else { + return false; + }; + rest = next; + } +} + +/// `Working`, `7s • esc to interrupt) · 1 background terminal running · /ps +/// to view · /stop to close` → `Working · 1 background terminal running`. +/// The parenthetical is the elapsed counter plus interrupt affordance, +/// dropped whole: an unclosed paren is CLI-side truncation mid-affordance +/// and drops to the end. Of the ` · ` suffixes, `/`-headed segments are key +/// hints; everything else is slow-moving state and is kept, with its own +/// ellipsis when the CLI truncated it. +fn codex_working(header: &str, after_paren: &str) -> String { let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]); - format!("Working{}", slow_segments(tail, |seg| seg.starts_with('/'))) + format!( + "{header}{}", + slow_segments(tail, |seg| seg.starts_with('/')) + ) } // ------------------------------------------------------------------ grok -- diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 61a52dd..daf354b 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -511,6 +511,182 @@ fn codex_working_normalization() { ); } +/// The status row anchors on its parenthetical, not on a literal verb: +/// the activity glyph blinks, drops to `◦`, or vanishes with animations +/// off; the header is whatever the CLI put there; the interrupt key is +/// remappable and its hint can be switched off entirely. +#[test] +fn codex_status_anchors_on_the_interrupt_parenthetical() { + let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); + for row in [ + "• Working (0s • esc to interrupt)", + // The blink's off frame. + "◦ Working (0s • esc to interrupt)", + // Animations off: the glyph and its space are omitted. + "Working (0s • esc to interrupt)", + // The interrupt key is remappable. + "• Working (0s • f12 to interrupt)", + // The hint is off: the parenthetical is the counter alone. + "• Working (0s)", + ] { + assert_eq!( + probe(row), + Some(("Working".to_string(), "codex:working")), + "{row:?}" + ); + } + + // Every elapsed shape codex formats, from a fresh turn to a day-long one. + for elapsed in [ + "0s", + "59s", + "1m 00s", + "59m 59s", + "1h 00m 00s", + "25h 02m 03s", + ] { + assert_eq!( + probe(&format!("• Working ({elapsed} • esc to interrupt)")), + Some(("Working".to_string(), "codex:working")), + "{elapsed:?}" + ); + } + + // The header is a free-form String; `Working` is only its default. + for header in [ + "Investigating rendering code", + "Reviewing approval request", + "Reviewing 2 approval requests", + "Waiting for background terminal", + "Booting MCP server: my-server", + // The header carries parentheses of its own: `(1/3)` is not a + // counter, so the anchor is the parenthetical after it. + "Starting MCP servers (1/3): a, b, c", + "Setting up sandbox...", + // Stream errors reach the header verbatim. + "Reconnecting... 1/5", + ] { + assert_eq!( + probe(&format!("{header} (7s • esc to interrupt)")), + Some((header.to_string(), "codex:working")), + "{header:?}" + ); + } + + // The suffix and truncation rules hold for a non-default header. + assert_eq!( + probe( + "• Reviewing 2 approval requests (7s • esc to interrupt) · 1 background terminal running · /ps to view" + ), + Some(( + "Reviewing 2 approval requests · 1 background terminal running".to_string(), + "codex:working" + )) + ); + assert_eq!( + probe("• Investigating rendering code (7s • esc to…"), + Some(("Investigating rendering code".to_string(), "codex:working")), + "an unclosed parenthetical drops to the end of the row" + ); +} + +/// Status-shaped rows whose counter is malformed refuse: the elapsed token +/// is the whole anchor, because the header above it is unconstrained. +#[test] +fn codex_status_rejects_malformed_counters() { + let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); + for row in [ + "• Working (soon • esc to interrupt)", + // claude's fractional seconds, not codex's zero-padded fields. + "• Working (9.9s • esc to interrupt)", + // The counter ends at the seconds field. + "• Working (1m • esc to interrupt)", + "• Working (0sx • esc to interrupt)", + // Units descend h → m → s. + "• Working (2m 1h • esc to interrupt)", + // No space before the paren, an empty header, and a header that + // does not open alphanumeric. + "• Working(0s • esc to interrupt)", + "• (0s • esc to interrupt)", + "• → Working (0s • esc to interrupt)", + ] { + assert_eq!(probe(row), None, "{row:?}"); + } +} + +/// Every composer glyph pins the adapter: `!` in bash mode, `»` at `ultra` +/// reasoning effort, `›` otherwise. The glyph stands alone or heads a +/// space; glued to text it is body content and the pin fails. +#[test] +fn codex_composer_accepts_every_prompt_glyph() { + let status = "• Working (3s • esc to interrupt)"; + for glyph in ['›', '»', '!'] { + for composer in [glyph.to_string(), format!("{glyph} Write tests")] { + assert_eq!( + CodexSummary.live_preview(&rs(&[status, "", &composer])), + Some(("Working".to_string(), "codex:working")), + "{composer:?}" + ); + } + assert_eq!( + CodexSummary.live_preview(&rs(&[status, "", &format!("{glyph}Write tests")])), + None, + "{glyph:?} glued to text is not the composer" + ); + } +} + +/// Queued-message blocks sit between the status row and the composer. +/// Their heads are walked past and their items never count against the +/// window: the queue's depth is the user's, not the CLI's. Nothing else +/// earns that pass. +#[test] +fn codex_status_walks_past_queued_message_blocks() { + for head in [ + "• Messages to be submitted after next tool call", + "• Messages to be submitted at end of turn", + "• Queued follow-up inputs", + ] { + let mut rows = vec![ + "• Working (0s • esc to interrupt)".to_string(), + String::new(), + head.to_string(), + ]; + rows.extend((0..24).map(|i| format!(" ↳ Hello, world! {i}"))); + rows.extend([String::new(), "› ".to_string()]); + assert_eq!( + CodexSummary.live_preview(&rows), + Some(("Working".to_string(), "codex:working")), + "{head:?}" + ); + } + + // A near-miss head is a foreign column-0 row and aborts the scan. + let foreign = rs(&[ + "• Working (0s • esc to interrupt)", + "", + "• Queued thoughts", + " ↳ one", + "", + "› ", + ]); + assert_eq!(CodexSummary.live_preview(&foreign), None); + + // Outside a queued block, indented rows still bound the scan. + let deep = |gap: usize| { + let mut rows = vec!["• Working (0s • esc to interrupt)".to_string()]; + rows.extend((0..gap).map(|i| format!(" └ line {i}"))); + rows.push("› ".to_string()); + CodexSummary.live_preview(&rows) + }; + assert_eq!( + deep(10), + Some(("Working".to_string(), "codex:working")), + "ten indented rows fill the window" + ); + assert_eq!(deep(11), None, "eleven exhaust it"); +} + /// `• Ran` extracts through its indented attachment, but never through a /// foreign column-0 row: scrollback `• Ran` rows from prior turns sit /// behind reply bullets and separators, and skipping those would @@ -607,17 +783,22 @@ fn codex_approval_modal_synthesizes_on_any_selection() { /// anchor, floor tier. #[test] fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() { - let quoted = rs(&[ - "• I found these options in the doc:", - "", - "› 1. Yes, proceed (y)", - " 2. No, cancel (esc)", - "", - "›", - "", - " gpt-5.6-sol high · 0 in · 0 out", - ]); - assert_eq!(CodexSummary.live_preview("ed), None); + // Every prompt glyph suppresses: the modal selector is always `›` + // whatever the composer renders, so a `»` or `!` composer below a + // quoted menu is still a live composer and still disqualifies it. + for composer in ["›", "»", "!"] { + let quoted = rs(&[ + "• I found these options in the doc:", + "", + "› 1. Yes, proceed (y)", + " 2. No, cancel (esc)", + "", + composer, + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]); + assert_eq!(CodexSummary.live_preview("ed), None, "{composer}"); + } } /// Without any composer row (codex exited; its resume hint owns the @@ -1012,6 +1193,31 @@ fn corpus_truncated_rows_still_anchor() { ); } +/// codex 0.147.0 rows, replayed from the CLI's own snapshot tests at the +/// geometry those snapshots were taken at. +#[test] +fn corpus_codex_0_147_rows_anchor() { + // The header is the model's first reasoning chunk, kept verbatim, and + // the `•`-headed transcript rows above it never surface. This frame + // paints no status line, so nothing prefixes the text. + let got = corpus( + include_bytes!("../../tests/corpus/preview_codex_reasoning.bin"), + &CodexSummary, + 80, + ); + assert_eq!(got, anchor("Investigating rendering code", "codex:working")); + + // Sixteen queued messages separate the status row from the composer. + // `gpt-5.6-sol default · /tmp/project` is not the token bar's + // `{model} · {…} in · {…} out`, so no model label is read. + let got = corpus( + include_bytes!("../../tests/corpus/preview_codex_queued.bin"), + &CodexSummary, + 36, + ); + assert_eq!(got, anchor("Working", "codex:working")); +} + /// At 30 columns, a wrapped status ellipsis fails the structure check and /// resolves to the alternate-screen marker. #[test] From 5887263fd981e78857d31758052ef1cd4374189c Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 15:53:17 -0700 Subject: [PATCH 03/10] fix(harness): correlate codex rollouts by provenance, not position alone --- src/harness/codex.rs | 303 ++++++++++++++++++++++++++++++------------- src/testutil.rs | 22 +++- 2 files changed, 236 insertions(+), 89 deletions(-) diff --git a/src/harness/codex.rs b/src/harness/codex.rs index eae89c2..21ba387 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,8 +1,17 @@ //! Codex does not let the caller select an ID at launch. This harness instead //! injects a `notify` override, chains compatible configured notifiers, and -//! scans both exit-hint forms. When neither channel yields an ID, it correlates -//! rollout files under +//! scans every exit line that carries an ID. When neither channel yields an ID, +//! it correlates rollout files under //! `/sessions/YYYY/MM/DD/rollout--.jsonl`. +//! +//! Those files are created lazily: `RolloutRecorder::new` precomputes the path +//! and defers creation until the first persisted item, so a session that never +//! received a prompt leaves no rollout at all and [`Codex::correlate_fs`] +//! returns `None`. That is the right answer — there is no conversation to +//! resume. Creation opens the path `O_APPEND|O_CREAT` in place rather than +//! writing a temp file and renaming it, so a zero-byte or half-written first +//! line is briefly observable; the `jzon::parse` guard in [`line1_admits`] +//! rejects one. use std::{ fmt::Write as _, @@ -79,6 +88,21 @@ impl Harness for Codex { fn scrape_exit(&self, text: &str) -> Option { let mut last = None; for line in text.lines() { + // Fatal exit with no hint: codex `println!`s a bare + // `Session ID: `, the last channel left once the notify hook + // has not fired and the rollout may be empty. Only a whole row + // counts. The phrase carries no program word to narrow it, so an + // unanchored match would let any scrollback line quoting it decide + // the resume ID of a task killed before codex printed an exit line + // at all — a wrong ID where the fall through to `correlate_fs` + // would have been provenance-checked. Rendered model output always + // carries a `• ` head or a two-space continuation indent, so it + // cannot reach offset 0. + if let Some(rest) = line.strip_prefix("Session ID: ") + && let Some(id) = leading_uuid(rest) + { + last = Some(id.to_string()); + } // Plain hint: `... run codex resume `. if let Some(id) = last_hint(line, &["codex resume "]) { last = Some(id); @@ -129,9 +153,16 @@ impl Harness for Codex { else { continue; }; - let Some(id) = stem.get(stem.len().saturating_sub(36)..) else { + // The stem opens with 19 timestamp characters + // (`YYYY-MM-DDTHH-MM-SS`) and one `-`; the thread ID runs from + // there to the first `_` or to the end. Reading the trailing 36 + // instead would return the rollout ID of a reverted thread's + // `rollout--_.jsonl` — a valid UUID + // naming the wrong conversation. + let Some(ids) = stem.get(20..) else { continue; }; + let id = ids.split_once('_').map_or(ids, |(thread, _)| thread); if !is_uuid(id) { continue; } @@ -141,7 +172,7 @@ impl Harness for Codex { if !within_window_ms(u128::from(ms), spawn_ms) { continue; } - if !line1_cwd_matches(&entry.path(), cwd) { + if !line1_admits(&entry.path(), cwd) { continue; } survivors.push(id.to_string()); @@ -166,28 +197,28 @@ enum NotifyRoute { Opaque, } -/// Classify the effective `notify` route from `config.toml` and its selected -/// profile. The first line-based `profile` assignment selects the profile, and -/// its notify assignment takes precedence over the base file. Because this is +/// Classify the effective `notify` route from `config.toml`. Because this is /// deliberately line-based rather than TOML-aware, two `notify` lines in one /// file are ambiguous and produce [`NotifyRoute::Opaque`]. +/// +/// `config.toml` is the only file read, and the injected `-c` override is why +/// that suffices: it lands in codex's `SessionFlags` layer at precedence 30, +/// outranking every layer a user config can occupy — user config 20, +/// user-with-profile 21, project 25. Only managed and enterprise config, at 40 +/// and 50, beat it. fn config_notify_route(home: Option<&Path>) -> NotifyRoute { let Some(root) = Codex.home_root(home) else { return NotifyRoute::Vacant; }; - let config_text = fs::read_to_string(root.join("config.toml")).unwrap_or_default(); - let profile_text = config_profile(&config_text) - .and_then(|p| fs::read_to_string(root.join(format!("{p}.config.toml"))).ok()) - .unwrap_or_default(); - for text in [&profile_text, &config_text] { - let mut values = text.lines().filter_map(notify_value); - let Some(value) = values.next() else { continue }; - if values.next().is_some() { - return NotifyRoute::Opaque; - } - return route_for(value); + let text = fs::read_to_string(root.join("config.toml")).unwrap_or_default(); + let mut values = text.lines().filter_map(notify_value); + let Some(value) = values.next() else { + return NotifyRoute::Vacant; + }; + if values.next().is_some() { + return NotifyRoute::Opaque; } - NotifyRoute::Vacant + route_for(value) } /// Classify one notify assignment for the newline-delimited chain transport. @@ -204,36 +235,6 @@ fn route_for(value: &str) -> NotifyRoute { } } -/// Return the first line-based `profile = name` assignment. Bare and quoted -/// values are valid; trailing comments are ignored. -fn config_profile(text: &str) -> Option { - for line in text.lines() { - let Some(rest) = line.trim_start().strip_prefix("profile") else { - continue; - }; - let Some(rest) = rest.trim_start_matches([' ', '\t']).strip_prefix('=') else { - continue; - }; - let val = unquote_toml(rest.trim()); - if !val.is_empty() { - return Some(val); - } - } - None -} - -/// Extract a quoted value or the first whitespace/`#`-delimited bare token. -fn unquote_toml(s: &str) -> String { - for q in ['"', '\''] { - if let Some(rest) = s.strip_prefix(q) - && let Some(end) = rest.find(q) - { - return rest[..end].to_string(); - } - } - s.split([' ', '\t', '#']).next().unwrap_or("").to_string() -} - /// Value after `=` of an uncommented bare `notify` assignment, or `None`. fn notify_value(line: &str) -> Option<&str> { let rest = line.trim_start().strip_prefix("notify")?; @@ -324,9 +325,19 @@ fn v7_millis(id: &str) -> Option { u64::from_str_radix(&format!("{}{}", &id[..8], &id[9..13]), 16).ok() } -/// Check whether the rollout's first record names `cwd`. Reads stop at 64 KiB -/// because later records do not participate in correlation. -fn line1_cwd_matches(path: &Path, cwd: &Path) -> bool { +/// Check whether the rollout's first record names `cwd` and belongs to a thread +/// the user started. Subagent and guardian-review threads inherit the parent's +/// cwd and are minted within seconds of it, so cwd and the window alone leave +/// several rollouts standing and correlation collapses to `None`; +/// `thread_source: "subagent"` and a `parent_thread_id` are what separate them. +/// +/// The test rejects rather than admits by name: codex's `ThreadSource` +/// deserializer turns any unknown string into `Feature(String)`, so requiring +/// `"user"` would silently drop legitimate future thread kinds. Absence is a +/// pass for the same reason — both fields are omitted when empty and neither +/// existed before 0.147.0, so pre-0.147 sessions stay resumable. Reads stop at +/// 64 KiB because later records do not participate in correlation. +fn line1_admits(path: &Path, cwd: &Path) -> bool { let Ok(file) = fs::File::open(path) else { return false; }; @@ -340,9 +351,13 @@ fn line1_cwd_matches(path: &Path, cwd: &Path) -> bool { let Ok(meta) = jzon::parse(&line) else { return false; }; - meta["payload"]["cwd"] - .as_str() - .is_some_and(|c| Path::new(c) == cwd) + let payload = &meta["payload"]; + if payload["thread_source"].as_str() == Some("subagent") + || !payload["parent_thread_id"].is_null() + { + return false; + } + payload["cwd"].as_str().is_some_and(|c| Path::new(c) == cwd) } #[cfg(test)] @@ -352,7 +367,7 @@ mod tests { use super::*; use crate::{ harness::fixtures::{OTHER, assert_all_opaque, assert_corpus_scrape, paths}, - testutil::{Scratch, temp, v7_at, write_rollout}, + testutil::{CORPUS_COLS, Scratch, temp, v7_at, write_rollout, write_rollout_named}, }; /// Codex's own launch and resume commands carry v7 IDs; the shared v4 @@ -654,49 +669,63 @@ mod tests { assert_eq!(Codex.scrape_exit(&both).as_deref(), Some(ID)); } + /// A fatal exit prints no resume hint and names the ID outright. Nothing + /// else recovers it: the notify hook never fired and the rollout may be + /// empty. #[test] - fn config_notify_route_resolves_profiles() { + fn scrape_exit_reads_the_fatal_session_id_line() { + assert_eq!( + Codex.scrape_exit(&format!("Session ID: {ID}")).as_deref(), + Some(ID) + ); + + // The label alone, a name, and a token-extending ID yield nothing. + assert_eq!(Codex.scrape_exit("Session ID:"), None); + assert_eq!(Codex.scrape_exit("Session ID: my session"), None); + assert_eq!(Codex.scrape_exit(&format!("Session ID: {ID}ff")), None); + + // Off the start of the row the phrase is quoted text, not codex's own + // line. A task killed before any exit line would otherwise resume on + // it; refusing costs only the fall through to `correlate_fs`. + for quoted in [ + format!("the log said Session ID: {ID}"), + format!("• Session ID: {ID}"), + format!(" Session ID: {ID}"), + ] { + assert_eq!(Codex.scrape_exit("ed), None, "{quoted:?}"); + } + + // Across lines, the last channel to speak wins, either way round. + let hint_last = format!("Session ID: {OTHER}\nrun codex resume {ID}"); + assert_eq!(Codex.scrape_exit(&hint_last).as_deref(), Some(ID)); + let id_last = format!("run codex resume {OTHER}\nSession ID: {ID}"); + assert_eq!(Codex.scrape_exit(&id_last).as_deref(), Some(ID)); + } + + /// A half-migrated home — a legacy `profile` key beside the file it once + /// selected — must not chain the profile's notifier: 0.147.0 refuses to + /// start on that key at all, and layers `.config.toml` only under + /// `-p`, a command this harness never instruments. Chaining it would run a + /// notifier codex itself would not. + #[test] + fn config_notify_route_reads_config_toml_alone() { let home = temp("codex_profile_notify"); let cfg = home.join("config.toml"); - let team = home.join("team.config.toml"); - let team_route = NotifyRoute::Chain(vec!["/team/hook".to_string()]); + fs::write(home.join("team.config.toml"), "notify = [\"/team/hook\"]\n").unwrap(); - // notify lives in the profile file selected by config.toml's own - // `profile` key. fs::write(&cfg, "profile = \"team\"\n").unwrap(); - fs::write(&team, "notify = [\"/team/hook\"]\n").unwrap(); - assert_eq!(config_notify_route(Some(&home)), team_route); - let inv = Codex.detect("codex").unwrap(); - let plan = Codex.instrument(&inv, &paths(), Some(&home)); - assert!( - plan.env - .contains(&(NOTIFY_CHAIN_ENV.into(), "/team/hook".into())), - "{:?}", - plan.env - ); - - // Bare (unquoted) value with a trailing comment resolves too. - fs::write(&cfg, "profile = team # mine\n").unwrap(); - assert_eq!(config_notify_route(Some(&home)), team_route); + assert_eq!(config_notify_route(Some(&home)), NotifyRoute::Vacant); - // The profile file's assignment overrides the base file's. + // The base file's own assignment is the only one that counts. fs::write(&cfg, "profile = \"team\"\nnotify = [\"/base/hook\"]\n").unwrap(); - assert_eq!(config_notify_route(Some(&home)), team_route); - - // Commented out in the profile file: the base assignment stands. - fs::write(&team, "# notify = [\"/team/hook\"]\n").unwrap(); assert_eq!( config_notify_route(Some(&home)), NotifyRoute::Chain(vec!["/base/hook".to_string()]) ); - // No assignment anywhere: vacant, plain injection. - fs::write(&cfg, "profile = \"team\"\n").unwrap(); - assert_eq!(config_notify_route(Some(&home)), NotifyRoute::Vacant); - - // A missing profile file leaves only the base config. - fs::write(&cfg, "profile = \"ghost\"\n").unwrap(); - assert_eq!(config_notify_route(Some(&home)), NotifyRoute::Vacant); + // Two assignment lines remain ambiguous. + fs::write(&cfg, "notify = [\"/a\"]\nnotify = [\"/b\"]\n").unwrap(); + assert_eq!(config_notify_route(Some(&home)), NotifyRoute::Opaque); } #[test] @@ -733,6 +762,96 @@ mod tests { ); } + /// Subagent and guardian-review threads mint their own ID, write their own + /// rollout, and inherit the parent's cwd, so cwd and the window alone leave + /// several rollouts standing. Line 1's provenance fields are what separate + /// them, and either one alone disqualifies a file. + #[test] + fn correlate_fs_excludes_spawned_threads() { + let home = temp("codex_subagent"); + let spawn_ms: u64 = 1_785_000_000_000; + let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); + let cwd = Path::new("/work/proj"); + let parent = write_rollout(&home, spawn_ms + 1_000, 1, cwd); + let resolves = |home: &Path| Codex.correlate_fs(cwd, spawned, Some(home)); + + // `thread_source` alone, as 0.147.0 writes it for a spawned thread. + write_rollout_named( + &home, + spawn_ms + 3_000, + 2, + cwd, + "", + r#","source":{"subagent":{"other":"guardian"}},"thread_source":"subagent""#, + ); + assert_eq!(resolves(&home).as_deref(), Some(parent.as_str())); + + // `parent_thread_id` alone: any value at all names a spawning thread. + write_rollout_named( + &home, + spawn_ms + 5_000, + 3, + cwd, + "", + &format!(r#","parent_thread_id":"{parent}""#), + ); + assert_eq!(resolves(&home).as_deref(), Some(parent.as_str())); + + // A second rollout carrying neither field is a real sibling, and + // uniqueness fails as it always has. + write_rollout(&home, spawn_ms + 7_000, 4, cwd); + assert_eq!(resolves(&home), None); + } + + /// Disqualify by field rather than requiring `thread_source: "user"`: + /// codex's deserializer turns any unknown string into `Feature(String)`, so + /// an allow-list would drop future thread kinds. Absence passes too, which + /// is what keeps pre-0.147 rollouts resumable — the case above already + /// leans on it. + #[test] + fn correlate_fs_admits_thread_sources_it_does_not_know() { + let spawn_ms: u64 = 1_785_000_000_000; + let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); + let cwd = Path::new("/work/proj"); + for source in ["user", "some_future_kind"] { + let home = temp("codex_thread_source"); + let id = write_rollout_named( + &home, + spawn_ms + 1_000, + 1, + cwd, + "", + &format!(r#","thread_source":"{source}""#), + ); + assert_eq!( + Codex.correlate_fs(cwd, spawned, Some(&home)).as_deref(), + Some(id.as_str()), + "{source:?}" + ); + } + } + + /// A thread created by `thread/revert` carries a second ID in its filename. + /// The trailing 36 characters are then the rollout ID — a valid UUID naming + /// a different object — so the thread ID is read from a fixed offset. + #[test] + fn correlate_fs_reads_the_thread_id_not_the_rollout_id() { + let spawn_ms: u64 = 1_785_000_000_000; + let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); + let cwd = Path::new("/work/proj"); + let rollout_id = v7_at(spawn_ms + 1_000, 9); + for suffix in [String::new(), format!("_{rollout_id}")] { + let home = temp("codex_revert_name"); + let thread = write_rollout_named(&home, spawn_ms + 1_000, 1, cwd, &suffix, ""); + assert_ne!(thread, rollout_id); + assert_eq!( + Codex.correlate_fs(cwd, spawned, Some(&home)).as_deref(), + Some(thread.as_str()), + "{suffix:?}" + ); + } + } + /// The ±2-day probe includes a rollout in the adjacent day directory. #[test] fn correlate_fs_spans_adjacent_day_directories() { @@ -764,6 +883,16 @@ mod tests { ); } + /// The anchor has to survive emulation, not just `str::lines`. + /// `text_with_history` joins soft-wrapped rows into one logical line, so a + /// preceding row that exactly fills the width is the case that could push + /// the fatal line off offset 0. + #[test] + fn fatal_session_id_holds_offset_zero_after_a_full_width_row() { + let bytes = format!("{}\r\nSession ID: {ID}\r\n", "x".repeat(CORPUS_COLS)); + assert_corpus_scrape(&Codex, bytes.as_bytes(), ID); + } + /// The scraper recovers an SGR-split exit hint from the corpus bytes after /// terminal emulation removes the styling. #[test] diff --git a/src/testutil.rs b/src/testutil.rs index c1a9e0d..fc2b067 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -222,6 +222,22 @@ pub(crate) fn v7_at(ms: u64, tail: u32) -> String { /// `session_meta` line; returns the ID. The filename timestamp is inert: /// correlation reads the v7 ID's embedded instant, never the name. pub(crate) fn write_rollout(home: &Path, ms: u64, tail: u32, cwd: &Path) -> String { + write_rollout_named(home, ms, tail, cwd, "", "") +} + +/// [`write_rollout`] with the two shapes correlation has to tell apart. +/// `stem_suffix` follows the thread ID in the filename — codex main's +/// `thread/revert` appends `_` there. `meta_extra` is spliced into +/// the `session_meta` payload verbatim, each member led by its own comma, so a +/// test can write the provenance fields 0.147.0 emits for spawned threads. +pub(crate) fn write_rollout_named( + home: &Path, + ms: u64, + tail: u32, + cwd: &Path, + stem_suffix: &str, + meta_extra: &str, +) -> String { let id = v7_at(ms, tail); let (y, m, d) = civil_from_days((ms / 86_400_000) as i64); let dir = home @@ -231,11 +247,13 @@ pub(crate) fn write_rollout(home: &Path, ms: u64, tail: u32, cwd: &Path) -> Stri .join(format!("{d:02}")); fs::create_dir_all(&dir).unwrap(); let meta = format!( - r#"{{"timestamp":"x","type":"session_meta","payload":{{"id":"{id}","cwd":"{}"}}}}"#, + r#"{{"timestamp":"x","type":"session_meta","payload":{{"id":"{id}","cwd":"{}"{meta_extra}}}}}"#, cwd.display() ); fs::write( - dir.join(format!("rollout-2026-07-13T09-00-00-{id}.jsonl")), + dir.join(format!( + "rollout-2026-07-13T09-00-00-{id}{stem_suffix}.jsonl" + )), format!("{meta}\n{{}}\n"), ) .unwrap(); From ffad972c6618b7f4a5389289dd8f633cb19286d8 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:12:07 -0700 Subject: [PATCH 04/10] fix(harness): read the codex model label off either status-line shape --- src/harness/summary.rs | 80 +++++++++++++++++++++++++----- src/harness/summary_tests.rs | 95 ++++++++++++++++++++++++++++++++++-- 2 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index fe2e011..8318964 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -314,9 +314,29 @@ impl SummaryAdapter for CodexSummary { } fn model_label(&self, rows: &[String]) -> Option { - let token = codex_token_line(rows)?; - // `codex_token_line` guarantees a non-empty first segment. - Some(rows[token].trim().split(" · ").next()?.to_string()) + let row = codex_model_row(rows)?; + // `codex_model_row` guarantees a non-empty first segment. + Some(rows[row].trim().split(" · ").next()?.to_string()) + } + + /// Canonicalize codex's two title animations so the rendered text holds + /// still: the ten-frame braille spinner folds to `⠋`, and the blocked-on- + /// user blink folds its `[ . ]` phase into `[ ! ]`. The spinner advances + /// every 100 ms and the blink runs at 1 Hz, both far inside the preview's + /// 500 ms minimum hold, so without this the dashboard re-renders on every + /// frame. `⠋` is the spinner's first frame; claude normalizes to `✻` + /// instead, and that difference is what keeps the two agents' titles + /// distinguishable on the dashboard. Other titles pass through unchanged. + fn normalize_title(&self, title: &str) -> Option { + for phase in CODEX_TITLE_BLINK { + if let Some(rest) = title.strip_prefix(phase) { + return Some(format!("{}{rest}", CODEX_TITLE_BLINK[0])); + } + } + let mut chars = title.chars(); + let frame = chars.next()?; + (('\u{2800}'..='\u{28FF}').contains(&frame) && chars.next()? == ' ') + .then(|| format!("⠋ {}", chars.as_str())) } } @@ -334,6 +354,18 @@ const CODEX_QUEUED_HEADS: &[&str] = &[ "• Queued follow-up inputs", ]; +/// Reasoning-effort words the `model-with-reasoning` status-line item renders +/// after the model slug. `default` is one of them: it is the word codex prints +/// when the profile names no effort, not the absence of a word. +const CODEX_EFFORT: &[&str] = &[ + "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default", +]; + +/// The two phases codex blinks between, at 1 Hz, while it is blocked on the +/// user. The first is the canonical one: `[ ! ]` is the phase that reads as an +/// alarm, so it is the one worth freezing on the dashboard. +const CODEX_TITLE_BLINK: &[&str] = &["[ ! ] ", "[ . ] "]; + /// Maximum indented rows crossed between the composer and the status row. /// Queued-message blocks are exempt: their height is the user's queue /// depth, so counting them would push the status row out of reach. @@ -375,21 +407,47 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "codex:approval-menu")) } -/// The token/status bar, when painted: the bottom-most -/// `{model} · {…} in · {…} out` row among the last six painted rows. -/// Independent of the composer pin because the bar may be absent; without it, -/// the anchor has no model prefix. -fn codex_token_line(rows: &[String]) -> Option { +/// The status line, when painted: the bottom-most row among the last six +/// painted ones whose ` · `-joined items open with the model. Independent of +/// the composer pin because the line is user-configured and may be absent — +/// or may omit the model entirely, in which case there is no model prefix. +/// +/// Two shapes qualify, because the items are a `[tui] status_line` array the +/// user orders and codex drops unavailable ones silently: +/// +/// - the opt-in `total-input-tokens`/`total-output-tokens` pair closing the +/// row as `{…} in · {…} out`, which pins the model to the first item; +/// - the default `model-with-reasoning` head, `{model} {effort}` with an +/// optional service tier appended, matched by [`codex_model_with_reasoning`]. +/// +/// Neither present means the model is not on the row, and no label is read: a +/// label lifted off `status_line = ["current-dir", "model"]` would name the +/// directory as the model. +fn codex_model_row(rows: &[String]) -> Option { let last = rows.iter().rposition(|r| !r.is_empty())?; (last.saturating_sub(5)..=last).rev().find(|&i| { let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); - segs.len() >= 3 - && !segs[0].is_empty() + if segs[0].is_empty() { + return false; + } + let in_out = segs.len() >= 3 && segs[segs.len() - 2].ends_with(" in") - && segs[segs.len() - 1].ends_with(" out") + && segs[segs.len() - 1].ends_with(" out"); + in_out || codex_model_with_reasoning(segs[0]) }) } +/// Whether an item is `model-with-reasoning`: `{model} {effort}`, plus the +/// service tier codex appends when the account has one (`gpt-5.4 xhigh fast`, +/// where the effort word is no longer last). Exactly two or three words, with +/// the effort word second — the render has no other shape, and holding the +/// count that tight is what keeps prose ending in an effort word (`I'll use +/// medium effort`) from being read as a model. +fn codex_model_with_reasoning(item: &str) -> bool { + let words: Vec<&str> = item.split_whitespace().collect(); + matches!(words.len(), 2 | 3) && CODEX_EFFORT.contains(&words[1]) +} + /// The composer: the bottom-most column-0 [`CODEX_PROMPT`] row — the glyph /// alone or the glyph and a space — that is not a modal selector. Rows /// below it are tolerated, never required: blank rows, indented affordance diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index daf354b..cb84556 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -511,6 +511,92 @@ fn codex_working_normalization() { ); } +/// The status line is a user-ordered item array, so the label reads either +/// shape that puts the model first: the opt-in `{…} in · {…} out` tail, or +/// the default `model-with-reasoning` head. A row carrying neither yields no +/// label rather than a guess. +#[test] +fn codex_label_reads_either_status_line_shape() { + let label = |row: &str| CodexSummary.model_label(&rs(&["›", "", row])); + for (row, want) in [ + // Rows codex 0.147.0's own snapshots render. + ( + " gpt-5.6-sol default · /tmp/project", + "gpt-5.6-sol default", + ), + (" gpt-5.4 high · feature-branch", "gpt-5.4 high"), + ( + " gpt-5.4 xhigh fast · Context 100% left · /tmp/project", + "gpt-5.4 xhigh fast", + ), + // The in/out tail still qualifies a row on its own, including one + // whose model item carries no effort word. + (" gpt-5.6-sol high · 0 in · 0 out", "gpt-5.6-sol high"), + (" gpt-5.6-sol · 28.2K in · 78 out", "gpt-5.6-sol"), + ( + " gpt-5.6-sol high · 5.26K used · 28.2K in · 78 out", + "gpt-5.6-sol high", + ), + ] { + assert_eq!(label(row), Some(want.to_string()), "{row:?}"); + } + + for effort in CODEX_EFFORT { + assert_eq!( + label(&format!(" gpt-5.6-sol {effort} · /tmp/project")), + Some(format!("gpt-5.6-sol {effort}")), + "{effort:?}" + ); + } + + for row in [ + // `status_line = ["current-dir", "model"]`: naming the directory as + // the model is worse than naming nothing. + " /tmp/project · gpt-5.6-sol", + // An effort word outside the first item does not qualify the row. + " /tmp/project · gpt-5.4 high", + // The plain `model` item, with no effort word to structure it. + " gpt-5.6-sol · /tmp/project", + // Prose ending in an effort word. + " I'll use medium effort · /tmp/project", + // The effort word with no model before it. + " high · /tmp/project", + ] { + assert_eq!(label(row), None, "{row:?}"); + } +} + +/// The braille spinner and the blocked-on-user blink each canonicalize to a +/// single string; idle and foreign titles pass through. +#[test] +fn codex_title_animations_canonicalize_to_constant_text() { + for frame in ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] { + assert_eq!( + CodexSummary.normalize_title(&format!("{frame} fleetcom")), + Some("⠋ fleetcom".to_string()), + "{frame:?}" + ); + } + + // The blink's two phases differ only in the bracketed glyph. + let on = CodexSummary.normalize_title("[ ! ] Action Required | fleetcom"); + let off = CodexSummary.normalize_title("[ . ] Action Required | fleetcom"); + assert_eq!(on, off, "both blink phases must normalize identically"); + assert_eq!(on, Some("[ ! ] Action Required | fleetcom".to_string())); + + // codex and claude fold braille to different glyphs on purpose: the + // dashboard's titles stay attributable to the CLI that painted them. + assert_ne!( + CodexSummary.normalize_title("⠹ fleetcom"), + ClaudeSummary.normalize_title("⠹ fleetcom") + ); + + // Idle drops the spinner, and foreign titles are not codex's to rewrite. + assert_eq!(CodexSummary.normalize_title("fleetcom"), None); + assert_eq!(CodexSummary.normalize_title("zellij: main"), None); + assert_eq!(CodexSummary.normalize_title("⠹"), None, "frame alone"); +} + /// The status row anchors on its parenthetical, not on a literal verb: /// the activity glyph blinks, drops to `◦`, or vanishes with animations /// off; the header is whatever the CLI put there; the interrupt key is @@ -1208,14 +1294,17 @@ fn corpus_codex_0_147_rows_anchor() { assert_eq!(got, anchor("Investigating rendering code", "codex:working")); // Sixteen queued messages separate the status row from the composer. - // `gpt-5.6-sol default · /tmp/project` is not the token bar's - // `{model} · {…} in · {…} out`, so no model label is read. + // The status line below is codex's default pair, `model-with-reasoning · + // current-dir`, so the label reads off its first item. let got = corpus( include_bytes!("../../tests/corpus/preview_codex_queued.bin"), &CodexSummary, 36, ); - assert_eq!(got, anchor("Working", "codex:working")); + assert_eq!( + got, + anchor("gpt-5.6-sol default · Working", "codex:working") + ); } /// At 30 columns, a wrapped status ellipsis fails the structure check and From f2660e184775dafbda7594ce97a5ce10b56029a9 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:12:26 -0700 Subject: [PATCH 05/10] docs(harness): name codex's chrome the way 0.147.0 renders it --- src/harness/summary.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 8318964..e55a716 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -13,8 +13,8 @@ //! To avoid treating it as live status, every matcher: //! //! 1. locates the chrome region structurally (claude's separator-pair input -//! box, codex's status bar and composer, grok's bordered input box) and -//! limits status candidates relative to it; +//! box, codex's composer and configured status line, grok's bordered +//! input box) and limits status candidates relative to it; //! 2. returns `None` when the expected structure is absent or inconsistent; //! 3. matches row prefixes so status rows truncated with an ellipsis at narrow //! widths remain recognizable. A wrapped row fails the structural check. From 3061e2ee1b2cc7650f25c520b6e3bbb131026b18 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 22:29:15 -0700 Subject: [PATCH 06/10] fix(harness): make the codex anchors refuse what they cannot distinguish --- src/harness/codex.rs | 7 +- src/harness/summary.rs | 134 +++++++++++++++++++++++------------ src/harness/summary_tests.rs | 109 +++++++++++++++++++--------- 3 files changed, 167 insertions(+), 83 deletions(-) diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 21ba387..0ac8a53 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -158,7 +158,8 @@ impl Harness for Codex { // there to the first `_` or to the end. Reading the trailing 36 // instead would return the rollout ID of a reverted thread's // `rollout--_.jsonl` — a valid UUID - // naming the wrong conversation. + // naming the wrong conversation. 0.147.0 writes no such name; + // codex main's `thread/revert` does. let Some(ids) = stem.get(20..) else { continue; }; @@ -204,8 +205,8 @@ enum NotifyRoute { /// `config.toml` is the only file read, and the injected `-c` override is why /// that suffices: it lands in codex's `SessionFlags` layer at precedence 30, /// outranking every layer a user config can occupy — user config 20, -/// user-with-profile 21, project 25. Only managed and enterprise config, at 40 -/// and 50, beat it. +/// user-with-profile 21, project 25 — and enterprise-managed config too, at +/// 15. Only the two legacy managed layers, at 40 and 50, beat it. fn config_notify_route(home: Option<&Path>) -> NotifyRoute { let Some(root) = Codex.home_root(home) else { return NotifyRoute::Vacant; diff --git a/src/harness/summary.rs b/src/harness/summary.rs index e55a716..5636e5f 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -321,17 +321,19 @@ impl SummaryAdapter for CodexSummary { /// Canonicalize codex's two title animations so the rendered text holds /// still: the ten-frame braille spinner folds to `⠋`, and the blocked-on- - /// user blink folds its `[ . ]` phase into `[ ! ]`. The spinner advances - /// every 100 ms and the blink runs at 1 Hz, both far inside the preview's - /// 500 ms minimum hold, so without this the dashboard re-renders on every - /// frame. `⠋` is the spinner's first frame; claude normalizes to `✻` - /// instead, and that difference is what keeps the two agents' titles - /// distinguishable on the dashboard. Other titles pass through unchanged. + /// user blink folds its `[ . ] ` phase into `[ ! ] `. The preview's 500 ms + /// title hold damps neither. The spinner advances every 100 ms, so the hold + /// only thins its repaints to one per window rather than stopping them; the + /// blink holds each phase a full second, longer than the hold, so every + /// phase change reaches the dashboard. `[ ! ]` is the phase that reads as + /// an alarm, so it is the one worth freezing — and a title already in that + /// phase needs no rewrite, which is why only one phase is folded. `⠋` is + /// the spinner's first frame; claude normalizes to `✻` instead, and that + /// difference is what keeps the two agents' titles distinguishable on the + /// dashboard. Other titles pass through unchanged. fn normalize_title(&self, title: &str) -> Option { - for phase in CODEX_TITLE_BLINK { - if let Some(rest) = title.strip_prefix(phase) { - return Some(format!("{}{rest}", CODEX_TITLE_BLINK[0])); - } + if let Some(rest) = title.strip_prefix("[ . ] ") { + return Some(format!("[ ! ] {rest}")); } let mut chars = title.chars(); let frame = chars.next()?; @@ -341,13 +343,22 @@ impl SummaryAdapter for CodexSummary { } /// Composer prompt glyphs: `!` in bash mode, `»` at `ultra` reasoning -/// effort, `›` otherwise. All three render at column 0 and are dim while -/// input is disabled, which costs the row no text. +/// effort, `›` otherwise. All three render at column 0. Disabling input swaps +/// whichever glyph the mode would paint for a dim `›`: the swap stays inside +/// this set, and dimness is style rather than text, so neither reaches the +/// pin. const CODEX_PROMPT: &[char] = &['›', '»', '!']; /// Queued-message group heads codex paints between the status row and the /// composer, each over its own ` ↳ `-indented item rows. The heads sit at -/// column 0 and are chrome, not status. +/// column 0 and are chrome, not status. Matched as prefixes: codex appends +/// ` (press {key} to interrupt and send immediately)` to the first head +/// whenever an interrupt key is bound, which is the default, so the painted +/// row is 93 columns and an exact match fails at every width that fits it. +/// All three take the prefix rule rather than one taking an exception — +/// over-matching a reply bullet that opens with a head's words costs one +/// skipped row, while missing a head aborts the walk this constant exists to +/// let through. const CODEX_QUEUED_HEADS: &[&str] = &[ "• Messages to be submitted after next tool call", "• Messages to be submitted at end of turn", @@ -356,16 +367,14 @@ const CODEX_QUEUED_HEADS: &[&str] = &[ /// Reasoning-effort words the `model-with-reasoning` status-line item renders /// after the model slug. `default` is one of them: it is the word codex prints -/// when the profile names no effort, not the absence of a word. +/// when the profile names no effort, not the absence of a word. `none` is not: +/// codex's label function folds `ReasoningEffort::None` into `default`, so the +/// word never reaches the row and listing it would only widen the surface a +/// two-word phrase has to be read as a model. const CODEX_EFFORT: &[&str] = &[ - "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default", + "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default", ]; -/// The two phases codex blinks between, at 1 Hz, while it is blocked on the -/// user. The first is the canonical one: `[ ! ]` is the phase that reads as an -/// alarm, so it is the one worth freezing on the dashboard. -const CODEX_TITLE_BLINK: &[&str] = &["[ ! ] ", "[ . ] "]; - /// Maximum indented rows crossed between the composer and the status row. /// Queued-message blocks are exempt: their height is the user's queue /// depth, so counting them would push the status row out of reach. @@ -423,9 +432,19 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { /// Neither present means the model is not on the row, and no label is read: a /// label lifted off `status_line = ["current-dir", "model"]` would name the /// directory as the model. +/// +/// Both shapes additionally require the row to be indented. codex renders the +/// status line through its footer, which prefixes every line with two spaces, +/// while the composer and reply bullets start at column 0 — and the composer +/// is inside this window, so a user who has typed `ultra mode` otherwise +/// paints a row that reads as `model-with-reasoning` and prefixes every +/// preview with `› ultra mode · `. fn codex_model_row(rows: &[String]) -> Option { let last = rows.iter().rposition(|r| !r.is_empty())?; (last.saturating_sub(5)..=last).rev().find(|&i| { + if !rows[i].starts_with(' ') { + return false; + } let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); if segs[0].is_empty() { return false; @@ -440,9 +459,13 @@ fn codex_model_row(rows: &[String]) -> Option { /// Whether an item is `model-with-reasoning`: `{model} {effort}`, plus the /// service tier codex appends when the account has one (`gpt-5.4 xhigh fast`, /// where the effort word is no longer last). Exactly two or three words, with -/// the effort word second — the render has no other shape, and holding the +/// the effort word second — the item has no other word count, and holding the /// count that tight is what keeps prose ending in an effort word (`I'll use -/// medium effort`) from being read as a model. +/// medium effort`) from being read as a model. The effort vocabulary is not +/// closed the way the count is: `ReasoningEffort::Custom(String)` carries a +/// model-defined word [`CODEX_EFFORT`] cannot list, and a row rendering one +/// yields no label at all — the safe direction, and the reason this stays a +/// list rather than a shape test. fn codex_model_with_reasoning(item: &str) -> bool { let words: Vec<&str> = item.split_whitespace().collect(); matches!(words.len(), 2 | 3) && CODEX_EFFORT.contains(&words[1]) @@ -485,7 +508,7 @@ fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static st indented += 1; continue; } - if CODEX_QUEUED_HEADS.contains(&row.as_str()) { + if CODEX_QUEUED_HEADS.iter().any(|h| row.starts_with(h)) { indented = 0; continue; } @@ -512,9 +535,9 @@ fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static st /// disabled — absent along with its space, so it is optional. The header is /// a free-form `String` (`Working` is only the default; a reasoning phrase, /// `Booting MCP server: {name}`, and verbatim stream errors all land there), -/// which leaves the parenthetical as the only fixed structure. Anchoring on -/// [`codex_elapsed`] rather than the closing `)` keeps rows truncated at the -/// terminal's width matchable. +/// which leaves the parenthetical as the only fixed structure. Matching it +/// through [`codex_interrupt_paren`] rather than on the closing `)` keeps rows +/// truncated at the terminal's width matchable. fn codex_status_head(row: &str) -> Option<(&str, &str)> { let rest = row .strip_prefix("• ") @@ -527,40 +550,59 @@ fn codex_status_head(row: &str) -> Option<(&str, &str)> { // (1/3): a, b, c`), so the first ` (` opening a counter wins. rest.match_indices(" (").find_map(|(i, _)| { let after = &rest[i + " (".len()..]; - codex_elapsed(after).then(|| (&rest[..i], after)) + codex_interrupt_paren(after).then(|| (&rest[..i], after)) }) } -/// Whether `s` opens with codex's compact elapsed counter: space-separated -/// `{digits}{unit}` fields in strictly descending `h`, `m`, `s` order, -/// ending at the seconds field — `0s`, `1m 00s`, `25h 02m 03s`. The counter -/// must close the row or be followed by a space or `)`; a field that is not -/// digits plus a unit (`1/3`, `9.9s`) fails. This token carries the whole -/// anchor, since the header left of it is free-form. -fn codex_elapsed(s: &str) -> bool { +/// Whether `s` opens with the status widget's parenthetical in full: +/// [`codex_elapsed`]'s counter, then ` • {key} to interrupt)`. The counter +/// alone is not enough of an anchor, because the row it anchors shares its +/// slot with the turn's last reply bullet: `• Build finished (3m 20s)` is a +/// sentence a coding agent writes, and reading it as live status reports a +/// finished turn as busy. The interrupt hint is the part conversation text +/// does not reproduce. +/// +/// This costs the one render codex writes without a hint, `({elapsed})` — +/// reachable only with the interrupt key unbound, or during Windows +/// elevated-sandbox setup, a platform this crate's unconditional `nix` +/// dependency rules out. That render is textually identical to the prose, so +/// no rule separates them; refusing both is the direction this file takes when +/// a shape is ambiguous. +/// +/// A row the CLI cut at the terminal's width never closes its paren and ends +/// in the CLI's own `…`, which is what admits it with the hint still partial. +fn codex_interrupt_paren(s: &str) -> bool { + let Some(hint) = codex_elapsed(s).and_then(|rest| rest.strip_prefix(" • ")) else { + return false; + }; + match hint.find(')') { + Some(end) => hint[..end].ends_with(" to interrupt"), + None => hint.ends_with('…'), + } +} + +/// The text after codex's compact elapsed counter, or `None` when `s` does +/// not open with one: space-separated `{digits}{unit}` fields in strictly +/// descending `h`, `m`, `s` order, ending at the seconds field — `0s`, +/// `1m 00s`, `25h 02m 03s`. A field that is not digits plus a unit (`1/3`, +/// `9.9s`) fails. +fn codex_elapsed(s: &str) -> Option<&str> { let mut rest = s; let mut units = "hms"; loop { let digits = rest.chars().take_while(char::is_ascii_digit).count(); if digits == 0 { - return false; + return None; } let tail = &rest[digits..]; - let Some(unit) = tail.chars().next() else { - return false; - }; - let Some(at) = units.find(unit) else { - return false; - }; + let unit = tail.chars().next()?; + let at = units.find(unit)?; units = &units[at + 1..]; let after = &tail[unit.len_utf8()..]; if unit == 's' { - return after.is_empty() || after.starts_with([' ', ')']); + return Some(after); } - let Some(next) = after.strip_prefix(' ') else { - return false; - }; - rest = next; + rest = after.strip_prefix(' ')?; } } diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index cb84556..6adc6a2 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -541,14 +541,6 @@ fn codex_label_reads_either_status_line_shape() { assert_eq!(label(row), Some(want.to_string()), "{row:?}"); } - for effort in CODEX_EFFORT { - assert_eq!( - label(&format!(" gpt-5.6-sol {effort} · /tmp/project")), - Some(format!("gpt-5.6-sol {effort}")), - "{effort:?}" - ); - } - for row in [ // `status_line = ["current-dir", "model"]`: naming the directory as // the model is worse than naming nothing. @@ -561,9 +553,22 @@ fn codex_label_reads_either_status_line_shape() { " I'll use medium effort · /tmp/project", // The effort word with no model before it. " high · /tmp/project", + // `none` is folded into `default` before the row is rendered, so the + // word is not a model's effort. + " gpt-5.6-sol none · /tmp/project", ] { assert_eq!(label(row), None, "{row:?}"); } + + // codex indents the status line by the footer's two columns; the composer + // and reply bullets do not. Without that discriminator a typed draft + // takes the `model-with-reasoning` shape and prefixes every preview. + assert_eq!(CodexSummary.model_label(&rs(&["› ultra mode"])), None); + assert_eq!( + CodexSummary.model_label(&rs(&["›", "", "gpt-5.6-sol high · 0 in · 0 out"])), + None, + "an unindented row is not the status line, whichever shape it takes" + ); } /// The braille spinner and the blocked-on-user blink each canonicalize to a @@ -578,11 +583,18 @@ fn codex_title_animations_canonicalize_to_constant_text() { ); } - // The blink's two phases differ only in the bracketed glyph. - let on = CodexSummary.normalize_title("[ ! ] Action Required | fleetcom"); - let off = CodexSummary.normalize_title("[ . ] Action Required | fleetcom"); - assert_eq!(on, off, "both blink phases must normalize identically"); - assert_eq!(on, Some("[ ! ] Action Required | fleetcom".to_string())); + // The blink's two phases differ only in the bracketed glyph, so rewriting + // one direction is the whole fold: `[ ! ]` passes through and renders + // verbatim, which is the same text the `[ . ]` phase folds to. + assert_eq!( + CodexSummary.normalize_title("[ . ] Action Required | fleetcom"), + Some("[ ! ] Action Required | fleetcom".to_string()) + ); + assert_eq!( + CodexSummary.normalize_title("[ ! ] Action Required | fleetcom"), + None, + "the frozen phase needs no rewrite" + ); // codex and claude fold braille to different glyphs on purpose: the // dashboard's titles stay attributable to the CLI that painted them. @@ -600,7 +612,7 @@ fn codex_title_animations_canonicalize_to_constant_text() { /// The status row anchors on its parenthetical, not on a literal verb: /// the activity glyph blinks, drops to `◦`, or vanishes with animations /// off; the header is whatever the CLI put there; the interrupt key is -/// remappable and its hint can be switched off entirely. +/// remappable. #[test] fn codex_status_anchors_on_the_interrupt_parenthetical() { let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); @@ -612,8 +624,6 @@ fn codex_status_anchors_on_the_interrupt_parenthetical() { "Working (0s • esc to interrupt)", // The interrupt key is remappable. "• Working (0s • f12 to interrupt)", - // The hint is off: the parenthetical is the counter alone. - "• Working (0s)", ] { assert_eq!( probe(row), @@ -659,7 +669,8 @@ fn codex_status_anchors_on_the_interrupt_parenthetical() { ); } - // The suffix and truncation rules hold for a non-default header. + // A non-default header carries its suffix through the same rules + // `codex_working_normalization` pins row by row for `Working`. assert_eq!( probe( "• Reviewing 2 approval requests (7s • esc to interrupt) · 1 background terminal running · /ps to view" @@ -669,15 +680,10 @@ fn codex_status_anchors_on_the_interrupt_parenthetical() { "codex:working" )) ); - assert_eq!( - probe("• Investigating rendering code (7s • esc to…"), - Some(("Investigating rendering code".to_string(), "codex:working")), - "an unclosed parenthetical drops to the end of the row" - ); } -/// Status-shaped rows whose counter is malformed refuse: the elapsed token -/// is the whole anchor, because the header above it is unconstrained. +/// Status-shaped rows whose counter is malformed refuse: the parenthetical +/// carries the whole anchor, because the header left of it is unconstrained. #[test] fn codex_status_rejects_malformed_counters() { let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); @@ -700,6 +706,33 @@ fn codex_status_rejects_malformed_counters() { } } +/// A finished turn's last reply bullet occupies the status row's slot, so +/// conversation prose that ends in a duration must not read as live status. +/// The whole interrupt parenthetical is the anchor; the counter alone is a +/// shape agents write in sentences. +#[test] +fn codex_status_refuses_conversation_prose() { + let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); + for row in [ + // A duration parenthetical, and nothing to say it is a counter. + "• Build finished (3m 20s)", + // A space after the seconds field is not the hint separator. + "• Benchmarks improved (12s → 8s)", + // ` • ` alone is reachable in prose; the hint text is not. + "• Fixed the timeout (5s • retry logic)", + // Truncation is the only reason a parenthetical goes unclosed, and + // the CLI's ellipsis is what proves it. + "• Timed the suite (12s • 4 shards", + ] { + assert_eq!(probe(row), None, "{row:?}"); + } + + // Refusing the bare counter costs the hint-off render, `({elapsed})`, + // which needs the interrupt key unbound: a static row over a wrong live + // one, per this file's anchor discipline. + assert_eq!(probe("• Working (12s)"), None); +} + /// Every composer glyph pins the adapter: `!` in bash mode, `»` at `ultra` /// reasoning effort, `›` otherwise. The glyph stands alone or heads a /// space; glued to text it is body content and the pin fails. @@ -728,11 +761,7 @@ fn codex_composer_accepts_every_prompt_glyph() { /// earns that pass. #[test] fn codex_status_walks_past_queued_message_blocks() { - for head in [ - "• Messages to be submitted after next tool call", - "• Messages to be submitted at end of turn", - "• Queued follow-up inputs", - ] { + let walks = |head: &str| { let mut rows = vec![ "• Working (0s • esc to interrupt)".to_string(), String::new(), @@ -740,13 +769,25 @@ fn codex_status_walks_past_queued_message_blocks() { ]; rows.extend((0..24).map(|i| format!(" ↳ Hello, world! {i}"))); rows.extend([String::new(), "› ".to_string()]); - assert_eq!( - CodexSummary.live_preview(&rows), - Some(("Working".to_string(), "codex:working")), - "{head:?}" - ); + CodexSummary.live_preview(&rows) + }; + let working = Some(("Working".to_string(), "codex:working")); + for head in CODEX_QUEUED_HEADS { + assert_eq!(walks(head), working, "{head:?}"); } + // The pending-steers head carries an interrupt hint whenever a key is + // bound, which is the default, and codex wraps rather than truncates it. + // From 93 columns up the row is painted whole — the case a bare literal + // never matches. + assert_eq!( + walks( + "• Messages to be submitted after next tool call (press esc to interrupt and send immediately)" + ), + working, + "the suffixed head at a width that does not wrap" + ); + // A near-miss head is a foreign column-0 row and aborts the scan. let foreign = rs(&[ "• Working (0s • esc to interrupt)", From 702daf1c5484d28e003d6134c3e7ff4b44de1e84 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 12:17:56 -0700 Subject: [PATCH 07/10] refactor(harness): make the codex adapter read like its siblings --- src/harness/codex.rs | 14 ++--- src/harness/summary.rs | 106 ++++++++++++++++------------------- src/harness/summary_tests.rs | 12 ++-- src/preview.rs | 2 + 4 files changed, 60 insertions(+), 74 deletions(-) diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 0ac8a53..532462c 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -89,15 +89,11 @@ impl Harness for Codex { let mut last = None; for line in text.lines() { // Fatal exit with no hint: codex `println!`s a bare - // `Session ID: `, the last channel left once the notify hook - // has not fired and the rollout may be empty. Only a whole row - // counts. The phrase carries no program word to narrow it, so an - // unanchored match would let any scrollback line quoting it decide - // the resume ID of a task killed before codex printed an exit line - // at all — a wrong ID where the fall through to `correlate_fs` - // would have been provenance-checked. Rendered model output always - // carries a `• ` head or a two-space continuation indent, so it - // cannot reach offset 0. + // `Session ID: `, the last channel left once the notify + // hook has not fired and the rollout may be empty. The phrase + // carries no program word to narrow it, so only a whole row at + // offset 0 counts: rendered model output always carries a `• ` + // head or a two-space continuation indent and never reaches it. if let Some(rest) = line.strip_prefix("Session ID: ") && let Some(id) = leading_uuid(rest) { diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 5636e5f..7dc59da 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -297,51 +297,6 @@ fn claude_welcome_label(rows: &[String]) -> Option { // ----------------------------------------------------------------- codex -- -/// codex (inline UI, primary screen). The pin is its composer: the -/// bottom-most column-0 prompt-glyph row that is not a modal selector; -/// status rows sit above it, and scrollback beyond the first foreign row is -/// out of bounds. The approval modal removes the composer and is checked -/// first. A token bar or indented hint rows may appear below the composer. -pub struct CodexSummary; - -impl SummaryAdapter for CodexSummary { - fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> { - if let Some(hit) = codex_approval(rows) { - return Some(hit); - } - let composer = codex_composer(rows)?; - codex_status(rows, composer) - } - - fn model_label(&self, rows: &[String]) -> Option { - let row = codex_model_row(rows)?; - // `codex_model_row` guarantees a non-empty first segment. - Some(rows[row].trim().split(" · ").next()?.to_string()) - } - - /// Canonicalize codex's two title animations so the rendered text holds - /// still: the ten-frame braille spinner folds to `⠋`, and the blocked-on- - /// user blink folds its `[ . ] ` phase into `[ ! ] `. The preview's 500 ms - /// title hold damps neither. The spinner advances every 100 ms, so the hold - /// only thins its repaints to one per window rather than stopping them; the - /// blink holds each phase a full second, longer than the hold, so every - /// phase change reaches the dashboard. `[ ! ]` is the phase that reads as - /// an alarm, so it is the one worth freezing — and a title already in that - /// phase needs no rewrite, which is why only one phase is folded. `⠋` is - /// the spinner's first frame; claude normalizes to `✻` instead, and that - /// difference is what keeps the two agents' titles distinguishable on the - /// dashboard. Other titles pass through unchanged. - fn normalize_title(&self, title: &str) -> Option { - if let Some(rest) = title.strip_prefix("[ . ] ") { - return Some(format!("[ ! ] {rest}")); - } - let mut chars = title.chars(); - let frame = chars.next()?; - (('\u{2800}'..='\u{28FF}').contains(&frame) && chars.next()? == ' ') - .then(|| format!("⠋ {}", chars.as_str())) - } -} - /// Composer prompt glyphs: `!` in bash mode, `»` at `ultra` reasoning /// effort, `›` otherwise. All three render at column 0. Disabling input swaps /// whichever glyph the mode would paint for a dim `›`: the swap stays inside @@ -380,6 +335,43 @@ const CODEX_EFFORT: &[&str] = &[ /// depth, so counting them would push the status row out of reach. const CODEX_STATUS_WINDOW: usize = 10; +/// codex (inline UI, primary screen). The pin is its composer: the +/// bottom-most column-0 prompt-glyph row that is not a modal selector; +/// status rows sit above it, and scrollback beyond the first foreign row is +/// out of bounds. The approval modal removes the composer and is checked +/// first. The status line or indented hint rows may appear below the composer. +pub struct CodexSummary; + +impl SummaryAdapter for CodexSummary { + fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> { + if let Some(hit) = codex_approval(rows) { + return Some(hit); + } + let composer = codex_composer(rows)?; + codex_status(rows, composer) + } + + fn model_label(&self, rows: &[String]) -> Option { + codex_model_label(rows) + } + + /// Canonicalize codex's two title animations: the ten-frame braille + /// spinner folds to its first frame `⠋`, the blocked-on-user blink folds + /// its `[ . ] ` phase into `[ ! ] `. The preview's 500 ms title hold damps + /// neither — the spinner advances every 100 ms, the blink holds each phase + /// a full second. Only `[ . ] ` folds: `[ ! ]` reads as an alarm, and a + /// title already in that phase needs no rewrite. Others pass unchanged. + fn normalize_title(&self, title: &str) -> Option { + if let Some(rest) = title.strip_prefix("[ . ] ") { + return Some(format!("[ ! ] {rest}")); + } + let mut chars = title.chars(); + let frame = chars.next()?; + (('\u{2800}'..='\u{28FF}').contains(&frame) && chars.next()? == ' ') + .then(|| format!("⠋ {}", chars.as_str())) + } +} + /// `› 1. Yes, proceed (y)`: the modal's selected option row (column-0 `›`, /// one digit, `. `). fn codex_menu_head(row: &str) -> bool { @@ -397,7 +389,7 @@ fn codex_numbered_option(row: &str) -> bool { /// codex's approval modal: a selector row with an indented numbered sibling /// below it, pinned to the last nine painted rows. The modal removes the -/// composer and token bar; that absence is the disambiguator (a menu quoted +/// composer and status line; that absence is the disambiguator (a menu quoted /// in the conversation always has the live composer below it, so any /// non-selector [`CODEX_PROMPT`] row under the selector suppresses the /// match). Suppression tests the glyph alone, without the composer's @@ -416,10 +408,10 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "codex:approval-menu")) } -/// The status line, when painted: the bottom-most row among the last six -/// painted ones whose ` · `-joined items open with the model. Independent of -/// the composer pin because the line is user-configured and may be absent — -/// or may omit the model entirely, in which case there is no model prefix. +/// The model label: the first ` · `-joined item of the bottom-most row among +/// the last six painted ones whose items open with the model. The row is the +/// status line, independent of the composer pin: it is user-configured and +/// may be absent — or omit the model entirely, leaving no model prefix. /// /// Two shapes qualify, because the items are a `[tui] status_line` array the /// user orders and codex drops unavailable ones silently: @@ -439,20 +431,20 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { /// is inside this window, so a user who has typed `ultra mode` otherwise /// paints a row that reads as `model-with-reasoning` and prefixes every /// preview with `› ultra mode · `. -fn codex_model_row(rows: &[String]) -> Option { +fn codex_model_label(rows: &[String]) -> Option { let last = rows.iter().rposition(|r| !r.is_empty())?; - (last.saturating_sub(5)..=last).rev().find(|&i| { + (last.saturating_sub(5)..=last).rev().find_map(|i| { if !rows[i].starts_with(' ') { - return false; + return None; } let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); if segs[0].is_empty() { - return false; + return None; } let in_out = segs.len() >= 3 && segs[segs.len() - 2].ends_with(" in") && segs[segs.len() - 1].ends_with(" out"); - in_out || codex_model_with_reasoning(segs[0]) + (in_out || codex_model_with_reasoning(segs[0])).then(|| segs[0].to_string()) }) } @@ -474,8 +466,8 @@ fn codex_model_with_reasoning(item: &str) -> bool { /// The composer: the bottom-most column-0 [`CODEX_PROMPT`] row — the glyph /// alone or the glyph and a space — that is not a modal selector. Rows /// below it are tolerated, never required: blank rows, indented affordance -/// hints (`tab to queue message`), or the token bar. The working layout can -/// paint hints below the composer with no bar at all. Prompt echoes in +/// hints (`tab to queue message`), or the status line. The working layout can +/// paint hints below the composer with no status line at all. Prompt echoes in /// scrollback share the glyph but sit above the composer, so the /// bottom-most wins. fn codex_composer(rows: &[String]) -> Option { diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 6adc6a2..c9623ce 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -505,10 +505,6 @@ fn codex_working_normalization() { "{row:?}" ); } - assert_eq!( - CodexSummary.model_label(&rs(&tail[1..])), - Some("gpt-5.6-sol high".to_string()) - ); } /// The status line is a user-ordered item array, so the label reads either @@ -739,7 +735,7 @@ fn codex_status_refuses_conversation_prose() { #[test] fn codex_composer_accepts_every_prompt_glyph() { let status = "• Working (3s • esc to interrupt)"; - for glyph in ['›', '»', '!'] { + for glyph in CODEX_PROMPT { for composer in [glyph.to_string(), format!("{glyph} Write tests")] { assert_eq!( CodexSummary.live_preview(&rs(&[status, "", &composer])), @@ -913,18 +909,18 @@ fn codex_quoted_menu_with_a_live_composer_is_not_a_modal() { // Every prompt glyph suppresses: the modal selector is always `›` // whatever the composer renders, so a `»` or `!` composer below a // quoted menu is still a live composer and still disqualifies it. - for composer in ["›", "»", "!"] { + for glyph in CODEX_PROMPT { let quoted = rs(&[ "• I found these options in the doc:", "", "› 1. Yes, proceed (y)", " 2. No, cancel (esc)", "", - composer, + &glyph.to_string(), "", " gpt-5.6-sol high · 0 in · 0 out", ]); - assert_eq!(CodexSummary.live_preview("ed), None, "{composer}"); + assert_eq!(CodexSummary.live_preview("ed), None, "{glyph}"); } } diff --git a/src/preview.rs b/src/preview.rs index 900ad96..369c2ae 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -80,6 +80,8 @@ pub trait SummaryAdapter: Sync { /// Optionally normalize a captured title for display. Emulator title /// capture remains program-agnostic; `None` renders the title verbatim. + /// Adapters fold animation frames to a per-CLI glyph, so a normalized + /// title still names the agent that painted it. fn normalize_title(&self, _title: &str) -> Option { None } From c67756b4d1def777e9faf9f9cb71dd32b6e11932 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 12:18:26 -0700 Subject: [PATCH 08/10] refactor(harness): finish the status-line rename in the codex tests --- src/harness/summary_tests.rs | 8 ++++---- tests/corpus/README.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index c9623ce..e1a3352 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -845,10 +845,10 @@ fn codex_ran_stops_at_foreign_rows() { assert_eq!(CodexSummary.live_preview(&behind_reply), None); } -/// A hint row may follow the composer without a token bar. The anchor +/// A hint row may follow the composer without a status line. The anchor /// still fires, without a model prefix. #[test] -fn codex_hint_row_layout_anchors_without_a_token_bar() { +fn codex_hint_row_layout_anchors_without_a_status_line() { let hinted = rs(&[ "• Running cargo test --test daemon_env", "", @@ -866,7 +866,7 @@ fn codex_hint_row_layout_anchors_without_a_token_bar() { assert_eq!(CodexSummary.model_label(&hinted), None); } -/// The approval modal replaces composer and token bar with a numbered +/// The approval modal replaces composer and status line with a numbered /// menu; the selector row plus a numbered sibling synthesizes the /// label, wherever the selection sits. #[test] @@ -1136,7 +1136,7 @@ fn corpus_positive_states_anchor_exactly() { "preview_codex_hint_row", include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"), &CodexSummary, - // No token bar in this layout: no model prefix, correctly. + // No status line in this layout: no model prefix, correctly. "Working", "codex:working", ), diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 353dfbf..b9487a6 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -69,8 +69,8 @@ roster below it. | `preview_codex_working_over_ran.bin` | codex working with a `• Ran` row higher in the same turn | `codex:working` wins at the pin; the stale row never surfaces | | `preview_codex_scrollback.bin` | codex finished turn, `• Ran` from the prior turn in scrollback | the scan stops at the reply bullet and resolves to the floor tier | | `preview_codex_ran.bin` | codex transient completion row | `codex:ran` extraction through the `└` attachment row | -| `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no token bar | `codex:working` through the composer pin; no model prefix without the bar | -| `preview_codex_approval.bin` | codex approval modal: composer and token bar replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | +| `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no status line | `codex:working` through the composer pin; no model prefix without one | +| `preview_codex_approval.bin` | codex approval modal: composer and status line replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | | `preview_codex_reasoning.bin` | codex 0.147.0 status row headed by the model's own reasoning phrase, over an `• Explored` group and a reply bullet, composer carrying text, no status line | `codex:working` keeps the CLI's header verbatim; the `•`-headed rows above it never surface, and no status line means no model prefix | | `preview_codex_queued.bin` | codex 0.147.0 `• Working` row separated from the composer by a `• Queued follow-up inputs` block, default `status_line` below | `codex:working` survives the queued-message heads; the model label reads the default `model-with-reasoning · current-dir` shape | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | From 02cc733f37f99b6f0ed7b516e1e92d10b99b2737 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 13:29:33 -0700 Subject: [PATCH 09/10] fix(harness): let codex correlation survive reverts and symlinked cwds --- src/harness/codex.rs | 48 +++++++++++++++++++++++++++++++++++++++++--- src/preview.rs | 7 ++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 532462c..a7e1e38 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -172,7 +172,14 @@ impl Harness for Codex { if !line1_admits(&entry.path(), cwd) { continue; } - survivors.push(id.to_string()); + // A reverted thread keeps its ID and gains a second rollout, + // so both names carry one conversation and both land in the + // window the shared ID's v7 instant defines. The same uuid + // twice is still one candidate. + let id = id.to_string(); + if !survivors.contains(&id) { + survivors.push(id); + } } } match survivors.as_slice() { @@ -354,7 +361,14 @@ fn line1_admits(path: &Path, cwd: &Path) -> bool { { return false; } - payload["cwd"].as_str().is_some_and(|c| Path::new(c) == cwd) + // codex records the cwd its own process reports, and `getcwd(3)` resolves + // symlinks: a task spawned in `/tmp/x` on macOS is recorded as + // `/private/tmp/x` and never matches verbatim. Resolving this side is + // enough — the recorded path is already physical. + payload["cwd"].as_str().is_some_and(|c| { + let recorded = Path::new(c); + recorded == cwd || cwd.canonicalize().is_ok_and(|p| p == recorded) + }) } #[cfg(test)] @@ -837,8 +851,11 @@ mod tests { let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); let cwd = Path::new("/work/proj"); let rollout_id = v7_at(spawn_ms + 1_000, 9); + // One home throughout: revert keeps the thread ID and adds a rollout + // rather than replacing one, so the two names coexist and the second + // pass proves the pair still resolves to a single conversation. + let home = temp("codex_revert_name"); for suffix in [String::new(), format!("_{rollout_id}")] { - let home = temp("codex_revert_name"); let thread = write_rollout_named(&home, spawn_ms + 1_000, 1, cwd, &suffix, ""); assert_ne!(thread, rollout_id); assert_eq!( @@ -849,6 +866,31 @@ mod tests { } } + /// codex records the cwd `getcwd(3)` reports, which has resolved every + /// symlink; fleetcom holds the path the task was spawned with. On macOS a + /// task under `/tmp` is recorded as `/private/tmp` and a verbatim compare + /// never matches. + #[test] + fn correlate_fs_matches_a_symlinked_spawn_path() { + let home = temp("codex_symlink_cwd"); + let spawn_ms: u64 = 1_785_000_000_000; + let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); + + let real = home.join("real"); + fs::create_dir_all(&real).unwrap(); + let link = home.join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // The rollout names the resolved path; the task carries the link. + let id = write_rollout(&home, spawn_ms + 1_000, 1, &real.canonicalize().unwrap()); + assert_eq!( + Codex.correlate_fs(&link, spawned, Some(&home)).as_deref(), + Some(id.as_str()) + ); + // An unrelated directory still fails, resolved or not. + assert_eq!(Codex.correlate_fs(&home, spawned, Some(&home)), None); + } + /// The ±2-day probe includes a rollout in the adjacent day directory. #[test] fn correlate_fs_spans_adjacent_day_directories() { diff --git a/src/preview.rs b/src/preview.rs index 369c2ae..dcc5afa 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -133,9 +133,10 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P }, }; } - // Leading indentation is layout, not meaning: codex's status bar (an - // inline UI's bottom-most row, the floor of an idle codex task) indents - // itself, and the spaces waste preview width. Trimmed here, not in + // Leading indentation is layout, not meaning: codex's status line (its + // bottom-most row, and so the floor of an idle codex task whenever the + // user configures one) indents itself, and the spaces waste preview + // width. Trimmed here, not in // `live_floor`: the emulator's row stays a faithful fact because it // doubles as the teardown-snapshot comparator. let floor = screen.live_floor(); From 2fe24f89df832ce36ab689295f318e94eaacdaf4 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 13:47:58 -0700 Subject: [PATCH 10/10] refactor(harness): streamline codex comments and improve clarity in summary and test files --- src/harness/codex.rs | 126 +++++++++----------------------- src/harness/summary.rs | 136 ++++++++++------------------------- src/harness/summary_tests.rs | 72 +++++++------------ src/preview.rs | 9 +-- src/testutil.rs | 8 +-- tests/corpus/README.md | 14 ++-- 6 files changed, 110 insertions(+), 255 deletions(-) diff --git a/src/harness/codex.rs b/src/harness/codex.rs index a7e1e38..da8d6f8 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,17 +1,8 @@ //! Codex does not let the caller select an ID at launch. This harness instead //! injects a `notify` override, chains compatible configured notifiers, and -//! scans every exit line that carries an ID. When neither channel yields an ID, -//! it correlates rollout files under -//! `/sessions/YYYY/MM/DD/rollout--.jsonl`. -//! -//! Those files are created lazily: `RolloutRecorder::new` precomputes the path -//! and defers creation until the first persisted item, so a session that never -//! received a prompt leaves no rollout at all and [`Codex::correlate_fs`] -//! returns `None`. That is the right answer — there is no conversation to -//! resume. Creation opens the path `O_APPEND|O_CREAT` in place rather than -//! writing a temp file and renaming it, so a zero-byte or half-written first -//! line is briefly observable; the `jzon::parse` guard in [`line1_admits`] -//! rejects one. +//! scans supported exit lines for an ID. When neither channel yields one, it +//! correlates rollout files under `/sessions/YYYY/MM/DD/`. +//! Missing, empty, and malformed rollouts do not produce a candidate. use std::{ fmt::Write as _, @@ -88,12 +79,8 @@ impl Harness for Codex { fn scrape_exit(&self, text: &str) -> Option { let mut last = None; for line in text.lines() { - // Fatal exit with no hint: codex `println!`s a bare - // `Session ID: `, the last channel left once the notify - // hook has not fired and the rollout may be empty. The phrase - // carries no program word to narrow it, so only a whole row at - // offset 0 counts: rendered model output always carries a `• ` - // head or a two-space continuation indent and never reaches it. + // `Session ID:` has no program marker and can appear in captured + // conversation text. Accept it only at the start of a row. if let Some(rest) = line.strip_prefix("Session ID: ") && let Some(id) = leading_uuid(rest) { @@ -149,13 +136,9 @@ impl Harness for Codex { else { continue; }; - // The stem opens with 19 timestamp characters - // (`YYYY-MM-DDTHH-MM-SS`) and one `-`; the thread ID runs from - // there to the first `_` or to the end. Reading the trailing 36 - // instead would return the rollout ID of a reverted thread's - // `rollout--_.jsonl` — a valid UUID - // naming the wrong conversation. 0.147.0 writes no such name; - // codex main's `thread/revert` does. + // The 20-byte timestamp prefix precedes the thread ID. A + // suffix may carry a second UUID, so reading the final UUID + // can select a rollout ID instead of the conversation. let Some(ids) = stem.get(20..) else { continue; }; @@ -172,10 +155,8 @@ impl Harness for Codex { if !line1_admits(&entry.path(), cwd) { continue; } - // A reverted thread keeps its ID and gains a second rollout, - // so both names carry one conversation and both land in the - // window the shared ID's v7 instant defines. The same uuid - // twice is still one candidate. + // Multiple rollouts may name the same thread. Correlation + // counts that thread once. let id = id.to_string(); if !survivors.contains(&id) { survivors.push(id); @@ -201,15 +182,9 @@ enum NotifyRoute { Opaque, } -/// Classify the effective `notify` route from `config.toml`. Because this is -/// deliberately line-based rather than TOML-aware, two `notify` lines in one -/// file are ambiguous and produce [`NotifyRoute::Opaque`]. -/// -/// `config.toml` is the only file read, and the injected `-c` override is why -/// that suffices: it lands in codex's `SessionFlags` layer at precedence 30, -/// outranking every layer a user config can occupy — user config 20, -/// user-with-profile 21, project 25 — and enterprise-managed config too, at -/// 15. Only the two legacy managed layers, at 40 and 50, beat it. +/// Classify the `notify` route declared in `config.toml`. The parser is +/// deliberately line-based: duplicate assignments are ambiguous and produce +/// [`NotifyRoute::Opaque`]. fn config_notify_route(home: Option<&Path>) -> NotifyRoute { let Some(root) = Codex.home_root(home) else { return NotifyRoute::Vacant; @@ -329,18 +304,11 @@ fn v7_millis(id: &str) -> Option { u64::from_str_radix(&format!("{}{}", &id[..8], &id[9..13]), 16).ok() } -/// Check whether the rollout's first record names `cwd` and belongs to a thread -/// the user started. Subagent and guardian-review threads inherit the parent's -/// cwd and are minted within seconds of it, so cwd and the window alone leave -/// several rollouts standing and correlation collapses to `None`; -/// `thread_source: "subagent"` and a `parent_thread_id` are what separate them. -/// -/// The test rejects rather than admits by name: codex's `ThreadSource` -/// deserializer turns any unknown string into `Feature(String)`, so requiring -/// `"user"` would silently drop legitimate future thread kinds. Absence is a -/// pass for the same reason — both fields are omitted when empty and neither -/// existed before 0.147.0, so pre-0.147 sessions stay resumable. Reads stop at -/// 64 KiB because later records do not participate in correlation. +/// Check the rollout's first record for a matching `cwd` and no explicit +/// spawned-thread provenance. Missing and unrecognized `thread_source` values +/// remain eligible; `"subagent"` or any `parent_thread_id` rejects the record. +/// Reads stop at 64 KiB because later records do not participate in +/// correlation. fn line1_admits(path: &Path, cwd: &Path) -> bool { let Ok(file) = fs::File::open(path) else { return false; @@ -361,10 +329,8 @@ fn line1_admits(path: &Path, cwd: &Path) -> bool { { return false; } - // codex records the cwd its own process reports, and `getcwd(3)` resolves - // symlinks: a task spawned in `/tmp/x` on macOS is recorded as - // `/private/tmp/x` and never matches verbatim. Resolving this side is - // enough — the recorded path is already physical. + // Rollouts can contain the physical cwd while the task retains a symlinked + // path. Canonicalize the task path before rejecting the match. payload["cwd"].as_str().is_some_and(|c| { let recorded = Path::new(c); recorded == cwd || cwd.canonicalize().is_ok_and(|p| p == recorded) @@ -680,9 +646,7 @@ mod tests { assert_eq!(Codex.scrape_exit(&both).as_deref(), Some(ID)); } - /// A fatal exit prints no resume hint and names the ID outright. Nothing - /// else recovers it: the notify hook never fired and the rollout may be - /// empty. + /// A fatal exit can name the session without printing a resume hint. #[test] fn scrape_exit_reads_the_fatal_session_id_line() { assert_eq!( @@ -695,9 +659,7 @@ mod tests { assert_eq!(Codex.scrape_exit("Session ID: my session"), None); assert_eq!(Codex.scrape_exit(&format!("Session ID: {ID}ff")), None); - // Off the start of the row the phrase is quoted text, not codex's own - // line. A task killed before any exit line would otherwise resume on - // it; refusing costs only the fall through to `correlate_fs`. + // An indented or embedded label can be conversation text. for quoted in [ format!("the log said Session ID: {ID}"), format!("• Session ID: {ID}"), @@ -706,18 +668,14 @@ mod tests { assert_eq!(Codex.scrape_exit("ed), None, "{quoted:?}"); } - // Across lines, the last channel to speak wins, either way round. + // Across lines, the last valid ID wins. let hint_last = format!("Session ID: {OTHER}\nrun codex resume {ID}"); assert_eq!(Codex.scrape_exit(&hint_last).as_deref(), Some(ID)); let id_last = format!("run codex resume {OTHER}\nSession ID: {ID}"); assert_eq!(Codex.scrape_exit(&id_last).as_deref(), Some(ID)); } - /// A half-migrated home — a legacy `profile` key beside the file it once - /// selected — must not chain the profile's notifier: 0.147.0 refuses to - /// start on that key at all, and layers `.config.toml` only under - /// `-p`, a command this harness never instruments. Chaining it would run a - /// notifier codex itself would not. + /// Notification chaining reads `config.toml` and ignores sibling files. #[test] fn config_notify_route_reads_config_toml_alone() { let home = temp("codex_profile_notify"); @@ -773,10 +731,7 @@ mod tests { ); } - /// Subagent and guardian-review threads mint their own ID, write their own - /// rollout, and inherit the parent's cwd, so cwd and the window alone leave - /// several rollouts standing. Line 1's provenance fields are what separate - /// them, and either one alone disqualifies a file. + /// Either spawned-thread provenance field disqualifies a rollout. #[test] fn correlate_fs_excludes_spawned_threads() { let home = temp("codex_subagent"); @@ -786,7 +741,7 @@ mod tests { let parent = write_rollout(&home, spawn_ms + 1_000, 1, cwd); let resolves = |home: &Path| Codex.correlate_fs(cwd, spawned, Some(home)); - // `thread_source` alone, as 0.147.0 writes it for a spawned thread. + // `thread_source` alone disqualifies the rollout. write_rollout_named( &home, spawn_ms + 3_000, @@ -808,17 +763,13 @@ mod tests { ); assert_eq!(resolves(&home).as_deref(), Some(parent.as_str())); - // A second rollout carrying neither field is a real sibling, and - // uniqueness fails as it always has. + // A second eligible thread makes correlation ambiguous. write_rollout(&home, spawn_ms + 7_000, 4, cwd); assert_eq!(resolves(&home), None); } - /// Disqualify by field rather than requiring `thread_source: "user"`: - /// codex's deserializer turns any unknown string into `Feature(String)`, so - /// an allow-list would drop future thread kinds. Absence passes too, which - /// is what keeps pre-0.147 rollouts resumable — the case above already - /// leans on it. + /// Unknown thread sources remain eligible unless another field marks the + /// rollout as spawned. #[test] fn correlate_fs_admits_thread_sources_it_does_not_know() { let spawn_ms: u64 = 1_785_000_000_000; @@ -842,18 +793,14 @@ mod tests { } } - /// A thread created by `thread/revert` carries a second ID in its filename. - /// The trailing 36 characters are then the rollout ID — a valid UUID naming - /// a different object — so the thread ID is read from a fixed offset. + /// A suffixed rollout filename carries the thread ID before the rollout ID. #[test] fn correlate_fs_reads_the_thread_id_not_the_rollout_id() { let spawn_ms: u64 = 1_785_000_000_000; let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms); let cwd = Path::new("/work/proj"); let rollout_id = v7_at(spawn_ms + 1_000, 9); - // One home throughout: revert keeps the thread ID and adds a rollout - // rather than replacing one, so the two names coexist and the second - // pass proves the pair still resolves to a single conversation. + // Both names coexist and resolve to one deduplicated thread. let home = temp("codex_revert_name"); for suffix in [String::new(), format!("_{rollout_id}")] { let thread = write_rollout_named(&home, spawn_ms + 1_000, 1, cwd, &suffix, ""); @@ -866,10 +813,7 @@ mod tests { } } - /// codex records the cwd `getcwd(3)` reports, which has resolved every - /// symlink; fleetcom holds the path the task was spawned with. On macOS a - /// task under `/tmp` is recorded as `/private/tmp` and a verbatim compare - /// never matches. + /// Correlation matches a physical rollout cwd to a symlinked task cwd. #[test] fn correlate_fs_matches_a_symlinked_spawn_path() { let home = temp("codex_symlink_cwd"); @@ -922,10 +866,8 @@ mod tests { ); } - /// The anchor has to survive emulation, not just `str::lines`. - /// `text_with_history` joins soft-wrapped rows into one logical line, so a - /// preceding row that exactly fills the width is the case that could push - /// the fatal line off offset 0. + /// A preceding full-width row does not merge with the session-ID row after + /// terminal emulation. #[test] fn fatal_session_id_holds_offset_zero_after_a_full_width_row() { let bytes = format!("{}\r\nSession ID: {ID}\r\n", "x".repeat(CORPUS_COLS)); diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 7dc59da..ddc20bd 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -13,8 +13,8 @@ //! To avoid treating it as live status, every matcher: //! //! 1. locates the chrome region structurally (claude's separator-pair input -//! box, codex's composer and configured status line, grok's bordered -//! input box) and limits status candidates relative to it; +//! box, codex's composer, grok's bordered input box) and limits status +//! candidates relative to it; //! 2. returns `None` when the expected structure is absent or inconsistent; //! 3. matches row prefixes so status rows truncated with an ellipsis at narrow //! widths remain recognizable. A wrapped row fails the structural check. @@ -297,35 +297,18 @@ fn claude_welcome_label(rows: &[String]) -> Option { // ----------------------------------------------------------------- codex -- -/// Composer prompt glyphs: `!` in bash mode, `»` at `ultra` reasoning -/// effort, `›` otherwise. All three render at column 0. Disabling input swaps -/// whichever glyph the mode would paint for a dim `›`: the swap stays inside -/// this set, and dimness is style rather than text, so neither reaches the -/// pin. +/// Column-0 glyphs accepted as the Codex composer prompt. const CODEX_PROMPT: &[char] = &['›', '»', '!']; -/// Queued-message group heads codex paints between the status row and the -/// composer, each over its own ` ↳ `-indented item rows. The heads sit at -/// column 0 and are chrome, not status. Matched as prefixes: codex appends -/// ` (press {key} to interrupt and send immediately)` to the first head -/// whenever an interrupt key is bound, which is the default, so the painted -/// row is 93 columns and an exact match fails at every width that fits it. -/// All three take the prefix rule rather than one taking an exception — -/// over-matching a reply bullet that opens with a head's words costs one -/// skipped row, while missing a head aborts the walk this constant exists to -/// let through. +/// Column-0 queued-message heads allowed between the status row and composer. +/// Prefix matching admits runtime affordances appended to a head. const CODEX_QUEUED_HEADS: &[&str] = &[ "• Messages to be submitted after next tool call", "• Messages to be submitted at end of turn", "• Queued follow-up inputs", ]; -/// Reasoning-effort words the `model-with-reasoning` status-line item renders -/// after the model slug. `default` is one of them: it is the word codex prints -/// when the profile names no effort, not the absence of a word. `none` is not: -/// codex's label function folds `ReasoningEffort::None` into `default`, so the -/// word never reaches the row and listing it would only widen the surface a -/// two-word phrase has to be read as a model. +/// Reasoning-effort words accepted in a `model-with-reasoning` item. const CODEX_EFFORT: &[&str] = &[ "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default", ]; @@ -355,12 +338,8 @@ impl SummaryAdapter for CodexSummary { codex_model_label(rows) } - /// Canonicalize codex's two title animations: the ten-frame braille - /// spinner folds to its first frame `⠋`, the blocked-on-user blink folds - /// its `[ . ] ` phase into `[ ! ] `. The preview's 500 ms title hold damps - /// neither — the spinner advances every 100 ms, the blink holds each phase - /// a full second. Only `[ . ] ` folds: `[ ! ]` reads as an alarm, and a - /// title already in that phase needs no rewrite. Others pass unchanged. + /// Fold braille frames to `⠋` and `[ . ] ` to `[ ! ] `. Other titles pass + /// unchanged. fn normalize_title(&self, title: &str) -> Option { if let Some(rest) = title.strip_prefix("[ . ] ") { return Some(format!("[ ! ] {rest}")); @@ -387,14 +366,11 @@ fn codex_numbered_option(row: &str) -> bool { t.len() > digits && digits >= 1 && t[digits..].starts_with(". ") } -/// codex's approval modal: a selector row with an indented numbered sibling -/// below it, pinned to the last nine painted rows. The modal removes the -/// composer and status line; that absence is the disambiguator (a menu quoted -/// in the conversation always has the live composer below it, so any -/// non-selector [`CODEX_PROMPT`] row under the selector suppresses the -/// match). Suppression tests the glyph alone, without the composer's -/// trailing-space rule: over-suppressing costs one preview, while -/// under-suppressing reports a modal the user is not looking at. +/// Codex's approval modal: a selector row with an indented numbered sibling +/// below it, pinned to the last nine painted rows. A quoted menu retains the +/// live composer below it, so any non-selector [`CODEX_PROMPT`] row after the +/// selector suppresses the match. Suppression tests the glyph alone because +/// modal detection must not reinterpret a live composer as quoted content. fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { let last = rows.iter().rposition(|r| !r.is_empty())?; let i = (last.saturating_sub(8)..=last).find(|&i| codex_menu_head(&rows[i]))?; @@ -408,29 +384,19 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "codex:approval-menu")) } -/// The model label: the first ` · `-joined item of the bottom-most row among -/// the last six painted ones whose items open with the model. The row is the -/// status line, independent of the composer pin: it is user-configured and -/// may be absent — or omit the model entirely, leaving no model prefix. -/// -/// Two shapes qualify, because the items are a `[tui] status_line` array the -/// user orders and codex drops unavailable ones silently: +/// Return the first ` · `-separated item from the bottom-most qualifying row +/// among the last six painted rows. The status line is independent of the +/// composer and may be absent or omit the model. /// -/// - the opt-in `total-input-tokens`/`total-output-tokens` pair closing the -/// row as `{…} in · {…} out`, which pins the model to the first item; -/// - the default `model-with-reasoning` head, `{model} {effort}` with an -/// optional service tier appended, matched by [`codex_model_with_reasoning`]. +/// Two shapes qualify: /// -/// Neither present means the model is not on the row, and no label is read: a -/// label lifted off `status_line = ["current-dir", "model"]` would name the -/// directory as the model. +/// - a `{…} in · {…} out` tail, which pins the model to the first item; +/// - a `model-with-reasoning` head, `{model} {effort}` with an optional third +/// word, matched by [`codex_model_with_reasoning`]. /// -/// Both shapes additionally require the row to be indented. codex renders the -/// status line through its footer, which prefixes every line with two spaces, -/// while the composer and reply bullets start at column 0 — and the composer -/// is inside this window, so a user who has typed `ultra mode` otherwise -/// paints a row that reads as `model-with-reasoning` and prefixes every -/// preview with `› ultra mode · `. +/// Neither shape means no label. The row must also be indented: the composer +/// and reply bullets begin at column 0 and can otherwise satisfy the same text +/// shapes. fn codex_model_label(rows: &[String]) -> Option { let last = rows.iter().rposition(|r| !r.is_empty())?; (last.saturating_sub(5)..=last).rev().find_map(|i| { @@ -448,16 +414,10 @@ fn codex_model_label(rows: &[String]) -> Option { }) } -/// Whether an item is `model-with-reasoning`: `{model} {effort}`, plus the -/// service tier codex appends when the account has one (`gpt-5.4 xhigh fast`, -/// where the effort word is no longer last). Exactly two or three words, with -/// the effort word second — the item has no other word count, and holding the -/// count that tight is what keeps prose ending in an effort word (`I'll use -/// medium effort`) from being read as a model. The effort vocabulary is not -/// closed the way the count is: `ReasoningEffort::Custom(String)` carries a -/// model-defined word [`CODEX_EFFORT`] cannot list, and a row rendering one -/// yields no label at all — the safe direction, and the reason this stays a -/// list rather than a shape test. +/// Whether an item has the accepted `model-with-reasoning` shape: two or three +/// words, with a recognized effort word second. The optional third word +/// occupies the service-tier position. The fixed effort vocabulary limits +/// prose-shaped false matches. fn codex_model_with_reasoning(item: &str) -> bool { let words: Vec<&str> = item.split_whitespace().collect(); matches!(words.len(), 2 | 3) && CODEX_EFFORT.contains(&words[1]) @@ -520,16 +480,10 @@ fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static st None } -/// The live status row, `[{glyph} ]{header} ({elapsed} • {key} to -/// interrupt)`, split into its header and the text after the opening paren. -/// The glyph is codex's activity indicator: a shimmered `•` on truecolor -/// stdout, `•`/`◦` alternating at 600 ms otherwise, and — with animations -/// disabled — absent along with its space, so it is optional. The header is -/// a free-form `String` (`Working` is only the default; a reasoning phrase, -/// `Booting MCP server: {name}`, and verbatim stream errors all land there), -/// which leaves the parenthetical as the only fixed structure. Matching it -/// through [`codex_interrupt_paren`] rather than on the closing `)` keeps rows -/// truncated at the terminal's width matchable. +/// Split a live status row into its header and the text after the opening +/// parenthesis. The optional activity prefix is `• ` or `◦ `; the header must +/// begin alphanumeric. [`codex_interrupt_paren`] supplies the fixed structure +/// and admits rows truncated at the terminal width. fn codex_status_head(row: &str) -> Option<(&str, &str)> { let rest = row .strip_prefix("• ") @@ -546,23 +500,10 @@ fn codex_status_head(row: &str) -> Option<(&str, &str)> { }) } -/// Whether `s` opens with the status widget's parenthetical in full: -/// [`codex_elapsed`]'s counter, then ` • {key} to interrupt)`. The counter -/// alone is not enough of an anchor, because the row it anchors shares its -/// slot with the turn's last reply bullet: `• Build finished (3m 20s)` is a -/// sentence a coding agent writes, and reading it as live status reports a -/// finished turn as busy. The interrupt hint is the part conversation text -/// does not reproduce. -/// -/// This costs the one render codex writes without a hint, `({elapsed})` — -/// reachable only with the interrupt key unbound, or during Windows -/// elevated-sandbox setup, a platform this crate's unconditional `nix` -/// dependency rules out. That render is textually identical to the prose, so -/// no rule separates them; refusing both is the direction this file takes when -/// a shape is ambiguous. -/// -/// A row the CLI cut at the terminal's width never closes its paren and ends -/// in the CLI's own `…`, which is what admits it with the hint still partial. +/// Whether `s` begins with an elapsed counter and interrupt affordance. An +/// elapsed counter alone is ambiguous with conversation prose and does not +/// qualify. An unclosed affordance qualifies only when the row ends in `…`, +/// the terminal-truncation marker. fn codex_interrupt_paren(s: &str) -> bool { let Some(hint) = codex_elapsed(s).and_then(|rest| rest.strip_prefix(" • ")) else { return false; @@ -601,10 +542,9 @@ fn codex_elapsed(s: &str) -> Option<&str> { /// `Working`, `7s • esc to interrupt) · 1 background terminal running · /ps /// to view · /stop to close` → `Working · 1 background terminal running`. /// The parenthetical is the elapsed counter plus interrupt affordance, -/// dropped whole: an unclosed paren is CLI-side truncation mid-affordance -/// and drops to the end. Of the ` · ` suffixes, `/`-headed segments are key -/// hints; everything else is slow-moving state and is kept, with its own -/// ellipsis when the CLI truncated it. +/// dropped whole. Without a closing parenthesis, no suffix is parsed. Of the +/// ` · ` suffixes, `/`-headed segments are key hints; every other nonempty +/// segment is preserved. fn codex_working(header: &str, after_paren: &str) -> String { let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]); format!( diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index e1a3352..e6cda74 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -507,15 +507,14 @@ fn codex_working_normalization() { } } -/// The status line is a user-ordered item array, so the label reads either -/// shape that puts the model first: the opt-in `{…} in · {…} out` tail, or -/// the default `model-with-reasoning` head. A row carrying neither yields no -/// label rather than a guess. +/// The label reads either status-line shape that puts the model first: a +/// `{…} in · {…} out` tail or a `model-with-reasoning` head. A row carrying +/// neither yields no label. #[test] fn codex_label_reads_either_status_line_shape() { let label = |row: &str| CodexSummary.model_label(&rs(&["›", "", row])); for (row, want) in [ - // Rows codex 0.147.0's own snapshots render. + // Model-with-reasoning rows. ( " gpt-5.6-sol default · /tmp/project", "gpt-5.6-sol default", @@ -549,16 +548,14 @@ fn codex_label_reads_either_status_line_shape() { " I'll use medium effort · /tmp/project", // The effort word with no model before it. " high · /tmp/project", - // `none` is folded into `default` before the row is rendered, so the - // word is not a model's effort. + // `none` is outside the accepted effort vocabulary. " gpt-5.6-sol none · /tmp/project", ] { assert_eq!(label(row), None, "{row:?}"); } - // codex indents the status line by the footer's two columns; the composer - // and reply bullets do not. Without that discriminator a typed draft - // takes the `model-with-reasoning` shape and prefixes every preview. + // Indentation distinguishes status lines from composers and reply bullets + // with the same text shape. assert_eq!(CodexSummary.model_label(&rs(&["› ultra mode"])), None); assert_eq!( CodexSummary.model_label(&rs(&["›", "", "gpt-5.6-sol high · 0 in · 0 out"])), @@ -579,9 +576,7 @@ fn codex_title_animations_canonicalize_to_constant_text() { ); } - // The blink's two phases differ only in the bracketed glyph, so rewriting - // one direction is the whole fold: `[ ! ]` passes through and renders - // verbatim, which is the same text the `[ . ]` phase folds to. + // `[ . ]` folds to `[ ! ]`; `[ ! ]` already has the canonical text. assert_eq!( CodexSummary.normalize_title("[ . ] Action Required | fleetcom"), Some("[ ! ] Action Required | fleetcom".to_string()) @@ -592,8 +587,7 @@ fn codex_title_animations_canonicalize_to_constant_text() { "the frozen phase needs no rewrite" ); - // codex and claude fold braille to different glyphs on purpose: the - // dashboard's titles stay attributable to the CLI that painted them. + // Each adapter uses a distinct canonical frame. assert_ne!( CodexSummary.normalize_title("⠹ fleetcom"), ClaudeSummary.normalize_title("⠹ fleetcom") @@ -605,10 +599,9 @@ fn codex_title_animations_canonicalize_to_constant_text() { assert_eq!(CodexSummary.normalize_title("⠹"), None, "frame alone"); } -/// The status row anchors on its parenthetical, not on a literal verb: -/// the activity glyph blinks, drops to `◦`, or vanishes with animations -/// off; the header is whatever the CLI put there; the interrupt key is -/// remappable. +/// The status row anchors on its parenthetical, not on a literal verb. The +/// activity glyph is optional and accepts both painted forms; the interrupt +/// key is unconstrained. #[test] fn codex_status_anchors_on_the_interrupt_parenthetical() { let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); @@ -628,7 +621,7 @@ fn codex_status_anchors_on_the_interrupt_parenthetical() { ); } - // Every elapsed shape codex formats, from a fresh turn to a day-long one. + // Accepted compact-duration shapes, from seconds through hours. for elapsed in [ "0s", "59s", @@ -644,7 +637,7 @@ fn codex_status_anchors_on_the_interrupt_parenthetical() { ); } - // The header is a free-form String; `Working` is only its default. + // Any alphanumeric header can precede the fixed parenthetical. for header in [ "Investigating rendering code", "Reviewing approval request", @@ -655,7 +648,6 @@ fn codex_status_anchors_on_the_interrupt_parenthetical() { // counter, so the anchor is the parenthetical after it. "Starting MCP servers (1/3): a, b, c", "Setting up sandbox...", - // Stream errors reach the header verbatim. "Reconnecting... 1/5", ] { assert_eq!( @@ -685,7 +677,7 @@ fn codex_status_rejects_malformed_counters() { let probe = |row: &str| CodexSummary.live_preview(&rs(&[row, "", "›"])); for row in [ "• Working (soon • esc to interrupt)", - // claude's fractional seconds, not codex's zero-padded fields. + // Fractional seconds are not accepted. "• Working (9.9s • esc to interrupt)", // The counter ends at the seconds field. "• Working (1m • esc to interrupt)", @@ -716,22 +708,18 @@ fn codex_status_refuses_conversation_prose() { "• Benchmarks improved (12s → 8s)", // ` • ` alone is reachable in prose; the hint text is not. "• Fixed the timeout (5s • retry logic)", - // Truncation is the only reason a parenthetical goes unclosed, and - // the CLI's ellipsis is what proves it. + // An unclosed parenthetical requires a terminal ellipsis. "• Timed the suite (12s • 4 shards", ] { assert_eq!(probe(row), None, "{row:?}"); } - // Refusing the bare counter costs the hint-off render, `({elapsed})`, - // which needs the interrupt key unbound: a static row over a wrong live - // one, per this file's anchor discipline. + // A bare counter is ambiguous with conversation prose. assert_eq!(probe("• Working (12s)"), None); } -/// Every composer glyph pins the adapter: `!` in bash mode, `»` at `ultra` -/// reasoning effort, `›` otherwise. The glyph stands alone or heads a -/// space; glued to text it is body content and the pin fails. +/// Every accepted composer glyph pins the adapter. The glyph stands alone or +/// heads a space; glued text does not qualify. #[test] fn codex_composer_accepts_every_prompt_glyph() { let status = "• Working (3s • esc to interrupt)"; @@ -753,8 +741,7 @@ fn codex_composer_accepts_every_prompt_glyph() { /// Queued-message blocks sit between the status row and the composer. /// Their heads are walked past and their items never count against the -/// window: the queue's depth is the user's, not the CLI's. Nothing else -/// earns that pass. +/// window. No other column-0 head receives that exemption. #[test] fn codex_status_walks_past_queued_message_blocks() { let walks = |head: &str| { @@ -772,10 +759,7 @@ fn codex_status_walks_past_queued_message_blocks() { assert_eq!(walks(head), working, "{head:?}"); } - // The pending-steers head carries an interrupt hint whenever a key is - // bound, which is the default, and codex wraps rather than truncates it. - // From 93 columns up the row is painted whole — the case a bare literal - // never matches. + // Prefix matching admits an affordance appended to the queued head. assert_eq!( walks( "• Messages to be submitted after next tool call (press esc to interrupt and send immediately)" @@ -1136,7 +1120,7 @@ fn corpus_positive_states_anchor_exactly() { "preview_codex_hint_row", include_bytes!("../../tests/corpus/preview_codex_hint_row.bin"), &CodexSummary, - // No status line in this layout: no model prefix, correctly. + // No status line means no model prefix. "Working", "codex:working", ), @@ -1316,13 +1300,11 @@ fn corpus_truncated_rows_still_anchor() { ); } -/// codex 0.147.0 rows, replayed from the CLI's own snapshot tests at the -/// geometry those snapshots were taken at. +/// Codex status layouts replay at their fixture-native widths. #[test] fn corpus_codex_0_147_rows_anchor() { - // The header is the model's first reasoning chunk, kept verbatim, and - // the `•`-headed transcript rows above it never surface. This frame - // paints no status line, so nothing prefixes the text. + // The status header remains verbatim. Transcript rows above it do not + // surface, and the absent status line contributes no model prefix. let got = corpus( include_bytes!("../../tests/corpus/preview_codex_reasoning.bin"), &CodexSummary, @@ -1331,8 +1313,8 @@ fn corpus_codex_0_147_rows_anchor() { assert_eq!(got, anchor("Investigating rendering code", "codex:working")); // Sixteen queued messages separate the status row from the composer. - // The status line below is codex's default pair, `model-with-reasoning · - // current-dir`, so the label reads off its first item. + // The status line below starts with `model-with-reasoning`, so its first + // item supplies the label. let got = corpus( include_bytes!("../../tests/corpus/preview_codex_queued.bin"), &CodexSummary, diff --git a/src/preview.rs b/src/preview.rs index dcc5afa..c17d97b 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -133,12 +133,9 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P }, }; } - // Leading indentation is layout, not meaning: codex's status line (its - // bottom-most row, and so the floor of an idle codex task whenever the - // user configures one) indents itself, and the spaces waste preview - // width. Trimmed here, not in - // `live_floor`: the emulator's row stays a faithful fact because it - // doubles as the teardown-snapshot comparator. + // An indented status line can remain as the idle floor. Trim only the + // display candidate: `live_floor` also feeds teardown-snapshot comparison + // and must preserve the emulator row verbatim. let floor = screen.live_floor(); let trimmed = floor.trim_start(); Preview::floor(if trimmed.len() == floor.len() { diff --git a/src/testutil.rs b/src/testutil.rs index fc2b067..eb7426f 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -225,11 +225,9 @@ pub(crate) fn write_rollout(home: &Path, ms: u64, tail: u32, cwd: &Path) -> Stri write_rollout_named(home, ms, tail, cwd, "", "") } -/// [`write_rollout`] with the two shapes correlation has to tell apart. -/// `stem_suffix` follows the thread ID in the filename — codex main's -/// `thread/revert` appends `_` there. `meta_extra` is spliced into -/// the `session_meta` payload verbatim, each member led by its own comma, so a -/// test can write the provenance fields 0.147.0 emits for spawned threads. +/// [`write_rollout`] with an optional filename suffix and additional +/// `session_meta` payload members. `stem_suffix` follows the thread ID; +/// `meta_extra` is inserted verbatim and must include each leading comma. pub(crate) fn write_rollout_named( home: &Path, ms: u64, diff --git a/tests/corpus/README.md b/tests/corpus/README.md index b9487a6..d8c7449 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -37,13 +37,9 @@ CRLF. Claude and Grok use the alternate screen; Codex is inline. Identifying and user-configured text is replaced with alignment-preserving synthetic values. Geometry is 40×120 unless noted. -Most Codex fixtures were sanitized from a live 0.144.6 session. Two were built -instead from codex 0.147.0's own snapshot tests, whose expectations are the -rendered rows themselves: `preview_codex_reasoning.bin` from a full-screen -vt100 snapshot at 40×80, and `preview_codex_queued.bin` at 40×36. Both are -bottom-anchored on a 40-row screen the way an inline TUI paints. Sourcing rows -from the CLI's own test suite pins them to a named upstream version, which a -sanitized capture cannot do. +`preview_codex_reasoning.bin` uses 40×80 geometry, and +`preview_codex_queued.bin` uses 40×36. Both are bottom-anchored on a 40-row +screen to reproduce the inline layout. The Codex hint-row and approval fixtures use approximate indentation, so their tests match trimmed heads and column-0 structure. The Claude waiting fixture @@ -71,8 +67,8 @@ roster below it. | `preview_codex_ran.bin` | codex transient completion row | `codex:ran` extraction through the `└` attachment row | | `preview_codex_hint_row.bin` | codex working with `tab to queue message` below the composer, no status line | `codex:working` through the composer pin; no model prefix without one | | `preview_codex_approval.bin` | codex approval modal: composer and status line replaced by a numbered menu | `codex:approval-menu` synthesizes `awaiting approval` | -| `preview_codex_reasoning.bin` | codex 0.147.0 status row headed by the model's own reasoning phrase, over an `• Explored` group and a reply bullet, composer carrying text, no status line | `codex:working` keeps the CLI's header verbatim; the `•`-headed rows above it never surface, and no status line means no model prefix | -| `preview_codex_queued.bin` | codex 0.147.0 `• Working` row separated from the composer by a `• Queued follow-up inputs` block, default `status_line` below | `codex:working` survives the queued-message heads; the model label reads the default `model-with-reasoning · current-dir` shape | +| `preview_codex_reasoning.bin` | codex status row with a reasoning phrase over an `• Explored` group and reply bullet; composer carrying text; no status line | `codex:working` keeps the header verbatim; the `•`-headed rows above it do not surface, and no status line means no model prefix | +| `preview_codex_queued.bin` | codex `• Working` row separated from the composer by a `• Queued follow-up inputs` block; `model-with-reasoning · current-dir` status line below | `codex:working` survives the queued-message heads; the first status-line item supplies the model label | | `preview_codex_body_menu.bin` | modal-shaped menu quoted in the body, live composer below | negative: the composer's presence suppresses the modal match; floor tier reports | | `preview_claude_waiting.bin` | claude waiting on a backgrounded subagent, `⏺` prose and agent roster around the box | `claude:waiting` extracts the ellipsis-less row verbatim; no model label mid-session | | `preview_claude_workflow_wait.bin` | claude waiting on a dynamic workflow, with 19 blank rows before the input box and a workflow roster below it | `claude:waiting` matches across the blank rows; the roster is excluded |