diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 319f282..03f5b89 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -85,14 +85,7 @@ impl Harness for Claude { fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option { let dir = self.home_root(home)?.join("projects").join(slug(cwd)?); - unique_in_window(dir, spawned, |entry| { - // A transcript's stem is its session ID. - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { - return None; - } - Some(path.file_stem()?.to_str()?.to_string()) - }) + unique_in_window(dir, spawned) } } @@ -162,17 +155,18 @@ fn record_for_pid( (rec.pid == pid && same_cwd && within_window_ms(rec.started_at, spawned_ms)).then_some(rec) } -/// Return the sole candidate created within [`super::CORRELATE_WINDOW`] of -/// `spawned`. Missing creation times, multiple candidates, and a sole invalid -/// UUID return `None`. -fn unique_in_window( - dir: PathBuf, - spawned: SystemTime, - candidate: impl Fn(&fs::DirEntry) -> Option, -) -> Option { +/// Return the UUID stem of the sole `.jsonl` transcript created within +/// [`super::CORRELATE_WINDOW`] of `spawned`. Unreadable entries and creation +/// times are ignored; directory errors, zero or multiple candidates, and an +/// invalid sole stem return `None`. +fn unique_in_window(dir: PathBuf, spawned: SystemTime) -> Option { let mut candidates: Vec = Vec::new(); for entry in fs::read_dir(dir).ok()?.flatten() { - let Some(name) = candidate(&entry) else { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { continue; }; let Ok(created) = entry.metadata().and_then(|m| m.created()) else { @@ -181,7 +175,7 @@ fn unique_in_window( if !within_window(created, spawned) { continue; } - candidates.push(name); + candidates.push(name.to_string()); } match candidates.as_slice() { [only] if is_uuid(only) => Some(only.clone()), diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 95d109e..7141aea 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -638,18 +638,31 @@ mod tests { #[test] fn home_env_vars_name_each_tools_override() { - assert_eq!(Claude.home_env_var(), "CLAUDE_CONFIG_DIR"); - assert_eq!(Codex.home_env_var(), "CODEX_HOME"); - assert_eq!(Grok.home_env_var(), "GROK_HOME"); - assert_eq!(Omp.home_env_var(), "PI_CODING_AGENT_SESSION_DIR"); - assert_eq!(Claude.home_dot_dir(), ".claude"); - assert_eq!(Codex.home_dot_dir(), ".codex"); - assert_eq!(Grok.home_dot_dir(), ".grok"); - assert_eq!(Omp.home_dot_dir(), ".omp/agent/sessions"); + // Expected environment override and default directory for each + // `AGENTS` entry, in the same order. + const OVERRIDES: [(&str, &str); 4] = [ + ("CLAUDE_CONFIG_DIR", ".claude"), + ("CODEX_HOME", ".codex"), + ("GROK_HOME", ".grok"), + ("PI_CODING_AGENT_SESSION_DIR", ".omp/agent/sessions"), + ]; + assert_eq!( + AGENTS.len(), + OVERRIDES.len(), + "a new harness needs its (env var, dot dir) row added here" + ); + for (a, (env_var, dot_dir)) in AGENTS.iter().zip(OVERRIDES) { + let program = a.harness.shape().0; + assert_eq!(a.harness.home_env_var(), env_var, "{program}"); + assert_eq!(a.harness.home_dot_dir(), dot_dir, "{program}"); + } } #[test] fn registry_detect_routes_to_the_matching_harness() { + // The literal count keeps this hand-written routing coverage aligned + // with `AGENTS`. + assert_eq!(AGENTS.len(), 4, "route the new harness's command here"); let (h, inv) = detect("claude").unwrap(); assert_eq!(h.home_dot_dir(), ".claude"); assert_eq!(inv, Invocation::Bare); diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 2231b52..8443786 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -7,6 +7,14 @@ //! command. It is therefore outside the session-ID validation boundary in //! [`is_uuid`](super::is_uuid). //! +//! # Title tiers +//! +//! Adapters also normalize terminal titles for the preview cascade's Title +//! tiers. An alternate-screen title falls back to the sanitized captured title +//! when normalization rejects it. A retained primary-screen title renders only +//! when the adapter recognizes its shape because any inline program can replace +//! the terminal title. +//! //! # Anchor discipline //! //! Status-shaped text can also appear in scrollback or conversation content. diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 8669040..921dea4 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -20,6 +20,15 @@ fn claude_screen>(above: &[S]) -> Vec { rows } +/// Append Grok's three-row input box, including its model label, to `above`. +fn grok_screen>(above: &[S]) -> Vec { + let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); + rows.push(" ╭──────────────────────╮".to_string()); + rows.push(" │ ❯ │".to_string()); + rows.push(" ╰── Grok 4.5 (xhigh) · always-approve ─╯".to_string()); + rows +} + /// Resolve a corpus fixture at 40 rows and return its text, source, and rule. fn corpus( bytes: &[u8], @@ -84,6 +93,13 @@ fn select_covers_every_registered_shape() { /// adapter fires that CLI's rule on that CLI's screen shape. #[test] fn select_routes_to_the_matching_adapter() { + // The literal count keeps this hand-written routing coverage aligned with + // the registered adapters. + assert_eq!( + crate::harness::AGENTS.len(), + 4, + "route the new adapter's screen here" + ); let sep = "─".repeat(80); let claude = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep]); assert_eq!( @@ -101,12 +117,9 @@ fn select_routes_to_the_matching_adapter() { select("codex").unwrap().live_preview(&codex).unwrap().1, "codex:working" ); - let grok = rs(&[ + let grok = grok_screen(&[ " ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]", "", - " ╭──────────────────────╮", - " │ ❯ │", - " ╰── Grok 4.5 (xhigh) · always-approve ─╯", ]); assert_eq!( select("grok").unwrap().live_preview(&grok).unwrap().1, @@ -1045,16 +1058,7 @@ fn codex_requires_the_composer_pin() { /// longer durations included; free text above the box refuses. #[test] fn grok_status_shapes() { - let boxed = [ - " ╭──────────────────────╮", - " │ ❯ │", - " ╰── Grok 4.5 (xhigh) · always-approve ─╯", - ]; - let probe = |status: &str| { - let mut rows = vec![status, ""]; - rows.extend(boxed); - GrokSummary.live_preview(&rs(&rows)) - }; + let probe = |status: &str| GrokSummary.live_preview(&grok_screen(&[status, ""])); assert_eq!( probe(" ⠼ Sleep 5 seconds then echo ok… 1.5s 2.8s ⇣14.2k [↓][stop]"), Some(("Sleep 5 seconds then echo ok…".to_string(), "grok:spinner")) @@ -1077,10 +1081,8 @@ fn grok_status_shapes() { None ); - let mut rows = vec![" ⠋ Thinking… 0.2s", ""]; - rows.extend(boxed); assert_eq!( - GrokSummary.model_label(&rs(&rows)), + GrokSummary.model_label(&grok_screen(&[" ⠋ Thinking… 0.2s", ""])), Some("Grok 4.5 (xhigh)".to_string()) ); // A plain border carries no label. @@ -1093,16 +1095,7 @@ fn grok_status_shapes() { /// do not match. A closer Worked-for row wins: no upward scan. #[test] fn grok_still_running_shapes() { - let boxed = [ - " ╭──────────────────────╮", - " │ ❯ │", - " ╰── Grok 4.5 (xhigh) · always-approve ─╯", - ]; - let probe = |status: &str| { - let mut rows = vec![status, ""]; - rows.extend(boxed); - GrokSummary.live_preview(&rs(&rows)) - }; + let probe = |status: &str| GrokSummary.live_preview(&grok_screen(&[status, ""])); assert_eq!( probe(" ◎ 1 subagent still running"), Some(("1 subagent still running".to_string(), "grok:still-running")) @@ -1142,15 +1135,14 @@ fn grok_still_running_shapes() { } // The probe is a single row: Worked-for closer to the box wins. - let mut rows = vec![ + let rows = grok_screen(&[ " ◎ 1 subagent still running", "", " Worked for 8.7s", "", - ]; - rows.extend(boxed); + ]); assert_eq!( - GrokSummary.live_preview(&rs(&rows)), + GrokSummary.live_preview(&rows), Some(("Worked for 8.7s".to_string(), "grok:worked")) ); } @@ -1513,6 +1505,16 @@ fn corpus_positive_states_anchor_exactly() { "omp:approval-menu", ), ]; + // Fixture names start with the program word: require a positive case for + // every registered harness. + for a in crate::harness::AGENTS { + let program = a.harness.shape().0; + let prefix = format!("preview_{program}_"); + assert!( + cases.iter().any(|Case(name, ..)| name.starts_with(&prefix)), + "a new adapter needs a positive corpus fixture named {prefix}*" + ); + } for Case(name, bytes, adapter, text, rule) in cases { let got = corpus(bytes, adapter, 120); assert_eq!(got, anchor(text, rule), "{name}"); diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 728f1f7..4cdf385 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -3,6 +3,10 @@ use super::*; /// Every command survives encode→frame-payload→decode unchanged, including /// the `Watch{None}` null, raw `Input` bytes (0 and 255), and the no-field /// `Shutdown`. +/// +/// `variant_index` exhaustively matches `Command`, and `seen` verifies that +/// `cases` covers every arm. This guards `decode_command`, whose unknown-tag +/// fallback prevents the compiler from detecting an omitted decode arm. #[test] fn command_round_trips() { let cases = [ @@ -142,10 +146,44 @@ fn command_round_trips() { Command::ListSessions, Command::Shutdown, ]; + // Keep this match exhaustive: `seen` then proves that `cases` covers every + // arm. + fn variant_index(c: &Command) -> usize { + match c { + Command::Spawn { .. } => 0, + Command::Kill { .. } => 1, + Command::Remove { .. } => 2, + Command::Restart { .. } => 3, + Command::Tag { .. } => 4, + Command::SetGroup { .. } => 5, + Command::SetName { .. } => 6, + Command::Resize { .. } => 7, + Command::Watch { .. } => 8, + Command::Input { .. } => 9, + Command::Paste { .. } => 10, + Command::Mouse { .. } => 11, + Command::Key { .. } => 12, + Command::Scrollback { .. } => 13, + Command::SaveSession { .. } => 14, + Command::LoadSession { .. } => 15, + Command::LoadRecovery { .. } => 16, + Command::ListSessions => 17, + Command::Shutdown => 18, + } + } + let mut seen = [false; 19]; for c in cases { + seen[variant_index(&c)] = true; let (k, p) = encode_command(&c); assert_eq!(decode_command(k, &p).as_ref(), Some(&c), "round-trip {c:?}"); } + for (i, covered) in seen.iter().enumerate() { + assert!( + covered, + "Command variant #{i} (see variant_index) never round-tripped: \ + add a `cases` entry above and its decode arm in decode_command" + ); + } } #[test] diff --git a/src/supervisor.rs b/src/supervisor.rs index f92ed3a..fd9791c 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -173,6 +173,36 @@ fn harness_home(env: &[(OsString, OsString)], h: &dyn harness::Harness) -> Optio h.resolve_home(&|key| env_get(env, key).map(PathBuf::from)) } +/// Whether `cmd` may change the task set or fields serialized by +/// `session_config`. Exhaustive matching requires every command variant to +/// declare its recovery effect. +fn affects_recipe(cmd: &Command) -> bool { + match cmd { + Command::Spawn { .. } + | Command::Remove { .. } + | Command::Restart { .. } + | Command::SetGroup { .. } + | Command::SetName { .. } + | Command::LoadSession { .. } + | Command::LoadRecovery { .. } => true, + // `Kill` changes lifecycle and `Tag` changes dashboard state; neither + // changes the task set or serialized fields. The remaining variants + // also leave the recipe unchanged. + Command::Kill { .. } + | Command::Tag { .. } + | Command::Resize { .. } + | Command::Watch { .. } + | Command::Input { .. } + | Command::Paste { .. } + | Command::Mouse { .. } + | Command::Key { .. } + | Command::Scrollback { .. } + | Command::SaveSession { .. } + | Command::ListSessions + | Command::Shutdown => false, + } +} + /// State for automatic recovery snapshots. Write failures do not interrupt /// task supervision, and teardown does not write or delete snapshots. struct Recovery { @@ -334,16 +364,7 @@ impl Supervisor { pub fn apply(&mut self, cmd: Command) { // Recipe-affecting command variants arm recovery before validation; // fingerprinting filters rejected commands and other no-ops. - if matches!( - &cmd, - Command::Spawn { .. } - | Command::Remove { .. } - | Command::Restart { .. } - | Command::SetGroup { .. } - | Command::SetName { .. } - | Command::LoadSession { .. } - | Command::LoadRecovery { .. } - ) { + if affects_recipe(&cmd) { self.recovery.dirty = true; self.recovery.last_mutation = Some(Instant::now()); } @@ -897,11 +918,14 @@ impl Supervisor { /// Build `{dir: [entries]}` in spawn order. Groups and names remain intact; /// agent entries use the command returned by `recipe_command`. fn session_config(&self) -> SessionConfig { - let mut order: Vec = (0..self.tasks.len()).collect(); - order.sort_by_key(|&i| self.tasks[i].id); + // `admit` appends monotonic IDs; `rerun` preserves both index and ID; + // removal preserves relative order. + debug_assert!( + self.tasks.is_sorted_by_key(|t| t.id), + "task set left id order" + ); let mut cfg = SessionConfig::new(); - for &i in &order { - let t = &self.tasks[i]; + for t in &self.tasks { cfg.entry(path::abbreviate(&t.cwd)) .or_default() .push(SessionEntry { diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs index 2836542..f605d62 100644 --- a/src/terminal/ansi.rs +++ b/src/terminal/ansi.rs @@ -217,7 +217,9 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { /// the display offset. Paired wide-char spacers are skipped so wide glyphs /// appear once; zero-width marks ride their base character; `'\t'` cells, /// concealed (SGR 8) cells, and orphaned wide halves read as the blank the -/// replayed screen shows; trailing spaces are trimmed per row. +/// replayed screen shows; trailing spaces are trimmed per row. This display +/// policy differs from [`crate::emulator::Emulator::live_rows`], which +/// preserves the stored glyphs for structural matching. pub fn contents(term: &Term) -> String { let grid = term.grid(); let cols = grid.columns(); diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index d2e2cd8..316b783 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -579,6 +579,10 @@ fn live_floor_of(term: &Term) -> String { /// Append a grid row's glyphs, omitting wide-character spacers, mapping tabs /// to spaces, and preserving combining marks. Callers handle trailing spaces. +/// +/// Unlike [`crate::ansi::contents`], this scan view preserves glyphs in +/// concealed (SGR 8) cells and orphaned wide halves. Harness matchers inspect +/// stored grid text, not replay-equivalent display text. fn push_row_glyphs(out: &mut String, row: &Row) { for cell in row { if cell diff --git a/src/terminal/golden.rs b/src/terminal/golden.rs index e1a2a30..08838c2 100644 --- a/src/terminal/golden.rs +++ b/src/terminal/golden.rs @@ -577,76 +577,45 @@ fn semantic_dec_scrollregion_charset_translation() { assert_eq!(al.grid().cursor.point, Point::new(Line(39), Column(0))); } -/// Compare `ObservedTerm` and the raw backend across the corpus: screen, -/// cursor, and alternate-screen mode must match. -#[test] -fn emulator_wrapper_matches_the_raw_backend_on_every_fixture() { - let fixtures: [(&str, &[u8]); 12] = [ - ( - "tmux_split", - include_bytes!("../../tests/corpus/tmux_split.bin"), - ), - ( - "vim_session", - include_bytes!("../../tests/corpus/vim_session.bin"), - ), - ( - "less_altscreen", - include_bytes!("../../tests/corpus/less_altscreen.bin"), - ), - ( - "top_live", - include_bytes!("../../tests/corpus/top_live.bin"), - ), - ( - "shell_colors", - include_bytes!("../../tests/corpus/shell_colors.bin"), - ), - ( - "build_log", - include_bytes!("../../tests/corpus/build_log.bin"), - ), - ( - "claude_resume", - include_bytes!("../../tests/corpus/claude_resume.bin"), - ), - ( - "codex_resume", - include_bytes!("../../tests/corpus/codex_resume.bin"), - ), - ( - "grok_resume", - include_bytes!("../../tests/corpus/grok_resume.bin"), - ), - ( - "wide_emoji", - include_bytes!("../../tests/corpus/wide_emoji.bin"), - ), - ( - "dec_scrollregion", - include_bytes!("../../tests/corpus/dec_scrollregion.bin"), - ), - ( - "topregion_scroll", - include_bytes!("../../tests/corpus/topregion_scroll.bin"), - ), - ]; - for (name, bytes) in fixtures { - let al = alacritty(bytes); - let mut emu = crate::testutil::corpus_emulator(); - emu.process(bytes); - let (_, al_cursor, al_hidden) = ansi::formatted(&al); - let (_, emu_cursor, emu_hidden) = emu.formatted(); - assert_eq!(emu.contents(), ansi::contents(&al), "{name}: screen"); - assert_eq!( - (emu_cursor, emu_hidden), - (al_cursor, al_hidden), - "{name}: cursor" - ); - assert_eq!( - emu.alternate_screen(), - al.mode().contains(TermMode::ALT_SCREEN), - "{name}: alt bit" - ); - } +/// Assert identical screen text, cursor state, and alternate-screen mode for +/// one fixture replayed through [`crate::emulator::Emulator`] and a raw `Term`. +fn assert_wrapper_matches(file: &str, bytes: &[u8]) { + let al = alacritty(bytes); + let mut emu = crate::testutil::corpus_emulator(); + emu.process(bytes); + let (_, al_cursor, al_hidden) = ansi::formatted(&al); + let (_, emu_cursor, emu_hidden) = emu.formatted(); + assert_eq!(emu.contents(), ansi::contents(&al), "{file}: screen"); + assert_eq!( + (emu_cursor, emu_hidden), + (al_cursor, al_hidden), + "{file}: cursor" + ); + assert_eq!( + emu.alternate_screen(), + al.mode().contains(TermMode::ALT_SCREEN), + "{file}: alt bit" + ); } + +macro_rules! wrapper_oracle { + ($name:ident, $file:literal) => { + #[test] + fn $name() { + assert_wrapper_matches($file, include_bytes!(concat!("../../tests/corpus/", $file))); + } + }; +} + +wrapper_oracle!(wrapper_tmux_split, "tmux_split.bin"); +wrapper_oracle!(wrapper_vim_session, "vim_session.bin"); +wrapper_oracle!(wrapper_less_altscreen, "less_altscreen.bin"); +wrapper_oracle!(wrapper_top_live, "top_live.bin"); +wrapper_oracle!(wrapper_shell_colors, "shell_colors.bin"); +wrapper_oracle!(wrapper_build_log, "build_log.bin"); +wrapper_oracle!(wrapper_claude_resume, "claude_resume.bin"); +wrapper_oracle!(wrapper_codex_resume, "codex_resume.bin"); +wrapper_oracle!(wrapper_grok_resume, "grok_resume.bin"); +wrapper_oracle!(wrapper_wide_emoji, "wide_emoji.bin"); +wrapper_oracle!(wrapper_dec_scrollregion, "dec_scrollregion.bin"); +wrapper_oracle!(wrapper_topregion_scroll, "topregion_scroll.bin");