1use crate::readiness::ScreenView;
33use regex::Regex;
34use serde::{Deserialize, Serialize};
35use std::path::Path;
36
37const MAX_GATE_DEPTH: usize = 16;
46
47#[derive(Debug, thiserror::Error, PartialEq, Eq)]
48pub enum ManifestError {
49 #[error("manifest is not valid TOML: {0}")]
50 Toml(String),
51 #[error("manifest io error for {path}: {detail}")]
52 Io { path: String, detail: String },
53 #[error("rule {rule}: missing or wrong-typed field '{field}'")]
54 Field { rule: String, field: String },
55 #[error("rule {rule}: unknown region selector '{region}'")]
56 UnknownRegion { rule: String, region: String },
57 #[error("rule {rule}: bad regex '{pattern}': {detail}")]
58 BadRegex {
59 rule: String,
60 pattern: String,
61 detail: String,
62 },
63 #[error("rule {rule}: gate nested deeper than {max}", max = MAX_GATE_DEPTH)]
64 GateTooDeep { rule: String },
65 #[error(
66 "rule {rule}: gate table must have exactly one of contains/regex/line_regex/all/any/not"
67 )]
68 BadGate { rule: String },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Region {
78 WholeRecent,
80 BottomNonEmptyLines(usize),
83 PromptBoxBody,
86 OscTitle,
88 OscProgress,
90}
91
92impl Region {
93 fn parse(s: &str, rule: &str) -> Result<Region, ManifestError> {
95 if let Some(arg) = s
96 .strip_prefix("bottom_non_empty_lines(")
97 .and_then(|r| r.strip_suffix(')'))
98 {
99 let n = arg
100 .trim()
101 .parse::<usize>()
102 .map_err(|_| ManifestError::Field {
103 rule: rule.to_string(),
104 field: "region (bottom_non_empty_lines arg)".to_string(),
105 })?;
106 if n == 0 {
107 return Err(ManifestError::Field {
110 rule: rule.to_string(),
111 field: "region (bottom_non_empty_lines arg must be > 0)".to_string(),
112 });
113 }
114 return Ok(Region::BottomNonEmptyLines(n));
115 }
116 match s {
117 "whole_recent" => Ok(Region::WholeRecent),
118 "prompt_box_body" => Ok(Region::PromptBoxBody),
119 "osc_title" => Ok(Region::OscTitle),
120 "osc_progress" => Ok(Region::OscProgress),
121 _ => Err(ManifestError::UnknownRegion {
122 rule: rule.to_string(),
123 region: s.to_string(),
124 }),
125 }
126 }
127
128 fn extract(&self, screen: &ScreenView) -> String {
132 match self {
133 Region::WholeRecent => screen.visible_text.to_string(),
134 Region::BottomNonEmptyLines(n) => {
135 let nonblank: Vec<&str> = screen
136 .visible_text
137 .lines()
138 .filter(|l| !l.trim().is_empty())
139 .collect();
140 let start = nonblank.len().saturating_sub(*n);
141 nonblank[start..].join("\n")
142 }
143 Region::PromptBoxBody => prompt_box_body(screen.visible_text),
144 Region::OscTitle => screen.osc_title.unwrap_or("").to_string(),
145 Region::OscProgress => screen.osc_progress.unwrap_or("").to_string(),
146 }
147 }
148}
149
150fn prompt_box_body(text: &str) -> String {
165 let lines: Vec<&str> = text.lines().collect();
166 let Some(bottom) = lines.iter().rposition(|l| l.contains('╰')) else {
167 return String::new();
168 };
169 let Some(top) = lines[..bottom].iter().rposition(|l| l.contains('╭')) else {
170 return String::new();
171 };
172 lines[top + 1..bottom]
173 .iter()
174 .map(|l| l.trim().trim_matches('│').trim().to_string())
175 .collect::<Vec<_>>()
176 .join("\n")
177}
178
179#[derive(Debug, Clone)]
183pub enum Gate {
184 Contains(String),
186 Regex(Regex),
188 LineRegex(Regex),
190 All(Vec<Gate>),
192 Any(Vec<Gate>),
194 Not(Box<Gate>),
196}
197
198impl Gate {
199 fn parse(v: &toml::Value, rule: &str, depth: usize) -> Result<Gate, ManifestError> {
202 if depth > MAX_GATE_DEPTH {
203 return Err(ManifestError::GateTooDeep {
204 rule: rule.to_string(),
205 });
206 }
207 let table = v.as_table().ok_or_else(|| ManifestError::BadGate {
208 rule: rule.to_string(),
209 })?;
210 if table.len() != 1 {
211 return Err(ManifestError::BadGate {
212 rule: rule.to_string(),
213 });
214 }
215 let (key, val) = table.iter().next().expect("len checked == 1");
216 let compile = |p: &str| {
217 Regex::new(p).map_err(|e| ManifestError::BadRegex {
218 rule: rule.to_string(),
219 pattern: p.to_string(),
220 detail: e.to_string(),
221 })
222 };
223 let as_str = || {
224 val.as_str().ok_or_else(|| ManifestError::Field {
225 rule: rule.to_string(),
226 field: format!("gate.{key}"),
227 })
228 };
229 let leaf_str = || {
233 let s = as_str()?;
234 if s.is_empty() {
235 return Err(ManifestError::Field {
236 rule: rule.to_string(),
237 field: format!("gate.{key} (must be non-empty)"),
238 });
239 }
240 Ok(s)
241 };
242 let as_array = || {
243 val.as_array().ok_or_else(|| ManifestError::Field {
244 rule: rule.to_string(),
245 field: format!("gate.{key}"),
246 })
247 };
248 match key.as_str() {
249 "contains" => Ok(Gate::Contains(leaf_str()?.to_string())),
250 "regex" => Ok(Gate::Regex(compile(leaf_str()?)?)),
251 "line_regex" => Ok(Gate::LineRegex(compile(leaf_str()?)?)),
252 "all" => Ok(Gate::All(Self::parse_children(as_array()?, rule, depth)?)),
253 "any" => Ok(Gate::Any(Self::parse_children(as_array()?, rule, depth)?)),
254 "not" => Ok(Gate::Not(Box::new(Gate::parse(val, rule, depth + 1)?))),
255 _ => Err(ManifestError::BadGate {
256 rule: rule.to_string(),
257 }),
258 }
259 }
260
261 fn parse_children(
262 arr: &[toml::Value],
263 rule: &str,
264 depth: usize,
265 ) -> Result<Vec<Gate>, ManifestError> {
266 if arr.is_empty() {
270 return Err(ManifestError::BadGate {
271 rule: rule.to_string(),
272 });
273 }
274 arr.iter()
275 .map(|child| Gate::parse(child, rule, depth + 1))
276 .collect()
277 }
278
279 fn matches(&self, text: &str) -> bool {
281 match self {
282 Gate::Contains(s) => text.contains(s.as_str()),
283 Gate::Regex(re) => re.is_match(text),
284 Gate::LineRegex(re) => text.lines().any(|l| re.is_match(l)),
285 Gate::All(gs) => gs.iter().all(|g| g.matches(text)),
286 Gate::Any(gs) => gs.iter().any(|g| g.matches(text)),
287 Gate::Not(g) => !g.matches(text),
288 }
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295enum SendMapping {
296 Digit,
298 DigitEnter,
300}
301
302#[derive(Debug, Clone)]
307struct AnswerGrammar {
308 option: Regex,
310 send: SendMapping,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct AnswerOption {
316 pub idx: String,
318 pub label: String,
320 pub keystroke: Vec<u8>,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct AnswerablePrompt {
331 pub prompt: String,
333 pub options: Vec<AnswerOption>,
334 pub fingerprint: [u8; 32],
336 pub region_lines: usize,
339}
340
341impl AnswerGrammar {
342 fn parse(
347 v: &toml::Value,
348 rule: &str,
349 state: &str,
350 region: &Region,
351 ) -> Result<AnswerGrammar, ManifestError> {
352 if state != "blocked" {
353 return Err(ManifestError::Field {
354 rule: rule.to_string(),
355 field: "answer (only allowed on a state = \"blocked\" rule)".to_string(),
356 });
357 }
358 if !matches!(region, Region::BottomNonEmptyLines(_)) {
359 return Err(ManifestError::Field {
360 rule: rule.to_string(),
361 field: "answer (region must be bottom_non_empty_lines(N))".to_string(),
362 });
363 }
364 let table = v.as_table().ok_or_else(|| ManifestError::Field {
365 rule: rule.to_string(),
366 field: "answer (must be a table)".to_string(),
367 })?;
368 const ALLOWED: &[&str] = &["option", "send"];
369 if let Some(unknown) = table.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
370 return Err(ManifestError::Field {
371 rule: rule.to_string(),
372 field: format!("answer unknown key '{unknown}'"),
373 });
374 }
375 let option_pat = table
376 .get("option")
377 .and_then(|x| x.as_str())
378 .ok_or_else(|| ManifestError::Field {
379 rule: rule.to_string(),
380 field: "answer.option".to_string(),
381 })?;
382 let option = Regex::new(option_pat).map_err(|e| ManifestError::BadRegex {
383 rule: rule.to_string(),
384 pattern: option_pat.to_string(),
385 detail: e.to_string(),
386 })?;
387 let names: Vec<&str> = option.capture_names().flatten().collect();
391 for need in ["idx", "label"] {
392 if !names.contains(&need) {
393 return Err(ManifestError::Field {
394 rule: rule.to_string(),
395 field: format!("answer.option (missing named capture '{need}')"),
396 });
397 }
398 }
399 let send = match table.get("send").and_then(|x| x.as_str()) {
400 Some("digit") => SendMapping::Digit,
401 Some("digit_enter") => SendMapping::DigitEnter,
402 _ => {
403 return Err(ManifestError::Field {
404 rule: rule.to_string(),
405 field: "answer.send (must be \"digit\" or \"digit_enter\")".to_string(),
406 })
407 }
408 };
409 Ok(AnswerGrammar { option, send })
410 }
411}
412
413#[derive(Debug, Clone)]
420pub struct ManifestRule {
421 pub id: String,
422 pub state: String,
423 pub priority: i32,
424 pub region: Region,
425 pub skip_state_update: bool,
426 pub gate: Gate,
427 context: Option<(Region, Gate)>,
428 answer: Option<AnswerGrammar>,
429}
430
431impl ManifestRule {
432 fn matched_region_text(&self, screen: &ScreenView) -> Option<String> {
437 let text = self.region.extract(screen);
438 if !self.gate.matches(&text) {
439 return None;
440 }
441 if let Some((region, gate)) = &self.context {
442 let context = region.extract(screen);
443 if !gate.matches(&context) {
444 return None;
445 }
446 }
447 Some(text)
448 }
449
450 pub fn extract_answer(&self, region_text: &str) -> Option<AnswerablePrompt> {
458 let grammar = self.answer.as_ref()?;
459 let Region::BottomNonEmptyLines(n) = self.region else {
461 return None;
462 };
463 let mut options: Vec<AnswerOption> = Vec::new();
464 let mut first_option_line: Option<usize> = None;
465 for (i, line) in region_text.lines().enumerate() {
466 let Some(caps) = grammar.option.captures(line) else {
467 continue;
468 };
469 let idx = caps.name("idx")?.as_str().to_string();
470 let label = caps.name("label")?.as_str().trim().to_string();
471 if idx.len() != 1 || !idx.as_bytes()[0].is_ascii_digit() || label.is_empty() {
474 return None;
475 }
476 if first_option_line.is_none() {
477 first_option_line = Some(i);
478 }
479 let mut keystroke = vec![idx.as_bytes()[0]];
480 if matches!(grammar.send, SendMapping::DigitEnter) {
481 keystroke.push(b'\r');
482 }
483 options.push(AnswerOption {
484 idx,
485 label,
486 keystroke,
487 });
488 }
489 if options.is_empty() {
490 return None;
491 }
492 let mut idxs: Vec<u8> = options.iter().map(|o| o.idx.as_bytes()[0] - b'0').collect();
496 idxs.sort_unstable();
497 let expected: Vec<u8> = (1..=options.len() as u8).collect();
498 if idxs != expected {
499 return None;
500 }
501 let first = first_option_line.unwrap_or(0);
502 let prompt = region_text
503 .lines()
504 .take(first)
505 .collect::<Vec<_>>()
506 .join("\n");
507 let fingerprint = *blake3::hash(region_text.as_bytes()).as_bytes();
508 Some(AnswerablePrompt {
509 prompt,
510 options,
511 fingerprint,
512 region_lines: n,
513 })
514 }
515}
516
517impl ManifestRule {
518 fn parse(v: &toml::Value) -> Result<ManifestRule, ManifestError> {
519 let id = v
521 .get("id")
522 .and_then(|x| x.as_str())
523 .ok_or_else(|| ManifestError::Field {
524 rule: "<unnamed>".to_string(),
525 field: "id".to_string(),
526 })?
527 .to_string();
528 if id.trim().is_empty() {
529 return Err(ManifestError::Field {
532 rule: "<unnamed>".to_string(),
533 field: "id (must be non-empty)".to_string(),
534 });
535 }
536 let str_field = |f: &str| {
537 v.get(f)
538 .and_then(|x| x.as_str())
539 .ok_or_else(|| ManifestError::Field {
540 rule: id.clone(),
541 field: f.to_string(),
542 })
543 };
544 let state = str_field("state")?.to_string();
545 let priority_i64 = v
546 .get("priority")
547 .and_then(|x| x.as_integer())
548 .ok_or_else(|| ManifestError::Field {
549 rule: id.clone(),
550 field: "priority".to_string(),
551 })?;
552 let priority = i32::try_from(priority_i64).map_err(|_| ManifestError::Field {
555 rule: id.clone(),
556 field: "priority (out of i32 range)".to_string(),
557 })?;
558 let region = Region::parse(str_field("region")?, &id)?;
559 let skip_state_update = match v.get("skip_state_update") {
562 None => false,
563 Some(x) => x.as_bool().ok_or_else(|| ManifestError::Field {
564 rule: id.clone(),
565 field: "skip_state_update (must be a boolean)".to_string(),
566 })?,
567 };
568 let gate_val = v.get("gate").ok_or_else(|| ManifestError::Field {
569 rule: id.clone(),
570 field: "gate".to_string(),
571 })?;
572 let gate = Gate::parse(gate_val, &id, 0)?;
573 let context = match (v.get("context_region"), v.get("context_gate")) {
574 (None, None) => None,
575 (Some(region), Some(gate)) => {
576 let region = region.as_str().ok_or_else(|| ManifestError::Field {
577 rule: id.clone(),
578 field: "context_region".to_string(),
579 })?;
580 Some((Region::parse(region, &id)?, Gate::parse(gate, &id, 0)?))
581 }
582 _ => {
583 return Err(ManifestError::Field {
584 rule: id.clone(),
585 field: "context_region and context_gate (must be provided together)"
586 .to_string(),
587 })
588 }
589 };
590 let answer = match v.get("answer") {
593 None => None,
594 Some(a) => Some(AnswerGrammar::parse(a, &id, &state, ®ion)?),
595 };
596 if let Some(table) = v.as_table() {
600 const ALLOWED: &[&str] = &[
601 "id",
602 "state",
603 "priority",
604 "region",
605 "skip_state_update",
606 "gate",
607 "context_region",
608 "context_gate",
609 "answer",
610 ];
611 if let Some(unknown) = table.keys().find(|k| !ALLOWED.contains(&k.as_str())) {
612 return Err(ManifestError::Field {
613 rule: id.clone(),
614 field: format!("unknown key '{unknown}'"),
615 });
616 }
617 }
618 Ok(ManifestRule {
619 id,
620 state,
621 priority,
622 region,
623 skip_state_update,
624 gate,
625 context,
626 answer,
627 })
628 }
629}
630
631#[derive(Debug, Clone, PartialEq, Eq)]
635pub struct Verdict<'a> {
636 pub rule_id: &'a str,
637 pub state: &'a str,
638 pub skip_state_update: bool,
639}
640
641#[derive(Debug, Clone)]
645pub struct Manifest {
646 pub min_engine_version: u32,
650 rules: Vec<ManifestRule>,
654}
655
656impl Manifest {
657 pub fn parse(s: &str) -> Result<Manifest, ManifestError> {
661 let root: toml::Value =
662 toml::from_str(s).map_err(|e| ManifestError::Toml(e.to_string()))?;
663 if let Some(table) = root.as_table() {
667 if let Some(unknown) = table
668 .keys()
669 .find(|k| !matches!(k.as_str(), "min_engine_version" | "rule"))
670 {
671 return Err(ManifestError::Field {
672 rule: "<root>".to_string(),
673 field: format!("unknown key '{unknown}'"),
674 });
675 }
676 }
677 let min_engine_version = match root.get("min_engine_version") {
680 None => 0,
681 Some(v) => v
682 .as_integer()
683 .and_then(|n| u32::try_from(n).ok())
684 .ok_or_else(|| ManifestError::Field {
685 rule: "<root>".to_string(),
686 field: "min_engine_version (must be a non-negative integer)".to_string(),
687 })?,
688 };
689 let mut rules = match root.get("rule") {
690 Some(v) => v
691 .as_array()
692 .ok_or_else(|| ManifestError::Field {
693 rule: "<root>".to_string(),
694 field: "rule (must be an array of tables)".to_string(),
695 })?
696 .iter()
697 .map(ManifestRule::parse)
698 .collect::<Result<Vec<_>, _>>()?,
699 None => Vec::new(),
700 };
701 rules.sort_by(|a, b| b.priority.cmp(&a.priority));
703 Ok(Manifest {
704 min_engine_version,
705 rules,
706 })
707 }
708
709 pub fn rules(&self) -> &[ManifestRule] {
712 &self.rules
713 }
714
715 pub fn evaluate(&self, screen: &ScreenView) -> Option<Verdict<'_>> {
719 self.rules.iter().find_map(|rule| {
720 rule.matched_region_text(screen).map(|_| Verdict {
721 rule_id: &rule.id,
722 state: &rule.state,
723 skip_state_update: rule.skip_state_update,
724 })
725 })
726 }
727
728 pub fn evaluate_answerable(
734 &self,
735 screen: &ScreenView,
736 ) -> Option<(Verdict<'_>, Option<AnswerablePrompt>)> {
737 self.rules.iter().find_map(|rule| {
738 let text = rule.matched_region_text(screen)?;
739 let answerable = rule.extract_answer(&text);
740 Some((
741 Verdict {
742 rule_id: &rule.id,
743 state: &rule.state,
744 skip_state_update: rule.skip_state_update,
745 },
746 answerable,
747 ))
748 })
749 }
750}
751
752pub fn bundled_manifest(agent: &str) -> Option<&'static str> {
757 match agent {
758 "claude" => Some(include_str!("manifests/claude.toml")),
759 "codex" => Some(include_str!("manifests/codex.toml")),
760 "gemini" => Some(include_str!("manifests/gemini.toml")),
761 "agy" => Some(include_str!("manifests/agy.toml")),
763 "opencode" => Some(include_str!("manifests/opencode.toml")),
764 "amp" => Some(include_str!("manifests/amp.toml")),
771 "cline" => Some(include_str!("manifests/cline.toml")),
772 "cursor" => Some(include_str!("manifests/cursor.toml")),
773 "devin" => Some(include_str!("manifests/devin.toml")),
774 "droid" => Some(include_str!("manifests/droid.toml")),
775 "copilot" => Some(include_str!("manifests/github-copilot.toml")),
776 "grok" => Some(include_str!("manifests/grok.toml")),
777 "hermes" => Some(include_str!("manifests/hermes.toml")),
778 "kilo" => Some(include_str!("manifests/kilo.toml")),
779 "kimi" => Some(include_str!("manifests/kimi.toml")),
780 "kiro" => Some(include_str!("manifests/kiro.toml")),
781 "pi" => Some(include_str!("manifests/pi.toml")),
782 "qodercli" => Some(include_str!("manifests/qodercli.toml")),
783 _ => None,
784 }
785}
786
787pub fn load_manifest(
798 agent: &str,
799 override_dir: Option<&Path>,
800) -> Option<Result<Manifest, ManifestError>> {
801 if let Some(dir) = override_dir {
802 let path = dir.join(format!("{agent}.toml"));
803 match std::fs::read_to_string(&path) {
812 Ok(text) => return Some(Manifest::parse(&text)),
813 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
814 Err(e) => {
815 return Some(Err(ManifestError::Io {
816 path: path.display().to_string(),
817 detail: e.to_string(),
818 }))
819 }
820 }
821 }
822 bundled_manifest(agent).map(Manifest::parse)
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 fn view(text: &str) -> ScreenView<'_> {
830 ScreenView {
831 visible_text: text,
832 cursor_row: 0,
833 cursor_col: 0,
834 osc_title: None,
835 osc_progress: None,
836 }
837 }
838
839 fn view_title<'a>(text: &'a str, title: &'a str) -> ScreenView<'a> {
840 ScreenView {
841 visible_text: text,
842 cursor_row: 0,
843 cursor_col: 0,
844 osc_title: Some(title),
845 osc_progress: None,
846 }
847 }
848
849 #[test]
850 fn parses_fields_and_sorts_by_priority_desc() {
851 let m = Manifest::parse(
852 r#"
853 min_engine_version = 2
854 [[rule]]
855 id = "low"
856 state = "idle"
857 priority = 10
858 region = "whole_recent"
859 gate = { contains = "x" }
860 [[rule]]
861 id = "high"
862 state = "working"
863 priority = 100
864 region = "whole_recent"
865 gate = { contains = "y" }
866 "#,
867 )
868 .unwrap();
869 assert_eq!(m.min_engine_version, 2);
870 assert_eq!(m.rules().len(), 2);
871 assert_eq!(m.rules()[0].id, "high", "highest priority sorts first");
872 assert_eq!(m.rules()[1].id, "low");
873 }
874
875 #[test]
876 fn min_engine_version_defaults_to_zero() {
877 let m = Manifest::parse(
878 r#"
879 [[rule]]
880 id = "r"
881 state = "idle"
882 priority = 1
883 region = "whole_recent"
884 gate = { contains = "x" }
885 "#,
886 )
887 .unwrap();
888 assert_eq!(m.min_engine_version, 0);
889 }
890
891 #[test]
894 fn highest_priority_match_wins_over_scrollback() {
895 let m = Manifest::parse(
896 r#"
897 [[rule]]
898 id = "scrollback_yes"
899 state = "blocked"
900 priority = 100
901 region = "whole_recent"
902 gate = { contains = "yes" }
903 [[rule]]
904 id = "live_prompt"
905 state = "idle"
906 priority = 900
907 region = "bottom_non_empty_lines(1)"
908 gate = { line_regex = "^\\s*❯" }
909 "#,
910 )
911 .unwrap();
912 let screen = "I said yes earlier\nlots of reply text\n ❯ ";
914 let v = m.evaluate(&view(screen)).unwrap();
915 assert_eq!(v.state, "idle", "live-region rule beats scrollback match");
916 assert_eq!(v.rule_id, "live_prompt");
917 }
918
919 #[test]
922 fn osc_title_braille_spinner_badges_working_from_title_alone() {
923 let m = Manifest::parse(
924 r#"
925 [[rule]]
926 id = "osc_title_working"
927 state = "working"
928 priority = 1100
929 region = "osc_title"
930 gate = { regex = "^[\\x{2800}-\\x{28FF}]" }
931 "#,
932 )
933 .unwrap();
934 let screen = view_title("old output\nmore scrollback\n", "\u{280b} Compiling");
936 let v = m.evaluate(&screen).unwrap();
937 assert_eq!(v.state, "working");
938 assert!(m.evaluate(&view("old output")).is_none());
940 }
941
942 #[test]
945 fn skip_state_update_flag_is_carried_through() {
946 let m = Manifest::parse(
947 r#"
948 [[rule]]
949 id = "transcript_viewer"
950 state = "idle"
951 priority = 1000
952 region = "bottom_non_empty_lines(3)"
953 skip_state_update = true
954 gate = { contains = "(END)" }
955 "#,
956 )
957 .unwrap();
958 let v = m.evaluate(&view("scrollback\nmore\n(END)")).unwrap();
959 assert!(v.skip_state_update, "pager rule must not update state");
960 }
961
962 #[test]
963 fn gate_all_any_not_compose() {
964 let m = Manifest::parse(
965 r#"
966 [[rule]]
967 id = "blocked_form"
968 state = "blocked"
969 priority = 980
970 region = "whole_recent"
971 gate = { all = [ { contains = "enter to select" }, { contains = "esc to cancel" }, { not = { contains = "esc to interrupt" } } ] }
972 "#,
973 )
974 .unwrap();
975 assert!(m
977 .evaluate(&view("press enter to select, esc to cancel"))
978 .is_some());
979 assert!(m.evaluate(&view("enter to select something")).is_none());
981 assert!(m
983 .evaluate(&view("enter to select, esc to cancel, esc to interrupt"))
984 .is_none());
985 }
986
987 #[test]
988 fn any_gate_matches_on_one() {
989 let m = Manifest::parse(
990 r#"
991 [[rule]]
992 id = "perm"
993 state = "blocked"
994 priority = 850
995 region = "whole_recent"
996 gate = { any = [ { contains = "do you want to proceed?" }, { contains = "1. Yes" } ] }
997 "#,
998 )
999 .unwrap();
1000 assert!(m.evaluate(&view("1. Yes\n2. No")).is_some());
1001 assert!(m.evaluate(&view("nothing relevant")).is_none());
1002 }
1003
1004 #[test]
1005 fn region_prompt_box_body_extracts_box_interior() {
1006 let m = Manifest::parse(
1007 r#"
1008 [[rule]]
1009 id = "live_prompt_box"
1010 state = "idle"
1011 priority = 950
1012 region = "prompt_box_body"
1013 gate = { regex = "❯" }
1014 "#,
1015 )
1016 .unwrap();
1017 let screen = "❯ earlier command in history\n\
1019 ╭──────────────╮\n\
1020 │ ❯ type here │\n\
1021 ╰──────────────╯";
1022 assert!(m.evaluate(&view(screen)).is_some());
1023 assert!(m.evaluate(&view("just text, no box")).is_none());
1025 }
1026
1027 #[test]
1028 fn region_osc_progress_reads_progress_payload() {
1029 let m = Manifest::parse(
1030 r#"
1031 [[rule]]
1032 id = "progressing"
1033 state = "working"
1034 priority = 500
1035 region = "osc_progress"
1036 gate = { regex = "^4;" }
1037 "#,
1038 )
1039 .unwrap();
1040 let screen = ScreenView {
1041 visible_text: "anything",
1042 cursor_row: 0,
1043 cursor_col: 0,
1044 osc_title: None,
1045 osc_progress: Some("4;1;50"),
1046 };
1047 assert_eq!(m.evaluate(&screen).unwrap().state, "working");
1048 }
1049
1050 #[test]
1051 fn no_rule_matches_returns_none() {
1052 let m = Manifest::parse(
1053 r#"
1054 [[rule]]
1055 id = "r"
1056 state = "idle"
1057 priority = 1
1058 region = "whole_recent"
1059 gate = { contains = "zzz" }
1060 "#,
1061 )
1062 .unwrap();
1063 assert!(m.evaluate(&view("nothing here")).is_none());
1064 }
1065
1066 #[test]
1067 fn empty_manifest_parses_to_no_rules() {
1068 let m = Manifest::parse("min_engine_version = 1").unwrap();
1069 assert!(m.rules().is_empty());
1070 assert!(m.evaluate(&view("anything")).is_none());
1071 }
1072
1073 #[test]
1074 fn bad_regex_is_a_parse_error_not_a_silent_drop() {
1075 let err = Manifest::parse(
1076 r#"
1077 [[rule]]
1078 id = "broken"
1079 state = "x"
1080 priority = 1
1081 region = "whole_recent"
1082 gate = { regex = "(" }
1083 "#,
1084 )
1085 .unwrap_err();
1086 assert!(matches!(err, ManifestError::BadRegex { rule, .. } if rule == "broken"));
1087 }
1088
1089 #[test]
1090 fn unknown_region_is_a_parse_error() {
1091 let err = Manifest::parse(
1092 r#"
1093 [[rule]]
1094 id = "r"
1095 state = "x"
1096 priority = 1
1097 region = "the_moon"
1098 gate = { contains = "x" }
1099 "#,
1100 )
1101 .unwrap_err();
1102 assert!(matches!(err, ManifestError::UnknownRegion { region, .. } if region == "the_moon"));
1103 }
1104
1105 #[test]
1106 fn missing_required_field_names_the_rule() {
1107 let err = Manifest::parse(
1108 r#"
1109 [[rule]]
1110 id = "r"
1111 priority = 1
1112 region = "whole_recent"
1113 gate = { contains = "x" }
1114 "#,
1115 )
1116 .unwrap_err();
1117 assert!(
1118 matches!(err, ManifestError::Field { rule, field } if rule == "r" && field == "state")
1119 );
1120 }
1121
1122 #[test]
1123 fn multi_key_gate_table_is_rejected() {
1124 let err = Manifest::parse(
1125 r#"
1126 [[rule]]
1127 id = "r"
1128 state = "x"
1129 priority = 1
1130 region = "whole_recent"
1131 gate = { contains = "a", regex = "b" }
1132 "#,
1133 )
1134 .unwrap_err();
1135 assert!(matches!(err, ManifestError::BadGate { rule } if rule == "r"));
1136 }
1137
1138 #[test]
1139 fn over_deep_gate_is_refused() {
1140 let mut gate = "{ contains = \"x\" }".to_string();
1142 for _ in 0..(MAX_GATE_DEPTH + 2) {
1143 gate = format!("{{ not = {gate} }}");
1144 }
1145 let toml = format!(
1146 "[[rule]]\nid = \"deep\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {gate}\n"
1147 );
1148 let err = Manifest::parse(&toml).unwrap_err();
1149 assert!(matches!(err, ManifestError::GateTooDeep { rule } if rule == "deep"));
1150 }
1151
1152 #[test]
1153 fn bottom_non_empty_lines_scopes_to_tail() {
1154 let m = Manifest::parse(
1155 r#"
1156 [[rule]]
1157 id = "r"
1158 state = "hit"
1159 priority = 1
1160 region = "bottom_non_empty_lines(2)"
1161 gate = { contains = "needle" }
1162 "#,
1163 )
1164 .unwrap();
1165 let screen = "needle up here\n\nfiller\nmore filler\nlast line";
1167 assert!(m.evaluate(&view(screen)).is_none());
1168 assert!(m.evaluate(&view("filler\nfiller\nneedle\nlast")).is_some());
1170 }
1171
1172 #[test]
1173 fn empty_composite_gate_is_rejected_not_fail_open() {
1174 for body in ["all = []", "any = []"] {
1177 let err = Manifest::parse(&format!(
1178 "[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {{ {body} }}\n"
1179 ))
1180 .unwrap_err();
1181 assert!(
1182 matches!(err, ManifestError::BadGate { rule } if rule == "r"),
1183 "{body} should be rejected"
1184 );
1185 }
1186 }
1187
1188 #[test]
1189 fn bottom_non_empty_lines_zero_is_rejected() {
1190 let err = Manifest::parse(
1191 r#"
1192 [[rule]]
1193 id = "r"
1194 state = "x"
1195 priority = 1
1196 region = "bottom_non_empty_lines(0)"
1197 gate = { contains = "x" }
1198 "#,
1199 )
1200 .unwrap_err();
1201 assert!(matches!(err, ManifestError::Field { rule, .. } if rule == "r"));
1202 }
1203
1204 #[test]
1205 fn malformed_scalar_fields_are_rejected_not_coerced() {
1206 let big = i64::from(i32::MAX) + 1;
1208 let err = Manifest::parse(&format!(
1209 "[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = {big}\nregion = \"whole_recent\"\ngate = {{ contains = \"x\" }}\n"
1210 ))
1211 .unwrap_err();
1212 assert!(matches!(err, ManifestError::Field { field, .. } if field.starts_with("priority")));
1213
1214 let err = Manifest::parse(
1216 r#"
1217 [[rule]]
1218 id = "r"
1219 state = "x"
1220 priority = 1
1221 region = "whole_recent"
1222 skip_state_update = "yes"
1223 gate = { contains = "x" }
1224 "#,
1225 )
1226 .unwrap_err();
1227 assert!(
1228 matches!(err, ManifestError::Field { field, .. } if field.starts_with("skip_state_update"))
1229 );
1230
1231 let err = Manifest::parse(
1233 r#"
1234 min_engine_version = "two"
1235 [[rule]]
1236 id = "r"
1237 state = "x"
1238 priority = 1
1239 region = "whole_recent"
1240 gate = { contains = "x" }
1241 "#,
1242 )
1243 .unwrap_err();
1244 assert!(
1245 matches!(err, ManifestError::Field { field, .. } if field.starts_with("min_engine_version"))
1246 );
1247 }
1248
1249 #[test]
1250 fn empty_leaf_gate_pattern_is_rejected_not_fail_open() {
1251 for leaf in [r#"contains = """#, r#"regex = """#, r#"line_regex = """#] {
1254 let err = Manifest::parse(&format!(
1255 "[[rule]]\nid = \"r\"\nstate = \"x\"\npriority = 1\nregion = \"whole_recent\"\ngate = {{ {leaf} }}\n"
1256 ))
1257 .unwrap_err();
1258 assert!(
1259 matches!(err, ManifestError::Field { rule, .. } if rule == "r"),
1260 "{leaf} should be rejected"
1261 );
1262 }
1263 }
1264
1265 #[test]
1266 fn unknown_rule_and_root_keys_are_rejected() {
1267 let err = Manifest::parse(
1270 r#"
1271 [[rule]]
1272 id = "r"
1273 state = "x"
1274 priority = 1
1275 region = "whole_recent"
1276 skip_state_updates = true
1277 gate = { contains = "x" }
1278 "#,
1279 )
1280 .unwrap_err();
1281 assert!(
1282 matches!(err, ManifestError::Field { rule, field } if rule == "r" && field.contains("unknown key"))
1283 );
1284
1285 let err = Manifest::parse(
1287 r#"
1288 min_engine_versions = 1
1289 [[rule]]
1290 id = "r"
1291 state = "x"
1292 priority = 1
1293 region = "whole_recent"
1294 gate = { contains = "x" }
1295 "#,
1296 )
1297 .unwrap_err();
1298 assert!(
1299 matches!(err, ManifestError::Field { rule, field } if rule == "<root>" && field.contains("unknown key"))
1300 );
1301 }
1302
1303 #[test]
1304 fn empty_id_is_rejected() {
1305 let err = Manifest::parse(
1306 r#"
1307 [[rule]]
1308 id = ""
1309 state = "x"
1310 priority = 1
1311 region = "whole_recent"
1312 gate = { contains = "x" }
1313 "#,
1314 )
1315 .unwrap_err();
1316 assert!(matches!(err, ManifestError::Field { field, .. } if field.starts_with("id")));
1317 }
1318
1319 use crate::readiness::{CodexReadinessDetector, GeminiReadinessDetector, ReadinessDetector};
1322
1323 fn bundled(agent: &str) -> Manifest {
1324 Manifest::parse(bundled_manifest(agent).expect("bundled manifest exists"))
1325 .expect("bundled manifest parses")
1326 }
1327
1328 fn manifest_ready(m: &Manifest, screen: &ScreenView) -> bool {
1332 matches!(m.evaluate(screen), Some(v) if v.state == "idle" && !v.skip_state_update)
1333 }
1334
1335 #[test]
1336 fn bundled_manifests_all_parse() {
1337 let synthetic = view("some scrollback\nesc to interrupt\n\u{276f} ");
1344 for agent in [
1345 "claude", "codex", "gemini", "agy", "opencode", "amp", "cline", "cursor", "devin", "droid", "copilot", "grok", "hermes", "kilo",
1347 "kimi", "kiro", "pi", "qodercli", ] {
1349 let src = bundled_manifest(agent).unwrap_or_else(|| panic!("{agent} is bundled"));
1350 let m = Manifest::parse(src)
1351 .unwrap_or_else(|e| panic!("{agent}.toml failed to parse: {e:?}"));
1352 assert!(!m.rules().is_empty(), "{agent}.toml has rules");
1353 let _ = m.evaluate(&synthetic);
1355 }
1356 assert!(bundled_manifest("aider").is_none(), "unknown agent -> None");
1360 }
1361
1362 #[test]
1366 fn ac_e6_4_codex_gemini_toml_match_hardcoded_detectors() {
1367 let codex_m = bundled("codex");
1368 let gemini_m = bundled("gemini");
1369 let cases: &[(&str, bool)] = &[
1371 ("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), (
1378 "I am Working on the Thinking task you asked about.\n\
1379 Here is a long reply that mentions Working again.\n\
1380 filler line\nanother filler\n\u{276f} ",
1381 true,
1382 ),
1383 ];
1384 for (text, want) in cases {
1385 let trimmed = text.trim_end();
1386 let screen = view(trimmed);
1387 assert_eq!(
1388 manifest_ready(&codex_m, &screen),
1389 *want,
1390 "codex.toml readiness mismatch for {trimmed:?}"
1391 );
1392 assert_eq!(
1395 manifest_ready(&codex_m, &screen),
1396 CodexReadinessDetector.is_ready(&screen).unwrap(),
1397 "codex.toml diverges from CodexReadinessDetector for {trimmed:?}"
1398 );
1399 assert_eq!(
1400 manifest_ready(&gemini_m, &screen),
1401 GeminiReadinessDetector.is_ready(&screen).unwrap(),
1402 "gemini.toml diverges from GeminiReadinessDetector for {trimmed:?}"
1403 );
1404 }
1405 }
1406
1407 #[test]
1410 fn ac_e6_2_claude_osc_title_spinner_badges_working() {
1411 let m = bundled("claude");
1412 let screen = view_title("old output\nmore scrollback\n", "\u{280b} Compiling");
1414 let v = m.evaluate(&screen).expect("spinner title matches");
1415 assert_eq!(v.state, "working");
1416 assert_eq!(v.rule_id, "osc_title_working");
1417 assert!(m
1419 .evaluate(&view("old output\nmore scrollback"))
1420 .is_none_or(|v| v.rule_id != "osc_title_working"));
1421 }
1422
1423 #[test]
1426 fn ac_e6_3_claude_transcript_viewer_holds_state() {
1427 let m = bundled("claude");
1428 let v = m
1429 .evaluate(&view("scrollback line\nmore scrollback\n(END)"))
1430 .expect("transcript pager marker matches");
1431 assert_eq!(v.rule_id, "transcript_viewer");
1432 assert!(
1433 v.skip_state_update,
1434 "pager rule must hold state, not set idle"
1435 );
1436 }
1437
1438 #[test]
1443 fn ac_e6_5_claude_title_spinner_outranks_idle_grid_box() {
1444 let m = bundled("claude");
1445 let grid = "\u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256e}\n\
1446 \u{2502} \u{276f} type here \u{2502}\n\
1447 \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256f}";
1448 let v_idle = m.evaluate(&view(grid)).expect("idle box matches");
1450 assert_eq!(v_idle.state, "idle");
1451 assert_eq!(v_idle.rule_id, "live_prompt_box");
1452 let v_working = m
1454 .evaluate(&view_title(grid, "\u{280b} Working"))
1455 .expect("spinner title matches");
1456 assert_eq!(v_working.state, "working");
1457 assert_eq!(v_working.rule_id, "osc_title_working");
1458 }
1459
1460 #[test]
1463 fn ac_e6_5_claude_permission_prompt_outranks_idle_box() {
1464 let m = bundled("claude");
1465 let screen = "do you want to proceed?\n\
1467 1. Yes\n\
1468 2. No\n\
1469 \u{256d}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256e}\n\
1470 \u{2502} \u{276f} type \u{2502}\n\
1471 \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{256f}";
1472 let v = m.evaluate(&view(screen)).expect("a rule matches");
1473 assert_eq!(
1474 v.state, "blocked",
1475 "permission prompt must beat the idle box"
1476 );
1477 assert_eq!(v.rule_id, "permission_prompt");
1478 }
1479
1480 #[test]
1481 fn load_manifest_prefers_override_then_bundled_then_none() {
1482 let m = load_manifest("claude", None)
1484 .expect("known agent")
1485 .expect("parses");
1486 assert!(!m.rules().is_empty());
1487 assert!(load_manifest("aider", None).is_none());
1491
1492 let dir = tempfile::tempdir().unwrap();
1494 std::fs::write(
1495 dir.path().join("codex.toml"),
1496 "[[rule]]\nid = \"override_only\"\nstate = \"idle\"\npriority = 1\nregion = \"whole_recent\"\ngate = { contains = \"OVR\" }\n",
1497 )
1498 .unwrap();
1499 let m = load_manifest("codex", Some(dir.path()))
1500 .expect("override present")
1501 .expect("override parses");
1502 assert_eq!(m.rules().len(), 1);
1503 assert_eq!(m.rules()[0].id, "override_only");
1504 let m = load_manifest("gemini", Some(dir.path()))
1506 .expect("falls back to bundled")
1507 .expect("parses");
1508 assert!(m.rules().iter().any(|r| r.id == "idle_prompt"));
1509 std::fs::write(dir.path().join("claude.toml"), "this = is = not = toml").unwrap();
1512 assert!(matches!(
1513 load_manifest("claude", Some(dir.path())),
1514 Some(Err(ManifestError::Toml(_)))
1515 ));
1516 std::fs::write(dir.path().join("gemini.toml"), [0xff, 0xfe, 0x00]).unwrap();
1519 assert!(matches!(
1520 load_manifest("gemini", Some(dir.path())),
1521 Some(Err(ManifestError::Io { .. }))
1522 ));
1523 }
1524
1525 #[test]
1530 fn x8f7f_agy_manifest_evaluates_idle_working_blocked() {
1531 let m = bundled("agy");
1532 assert_eq!(
1533 m.evaluate(&view("agy 1.0\n\u{276f} ")).unwrap().state,
1534 "idle"
1535 );
1536 assert_eq!(
1537 m.evaluate(&view("running tool...\nesc to interrupt\n\u{276f}"))
1538 .unwrap()
1539 .state,
1540 "working", );
1542 assert_eq!(
1543 m.evaluate(&view("Waiting for auth...\n\u{276f}"))
1544 .unwrap()
1545 .state,
1546 "blocked", );
1548 }
1549
1550 #[test]
1554 fn x8f7f_opencode_manifest_matches_reference_screens() {
1555 let m = bundled("opencode");
1556 assert_eq!(
1558 m.evaluate(&view("△ Permission required")).unwrap().state,
1559 "blocked",
1560 );
1561 assert_eq!(
1563 m.evaluate(&view("thinking\nesc to interrupt"))
1564 .unwrap()
1565 .state,
1566 "working",
1567 );
1568 assert_eq!(
1569 m.evaluate(&view("progress \u{25a0}\u{25a0}\u{25a0}\u{25a0}\u{25a0}"))
1570 .unwrap()
1571 .state,
1572 "working", );
1574 assert_eq!(
1577 m.evaluate(&view(
1578 "esc dismiss enter confirm \u{2191}\u{2193} select"
1579 ))
1580 .unwrap()
1581 .state,
1582 "blocked",
1583 );
1584 assert!(m.evaluate(&view("here is your answer")).is_none());
1587 }
1588
1589 #[test]
1592 fn x8f7f_unknown_source_key_fails_loud() {
1593 let bad = "[[rule]]\nid = \"p\"\nstate = \"blocked\"\npriority = 1\n\
1594 region = \"whole_recent\"\nvisible_blocker = true\n\
1595 gate = { contains = \"x\" }\n";
1596 assert!(matches!(
1597 Manifest::parse(bad),
1598 Err(ManifestError::Field { .. })
1599 ));
1600 }
1601
1602 #[test]
1609 fn x8f7f_staged_manifest_fires_once_hosted() {
1610 for hosted in ["opencode", "agy"] {
1611 assert!(bundled_manifest(hosted).is_some(), "{hosted} bundled");
1612 assert!(
1613 crate::provider::for_name(hosted).is_some(),
1614 "{hosted} IS hostable -> manifest can fire",
1615 );
1616 }
1617 assert!(bundled_manifest("aider").is_none(), "aider not bundled");
1618 assert!(
1619 crate::provider::for_name("aider").is_none(),
1620 "aider not hosted"
1621 );
1622 }
1623
1624 fn blocked_answer_manifest() -> Manifest {
1627 Manifest::parse(
1628 r#"
1629 [[rule]]
1630 id = "perm"
1631 state = "blocked"
1632 priority = 900
1633 region = "bottom_non_empty_lines(8)"
1634 gate = { contains = "proceed?" }
1635 [rule.answer]
1636 option = '^\s*\x{276f}?\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
1637 send = "digit"
1638 "#,
1639 )
1640 .unwrap()
1641 }
1642
1643 #[test]
1647 fn xc929_extract_answer_clean_numbered_menu() {
1648 let m = blocked_answer_manifest();
1649 let screen = "Do you want to proceed?\n ❯ 1. Yes\n 2. No";
1651 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1652 assert_eq!(v.state, "blocked");
1653 let ans = ans.expect("a clean numbered menu is answerable");
1654 assert_eq!(ans.options.len(), 2);
1655 assert_eq!(ans.options[0].idx, "1");
1656 assert_eq!(ans.options[0].label, "Yes");
1657 assert_eq!(ans.options[0].keystroke, b"1");
1658 assert_eq!(ans.options[1].idx, "2");
1659 assert_eq!(ans.options[1].label, "No");
1660 assert_eq!(ans.options[1].keystroke, b"2");
1661 assert_eq!(ans.region_lines, 8);
1662 assert_eq!(ans.prompt, "Do you want to proceed?");
1663 assert_eq!(ans.fingerprint, *blake3::hash(screen.as_bytes()).as_bytes());
1665 }
1666
1667 #[test]
1668 fn answer_rule_can_gate_context_outside_the_answer_region() {
1669 let m = Manifest::parse(
1670 r#"
1671 [[rule]]
1672 id = "approval"
1673 state = "blocked"
1674 priority = 10
1675 region = "bottom_non_empty_lines(4)"
1676 gate = { line_regex = '^\s*\x{203a}\s*[0-9]\.\s' }
1677 context_region = "whole_recent"
1678 context_gate = { contains = "Would you like to run the following command?" }
1679 [rule.answer]
1680 option = '^\s*\x{203a}?\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
1681 send = "digit"
1682 "#,
1683 )
1684 .unwrap();
1685 let wrapped = (0..30)
1686 .map(|i| format!("wrapped command row {i}"))
1687 .collect::<Vec<_>>()
1688 .join("\n");
1689 let screen = format!(
1690 "Would you like to run the following command?\n{wrapped}\n\
1691 \u{203a} 1. Yes, proceed\n 2. Always allow\n 3. No, cancel\n Press enter to confirm"
1692 );
1693 let (v, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
1694 assert_eq!(v.rule_id, "approval");
1695 let ans = ans.expect("context gate detects the question; bottom region extracts the menu");
1696 assert_eq!(ans.options.len(), 3);
1697 assert_eq!(ans.region_lines, 4);
1698
1699 let no_question = format!(
1700 "unrelated output\n{wrapped}\n\
1701 \u{203a} 1. Yes, proceed\n 2. Always allow\n 3. No, cancel\n Press enter to confirm"
1702 );
1703 assert!(m.evaluate_answerable(&view(&no_question)).is_none());
1704 }
1705
1706 #[test]
1708 fn xc929_extract_answer_rejects_duplicate_indices() {
1709 let m = blocked_answer_manifest();
1710 let screen = "proceed?\n1. Yes\n1. No";
1711 let (_, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1712 assert!(ans.is_none(), "duplicate index -> not answerable");
1713 }
1714
1715 #[test]
1718 fn xc929_extract_answer_rejects_truncated_menu() {
1719 let m = blocked_answer_manifest();
1720 let screen = "proceed?\n2. No\n3. Cancel";
1721 let (_, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1722 assert!(ans.is_none(), "menu not starting at 1 -> truncated -> None");
1723 }
1724
1725 #[test]
1728 fn xc929_extraction_failure_is_additive_badge_survives() {
1729 let m = blocked_answer_manifest();
1730 let screen = "proceed?\nuse arrows to select";
1731 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1732 assert_eq!(
1733 v.state, "blocked",
1734 "detection unaffected by extraction miss"
1735 );
1736 assert!(ans.is_none(), "no numbered options -> focus-only");
1737 }
1738
1739 #[test]
1743 fn xc929_extract_answer_keeps_full_label() {
1744 let m = blocked_answer_manifest();
1745 let long = "No, and tell Claude what to do differently (esc)";
1746 let screen = format!("proceed?\n1. Yes\n2. {long}");
1747 let (_, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
1748 assert_eq!(ans.unwrap().options[1].label, long);
1749 }
1750
1751 #[test]
1753 fn xc929_send_digit_enter_appends_cr() {
1754 let m = Manifest::parse(
1755 r#"
1756 [[rule]]
1757 id = "perm"
1758 state = "blocked"
1759 priority = 900
1760 region = "bottom_non_empty_lines(8)"
1761 gate = { contains = "?" }
1762 [rule.answer]
1763 option = '^\s*(?P<idx>[0-9])\.\s+(?P<label>.+?)\s*$'
1764 send = "digit_enter"
1765 "#,
1766 )
1767 .unwrap();
1768 let (_, ans) = m.evaluate_answerable(&view("pick?\n1. A\n2. B")).unwrap();
1769 assert_eq!(ans.unwrap().options[0].keystroke, b"1\r");
1770 }
1771
1772 #[test]
1774 fn xc929_answer_on_non_blocked_rule_fails_loud() {
1775 let err = Manifest::parse(
1776 r#"
1777 [[rule]]
1778 id = "r"
1779 state = "idle"
1780 priority = 1
1781 region = "bottom_non_empty_lines(8)"
1782 gate = { contains = "x" }
1783 [rule.answer]
1784 option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
1785 send = "digit"
1786 "#,
1787 )
1788 .unwrap_err();
1789 assert!(
1790 matches!(err, ManifestError::Field { rule, field } if rule == "r" && field.starts_with("answer"))
1791 );
1792 }
1793
1794 #[test]
1797 fn xc929_answer_on_non_bottom_n_region_fails_loud() {
1798 let err = Manifest::parse(
1799 r#"
1800 [[rule]]
1801 id = "r"
1802 state = "blocked"
1803 priority = 1
1804 region = "whole_recent"
1805 gate = { contains = "x" }
1806 [rule.answer]
1807 option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
1808 send = "digit"
1809 "#,
1810 )
1811 .unwrap_err();
1812 assert!(
1813 matches!(err, ManifestError::Field { field, .. } if field.contains("bottom_non_empty_lines"))
1814 );
1815 }
1816
1817 #[test]
1819 fn xc929_answer_missing_named_capture_fails_loud() {
1820 let err = Manifest::parse(
1821 r#"
1822 [[rule]]
1823 id = "r"
1824 state = "blocked"
1825 priority = 1
1826 region = "bottom_non_empty_lines(8)"
1827 gate = { contains = "x" }
1828 [rule.answer]
1829 option = '(?P<idx>[0-9])\.'
1830 send = "digit"
1831 "#,
1832 )
1833 .unwrap_err();
1834 assert!(matches!(err, ManifestError::Field { field, .. } if field.contains("label")));
1835 }
1836
1837 #[test]
1839 fn xc929_answer_bad_send_fails_loud() {
1840 let err = Manifest::parse(
1841 r#"
1842 [[rule]]
1843 id = "r"
1844 state = "blocked"
1845 priority = 1
1846 region = "bottom_non_empty_lines(8)"
1847 gate = { contains = "x" }
1848 [rule.answer]
1849 option = '(?P<idx>[0-9])\.\s+(?P<label>.+)'
1850 send = "arrows"
1851 "#,
1852 )
1853 .unwrap_err();
1854 assert!(
1855 matches!(err, ManifestError::Field { field, .. } if field.starts_with("answer.send"))
1856 );
1857 }
1858
1859 #[test]
1862 fn xc929_bundled_claude_permission_prompt_is_answerable() {
1863 let m = bundled("claude");
1864 let screen =
1865 "Do you want to proceed?\n ❯ 1. Yes\n 2. No, and tell Claude what to do differently";
1866 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1867 assert_eq!(v.rule_id, "permission_prompt");
1868 let ans = ans.expect("claude permission prompt is answerable");
1869 assert_eq!(ans.options.len(), 2);
1870 assert_eq!(ans.options[0].keystroke, b"1");
1871 assert_eq!(ans.options[1].idx, "2");
1872 }
1873
1874 #[test]
1878 fn x5103_bundled_codex_trust_prompt_is_answerable() {
1879 let m = bundled("codex");
1880 let screen = "> You are in /tmp/foo\n \
1881 Do you trust the contents of this directory? Trusting loads config.\n\
1882 \u{203a} 1. Yes, continue\n 2. No, quit\n\n Press enter to continue";
1883 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1884 assert_eq!(v.rule_id, "trust_prompt");
1885 assert_eq!(v.state, "blocked");
1886 let ans = ans.expect("codex numbered trust menu is answerable");
1887 assert_eq!(ans.options.len(), 2);
1888 assert_eq!(ans.options[0].idx, "1");
1889 assert_eq!(ans.options[0].label, "Yes, continue");
1890 assert_eq!(ans.options[0].keystroke, b"1");
1891 assert_eq!(ans.options[1].idx, "2");
1892 assert_eq!(ans.options[1].keystroke, b"2");
1893 }
1894
1895 #[test]
1900 fn x5103_codex_model_printed_list_is_not_a_false_trust_prompt() {
1901 let m = bundled("codex");
1902 let screen = "The permission flow. Do you trust the folder? Options:\n\
1903 1. Yes, it loads config\n 2. No, sandboxed\n\u{203a} ";
1904 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1905 assert_ne!(
1906 v.rule_id, "trust_prompt",
1907 "a printed list without the › selector must not fire trust_prompt"
1908 );
1909 assert!(ans.is_none(), "no live menu -> not answerable");
1910 }
1911
1912 #[test]
1915 fn xf498_bundled_codex_command_approval_is_answerable() {
1916 let m = bundled("codex");
1917 let screen = "Would you like to run the following command?\n\n\
1918 Environment: local\n\n\
1919 $ touch /tmp/fno-x-f498-approval-capture\n\n\
1920 \u{203a} 1. Yes, proceed (y)\n\
1921 2. Yes, and don't ask again for commands that start with `touch /tmp/fno-x-f498-approval-capture` (p)\n\
1922 3. No, and tell Codex what to do differently (esc)\n\n\
1923 Press enter to confirm or esc to cancel";
1924 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1925 assert_eq!(v.rule_id, "approval_prompt");
1926 assert_eq!(v.state, "blocked");
1927 let ans = ans.expect("codex command approval is answerable");
1928 assert_eq!(ans.options.len(), 3);
1929 assert_eq!(ans.options[0].label, "Yes, proceed (y)");
1930 assert_eq!(ans.options[0].keystroke, b"1");
1931 assert_eq!(ans.options[2].idx, "3");
1932 assert_eq!(ans.options[2].keystroke, b"3");
1933 }
1934
1935 #[test]
1936 fn xf498_codex_model_printed_list_is_not_a_false_command_approval() {
1937 let m = bundled("codex");
1938 let screen = "Here is an example. Would you like to run the following command?\n\
1939 1. Yes, proceed\n 2. No, cancel\n\u{203a} ";
1940 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1941 assert_ne!(v.rule_id, "approval_prompt");
1942 assert!(ans.is_none(), "no marked live menu -> not answerable");
1943 }
1944
1945 #[test]
1946 fn xf498_bundled_codex_edit_approval_is_answerable() {
1947 let m = bundled("codex");
1948 let screen = "Added .fno-x-f498-edit-capture (+1 -0)\n\
1949 1 +CAPTURE\n\n\
1950 Would you like to make the following edits?\n\n\
1951 \u{203a} 1. Yes, proceed (y)\n\
1952 2. Yes, and don't ask again for these files (a)\n\
1953 3. No, and tell Codex what to do differently (esc)\n\n\
1954 Press enter to confirm or esc to cancel";
1955 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
1956 assert_eq!(v.rule_id, "approval_prompt");
1957 let ans = ans.expect("codex edit approval is answerable");
1958 assert_eq!(ans.options.len(), 3);
1959 assert_eq!(
1960 ans.options[1].label,
1961 "Yes, and don't ask again for these files (a)"
1962 );
1963 assert_eq!(ans.options[2].keystroke, b"3");
1964 }
1965
1966 #[test]
1967 fn xf498_codex_approval_survives_narrow_terminal_wrapping() {
1968 let m = bundled("codex");
1969 let wrapped = (0..40)
1970 .map(|i| format!("wrapped command row {i}"))
1971 .collect::<Vec<_>>()
1972 .join("\n");
1973 let screen = format!(
1974 "Would you like to run the following command?\nEnvironment: local\n{wrapped}\n\
1975 \u{203a} 1. Yes, proceed (y)\n\
1976 2. Yes, and don't ask again (p)\n\
1977 3. No, and tell Codex what to do differently (esc)\n\
1978 Press enter to confirm or esc to cancel"
1979 );
1980 let (v, ans) = m.evaluate_answerable(&view(&screen)).unwrap();
1981 assert_eq!(v.rule_id, "approval_prompt");
1982 assert!(ans.is_some());
1983 }
1984
1985 #[test]
1989 fn x5103_bundled_gemini_trust_prompt_is_answerable() {
1990 let m = bundled("gemini");
1991 let screen = "\u{256d}\u{2500}\u{2500}\u{2500}\u{256e}\n\
1992 \u{2502} Do you trust the files in this folder? \u{2502}\n\
1993 \u{2502} Trusting a folder allows Gemini CLI to load. \u{2502}\n\
1994 \u{2502} \u{2502}\n\
1995 \u{2502} \u{25cf} 1. Trust folder (foo) \u{2502}\n\
1996 \u{2502} 2. Trust parent folder (tmp) \u{2502}\n\
1997 \u{2502} 3. Don't trust \u{2502}\n\
1998 \u{2570}\u{2500}\u{2500}\u{2500}\u{256f}";
1999 let (v, ans) = m.evaluate_answerable(&view(screen)).unwrap();
2000 assert_eq!(v.rule_id, "trust_prompt");
2001 assert_eq!(v.state, "blocked");
2002 let ans = ans.expect("gemini boxed numbered menu is answerable");
2003 assert_eq!(ans.options.len(), 3);
2004 assert_eq!(ans.options[0].idx, "1");
2005 assert_eq!(ans.options[0].label, "Trust folder (foo)");
2006 assert_eq!(ans.options[0].keystroke, b"1");
2007 assert_eq!(ans.options[2].idx, "3");
2008 assert_eq!(ans.options[2].label, "Don't trust");
2009 }
2010
2011 #[test]
2015 fn x5103_bundled_agy_arrow_menu_is_focus_only() {
2016 let m = bundled("agy");
2017 let screen = "Do you trust the contents of this project?\n\
2018 Antigravity CLI requires permission to read, edit, and execute files here.\n\
2019 > Yes, I trust this folder\n No, exit\n \u{2191}/\u{2193} Navigate \u{b7} enter Confirm";
2020 assert!(
2021 m.evaluate_answerable(&view(screen)).is_none(),
2022 "agy arrow-only menu must not be answerable (focus-only fallback)"
2023 );
2024 }
2025}