Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 12 additions & 18 deletions src/harness/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,7 @@ impl Harness for Claude {

fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option<String> {
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)
}
}

Expand Down Expand Up @@ -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<String>,
) -> Option<String> {
/// 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<String> {
let mut candidates: Vec<String> = 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 {
Expand All @@ -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()),
Expand Down
29 changes: 21 additions & 8 deletions src/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/harness/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 33 additions & 31 deletions src/harness/summary_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ fn claude_screen<S: AsRef<str>>(above: &[S]) -> Vec<String> {
rows
}

/// Append Grok's three-row input box, including its model label, to `above`.
fn grok_screen<S: AsRef<str>>(above: &[S]) -> Vec<String> {
let mut rows: Vec<String> = 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],
Expand Down Expand Up @@ -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!(
Expand All @@ -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,
Expand Down Expand Up @@ -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"))
Expand All @@ -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.
Expand All @@ -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"))
Expand Down Expand Up @@ -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"))
);
}
Expand Down Expand Up @@ -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}");
Expand Down
38 changes: 38 additions & 0 deletions src/protocol_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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]
Expand Down
52 changes: 38 additions & 14 deletions src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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<usize> = (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 {
Expand Down
4 changes: 3 additions & 1 deletion src/terminal/ansi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ pub fn formatted<T>(term: &Term<T>) -> (Vec<u8>, (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<T>(term: &Term<T>) -> String {
let grid = term.grid();
let cols = grid.columns();
Expand Down
4 changes: 4 additions & 0 deletions src/terminal/emulator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,10 @@ fn live_floor_of(term: &Term<ProbeSink>) -> 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<Cell>) {
for cell in row {
if cell
Expand Down
Loading