diff --git a/src/harness/claude.rs b/src/harness/claude.rs index c30e07b..319f282 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -10,6 +10,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use super::summary::AWAITING_APPROVAL; use super::{ CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, pin_plan, shell_quote, within_window, within_window_ms, @@ -115,7 +116,7 @@ struct SessionRecord { /// remain verbatim. A missing reason falls back to `awaiting input`. fn waiting_preview(reason: Option<&str>) -> (String, &'static str) { match reason.filter(|r| !r.is_empty()) { - Some("permission prompt") => ("awaiting approval".to_string(), "claude:registry-approval"), + Some("permission prompt") => (AWAITING_APPROVAL.to_string(), "claude:registry-approval"), Some(other) => (other.to_string(), "claude:registry-waiting"), None => ("awaiting input".to_string(), "claude:registry-waiting"), } @@ -155,8 +156,8 @@ fn record_for_pid( let text = fs::read_to_string(dir.join(format!("{pid}.json"))).ok()?; let rec = parse_record(&text)?; let spawned_ms = spawned.duration_since(UNIX_EPOCH).ok()?.as_millis(); - // Accept the task's literal path or its canonical form; reject a failed - // canonicalization unless the literal paths already match. + // Test literal equality before canonicalizing the task path: identical + // nonexistent paths remain eligible. let same_cwd = rec.cwd == cwd || cwd.canonicalize().is_ok_and(|c| rec.cwd == c); (rec.pid == pid && same_cwd && within_window_ms(rec.started_at, spawned_ms)).then_some(rec) } diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 0566f82..0412e3f 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -14,7 +14,7 @@ use std::{ use super::{ CAPTURE_ENV, CapturePaths, Harness, Invocation, NOTIFY_CHAIN_ENV, SpawnPlan, is_uuid, - last_hint, leading_uuid, shell_quote, within_window_ms, + last_hint, leading_uuid, shell_quote, v7_millis, within_window_ms, }; pub struct Codex; @@ -295,15 +295,6 @@ fn toml_escape(s: &str) -> String { out } -/// Extract the millisecond timestamp from a validated v7 UUID. Other versions -/// return `None`. -fn v7_millis(id: &str) -> Option { - if id.as_bytes()[14] != b'7' { - return None; - } - u64::from_str_radix(&format!("{}{}", &id[..8], &id[9..13]), 16).ok() -} - /// 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. @@ -524,10 +515,8 @@ mod tests { assert_eq!(toml_escape("a\tb"), "a\\u0009b"); // Execute the suffix through a shell and inspect the resulting words. let paths = CapturePaths { - capture_file: PathBuf::from("/c"), - claude_settings: PathBuf::from("/s"), codex_notify: PathBuf::from(r#"/Odd Path/it's "here"\now"#), - omp_capture: PathBuf::from("/e.js"), + ..paths() }; let inv = Codex.detect("codex").unwrap(); let plan = Codex.instrument(&inv, &paths, Some(&no_config_home())); diff --git a/src/harness/mod.rs b/src/harness/mod.rs index b7f8249..95d109e 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -352,6 +352,16 @@ fn within_window_ms(a: u128, b: u128) -> bool { a.abs_diff(b) <= CORRELATE_WINDOW.as_millis() } +/// Milliseconds embedded in the first 48 bits of a UUIDv7: the session's +/// creation instant. `None` when `id` is not v7. `id` must already satisfy +/// [`is_uuid`], which fixes its length and alphabet. +fn v7_millis(id: &str) -> Option { + if id.as_bytes()[14] != b'7' { + return None; + } + u64::from_str_radix(&format!("{}{}", &id[..8], &id[9..13]), 16).ok() +} + /// Single-quote `s` for `$SHELL -c`, encoding embedded `'` as `'\''`. fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) @@ -649,6 +659,9 @@ mod tests { let (h, inv) = detect("grok").unwrap(); assert_eq!(h.home_dot_dir(), ".grok"); assert_eq!(inv, Invocation::Bare); + let (h, inv) = detect("omp").unwrap(); + assert_eq!(h.home_dot_dir(), ".omp/agent/sessions"); + assert_eq!(inv, Invocation::Bare); assert!(detect("vim").is_none()); assert!(detect("").is_none()); } diff --git a/src/harness/omp.rs b/src/harness/omp.rs index da7312a..59f98fa 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -32,7 +32,7 @@ use std::{ use super::{ CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, leading_uuid, shell_quote, - within_window_ms, + v7_millis, within_window_ms, }; /// Command fragment shared by ordinary exit and recovery hints. @@ -222,16 +222,6 @@ impl Harness for Omp { } } -/// Milliseconds embedded in the first 48 bits of a UUIDv7: the session's -/// creation instant. `None` when `id` is not v7. `id` must already satisfy -/// [`is_uuid`], which fixes its length and alphabet. -fn v7_millis(id: &str) -> Option { - if id.as_bytes()[14] != b'7' { - return None; - } - u64::from_str_radix(&format!("{}{}", &id[..8], &id[9..13]), 16).ok() -} - /// Whether either of the first two records is a session header naming `cwd`, /// its canonical form, or a path with the same canonical target. The optional /// first record is a fixed-width title slot. Nothing later can affect diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 41b4495..496e914 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -16,8 +16,8 @@ //! box, codex's composer, grok's bordered input box, omp's two-row 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. +//! 3. preserves CLI-generated ellipsis truncation. omp also requires its +//! trailing interrupt hint; wrapped rows fail that structural check. //! //! Normalization removes spinner glyphs, elapsed counters, throughput data, //! and key hints while preserving the CLI's status text. The only synthesized @@ -42,6 +42,10 @@ pub fn select(command: &str) -> Option<&'static dyn SummaryAdapter> { .map(|a| a.summary) } +/// Preview text shared by approval-menu matchers and Claude's registry +/// permission prompt. +pub(crate) const AWAITING_APPROVAL: &str = "awaiting approval"; + /// Whether `row` is a full-width horizontal rule: nothing but `─`, long /// enough that box borders and inline list rules never qualify. claude's /// input box is fenced by two such rows. @@ -56,6 +60,12 @@ fn is_rule_row(row: &str) -> bool { n >= 40 } +/// Whether `c` is a Unicode Braille Patterns code point used as a spinner +/// frame by the supported CLIs. +fn braille_frame(c: char) -> bool { + ('\u{2800}'..='\u{28FF}').contains(&c) +} + /// The status phrase of a spinner row: a frame char accepted by `is_frame`, /// a space, then text through the first `…` inclusive. The phrase must open /// alphanumeric; past that it is task-derived and unconstrained. Trailing @@ -126,7 +136,7 @@ impl SummaryAdapter for ClaudeSummary { let frame = chars.next()?; // Normalize the entire quadrant-circle block as one animation set. let framed = CLAUDE_SPINNER.contains(&frame) - || ('\u{2800}'..='\u{28FF}').contains(&frame) + || braille_frame(frame) || ('\u{25D0}'..='\u{25D3}').contains(&frame); (framed && chars.next()? == ' ').then(|| format!("✻ {}", chars.as_str())) } @@ -267,7 +277,7 @@ fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { let next = rows[i + 1..].iter().find(|r| !r.is_empty())?; next.trim_start() .starts_with("2. ") - .then(|| ("awaiting approval".to_string(), "claude:approval-menu")) + .then(|| (AWAITING_APPROVAL.to_string(), "claude:approval-menu")) } /// Complete effort values accepted before a welcome-box ellipsis. @@ -362,8 +372,7 @@ impl SummaryAdapter for CodexSummary { } let mut chars = title.chars(); let frame = chars.next()?; - (('\u{2800}'..='\u{28FF}').contains(&frame) && chars.next()? == ' ') - .then(|| format!("⠋ {}", chars.as_str())) + (braille_frame(frame) && chars.next()? == ' ').then(|| format!("⠋ {}", chars.as_str())) } } @@ -397,7 +406,7 @@ fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> { rows[i + 1..] .iter() .all(|r| !r.starts_with(CODEX_PROMPT) || codex_menu_head(r)) - .then(|| ("awaiting approval".to_string(), "codex:approval-menu")) + .then(|| (AWAITING_APPROVAL.to_string(), "codex:approval-menu")) } /// Return the first ` · `-separated item from the bottom-most qualifying row @@ -587,7 +596,7 @@ impl SummaryAdapter for GrokSummary { let t = probe.trim_start(); // Keep the label through its first ellipsis. Wrapped tail rows have no // spinner prefix, so they fail the frame check and fall through. - if let Some(text) = spinner_text(t, |c| ('\u{2800}'..='\u{28FF}').contains(&c)) { + if let Some(text) = spinner_text(t, braille_frame) { return Some((text, "grok:spinner")); } // Still-running is the same probe, never a scan: a closer spinner @@ -678,12 +687,6 @@ fn grok_border_label(row: &str) -> Option { // ------------------------------------------------------------------- omp -- -/// Accept any braille code point as a spinner frame. The row still requires an -/// interrupt hint, and ASCII punctuation is too weak to anchor safely. -fn omp_frame(c: char) -> bool { - ('\u{2800}'..='\u{28FF}').contains(&c) -} - /// Interrupt-hint suffixes accepted on an anchored status row. const OMP_HINTS: &[&str] = &["⟦esc⟧", "⟨esc⟩"]; @@ -736,7 +739,7 @@ fn omp_input_box(rows: &[String]) -> Option { fn omp_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?; let mut chars = probe.trim_start().chars(); - if !omp_frame(chars.next()?) || chars.next()? != ' ' { + if !braille_frame(chars.next()?) || chars.next()? != ' ' { return None; } let rest = chars.as_str(); @@ -764,7 +767,7 @@ fn omp_approval(rows: &[String]) -> Option<(String, &'static str)> { rows[i.saturating_sub(6)..i] .iter() .any(|r| omp_allow_head(r)) - .then(|| ("awaiting approval".to_string(), "omp:approval-menu")) + .then(|| (AWAITING_APPROVAL.to_string(), "omp:approval-menu")) } /// The selector's chosen row: a cursor spelling, a space, then `Approve` and diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index c9f5e10..f647f55 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -107,6 +107,11 @@ fn select_routes_to_the_matching_adapter() { select("grok").unwrap().live_preview(&grok).unwrap().1, "grok:spinner" ); + let omp = omp_screen(&[" ⠴ Listing directory contents ⟦esc⟧", ""]); + assert_eq!( + select("omp").unwrap().live_preview(&omp).unwrap().1, + "omp:spinner" + ); } /// The spinner phrase survives, the elapsed/token parenthetical drops, @@ -1137,6 +1142,183 @@ fn grok_still_running_shapes() { ); } +/// Place the provided rows above an adjacent two-row omp input box. +fn omp_screen>(above: &[S]) -> Vec { + let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); + rows.push("╭── π > ⬢ model · ◒ high > ◫ 12.2%/131K ▶──╮".to_string()); + rows.push("╰─ ─╯".to_string()); + rows +} + +/// Build an omp approval screen with `head` naming the tool. +fn omp_selector(head: &str) -> Vec { + omp_selector_row(" ❯ Approve", head) +} + +/// Build an approval selector with a configurable selected row. +fn omp_selector_row(approve: &str, head: &str) -> Vec { + let rule = "─".repeat(120); + rs(&[ + " ⠴ Listing directory contents ⟦esc⟧", + "", + &rule, + "", + head, + " Command: ls -la", + "", + approve, + " Deny", + "", + " up/down navigate enter select esc cancel", + "", + &rule, + ]) +} + +/// The intent phrase survives verbatim across both anchoring bracket themes, +/// the CLI's own truncating ellipsis included. +#[test] +fn omp_status_row_extracts_the_intent_phrase() { + let probe = |row: &str| OmpSummary.live_preview(&omp_screen(&[row, ""])); + for hint in ["⟦esc⟧", "⟨esc⟩"] { + assert_eq!( + probe(&format!(" ⠴ Listing directory contents {hint}")), + Some(("Listing directory contents".to_string(), "omp:spinner")), + "{hint:?}" + ); + } + // omp's default phrase, used when the model streams no intent of its own. + assert_eq!( + probe(" ⠹ Working… ⟦esc⟧"), + Some(("Working…".to_string(), "omp:spinner")) + ); + // ASCII frames and hints do not satisfy the anchored status grammar. + assert_eq!(probe(" - Working… [esc]"), None); + assert_eq!(probe(" ⠹ Working… [esc]"), None); + assert_eq!( + probe(" ⠋ Reading the fixture corpus rea… ⟦esc⟧"), + Some(("Reading the fixture corpus rea…".to_string(), "omp:spinner")), + "a phrase the CLI truncated keeps its own ellipsis" + ); + // The model text is a user-configured status-line segment: never read. + assert_eq!( + OmpSummary.model_label(&omp_screen(&[" ⠴ Listing directory contents ⟦esc⟧", ""])), + None + ); +} + +/// The status row needs its frame, its separating space, a phrase, and the +/// interrupt hint. A row whose hint wrapped onto the next line fails here +/// rather than surfacing half a phrase. +#[test] +fn omp_status_row_requires_the_whole_skeleton() { + let probe = |row: &str| OmpSummary.live_preview(&omp_screen(&[row, ""])); + for row in [ + " ⠴ Listing directory contents", + " ⠴Listing directory contents ⟦esc⟧", + " ⠴ ⟦esc⟧", + " ⠴ · queued ⟦esc⟧", + " Listing directory contents ⟦esc⟧", + " Press ⟦esc⟧ to interrupt", + ] { + assert_eq!(probe(row), None, "{row:?}"); + } +} + +/// The pin is the row above the input box, not a substring search: a +/// status-shaped row parked in the transcript never anchors, and the +/// tool-call preview box's matching corners are not the input box. +#[test] +fn omp_pins_the_status_row_to_the_input_box() { + let quoted = omp_screen(&[ + " ⠴ Listing directory contents ⟦esc⟧", + "", + " That row is chrome, not transcript.", + "", + ]); + assert_eq!(OmpSummary.live_preview("ed), None); + + // The preview box fences a `│`-headed command row between the same + // corners, so the pair is not adjacent and the pin fails. + let preview_box = rs(&[ + " ⠴ Listing directory contents ⟦esc⟧", + "", + "╭──────────────────╮", + "│ $ ls -la │", + "╰──────────────────╯", + ]); + assert_eq!(OmpSummary.live_preview(&preview_box), None); +} + +/// Approval requires an `Allow tool:` head, a selected `Approve` row, and the +/// `Deny` sibling below it. A spinner above the selector does not win. +#[test] +fn omp_approval_requires_the_selector_shape() { + assert_eq!( + OmpSummary.live_preview(&omp_selector(" Allow tool: bash")), + Some(("awaiting approval".to_string(), "omp:approval-menu")) + ); + for head in [" Allow tool: ", " Reviewing the plan"] { + assert_eq!( + OmpSummary.live_preview(&omp_selector(head)), + None, + "{head:?}" + ); + } + + // `Deny` must be the next painted row below the selection. + let lone = rs(&[" Allow tool: bash", "", " ❯ Approve", "", " esc cancel"]); + assert_eq!(OmpSummary.live_preview(&lone), None); + + // The selection must occupy one of the final nine rows. + let mut buried = omp_selector(" Allow tool: bash"); + buried.extend(std::iter::repeat_n(" tool output".to_string(), 6)); + assert_eq!(OmpSummary.live_preview(&buried), None); + + // A live input box below the quoted selector routes to spinner matching. + // The selector's trailing rule is the nearest painted row and fails. + let mut quoted = omp_selector(" Allow tool: bash"); + quoted.push(String::new()); + assert_eq!(OmpSummary.live_preview(&omp_screen("ed)), None); +} + +/// Every supported selector cursor reports a blocked task. +#[test] +fn omp_approval_accepts_every_cursor_preset() { + for cursor in ['❯', '\u{f054}', '>'] { + assert_eq!( + OmpSummary.live_preview(&omp_selector_row( + &format!(" {cursor} Approve"), + " Allow tool: bash" + )), + Some(("awaiting approval".to_string(), "omp:approval-menu")), + "{cursor:?}" + ); + } + // The selected row must contain `Approve` exactly after the cursor: `>` + // also opens a quoted line and is trusted only in this exact shape. + for approve in [" > Approve now", " >Approve", " > approve", " * Approve"] { + assert_eq!( + OmpSummary.live_preview(&omp_selector_row(approve, " Allow tool: bash")), + None, + "{approve:?}" + ); + } +} + +/// ASCII box glyphs do not anchor status: transcript tables and rules use the +/// same glyphs. +#[test] +fn omp_ascii_box_glyphs_do_not_anchor() { + let ascii_box = rs(&[ + " - Listing directory contents [esc]", + "", + "+-- pi > model . high > 12.2%/131K --+", + "+- -+", + ]); + assert_eq!(OmpSummary.live_preview(&ascii_box), None); +} + // ------------------------------------------------------- corpus replay -- /// Positive per-state fixtures at capture geometry (40×120): exact @@ -1253,6 +1435,20 @@ fn corpus_positive_states_anchor_exactly() { "Grok 4.5 (xhigh) · 1 subagent still running", "grok:still-running", ), + Case( + "preview_omp_working", + include_bytes!("../../tests/corpus/preview_omp_working.bin"), + &OmpSummary, + "Listing directory contents", + "omp:spinner", + ), + Case( + "preview_omp_approval", + include_bytes!("../../tests/corpus/preview_omp_approval.bin"), + &OmpSummary, + "awaiting approval", + "omp:approval-menu", + ), ]; for Case(name, bytes, adapter, text, rule) in cases { let got = corpus(bytes, adapter, 120); @@ -1292,6 +1488,18 @@ fn corpus_idle_states_fall_through() { } } +/// omp is an inline UI: an idle screen falls through to the floor tier — +/// its input row — never the alternate-screen marker the other CLIs reach. +#[test] +fn corpus_omp_idle_falls_through_to_the_floor() { + let got = corpus( + include_bytes!("../../tests/corpus/preview_omp_idle.bin"), + &OmpSummary, + 120, + ); + assert_eq!(got, floor(&format!("╰─{}─╯", " ".repeat(116)))); +} + /// Status-shaped conversation text does not extract. /// The claude fixtures quote an approval menu in the conversation; the /// codex fixtures hold `• Ran` in scrollback behind a finished turn. @@ -1359,6 +1567,16 @@ fn corpus_body_shaped_text_never_extracts() { 120, ); assert_eq!(got, marker()); + + // omp: a status-shaped row quoted in the transcript with prose between + // it and the idle input box. The pin is the row directly above the box, + // not a substring search, so the quote never anchors and the floor wins. + let got = corpus( + include_bytes!("../../tests/corpus/preview_omp_body_hint.bin"), + &OmpSummary, + 120, + ); + assert_eq!(got, floor(&format!("╰─{}─╯", " ".repeat(116)))); } /// 80-column truncation: the CLIs cut their status rows at a word @@ -1473,224 +1691,3 @@ fn corpus_non_agent_tuis_keep_their_tiers() { assert_eq!(with.source, PreviewSource::Marker, "{name}"); } } - -// -------------------------------------------------------------------- omp -- - -/// Place the provided rows above an adjacent two-row omp input box. -fn omp_screen>(above: &[S]) -> Vec { - let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); - rows.push("╭── π > ⬢ model · ◒ high > ◫ 12.2%/131K ▶──╮".to_string()); - rows.push("╰─ ─╯".to_string()); - rows -} - -/// omp's approval screen: the selector replaces the input box while the -/// status row keeps animating above it. `head` is the row that names the -/// tool. -fn omp_selector(head: &str) -> Vec { - omp_selector_row(" ❯ Approve", head) -} - -/// Build an approval selector with a configurable selected row. -fn omp_selector_row(approve: &str, head: &str) -> Vec { - let rule = "─".repeat(120); - rs(&[ - " ⠴ Listing directory contents ⟦esc⟧", - "", - &rule, - "", - head, - " Command: ls -la", - "", - approve, - " Deny", - "", - " up/down navigate enter select esc cancel", - "", - &rule, - ]) -} - -/// The intent phrase survives verbatim across both anchoring bracket themes, -/// the CLI's own truncating ellipsis included. -#[test] -fn omp_status_row_extracts_the_intent_phrase() { - let probe = |row: &str| OmpSummary.live_preview(&omp_screen(&[row, ""])); - for hint in ["⟦esc⟧", "⟨esc⟩"] { - assert_eq!( - probe(&format!(" ⠴ Listing directory contents {hint}")), - Some(("Listing directory contents".to_string(), "omp:spinner")), - "{hint:?}" - ); - } - // omp's default phrase, used when the model streams no intent of its own. - assert_eq!( - probe(" ⠹ Working… ⟦esc⟧"), - Some(("Working…".to_string(), "omp:spinner")) - ); - // ASCII frames and hints do not satisfy the anchored status grammar. - assert_eq!(probe(" - Working… [esc]"), None); - assert_eq!(probe(" ⠹ Working… [esc]"), None); - assert_eq!( - probe(" ⠋ Reading the fixture corpus rea… ⟦esc⟧"), - Some(("Reading the fixture corpus rea…".to_string(), "omp:spinner")), - "a phrase the CLI truncated keeps its own ellipsis" - ); - // The model text is a user-configured status-line segment: never read. - assert_eq!( - OmpSummary.model_label(&omp_screen(&[" ⠴ Listing directory contents ⟦esc⟧", ""])), - None - ); -} - -/// The status row needs its frame, its separating space, a phrase, and the -/// interrupt hint. A row whose hint wrapped onto the next line fails here -/// rather than surfacing half a phrase. -#[test] -fn omp_status_row_requires_the_whole_skeleton() { - let probe = |row: &str| OmpSummary.live_preview(&omp_screen(&[row, ""])); - for row in [ - " ⠴ Listing directory contents", - " ⠴Listing directory contents ⟦esc⟧", - " ⠴ ⟦esc⟧", - " ⠴ · queued ⟦esc⟧", - " Listing directory contents ⟦esc⟧", - " Press ⟦esc⟧ to interrupt", - ] { - assert_eq!(probe(row), None, "{row:?}"); - } -} - -/// The pin is the row above the input box, not a substring search: a -/// status-shaped row parked in the transcript never anchors, and the -/// tool-call preview box's matching corners are not the input box. -#[test] -fn omp_pins_the_status_row_to_the_input_box() { - let quoted = omp_screen(&[ - " ⠴ Listing directory contents ⟦esc⟧", - "", - " That row is chrome, not transcript.", - "", - ]); - assert_eq!(OmpSummary.live_preview("ed), None); - - // The preview box fences a `│`-headed command row between the same - // corners, so the pair is not adjacent and the pin fails. - let preview_box = rs(&[ - " ⠴ Listing directory contents ⟦esc⟧", - "", - "╭──────────────────╮", - "│ $ ls -la │", - "╰──────────────────╯", - ]); - assert_eq!(OmpSummary.live_preview(&preview_box), None); -} - -/// The selector synthesizes its label from the `Allow tool:` head, the -/// selection, and the `Deny` sibling below it. The status row keeps -/// painting throughout and never wins. -#[test] -fn omp_approval_requires_the_selector_shape() { - assert_eq!( - OmpSummary.live_preview(&omp_selector(" Allow tool: bash")), - Some(("awaiting approval".to_string(), "omp:approval-menu")) - ); - for head in [" Allow tool: ", " Reviewing the plan"] { - assert_eq!( - OmpSummary.live_preview(&omp_selector(head)), - None, - "{head:?}" - ); - } - - // `Deny` must be the next painted row below the selection. - let lone = rs(&[" Allow tool: bash", "", " ❯ Approve", "", " esc cancel"]); - assert_eq!(OmpSummary.live_preview(&lone), None); - - // The selection must occupy one of the final nine rows. - let mut buried = omp_selector(" Allow tool: bash"); - buried.extend(std::iter::repeat_n(" tool output".to_string(), 6)); - assert_eq!(OmpSummary.live_preview(&buried), None); - - // The same block quoted in the transcript keeps the live input box - // below it; the box routes to the status probe, which sees prose. - let mut quoted = omp_selector(" Allow tool: bash"); - quoted.push(String::new()); - assert_eq!(OmpSummary.live_preview(&omp_screen("ed)), None); -} - -/// Every supported selector cursor reports a blocked task. -#[test] -fn omp_approval_accepts_every_cursor_preset() { - for cursor in ['❯', '\u{f054}', '>'] { - assert_eq!( - OmpSummary.live_preview(&omp_selector_row( - &format!(" {cursor} Approve"), - " Allow tool: bash" - )), - Some(("awaiting approval".to_string(), "omp:approval-menu")), - "{cursor:?}" - ); - } - // The row after the cursor must be `Approve` exactly: `>` also opens a - // quoted line, and the selector is the one place a bare `>` is trusted. - for approve in [" > Approve now", " >Approve", " > approve", " * Approve"] { - assert_eq!( - OmpSummary.live_preview(&omp_selector_row(approve, " Allow tool: bash")), - None, - "{approve:?}" - ); - } -} - -/// ASCII box glyphs are indistinguishable from transcript tables and rules, so -/// they do not anchor status and the preview falls through to the floor tier. -#[test] -fn omp_ascii_box_glyphs_do_not_anchor() { - let ascii_box = rs(&[ - " - Listing directory contents [esc]", - "", - "+-- pi > model . high > 12.2%/131K --+", - "+- -+", - ]); - assert_eq!(OmpSummary.live_preview(&ascii_box), None); -} - -/// omp corpus replay at capture geometry (40×120): exact status text, Anchor -/// provenance, and matcher ID. -#[test] -fn corpus_omp_states_anchor_exactly() { - for (name, bytes, want) in [ - ( - "preview_omp_working", - &include_bytes!("../../tests/corpus/preview_omp_working.bin")[..], - anchor("Listing directory contents", "omp:spinner"), - ), - ( - "preview_omp_approval", - &include_bytes!("../../tests/corpus/preview_omp_approval.bin")[..], - anchor("awaiting approval", "omp:approval-menu"), - ), - ] { - assert_eq!(corpus(bytes, &OmpSummary, 120), want, "{name}"); - } -} - -/// omp is an inline UI, so a screen with no anchor falls through to the -/// floor tier — its input row — never the alternate-screen marker. -#[test] -fn corpus_omp_states_fall_through_to_the_floor() { - let input_row = floor(&format!("╰─{}─╯", " ".repeat(116))); - for (name, bytes) in [ - ( - "preview_omp_idle", - &include_bytes!("../../tests/corpus/preview_omp_idle.bin")[..], - ), - ( - "preview_omp_body_hint", - &include_bytes!("../../tests/corpus/preview_omp_body_hint.bin")[..], - ), - ] { - assert_eq!(corpus(bytes, &OmpSummary, 120), input_row, "{name}"); - } -} diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 490a3a5..0161cfa 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -536,6 +536,48 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { ); } +/// An `omp` spawn receives `-e ` and the capture environment. omp +/// cannot pin an ID at launch, so the task carries no session ID. +#[test] +fn spawn_omp_loads_the_capture_extension() { + let dir = scratch("cap_omp"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "omp", &dir); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "omp", dir.to_path_buf()); + + let argv = wait_argv(&mut s, &dir.join("argv")); + let ei = argv + .iter() + .position(|a| a == "-e") + .expect("the stub must receive -e"); + let module = PathBuf::from(&argv[ei + 1]); + assert!(module.is_file(), "the extension module must exist"); + let text = std::fs::read_to_string(&module).unwrap(); + assert!( + text.contains("FLEETCOM_CAPTURE_FILE"), + "the module must write to the capture env: {text:?}" + ); + let t = &s.tasks[0]; + let cap = t.capture_file.clone().expect("capture file set"); + assert_eq!( + module.parent(), + cap.parent(), + "assets and captures must share the namespace" + ); + assert_eq!( + std::fs::read_to_string(dir.join("capenv")).unwrap(), + cap.display().to_string(), + "the child env must name this run's capture file" + ); + assert_eq!( + t.command, "omp", + "instrumentation must never leak into the stored command" + ); + assert!(t.harness.is_some()); + assert!(t.resume_id.is_none(), "omp cannot pin an id at launch"); +} + /// A `grok` exit hint becomes the session ID used by the saved recipe. /// The spawn pin stays; scrape must outrank it. #[test] @@ -1444,48 +1486,6 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { assert_eq!(names.len(), 1, "one incarnation owns one snapshot file"); } -/// An `omp` spawn receives `-e ` and the capture environment. omp -/// cannot pin an ID at launch, so the task carries none. -#[test] -fn spawn_omp_loads_the_capture_extension() { - let dir = scratch("cap_omp"); - let (bin, runtime) = (dir.join("bin"), dir.join("run")); - install_stub(&bin, "omp", &dir); - let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); - spawn(&mut s, "omp", dir.to_path_buf()); - - let argv = wait_argv(&mut s, &dir.join("argv")); - let ei = argv - .iter() - .position(|a| a == "-e") - .expect("the stub must receive -e"); - let module = PathBuf::from(&argv[ei + 1]); - assert!(module.is_file(), "the extension module must exist"); - let text = std::fs::read_to_string(&module).unwrap(); - assert!( - text.contains("FLEETCOM_CAPTURE_FILE"), - "the module must write to the capture env: {text:?}" - ); - let t = &s.tasks[0]; - let cap = t.capture_file.clone().expect("capture file set"); - assert_eq!( - module.parent(), - cap.parent(), - "assets and captures must share the namespace" - ); - assert_eq!( - std::fs::read_to_string(dir.join("capenv")).unwrap(), - cap.display().to_string(), - "the child env must name this run's capture file" - ); - assert_eq!( - t.command, "omp", - "instrumentation must never leak into the stored command" - ); - assert!(t.harness.is_some()); - assert!(t.resume_id.is_none(), "omp cannot pin an id at launch"); -} - // --- live registry blocked status -------------------------------------- /// Tick until the sole task's preview satisfies `pred` or the budget expires,