use crate::readiness::ScreenView;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
const MAX_GATE_DEPTH: usize = 16;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ManifestError {
#[error("manifest is not valid TOML: {0}")]
Toml(String),
#[error("manifest io error for {path}: {detail}")]
Io { path: String, detail: String },
#[error("rule {rule}: missing or wrong-typed field '{field}'")]
Field { rule: String, field: String },
#[error("rule {rule}: unknown region selector '{region}'")]
UnknownRegion { rule: String, region: String },
#[error("rule {rule}: bad regex '{pattern}': {detail}")]
BadRegex {
rule: String,
pattern: String,
detail: String,
},
#[error("rule {rule}: gate nested deeper than {max}", max = MAX_GATE_DEPTH)]
GateTooDeep { rule: String },
#[error(
"rule {rule}: gate table must have exactly one of contains/regex/line_regex/all/any/not"
)]
BadGate { rule: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Region {
WholeRecent,
BottomNonEmptyLines(usize),
PromptBoxBody,
OscTitle,
OscProgress,
}
impl Region {
fn parse(s: &str, rule: &str) -> Result<Region, ManifestError> {
if let Some(arg) = s
.strip_prefix("bottom_non_empty_lines(")
.and_then(|r| r.strip_suffix(')'))
{
let n = arg
.trim()
.parse::<usize>()
.map_err(|_| ManifestError::Field {
rule: rule.to_string(),
field: "region (bottom_non_empty_lines arg)".to_string(),
})?;
if n == 0 {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: "region (bottom_non_empty_lines arg must be > 0)".to_string(),
});
}
return Ok(Region::BottomNonEmptyLines(n));
}
match s {
"whole_recent" => Ok(Region::WholeRecent),
"prompt_box_body" => Ok(Region::PromptBoxBody),
"osc_title" => Ok(Region::OscTitle),
"osc_progress" => Ok(Region::OscProgress),
_ => Err(ManifestError::UnknownRegion {
rule: rule.to_string(),
region: s.to_string(),
}),
}
}
fn extract(&self, screen: &ScreenView) -> String {
match self {
Region::WholeRecent => screen.visible_text.to_string(),
Region::BottomNonEmptyLines(n) => {
let nonblank: Vec<&str> = screen
.visible_text
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let start = nonblank.len().saturating_sub(*n);
nonblank[start..].join("\n")
}
Region::PromptBoxBody => prompt_box_body(screen.visible_text),
Region::OscTitle => screen.osc_title.unwrap_or("").to_string(),
Region::OscProgress => screen.osc_progress.unwrap_or("").to_string(),
}
}
}
fn prompt_box_body(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let Some(bottom) = lines.iter().rposition(|l| l.contains('╰')) else {
return String::new();
};
let Some(top) = lines[..bottom].iter().rposition(|l| l.contains('╭')) else {
return String::new();
};
lines[top + 1..bottom]
.iter()
.map(|l| l.trim().trim_matches('│').trim().to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone)]
pub enum Gate {
Contains(String),
Regex(Regex),
LineRegex(Regex),
All(Vec<Gate>),
Any(Vec<Gate>),
Not(Box<Gate>),
}
impl Gate {
fn parse(v: &toml::Value, rule: &str, depth: usize) -> Result<Gate, ManifestError> {
if depth > MAX_GATE_DEPTH {
return Err(ManifestError::GateTooDeep {
rule: rule.to_string(),
});
}
let table = v.as_table().ok_or_else(|| ManifestError::BadGate {
rule: rule.to_string(),
})?;
if table.len() != 1 {
return Err(ManifestError::BadGate {
rule: rule.to_string(),
});
}
let (key, val) = table.iter().next().expect("len checked == 1");
let compile = |p: &str| {
Regex::new(p).map_err(|e| ManifestError::BadRegex {
rule: rule.to_string(),
pattern: p.to_string(),
detail: e.to_string(),
})
};
let as_str = || {
val.as_str().ok_or_else(|| ManifestError::Field {
rule: rule.to_string(),
field: format!("gate.{key}"),
})
};
let leaf_str = || {
let s = as_str()?;
if s.is_empty() {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: format!("gate.{key} (must be non-empty)"),
});
}
Ok(s)
};
let as_array = || {
val.as_array().ok_or_else(|| ManifestError::Field {
rule: rule.to_string(),
field: format!("gate.{key}"),
})
};
match key.as_str() {
"contains" => Ok(Gate::Contains(leaf_str()?.to_string())),
"regex" => Ok(Gate::Regex(compile(leaf_str()?)?)),
"line_regex" => Ok(Gate::LineRegex(compile(leaf_str()?)?)),
"all" => Ok(Gate::All(Self::parse_children(as_array()?, rule, depth)?)),
"any" => Ok(Gate::Any(Self::parse_children(as_array()?, rule, depth)?)),
"not" => Ok(Gate::Not(Box::new(Gate::parse(val, rule, depth + 1)?))),
_ => Err(ManifestError::BadGate {
rule: rule.to_string(),
}),
}
}
fn parse_children(
arr: &[toml::Value],
rule: &str,
depth: usize,
) -> Result<Vec<Gate>, ManifestError> {
if arr.is_empty() {
return Err(ManifestError::BadGate {
rule: rule.to_string(),
});
}
arr.iter()
.map(|child| Gate::parse(child, rule, depth + 1))
.collect()
}
fn matches(&self, text: &str) -> bool {
match self {
Gate::Contains(s) => text.contains(s.as_str()),
Gate::Regex(re) => re.is_match(text),
Gate::LineRegex(re) => text.lines().any(|l| re.is_match(l)),
Gate::All(gs) => gs.iter().all(|g| g.matches(text)),
Gate::Any(gs) => gs.iter().any(|g| g.matches(text)),
Gate::Not(g) => !g.matches(text),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SendMapping {
Digit,
DigitEnter,
}
#[derive(Debug, Clone)]
struct AnswerGrammar {
option: Regex,
send: SendMapping,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnswerOption {
pub idx: String,
pub label: String,
pub keystroke: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnswerablePrompt {
pub prompt: String,
pub options: Vec<AnswerOption>,
pub fingerprint: [u8; 32],
pub region_lines: usize,
}
impl AnswerGrammar {
fn parse(
v: &toml::Value,
rule: &str,
state: &str,
region: &Region,
) -> Result<AnswerGrammar, ManifestError> {
if state != "blocked" {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: "answer (only allowed on a state = \"blocked\" rule)".to_string(),
});
}
if !matches!(region, Region::BottomNonEmptyLines(_)) {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: "answer (region must be bottom_non_empty_lines(N))".to_string(),
});
}
let table = v.as_table().ok_or_else(|| ManifestError::Field {
rule: rule.to_string(),
field: "answer (must be a table)".to_string(),
})?;
const ALLOWED: &[&str] = &["option", "send"];
if let Some(unknown) = table.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: format!("answer unknown key '{unknown}'"),
});
}
let option_pat = table
.get("option")
.and_then(|x| x.as_str())
.ok_or_else(|| ManifestError::Field {
rule: rule.to_string(),
field: "answer.option".to_string(),
})?;
let option = Regex::new(option_pat).map_err(|e| ManifestError::BadRegex {
rule: rule.to_string(),
pattern: option_pat.to_string(),
detail: e.to_string(),
})?;
let names: Vec<&str> = option.capture_names().flatten().collect();
for need in ["idx", "label"] {
if !names.contains(&need) {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: format!("answer.option (missing named capture '{need}')"),
});
}
}
let send = match table.get("send").and_then(|x| x.as_str()) {
Some("digit") => SendMapping::Digit,
Some("digit_enter") => SendMapping::DigitEnter,
_ => {
return Err(ManifestError::Field {
rule: rule.to_string(),
field: "answer.send (must be \"digit\" or \"digit_enter\")".to_string(),
})
}
};
Ok(AnswerGrammar { option, send })
}
}
#[derive(Debug, Clone)]
pub struct ManifestRule {
pub id: String,
pub state: String,
pub priority: i32,
pub region: Region,
pub skip_state_update: bool,
pub gate: Gate,
context: Option<(Region, Gate)>,
answer: Option<AnswerGrammar>,
}
impl ManifestRule {
fn matched_region_text(&self, screen: &ScreenView) -> Option<String> {
let text = self.region.extract(screen);
if !self.gate.matches(&text) {
return None;
}
if let Some((region, gate)) = &self.context {
let context = region.extract(screen);
if !gate.matches(&context) {
return None;
}
}
Some(text)
}
pub fn extract_answer(&self, region_text: &str) -> Option<AnswerablePrompt> {
let grammar = self.answer.as_ref()?;
let Region::BottomNonEmptyLines(n) = self.region else {
return None;
};
let mut options: Vec<AnswerOption> = Vec::new();
let mut first_option_line: Option<usize> = None;
for (i, line) in region_text.lines().enumerate() {
let Some(caps) = grammar.option.captures(line) else {
continue;
};
let idx = caps.name("idx")?.as_str().to_string();
let label = caps.name("label")?.as_str().trim().to_string();
if idx.len() != 1 || !idx.as_bytes()[0].is_ascii_digit() || label.is_empty() {
return None;
}
if first_option_line.is_none() {
first_option_line = Some(i);
}
let mut keystroke = vec![idx.as_bytes()[0]];
if matches!(grammar.send, SendMapping::DigitEnter) {
keystroke.push(b'\r');
}
options.push(AnswerOption {
idx,
label,
keystroke,
});
}
if options.is_empty() {
return None;
}
let mut idxs: Vec<u8> = options.iter().map(|o| o.idx.as_bytes()[0] - b'0').collect();
idxs.sort_unstable();
let expected: Vec<u8> = (1..=options.len() as u8).collect();
if idxs != expected {
return None;
}
let first = first_option_line.unwrap_or(0);
let prompt = region_text
.lines()
.take(first)
.collect::<Vec<_>>()
.join("\n");
let fingerprint = *blake3::hash(region_text.as_bytes()).as_bytes();
Some(AnswerablePrompt {
prompt,
options,
fingerprint,
region_lines: n,
})
}
}
impl ManifestRule {
fn parse(v: &toml::Value) -> Result<ManifestRule, ManifestError> {
let id = v
.get("id")
.and_then(|x| x.as_str())
.ok_or_else(|| ManifestError::Field {
rule: "<unnamed>".to_string(),
field: "id".to_string(),
})?
.to_string();
if id.trim().is_empty() {
return Err(ManifestError::Field {
rule: "<unnamed>".to_string(),
field: "id (must be non-empty)".to_string(),
});
}
let str_field = |f: &str| {
v.get(f)
.and_then(|x| x.as_str())
.ok_or_else(|| ManifestError::Field {
rule: id.clone(),
field: f.to_string(),
})
};
let state = str_field("state")?.to_string();
let priority_i64 = v
.get("priority")
.and_then(|x| x.as_integer())
.ok_or_else(|| ManifestError::Field {
rule: id.clone(),
field: "priority".to_string(),
})?;
let priority = i32::try_from(priority_i64).map_err(|_| ManifestError::Field {
rule: id.clone(),
field: "priority (out of i32 range)".to_string(),
})?;
let region = Region::parse(str_field("region")?, &id)?;
let skip_state_update = match v.get("skip_state_update") {
None => false,
Some(x) => x.as_bool().ok_or_else(|| ManifestError::Field {
rule: id.clone(),
field: "skip_state_update (must be a boolean)".to_string(),
})?,
};
let gate_val = v.get("gate").ok_or_else(|| ManifestError::Field {
rule: id.clone(),
field: "gate".to_string(),
})?;
let gate = Gate::parse(gate_val, &id, 0)?;
let context = match (v.get("context_region"), v.get("context_gate")) {
(None, None) => None,
(Some(region), Some(gate)) => {
let region = region.as_str().ok_or_else(|| ManifestError::Field {
rule: id.clone(),
field: "context_region".to_string(),
})?;
Some((Region::parse(region, &id)?, Gate::parse(gate, &id, 0)?))
}
_ => {
return Err(ManifestError::Field {
rule: id.clone(),
field: "context_region and context_gate (must be provided together)"
.to_string(),
})
}
};
let answer = match v.get("answer") {
None => None,
Some(a) => Some(AnswerGrammar::parse(a, &id, &state, ®ion)?),
};
if let Some(table) = v.as_table() {
const ALLOWED: &[&str] = &[
"id",
"state",
"priority",
"region",
"skip_state_update",
"gate",
"context_region",
"context_gate",
"answer",
];
if let Some(unknown) = table.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
return Err(ManifestError::Field {
rule: id.clone(),
field: format!("unknown key '{unknown}'"),
});
}
}
Ok(ManifestRule {
id,
state,
priority,
region,
skip_state_update,
gate,
context,
answer,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict<'a> {
pub rule_id: &'a str,
pub state: &'a str,
pub skip_state_update: bool,
}
#[derive(Debug, Clone)]
pub struct Manifest {
pub min_engine_version: u32,
rules: Vec<ManifestRule>,
}
impl Manifest {
pub fn parse(s: &str) -> Result<Manifest, ManifestError> {
let root: toml::Value =
toml::from_str(s).map_err(|e| ManifestError::Toml(e.to_string()))?;
if let Some(table) = root.as_table() {
if let Some(unknown) = table
.keys()
.find(|k| !matches!(k.as_str(), "min_engine_version" | "rule"))
{
return Err(ManifestError::Field {
rule: "<root>".to_string(),
field: format!("unknown key '{unknown}'"),
});
}
}
let min_engine_version = match root.get("min_engine_version") {
None => 0,
Some(v) => v
.as_integer()
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| ManifestError::Field {
rule: "<root>".to_string(),
field: "min_engine_version (must be a non-negative integer)".to_string(),
})?,
};
let mut rules = match root.get("rule") {
Some(v) => v
.as_array()
.ok_or_else(|| ManifestError::Field {
rule: "<root>".to_string(),
field: "rule (must be an array of tables)".to_string(),
})?
.iter()
.map(ManifestRule::parse)
.collect::<Result<Vec<_>, _>>()?,
None => Vec::new(),
};
rules.sort_by(|a, b| b.priority.cmp(&a.priority));
Ok(Manifest {
min_engine_version,
rules,
})
}
pub fn rules(&self) -> &[ManifestRule] {
&self.rules
}
pub fn evaluate(&self, screen: &ScreenView) -> Option<Verdict<'_>> {
self.rules.iter().find_map(|rule| {
rule.matched_region_text(screen).map(|_| Verdict {
rule_id: &rule.id,
state: &rule.state,
skip_state_update: rule.skip_state_update,
})
})
}
pub fn evaluate_answerable(
&self,
screen: &ScreenView,
) -> Option<(Verdict<'_>, Option<AnswerablePrompt>)> {
self.rules.iter().find_map(|rule| {
let text = rule.matched_region_text(screen)?;
let answerable = rule.extract_answer(&text);
Some((
Verdict {
rule_id: &rule.id,
state: &rule.state,
skip_state_update: rule.skip_state_update,
},
answerable,
))
})
}
}
pub fn bundled_manifest(agent: &str) -> Option<&'static str> {
match agent {
"claude" => Some(include_str!("manifests/claude.toml")),
"codex" => Some(include_str!("manifests/codex.toml")),
"gemini" => Some(include_str!("manifests/gemini.toml")),
"agy" => Some(include_str!("manifests/agy.toml")),
"opencode" => Some(include_str!("manifests/opencode.toml")),
"amp" => Some(include_str!("manifests/amp.toml")),
"cline" => Some(include_str!("manifests/cline.toml")),
"cursor" => Some(include_str!("manifests/cursor.toml")),
"devin" => Some(include_str!("manifests/devin.toml")),
"droid" => Some(include_str!("manifests/droid.toml")),
"copilot" => Some(include_str!("manifests/github-copilot.toml")),
"grok" => Some(include_str!("manifests/grok.toml")),
"hermes" => Some(include_str!("manifests/hermes.toml")),
"kilo" => Some(include_str!("manifests/kilo.toml")),
"kimi" => Some(include_str!("manifests/kimi.toml")),
"kiro" => Some(include_str!("manifests/kiro.toml")),
"pi" => Some(include_str!("manifests/pi.toml")),
"qodercli" => Some(include_str!("manifests/qodercli.toml")),
_ => None,
}
}
pub fn load_manifest(
agent: &str,
override_dir: Option<&Path>,
) -> Option<Result<Manifest, ManifestError>> {
if let Some(dir) = override_dir {
let path = dir.join(format!("{agent}.toml"));
match std::fs::read_to_string(&path) {
Ok(text) => return Some(Manifest::parse(&text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Some(Err(ManifestError::Io {
path: path.display().to_string(),
detail: e.to_string(),
}))
}
}
}
bundled_manifest(agent).map(Manifest::parse)
}
#[cfg(test)]
mod tests {
use super::*;
fn view(text: &str) -> ScreenView<'_> {
ScreenView {
visible_text: text,
cursor_row: 0,
cursor_col: 0,
osc_title: None,
osc_progress: None,
}
}
fn view_title<'a>(text: &'a str, title: &'a str) -> ScreenView<'a> {
ScreenView {
visible_text: text,
cursor_row: 0,
cursor_col: 0,
osc_title: Some(title),
osc_progress: None,
}
}
#[test]
fn parses_fields_and_sorts_by_priority_desc() {
let m = Manifest::parse(
r#"
min_engine_version = 2
[[rule]]
id = "low"
state = "idle"
priority = 10
region = "whole_recent"
gate = { contains = "x" }
[[rule]]
id = "high"
state = "working"
priority = 100
region = "whole_recent"
gate = { contains = "y" }
"#,
)
.unwrap();
assert_eq!(m.min_engine_version, 2);
assert_eq!(m.rules().len(), 2);
assert_eq!(m.rules()[0].id, "high", "highest priority sorts first");
assert_eq!(m.rules()[1].id, "low");
}
#[test]
fn min_engine_version_defaults_to_zero() {
let m = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "idle"
priority = 1
region = "whole_recent"
gate = { contains = "x" }
"#,
)
.unwrap();
assert_eq!(m.min_engine_version, 0);
}
#[test]
fn highest_priority_match_wins_over_scrollback() {
let m = Manifest::parse(
r#"
[[rule]]
id = "scrollback_yes"
state = "blocked"
priority = 100
region = "whole_recent"
gate = { contains = "yes" }
[[rule]]
id = "live_prompt"
state = "idle"
priority = 900
region = "bottom_non_empty_lines(1)"
gate = { line_regex = "^\\s*❯" }
"#,
)
.unwrap();
let screen = "I said yes earlier\nlots of reply text\n ❯ ";
let v = m.evaluate(&view(screen)).unwrap();
assert_eq!(v.state, "idle", "live-region rule beats scrollback match");
assert_eq!(v.rule_id, "live_prompt");
}
#[test]
fn osc_title_braille_spinner_badges_working_from_title_alone() {
let m = Manifest::parse(
r#"
[[rule]]
id = "osc_title_working"
state = "working"
priority = 1100
region = "osc_title"
gate = { regex = "^[\\x{2800}-\\x{28FF}]" }
"#,
)
.unwrap();
let screen = view_title("old output\nmore scrollback\n", "\u{280b} Compiling");
let v = m.evaluate(&screen).unwrap();
assert_eq!(v.state, "working");
assert!(m.evaluate(&view("old output")).is_none());
}
#[test]
fn skip_state_update_flag_is_carried_through() {
let m = Manifest::parse(
r#"
[[rule]]
id = "transcript_viewer"
state = "idle"
priority = 1000
region = "bottom_non_empty_lines(3)"
skip_state_update = true
gate = { contains = "(END)" }
"#,
)
.unwrap();
let v = m.evaluate(&view("scrollback\nmore\n(END)")).unwrap();
assert!(v.skip_state_update, "pager rule must not update state");
}
#[test]
fn gate_all_any_not_compose() {
let m = Manifest::parse(
r#"
[[rule]]
id = "blocked_form"
state = "blocked"
priority = 980
region = "whole_recent"
gate = { all = [ { contains = "enter to select" }, { contains = "esc to cancel" }, { not = { contains = "esc to interrupt" } } ] }
"#,
)
.unwrap();
assert!(m
.evaluate(&view("press enter to select, esc to cancel"))
.is_some());
assert!(m.evaluate(&view("enter to select something")).is_none());
assert!(m
.evaluate(&view("enter to select, esc to cancel, esc to interrupt"))
.is_none());
}
#[test]
fn any_gate_matches_on_one() {
let m = Manifest::parse(
r#"
[[rule]]
id = "perm"
state = "blocked"
priority = 850
region = "whole_recent"
gate = { any = [ { contains = "do you want to proceed?" }, { contains = "1. Yes" } ] }
"#,
)
.unwrap();
assert!(m.evaluate(&view("1. Yes\n2. No")).is_some());
assert!(m.evaluate(&view("nothing relevant")).is_none());
}
#[test]
fn region_prompt_box_body_extracts_box_interior() {
let m = Manifest::parse(
r#"
[[rule]]
id = "live_prompt_box"
state = "idle"
priority = 950
region = "prompt_box_body"
gate = { regex = "❯" }
"#,
)
.unwrap();
let screen = "❯ earlier command in history\n\
╭──────────────╮\n\
│ ❯ type here │\n\
╰──────────────╯";
assert!(m.evaluate(&view(screen)).is_some());
assert!(m.evaluate(&view("just text, no box")).is_none());
}
#[test]
fn region_osc_progress_reads_progress_payload() {
let m = Manifest::parse(
r#"
[[rule]]
id = "progressing"
state = "working"
priority = 500
region = "osc_progress"
gate = { regex = "^4;" }
"#,
)
.unwrap();
let screen = ScreenView {
visible_text: "anything",
cursor_row: 0,
cursor_col: 0,
osc_title: None,
osc_progress: Some("4;1;50"),
};
assert_eq!(m.evaluate(&screen).unwrap().state, "working");
}
#[test]
fn no_rule_matches_returns_none() {
let m = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "idle"
priority = 1
region = "whole_recent"
gate = { contains = "zzz" }
"#,
)
.unwrap();
assert!(m.evaluate(&view("nothing here")).is_none());
}
#[test]
fn empty_manifest_parses_to_no_rules() {
let m = Manifest::parse("min_engine_version = 1").unwrap();
assert!(m.rules().is_empty());
assert!(m.evaluate(&view("anything")).is_none());
}
#[test]
fn bad_regex_is_a_parse_error_not_a_silent_drop() {
let err = Manifest::parse(
r#"
[[rule]]
id = "broken"
state = "x"
priority = 1
region = "whole_recent"
gate = { regex = "(" }
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::BadRegex { rule, .. } if rule == "broken"));
}
#[test]
fn unknown_region_is_a_parse_error() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "x"
priority = 1
region = "the_moon"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::UnknownRegion { region, .. } if region == "the_moon"));
}
#[test]
fn missing_required_field_names_the_rule() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
priority = 1
region = "whole_recent"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { rule, field } if rule == "r" && field == "state")
);
}
#[test]
fn multi_key_gate_table_is_rejected() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "x"
priority = 1
region = "whole_recent"
gate = { contains = "a", regex = "b" }
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::BadGate { rule } if rule == "r"));
}
#[test]
fn over_deep_gate_is_refused() {
let mut gate = "{ contains = \"x\" }".to_string();
for _ in 0..(MAX_GATE_DEPTH + 2) {
gate = format!("{{ not = {gate} }}");
}
let toml = format!(
"[[rule]]\nid = \"deep\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {gate}\n"
);
let err = Manifest::parse(&toml).unwrap_err();
assert!(matches!(err, ManifestError::GateTooDeep { rule } if rule == "deep"));
}
#[test]
fn bottom_non_empty_lines_scopes_to_tail() {
let m = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "hit"
priority = 1
region = "bottom_non_empty_lines(2)"
gate = { contains = "needle" }
"#,
)
.unwrap();
let screen = "needle up here\n\nfiller\nmore filler\nlast line";
assert!(m.evaluate(&view(screen)).is_none());
assert!(m.evaluate(&view("filler\nfiller\nneedle\nlast")).is_some());
}
#[test]
fn empty_composite_gate_is_rejected_not_fail_open() {
for body in ["all = []", "any = []"] {
let err = Manifest::parse(&format!(
"[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {{ {body} }}\n"
))
.unwrap_err();
assert!(
matches!(err, ManifestError::BadGate { rule } if rule == "r"),
"{body} should be rejected"
);
}
}
#[test]
fn bottom_non_empty_lines_zero_is_rejected() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "x"
priority = 1
region = "bottom_non_empty_lines(0)"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::Field { rule, .. } if rule == "r"));
}
#[test]
fn malformed_scalar_fields_are_rejected_not_coerced() {
let big = i64::from(i32::MAX) + 1;
let err = Manifest::parse(&format!(
"[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = {big}\nregion = \"whole_recent\"\ngate = {{ contains = \"x\" }}\n"
))
.unwrap_err();
assert!(matches!(err, ManifestError::Field { field, .. } if field.starts_with("priority")));
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "x"
priority = 1
region = "whole_recent"
skip_state_update = "yes"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { field, .. } if field.starts_with("skip_state_update"))
);
let err = Manifest::parse(
r#"
min_engine_version = "two"
[[rule]]
id = "r"
state = "x"
priority = 1
region = "whole_recent"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { field, .. } if field.starts_with("min_engine_version"))
);
}
#[test]
fn empty_leaf_gate_pattern_is_rejected_not_fail_open() {
for leaf in [r#"contains = """#, r#"regex = """#, r#"line_regex = """#] {
let err = Manifest::parse(&format!(
"[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {{ {leaf} }}\n"
))
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { rule, .. } if rule == "r"),
"{leaf} should be rejected"
);
}
}
#[test]
fn unknown_rule_and_root_keys_are_rejected() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "x"
priority = 1
region = "whole_recent"
skip_state_updates = true
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { rule, field } if rule == "r" && field.contains("unknown key"))
);
let err = Manifest::parse(
r#"
min_engine_versions = 1
[[rule]]
id = "r"
state = "x"
priority = 1
region = "whole_recent"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { rule, field } if rule == "<root>" && field.contains("unknown key"))
);
}
#[test]
fn empty_id_is_rejected() {
let err = Manifest::parse(
r#"
[[rule]]
id = ""
state = "x"
priority = 1
region = "whole_recent"
gate = { contains = "x" }
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::Field { field, .. } if field.starts_with("id")));
}
use crate::readiness::{CodexReadinessDetector, GeminiReadinessDetector, ReadinessDetector};
fn bundled(agent: &str) -> Manifest {
Manifest::parse(bundled_manifest(agent).expect("bundled manifest exists"))
.expect("bundled manifest parses")
}
fn manifest_ready(m: &Manifest, screen: &ScreenView) -> bool {
matches!(m.evaluate(screen), Some(v) if v.state == "idle" && !v.skip_state_update)
}
#[test]
fn bundled_manifests_all_parse() {
let synthetic = view("some scrollback\nesc to interrupt\n\u{276f} ");
for agent in [
"claude", "codex", "gemini", "agy", "opencode", "amp", "cline", "cursor", "devin", "droid", "copilot", "grok", "hermes", "kilo",
"kimi", "kiro", "pi", "qodercli", ] {
let src = bundled_manifest(agent).unwrap_or_else(|| panic!("{agent} is bundled"));
let m = Manifest::parse(src)
.unwrap_or_else(|e| panic!("{agent}.toml failed to parse: {e:?}"));
assert!(!m.rules().is_empty(), "{agent}.toml has rules");
let _ = m.evaluate(&synthetic);
}
assert!(bundled_manifest("aider").is_none(), "unknown agent -> None");
}
#[test]
fn ac_e6_4_codex_gemini_toml_match_hardcoded_detectors() {
let codex_m = bundled("codex");
let gemini_m = bundled("gemini");
let cases: &[(&str, bool)] = &[
("codex 0.130\n\n build feature X\n\u{276f} ", true), ("running tool...\nEsc to interrupt\n\u{276f}", false), ("loading a 5000 byte banner of text", false), ("Waiting for auth...\n\u{276f}", false), ("Gemini ready\n\u{203a} ", true), (
"I am Working on the Thinking task you asked about.\n\
Here is a long reply that mentions Working again.\n\
filler line\nanother filler\n\u{276f} ",
true,
),
];
for (text, want) in cases {
let trimmed = text.trim_end();
let screen = view(trimmed);
assert_eq!(
manifest_ready(&codex_m, &screen),
*want,
"codex.toml readiness mismatch for {trimmed:?}"
);
assert_eq!(
manifest_ready(&codex_m, &screen),
CodexReadinessDetector.is_ready(&screen).unwrap(),
"codex.toml diverges from CodexReadinessDetector for {trimmed:?}"
);
assert_eq!(
manifest_ready(&gemini_m, &screen),
GeminiReadinessDetector.is_ready(&screen).unwrap(),
"gemini.toml diverges from GeminiReadinessDetector for {trimmed:?}"
);
}
}
#[test]
fn ac_e6_2_claude_osc_title_spinner_badges_working() {
let m = bundled("claude");
let screen = view_title("old output\nmore scrollback\n", "\u{280b} Compiling");
let v = m.evaluate(&screen).expect("spinner title matches");
assert_eq!(v.state, "working");
assert_eq!(v.rule_id, "osc_title_working");
assert!(m
.evaluate(&view("old output\nmore scrollback"))
.is_none_or(|v| v.rule_id != "osc_title_working"));
}
#[test]
fn ac_e6_3_claude_transcript_viewer_holds_state() {
let m = bundled("claude");
let v = m
.evaluate(&view("scrollback line\nmore scrollback\n(END)"))
.expect("transcript pager marker matches");
assert_eq!(v.rule_id, "transcript_viewer");
assert!(
v.skip_state_update,
"pager rule must hold state, not set idle"
);
}
#[test]
fn ac_e6_5_claude_title_spinner_outranks_idle_grid_box() {
let m = bundled("claude");
let grid = "\u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256e}\n\
\u{2502} \u{276f} type here \u{2502}\n\
\u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256f}";
let v_idle = m.evaluate(&view(grid)).expect("idle box matches");
assert_eq!(v_idle.state, "idle");
assert_eq!(v_idle.rule_id, "live_prompt_box");
let v_working = m
.evaluate(&view_title(grid, "\u{280b} Working"))
.expect("spinner title matches");
assert_eq!(v_working.state, "working");
assert_eq!(v_working.rule_id, "osc_title_working");
}
#[test]
fn ac_e6_5_claude_permission_prompt_outranks_idle_box() {
let m = bundled("claude");
let screen = "do you want to proceed?\n\
1. Yes\n\
2. No\n\
\u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256e}\n\
\u{2502} \u{276f} type \u{2502}\n\
\u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256f}";
let v = m.evaluate(&view(screen)).expect("a rule matches");
assert_eq!(
v.state, "blocked",
"permission prompt must beat the idle box"
);
assert_eq!(v.rule_id, "permission_prompt");
}
#[test]
fn load_manifest_prefers_override_then_bundled_then_none() {
let m = load_manifest("claude", None)
.expect("known agent")
.expect("parses");
assert!(!m.rules().is_empty());
assert!(load_manifest("aider", None).is_none());
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("codex.toml"),
"[[rule]]\nid = \"override_only\"\nstate = \"idle\"\npriority = 1\nregion = \"whole_recent\"\ngate = { contains = \"OVR\" }\n",
)
.unwrap();
let m = load_manifest("codex", Some(dir.path()))
.expect("override present")
.expect("override parses");
assert_eq!(m.rules().len(), 1);
assert_eq!(m.rules()[0].id, "override_only");
let m = load_manifest("gemini", Some(dir.path()))
.expect("falls back to bundled")
.expect("parses");
assert!(m.rules().iter().any(|r| r.id == "idle_prompt"));
std::fs::write(dir.path().join("claude.toml"), "this = is = not = toml").unwrap();
assert!(matches!(
load_manifest("claude", Some(dir.path())),
Some(Err(ManifestError::Toml(_)))
));
std::fs::write(dir.path().join("gemini.toml"), [0xff, 0xfe, 0x00]).unwrap();
assert!(matches!(
load_manifest("gemini", Some(dir.path())),
Some(Err(ManifestError::Io { .. }))
));
}
#[test]
fn x8f7f_agy_manifest_evaluates_idle_working_blocked() {
let m = bundled("agy");
assert_eq!(
m.evaluate(&view("agy 1.0\n\u{276f} ")).unwrap().state,
"idle"
);
assert_eq!(
m.evaluate(&view("running tool...\nesc to interrupt\n\u{276f}"))
.unwrap()
.state,
"working", );
assert_eq!(
m.evaluate(&view("Waiting for auth...\n\u{276f}"))
.unwrap()
.state,
"blocked", );
}
#[test]
fn x8f7f_opencode_manifest_matches_reference_screens() {
let m = bundled("opencode");
assert_eq!(
m.evaluate(&view("△ Permission required")).unwrap().state,
"blocked",
);
assert_eq!(
m.evaluate(&view("thinking\nesc to interrupt"))
.unwrap()
.state,
"working",
);
assert_eq!(
m.evaluate(&view("progress \u{25a0}\u{25a0}\u{25a0}\u{25a0}\u{25a0}"))
.unwrap()
.state,
"working", );
assert_eq!(
m.evaluate(&view(
"esc dismiss enter confirm \u{2191}\u{2193} select"
))
.unwrap()
.state,
"blocked",
);
assert!(m.evaluate(&view("here is your answer")).is_none());
}
#[test]
fn x8f7f_unknown_source_key_fails_loud() {
let bad = "[[rule]]\nid = \"p\"\nstate = \"blocked\"\npriority = 1\n\
region = \"whole_recent\"\nvisible_blocker = true\n\
gate = { contains = \"x\" }\n";
assert!(matches!(
Manifest::parse(bad),
Err(ManifestError::Field { .. })
));
}
#[test]
fn x8f7f_staged_manifest_fires_once_hosted() {
for hosted in ["opencode", "agy"] {
assert!(bundled_manifest(hosted).is_some(), "{hosted} bundled");
assert!(
crate::provider::for_name(hosted).is_some(),
"{hosted} IS hostable -> manifest can fire",
);
}
assert!(bundled_manifest("aider").is_none(), "aider not bundled");
assert!(
crate::provider::for_name("aider").is_none(),
"aider not hosted"
);
}
fn blocked_answer_manifest() -> Manifest {
Manifest::parse(
r#"
[[rule]]
id = "perm"
state = "blocked"
priority = 900
region = "bottom_non_empty_lines(8)"
gate = { contains = "proceed?" }
[rule.answer]
option = '^\s*\x{276f}?\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
send = "digit"
"#,
)
.unwrap()
}
#[test]
fn xc929_extract_answer_clean_numbered_menu() {
let m = blocked_answer_manifest();
let screen = "Do you want to proceed?\n ❯ 1. Yes\n 2. No";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(v.state, "blocked");
let ans = ans.expect("a clean numbered menu is answerable");
assert_eq!(ans.options.len(), 2);
assert_eq!(ans.options[0].idx, "1");
assert_eq!(ans.options[0].label, "Yes");
assert_eq!(ans.options[0].keystroke, b"1");
assert_eq!(ans.options[1].idx, "2");
assert_eq!(ans.options[1].label, "No");
assert_eq!(ans.options[1].keystroke, b"2");
assert_eq!(ans.region_lines, 8);
assert_eq!(ans.prompt, "Do you want to proceed?");
assert_eq!(ans.fingerprint, *blake3::hash(screen.as_bytes()).as_bytes());
}
#[test]
fn answer_rule_can_gate_context_outside_the_answer_region() {
let m = Manifest::parse(
r#"
[[rule]]
id = "approval"
state = "blocked"
priority = 10
region = "bottom_non_empty_lines(4)"
gate = { line_regex = '^\s*\x{203a}\s*[0-9]\.\s' }
context_region = "whole_recent"
context_gate = { contains = "Would you like to run the following command?" }
[rule.answer]
option = '^\s*\x{203a}?\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
send = "digit"
"#,
)
.unwrap();
let wrapped = (0..30)
.map(|i| format!("wrapped command row {i}"))
.collect::<Vec<_>>()
.join("\n");
let screen = format!(
"Would you like to run the following command?\n{wrapped}\n\
\u{203a} 1. Yes, proceed\n 2. Always allow\n 3. No, cancel\n Press enter to confirm"
);
let (v, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
assert_eq!(v.rule_id, "approval");
let ans = ans.expect("context gate detects the question; bottom region extracts the menu");
assert_eq!(ans.options.len(), 3);
assert_eq!(ans.region_lines, 4);
let no_question = format!(
"unrelated output\n{wrapped}\n\
\u{203a} 1. Yes, proceed\n 2. Always allow\n 3. No, cancel\n Press enter to confirm"
);
assert!(m.evaluate_answerable(&view(&no_question)).is_none());
}
#[test]
fn xc929_extract_answer_rejects_duplicate_indices() {
let m = blocked_answer_manifest();
let screen = "proceed?\n1. Yes\n1. No";
let (_, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert!(ans.is_none(), "duplicate index -> not answerable");
}
#[test]
fn xc929_extract_answer_rejects_truncated_menu() {
let m = blocked_answer_manifest();
let screen = "proceed?\n2. No\n3. Cancel";
let (_, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert!(ans.is_none(), "menu not starting at 1 -> truncated -> None");
}
#[test]
fn xc929_extraction_failure_is_additive_badge_survives() {
let m = blocked_answer_manifest();
let screen = "proceed?\nuse arrows to select";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(
v.state, "blocked",
"detection unaffected by extraction miss"
);
assert!(ans.is_none(), "no numbered options -> focus-only");
}
#[test]
fn xc929_extract_answer_keeps_full_label() {
let m = blocked_answer_manifest();
let long = "No, and tell Claude what to do differently (esc)";
let screen = format!("proceed?\n1. Yes\n2. {long}");
let (_, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
assert_eq!(ans.unwrap().options[1].label, long);
}
#[test]
fn xc929_send_digit_enter_appends_cr() {
let m = Manifest::parse(
r#"
[[rule]]
id = "perm"
state = "blocked"
priority = 900
region = "bottom_non_empty_lines(8)"
gate = { contains = "?" }
[rule.answer]
option = '^\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
send = "digit_enter"
"#,
)
.unwrap();
let (_, ans) = m.evaluate_answerable(&view("pick?\n1. A\n2. B")).unwrap();
assert_eq!(ans.unwrap().options[0].keystroke, b"1\r");
}
#[test]
fn xc929_answer_on_non_blocked_rule_fails_loud() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "idle"
priority = 1
region = "bottom_non_empty_lines(8)"
gate = { contains = "x" }
[rule.answer]
option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
send = "digit"
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { rule, field } if rule == "r" && field.starts_with("answer"))
);
}
#[test]
fn xc929_answer_on_non_bottom_n_region_fails_loud() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "blocked"
priority = 1
region = "whole_recent"
gate = { contains = "x" }
[rule.answer]
option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
send = "digit"
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { field, .. } if field.contains("bottom_non_empty_lines"))
);
}
#[test]
fn xc929_answer_missing_named_capture_fails_loud() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "blocked"
priority = 1
region = "bottom_non_empty_lines(8)"
gate = { contains = "x" }
[rule.answer]
option = '(?P<idx>[0-9])\.'
send = "digit"
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::Field { field, .. } if field.contains("label")));
}
#[test]
fn xc929_answer_bad_send_fails_loud() {
let err = Manifest::parse(
r#"
[[rule]]
id = "r"
state = "blocked"
priority = 1
region = "bottom_non_empty_lines(8)"
gate = { contains = "x" }
[rule.answer]
option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
send = "arrows"
"#,
)
.unwrap_err();
assert!(
matches!(err, ManifestError::Field { field, .. } if field.starts_with("answer.send"))
);
}
#[test]
fn xc929_bundled_claude_permission_prompt_is_answerable() {
let m = bundled("claude");
let screen =
"Do you want to proceed?\n ❯ 1. Yes\n 2. No, and tell Claude what to do differently";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(v.rule_id, "permission_prompt");
let ans = ans.expect("claude permission prompt is answerable");
assert_eq!(ans.options.len(), 2);
assert_eq!(ans.options[0].keystroke, b"1");
assert_eq!(ans.options[1].idx, "2");
}
#[test]
fn x5103_bundled_codex_trust_prompt_is_answerable() {
let m = bundled("codex");
let screen = "> You are in /tmp/foo\n \
Do you trust the contents of this directory? Trusting loads config.\n\
\u{203a} 1. Yes, continue\n 2. No, quit\n\n Press enter to continue";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(v.rule_id, "trust_prompt");
assert_eq!(v.state, "blocked");
let ans = ans.expect("codex numbered trust menu is answerable");
assert_eq!(ans.options.len(), 2);
assert_eq!(ans.options[0].idx, "1");
assert_eq!(ans.options[0].label, "Yes, continue");
assert_eq!(ans.options[0].keystroke, b"1");
assert_eq!(ans.options[1].idx, "2");
assert_eq!(ans.options[1].keystroke, b"2");
}
#[test]
fn x5103_codex_model_printed_list_is_not_a_false_trust_prompt() {
let m = bundled("codex");
let screen = "The permission flow. Do you trust the folder? Options:\n\
1. Yes, it loads config\n 2. No, sandboxed\n\u{203a} ";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_ne!(
v.rule_id, "trust_prompt",
"a printed list without the › selector must not fire trust_prompt"
);
assert!(ans.is_none(), "no live menu -> not answerable");
}
#[test]
fn xf498_bundled_codex_command_approval_is_answerable() {
let m = bundled("codex");
let screen = "Would you like to run the following command?\n\n\
Environment: local\n\n\
$ touch /tmp/fno-x-f498-approval-capture\n\n\
\u{203a} 1. Yes, proceed (y)\n\
2. Yes, and don't ask again for commands that start with `touch /tmp/fno-x-f498-approval-capture` (p)\n\
3. No, and tell Codex what to do differently (esc)\n\n\
Press enter to confirm or esc to cancel";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(v.rule_id, "approval_prompt");
assert_eq!(v.state, "blocked");
let ans = ans.expect("codex command approval is answerable");
assert_eq!(ans.options.len(), 3);
assert_eq!(ans.options[0].label, "Yes, proceed (y)");
assert_eq!(ans.options[0].keystroke, b"1");
assert_eq!(ans.options[2].idx, "3");
assert_eq!(ans.options[2].keystroke, b"3");
}
#[test]
fn xf498_codex_model_printed_list_is_not_a_false_command_approval() {
let m = bundled("codex");
let screen = "Here is an example. Would you like to run the following command?\n\
1. Yes, proceed\n 2. No, cancel\n\u{203a} ";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_ne!(v.rule_id, "approval_prompt");
assert!(ans.is_none(), "no marked live menu -> not answerable");
}
#[test]
fn xf498_bundled_codex_edit_approval_is_answerable() {
let m = bundled("codex");
let screen = "Added .fno-x-f498-edit-capture (+1 -0)\n\
1 +CAPTURE\n\n\
Would you like to make the following edits?\n\n\
\u{203a} 1. Yes, proceed (y)\n\
2. Yes, and don't ask again for these files (a)\n\
3. No, and tell Codex what to do differently (esc)\n\n\
Press enter to confirm or esc to cancel";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(v.rule_id, "approval_prompt");
let ans = ans.expect("codex edit approval is answerable");
assert_eq!(ans.options.len(), 3);
assert_eq!(
ans.options[1].label,
"Yes, and don't ask again for these files (a)"
);
assert_eq!(ans.options[2].keystroke, b"3");
}
#[test]
fn xf498_codex_approval_survives_narrow_terminal_wrapping() {
let m = bundled("codex");
let wrapped = (0..40)
.map(|i| format!("wrapped command row {i}"))
.collect::<Vec<_>>()
.join("\n");
let screen = format!(
"Would you like to run the following command?\nEnvironment: local\n{wrapped}\n\
\u{203a} 1. Yes, proceed (y)\n\
2. Yes, and don't ask again (p)\n\
3. No, and tell Codex what to do differently (esc)\n\
Press enter to confirm or esc to cancel"
);
let (v, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
assert_eq!(v.rule_id, "approval_prompt");
assert!(ans.is_some());
}
#[test]
fn x5103_bundled_gemini_trust_prompt_is_answerable() {
let m = bundled("gemini");
let screen = "\u{256d}\u{2500}\u{2500}\u{2500}\u{256e}\n\
\u{2502} Do you trust the files in this folder? \u{2502}\n\
\u{2502} Trusting a folder allows Gemini CLI to load. \u{2502}\n\
\u{2502} \u{2502}\n\
\u{2502} \u{25cf} 1. Trust folder (foo) \u{2502}\n\
\u{2502} 2. Trust parent folder (tmp) \u{2502}\n\
\u{2502} 3. Don't trust \u{2502}\n\
\u{2570}\u{2500}\u{2500}\u{2500}\u{256f}";
let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
assert_eq!(v.rule_id, "trust_prompt");
assert_eq!(v.state, "blocked");
let ans = ans.expect("gemini boxed numbered menu is answerable");
assert_eq!(ans.options.len(), 3);
assert_eq!(ans.options[0].idx, "1");
assert_eq!(ans.options[0].label, "Trust folder (foo)");
assert_eq!(ans.options[0].keystroke, b"1");
assert_eq!(ans.options[2].idx, "3");
assert_eq!(ans.options[2].label, "Don't trust");
}
#[test]
fn x5103_bundled_agy_arrow_menu_is_focus_only() {
let m = bundled("agy");
let screen = "Do you trust the contents of this project?\n\
Antigravity CLI requires permission to read, edit, and execute files here.\n\
> Yes, I trust this folder\n No, exit\n \u{2191}/\u{2193} Navigate \u{b7} enter Confirm";
assert!(
m.evaluate_answerable(&view(screen)).is_none(),
"agy arrow-only menu must not be answerable (focus-only fallback)"
);
}
}