diff --git a/src/harness/codex.rs b/src/harness/codex.rs index eae89c2..da8d6f8 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -1,8 +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 both exit-hint forms. When neither channel yields an ID, it correlates -//! rollout files under -//! `/sessions/YYYY/MM/DD/rollout--.jsonl`. +//! 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 _, @@ -79,6 +79,13 @@ impl Harness for Codex { fn scrape_exit(&self, text: &str) -> Option { let mut last = None; for line in text.lines() { + // `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) + { + last = Some(id.to_string()); + } // Plain hint: `... run codex resume `. if let Some(id) = last_hint(line, &["codex resume "]) { last = Some(id); @@ -129,9 +136,13 @@ impl Harness for Codex { else { continue; }; - let Some(id) = stem.get(stem.len().saturating_sub(36)..) else { + // 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; }; + let id = ids.split_once('_').map_or(ids, |(thread, _)| thread); if !is_uuid(id) { continue; } @@ -141,10 +152,15 @@ 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()); + // Multiple rollouts may name the same thread. Correlation + // counts that thread once. + let id = id.to_string(); + if !survivors.contains(&id) { + survivors.push(id); + } } } match survivors.as_slice() { @@ -166,28 +182,22 @@ 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 -/// deliberately line-based rather than TOML-aware, two `notify` lines in one -/// file are ambiguous and produce [`NotifyRoute::Opaque`]. +/// 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; }; - 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 +214,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 +304,12 @@ 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 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; }; @@ -340,9 +323,18 @@ 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; + } + // 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) + }) } #[cfg(test)] @@ -352,7 +344,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 +646,55 @@ mod tests { assert_eq!(Codex.scrape_exit(&both).as_deref(), Some(ID)); } + /// A fatal exit can name the session without printing a resume hint. + #[test] + 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); + + // An indented or embedded label can be conversation text. + 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 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)); + } + + /// Notification chaining reads `config.toml` and ignores sibling files. #[test] - fn config_notify_route_resolves_profiles() { + 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 +731,110 @@ mod tests { ); } + /// Either spawned-thread provenance field disqualifies a rollout. + #[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 disqualifies the rollout. + 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 eligible thread makes correlation ambiguous. + write_rollout(&home, spawn_ms + 7_000, 4, cwd); + assert_eq!(resolves(&home), None); + } + + /// 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; + 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 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); + // 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, ""); + assert_ne!(thread, rollout_id); + assert_eq!( + Codex.correlate_fs(cwd, spawned, Some(&home)).as_deref(), + Some(thread.as_str()), + "{suffix:?}" + ); + } + } + + /// 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"); + 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() { @@ -764,6 +866,14 @@ mod tests { ); } + /// 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)); + 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/harness/summary.rs b/src/harness/summary.rs index 6e0dd2f..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 status bar and composer, 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,11 +297,32 @@ fn claude_welcome_label(rows: &[String]) -> Option { // ----------------------------------------------------------------- codex -- +/// Column-0 glyphs accepted as the Codex composer prompt. +const CODEX_PROMPT: &[char] = &['›', '»', '!']; + +/// 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 accepted in a `model-with-reasoning` item. +const CODEX_EFFORT: &[&str] = &[ + "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "default", +]; + +/// 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; + /// 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. The status line or indented hint rows may appear below the composer. pub struct CodexSummary; impl SummaryAdapter for CodexSummary { @@ -314,9 +335,19 @@ 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()) + codex_model_label(rows) + } + + /// 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}")); + } + let mut chars = title.chars(); + let frame = chars.next()?; + (('\u{2800}'..='\u{28FF}').contains(&frame) && chars.next()? == ' ') + .then(|| format!("⠋ {}", chars.as_str())) } } @@ -335,11 +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 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). +/// 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]))?; @@ -349,71 +380,177 @@ 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")) } -/// 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 { +/// 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. +/// +/// Two shapes qualify: +/// +/// - 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`]. +/// +/// 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(|&i| { + (last.saturating_sub(5)..=last).rev().find_map(|i| { + if !rows[i].starts_with(' ') { + return None; + } let segs: Vec<&str> = rows[i].trim().split(" · ").collect(); - segs.len() >= 3 - && !segs[0].is_empty() + if segs[0].is_empty() { + return None; + } + 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])).then(|| segs[0].to_string()) }) } -/// 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. +/// 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]) +} + +/// 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 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 { - 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.iter().any(|h| row.starts_with(h)) { + 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 { +/// 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("• ") + .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_interrupt_paren(after).then(|| (&rest[..i], after)) + }) +} + +/// 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; + }; + 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 None; + } + let tail = &rest[digits..]; + let unit = tail.chars().next()?; + let at = units.find(unit)?; + units = &units[at + 1..]; + let after = &tail[unit.len_utf8()..]; + if unit == 's' { + return Some(after); + } + rest = after.strip_prefix(' ')?; + } +} + +/// `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. 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!("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..e6cda74 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -505,12 +505,295 @@ fn codex_working_normalization() { "{row:?}" ); } +} + +/// 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 [ + // Model-with-reasoning rows. + ( + " 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 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", + // `none` is outside the accepted effort vocabulary. + " gpt-5.6-sol none · /tmp/project", + ] { + assert_eq!(label(row), None, "{row:?}"); + } + + // 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"])), + 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 +/// 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:?}" + ); + } + + // `[ . ]` folds to `[ ! ]`; `[ ! ]` already has the canonical text. + 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" + ); + + // Each adapter uses a distinct canonical frame. + 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 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, "", "›"])); + 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)", + ] { + assert_eq!( + probe(row), + Some(("Working".to_string(), "codex:working")), + "{row:?}" + ); + } + + // Accepted compact-duration shapes, from seconds through hours. + 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:?}" + ); + } + + // Any alphanumeric header can precede the fixed parenthetical. + 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...", + "Reconnecting... 1/5", + ] { + assert_eq!( + probe(&format!("{header} (7s • esc to interrupt)")), + Some((header.to_string(), "codex:working")), + "{header:?}" + ); + } + + // A non-default header carries its suffix through the same rules + // `codex_working_normalization` pins row by row for `Working`. assert_eq!( - CodexSummary.model_label(&rs(&tail[1..])), - Some("gpt-5.6-sol high".to_string()) + 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" + )) ); } +/// 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, "", "›"])); + for row in [ + "• Working (soon • esc to interrupt)", + // Fractional seconds are not accepted. + "• 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:?}"); + } +} + +/// 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)", + // An unclosed parenthetical requires a terminal ellipsis. + "• Timed the suite (12s • 4 shards", + ] { + assert_eq!(probe(row), None, "{row:?}"); + } + + // A bare counter is ambiguous with conversation prose. + assert_eq!(probe("• Working (12s)"), None); +} + +/// 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)"; + for glyph in CODEX_PROMPT { + 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. No other column-0 head receives that exemption. +#[test] +fn codex_status_walks_past_queued_message_blocks() { + let walks = |head: &str| { + 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()]); + CodexSummary.live_preview(&rows) + }; + let working = Some(("Working".to_string(), "codex:working")); + for head in CODEX_QUEUED_HEADS { + assert_eq!(walks(head), working, "{head:?}"); + } + + // 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)" + ), + 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)", + "", + "• 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 @@ -546,10 +829,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", "", @@ -567,7 +850,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] @@ -607,17 +890,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 glyph in CODEX_PROMPT { + let quoted = rs(&[ + "• I found these options in the doc:", + "", + "› 1. Yes, proceed (y)", + " 2. No, cancel (esc)", + "", + &glyph.to_string(), + "", + " gpt-5.6-sol high · 0 in · 0 out", + ]); + assert_eq!(CodexSummary.live_preview("ed), None, "{glyph}"); + } } /// Without any composer row (codex exited; its resume hint owns the @@ -832,7 +1120,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 means no model prefix. "Working", "codex:working", ), @@ -1012,6 +1300,32 @@ fn corpus_truncated_rows_still_anchor() { ); } +/// Codex status layouts replay at their fixture-native widths. +#[test] +fn corpus_codex_0_147_rows_anchor() { + // 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, + 80, + ); + assert_eq!(got, anchor("Investigating rendering code", "codex:working")); + + // Sixteen queued messages separate the status row from the composer. + // 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, + 36, + ); + assert_eq!( + got, + anchor("gpt-5.6-sol default · Working", "codex:working") + ); +} + /// At 30 columns, a wrapped status ellipsis fails the structure check and /// resolves to the alternate-screen marker. #[test] diff --git a/src/preview.rs b/src/preview.rs index 900ad96..c17d97b 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 } @@ -131,11 +133,9 @@ 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 - // `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 c1a9e0d..eb7426f 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -222,6 +222,20 @@ 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 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, + 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 +245,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(); diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 73e8616..d8c7449 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -37,6 +37,10 @@ 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. +`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 omits the welcome box and includes agent-roster rows below the input box. The @@ -61,8 +65,10 @@ 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 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 | 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