Skip to main content

fno_agents/
readiness.rs

1//! Readiness detection (design module `readiness.rs`).
2//!
3//! A PTY-managed agent is "ready" when its CLI is waiting for input (prompt
4//! drawn, not mid-render, not at an auth wall). The daemon must not send `ask`
5//! input before readiness or the keystrokes land in the wrong UI state.
6//!
7//! Open Question #9 is a load-bearing constraint here: AgentRelay's "total
8//! bytes > 500 -> assume ready" generic fallback is **rejected**. A banner can
9//! emit 500 bytes without the CLI being ready, and Gemini's "Waiting for auth"
10//! is a known false-ready. So:
11//!
12//! - Per-CLI [`ReadinessDetector`] impls are mandatory; there is no generic
13//!   byte-count detector.
14//! - Absence of a per-CLI signal is [`ReadinessError::UnknownReadinessSignal`],
15//!   surfaced as a runtime error, never a guessed `true`.
16//!
17//! Wave 1 shipped the trait + the [`ScreenView`] seam + a fully-specified test
18//! detector. Wave 2 adds the [`ScreenView`] construction (in [`crate::screen`],
19//! backed by `alacritty_terminal`) and the real per-CLI
20//! [`CodexReadinessDetector`] / [`GeminiReadinessDetector`] impls below
21//! (Open Questions #2/#3).
22
23/// A read-only view of the terminal screen the detector inspects. It is built
24/// from the terminal grid in [`crate::screen`] after feeding it the PTY output
25/// stream; the trait depends only on this shape so the substrate stays
26/// decoupled from the terminal-emulator crate.
27#[derive(Debug, Clone, Copy)]
28pub struct ScreenView<'a> {
29    /// The visible screen contents, rows joined by `\n`, trailing blanks
30    /// trimmed. What a human would see.
31    pub visible_text: &'a str,
32    /// Cursor position (0-based). Some prompts are only distinguishable by
33    /// where the cursor rests.
34    pub cursor_row: usize,
35    pub cursor_col: usize,
36}
37
38#[derive(Debug, thiserror::Error, PartialEq, Eq)]
39pub enum ReadinessError {
40    /// No per-CLI readiness signal is available for this provider. Surfaced as
41    /// a runtime error rather than a guessed readiness (Open Question #9).
42    #[error("no readiness signal available for provider '{provider}'; refusing to guess")]
43    UnknownReadinessSignal { provider: String },
44}
45
46/// Per-CLI readiness signal. Implementations inspect a [`ScreenView`] and
47/// return whether the CLI is waiting for input. Implementations MUST NOT infer
48/// readiness from raw byte counts.
49pub trait ReadinessDetector: Send + Sync {
50    /// `&str` (not `&'static str`) so a detector can name a provider known only
51    /// at runtime (the daemon's wildcard NoSignalDetector carries the real
52    /// provider name rather than the literal "unknown"; cv-789fdba0).
53    fn provider_name(&self) -> &str;
54
55    /// `Ok(true)` when the CLI is ready for input, `Ok(false)` when not yet,
56    /// `Err(UnknownReadinessSignal)` when this provider has no usable signal.
57    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError>;
58}
59
60/// Detector for providers that genuinely have no readiness signal yet. Always
61/// errors; exists so the daemon can register a placeholder and get the
62/// fail-loud behavior instead of a silent false-positive. (A previous design
63/// would have returned `Ok(true)` here; that is the bug Open Question #9 bans.)
64pub struct NoSignalDetector {
65    pub provider: String,
66}
67
68impl ReadinessDetector for NoSignalDetector {
69    fn provider_name(&self) -> &str {
70        &self.provider
71    }
72
73    fn is_ready(&self, _screen: &ScreenView) -> Result<bool, ReadinessError> {
74        Err(ReadinessError::UnknownReadinessSignal {
75            provider: self.provider.clone(),
76        })
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Per-CLI detectors (Wave 2). Codex = Open Question #2; Gemini = Open Question
82// #3. Both follow the same discipline: never claim readiness from byte counts;
83// reject known false-ready walls (auth / trust prompts) and mid-work states;
84// claim ready only on a positive prompt-glyph signal.
85//
86// SMOKE-PINNING NOTE: the exact prompt glyphs each CLI draws when idle are
87// best-known from the providers' documentation and the US4 captures, but have
88// not been pinned against a live interactive TUI in this PR (running an
89// interactive CLI headlessly risks an auth/trust hang). The detectors are
90// CONSERVATIVE by construction: the only failure mode of a wrong glyph is a
91// false-NOT-ready (the daemon waits/retries), never a false-ready (input sent
92// into the wrong UI state) - which is exactly the bias Open Question #9
93// mandates. `cli/scripts/smoke/capture-readiness-grid.sh` regenerates the grid
94// fixtures against the live CLIs; tune `PROMPT_GLYPHS` from a capture.
95// ---------------------------------------------------------------------------
96
97/// Prompt indicators a modern CLI composer draws on its idle input line. A
98/// match on the last non-blank line's trailing glyph is the positive readiness
99/// signal. Centralized so a smoke capture tunes one place.
100pub const PROMPT_GLYPHS: &[char] = &['\u{276f}', '\u{203a}', '\u{2595}']; // ❯ › ▌
101
102/// Visible substrings that mean the CLI is mid-work and NOT accepting input,
103/// even if a prompt glyph is also on screen.
104const BUSY_MARKERS: &[&str] = &[
105    "esc to interrupt",
106    "Esc to interrupt",
107    "Working",
108    "Thinking",
109];
110
111/// Visible substrings for a blocking wall the operator must clear first (auth,
112/// trust). The Gemini "Waiting for auth" false-ready (Open Question #3) lives
113/// here.
114const WALL_MARKERS: &[&str] = &[
115    "Waiting for auth",
116    "waiting for auth",
117    "Do you trust",
118    "Login required",
119];
120
121/// How many trailing non-blank lines form the "status region" the busy/wall
122/// markers are matched against. These CLIs draw their composer + status bar
123/// (the spinner, "esc to interrupt", auth wall) in the bottom few rows; model
124/// reply text scrolls ABOVE it. Scoping the marker match to this region stops a
125/// normal reply that happens to contain "Working"/"Thinking" from pinning the
126/// detector permanently not-ready (Codex review P1).
127const STATUS_REGION_LINES: usize = 3;
128
129/// Shared readiness decision: not-ready under any wall or busy marker *in the
130/// bottom status region*; otherwise ready only when the last non-blank line
131/// ends with a recognized prompt glyph. Never guesses from byte counts (Open
132/// Question #9).
133fn prompt_ready(screen: &ScreenView, glyphs: &[char]) -> bool {
134    let nonblank: Vec<&str> = screen
135        .visible_text
136        .lines()
137        .filter(|l| !l.trim().is_empty())
138        .collect();
139    let Some(last) = nonblank.last() else {
140        return false; // blank screen: nothing drawn yet, not ready
141    };
142    // Status region = the last few non-blank lines (where the composer/status
143    // bar lives), NOT the whole scrollback.
144    let region_start = nonblank.len().saturating_sub(STATUS_REGION_LINES);
145    let region = nonblank[region_start..].join("\n");
146    if WALL_MARKERS.iter().any(|m| region.contains(m)) {
147        return false;
148    }
149    if BUSY_MARKERS.iter().any(|m| region.contains(m)) {
150        return false;
151    }
152    let tail = last.trim_end();
153    glyphs.iter().any(|g| tail.ends_with(*g))
154}
155
156/// Provider-agnostic readiness check for the grid attention scanner
157/// (fu-grid-pagination / ab-82dddd5f). codex + gemini share [`PROMPT_GLYPHS`]
158/// and the same [`prompt_ready`] logic, and the grid only ever hosts those
159/// two (claude is excluded, grid Locked Decision 6), so a single shared check
160/// is correct here. Run client-side on a pane's `Term` snapshot to flag an
161/// off-screen agent waiting for input. Inherits the same wall / busy-marker
162/// discipline as the per-CLI detectors: never a byte-count guess, and a
163/// "Waiting for auth" wall reads as not-waiting. A brief false / late badge
164/// is tolerable for an awareness hint, but a false-ready into a busy state is
165/// not - hence the shared `prompt_ready` bias.
166pub fn screen_is_waiting(screen: &ScreenView) -> bool {
167    prompt_ready(screen, PROMPT_GLYPHS)
168}
169
170/// Codex interactive-composer readiness (Open Question #2).
171pub struct CodexReadinessDetector;
172
173impl ReadinessDetector for CodexReadinessDetector {
174    fn provider_name(&self) -> &str {
175        "codex"
176    }
177
178    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
179        Ok(prompt_ready(screen, PROMPT_GLYPHS))
180    }
181}
182
183/// Gemini interactive-composer readiness (Open Question #3). Shares the prompt
184/// glyph set with codex; the load-bearing difference is rejecting the
185/// "Waiting for auth" wall, handled by the shared `WALL_MARKERS`.
186pub struct GeminiReadinessDetector;
187
188impl ReadinessDetector for GeminiReadinessDetector {
189    fn provider_name(&self) -> &str {
190        "gemini"
191    }
192
193    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
194        Ok(prompt_ready(screen, PROMPT_GLYPHS))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    /// A minimal grid-pattern detector to exercise the trait. Real Codex/Gemini
203    /// detectors (Wave 2) match smoke-captured prompts; this stand-in matches a
204    /// trailing prompt glyph on the cursor row and rejects a known false-ready
205    /// auth banner, mirroring the discipline the real impls must follow.
206    struct PromptGlyphDetector;
207    impl ReadinessDetector for PromptGlyphDetector {
208        fn provider_name(&self) -> &str {
209            "test-cli"
210        }
211        fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
212            if screen.visible_text.contains("Waiting for auth") {
213                return Ok(false); // known false-ready (the Gemini trap)
214            }
215            Ok(screen.visible_text.trim_end().ends_with('\u{276f}')) // matches "❯"
216        }
217    }
218
219    fn view(text: &str) -> ScreenView<'_> {
220        ScreenView {
221            visible_text: text,
222            cursor_row: 0,
223            cursor_col: 0,
224        }
225    }
226
227    #[test]
228    fn ready_on_prompt_glyph() {
229        let d = PromptGlyphDetector;
230        assert_eq!(d.is_ready(&view("project \u{276f}")), Ok(true));
231        assert_eq!(d.is_ready(&view("loading...")), Ok(false));
232    }
233
234    #[test]
235    fn auth_wall_is_not_ready() {
236        let d = PromptGlyphDetector;
237        // Even with a trailing glyph, the auth banner must report not-ready.
238        assert_eq!(d.is_ready(&view("Waiting for auth \u{276f}")), Ok(false));
239    }
240
241    #[test]
242    fn no_signal_detector_errors_never_guesses() {
243        let d = NoSignalDetector {
244            provider: "opencode".to_string(),
245        };
246        // The detector reports the real provider name (not the literal
247        // "unknown") in both provider_name() and the error (cv-789fdba0).
248        assert_eq!(d.provider_name(), "opencode");
249        assert_eq!(
250            d.is_ready(&view("anything at all, 9999 bytes of banner")),
251            Err(ReadinessError::UnknownReadinessSignal {
252                provider: "opencode".into()
253            })
254        );
255    }
256
257    #[test]
258    fn codex_detector_ready_on_idle_prompt() {
259        let d = CodexReadinessDetector;
260        assert_eq!(d.provider_name(), "codex");
261        // Idle composer: last non-blank line ends with a prompt glyph.
262        assert_eq!(
263            d.is_ready(&view(
264                "codex 0.130\n\n  build feature X\n\u{276f} ".trim_end()
265            )),
266            Ok(true)
267        );
268    }
269
270    #[test]
271    fn codex_detector_not_ready_while_working() {
272        let d = CodexReadinessDetector;
273        // Busy marker overrides any prompt glyph also on screen.
274        assert_eq!(
275            d.is_ready(&view("running tool...\nEsc to interrupt\n\u{276f}")),
276            Ok(false)
277        );
278    }
279
280    #[test]
281    fn codex_detector_not_ready_without_prompt_signal() {
282        let d = CodexReadinessDetector;
283        // No prompt glyph anywhere: refuse to claim ready (no byte-count guess).
284        assert_eq!(
285            d.is_ready(&view("loading a 5000 byte banner of text")),
286            Ok(false)
287        );
288    }
289
290    #[test]
291    fn gemini_detector_rejects_waiting_for_auth_false_ready() {
292        let d = GeminiReadinessDetector;
293        assert_eq!(d.provider_name(), "gemini");
294        // The documented Gemini trap: prompt glyph present but auth wall up.
295        assert_eq!(
296            d.is_ready(&view("Waiting for auth...\n\u{276f}")),
297            Ok(false)
298        );
299    }
300
301    #[test]
302    fn gemini_detector_ready_on_idle_prompt() {
303        let d = GeminiReadinessDetector;
304        assert_eq!(
305            d.is_ready(&view("Gemini ready\n\u{203a} ".trim_end())),
306            Ok(true)
307        );
308    }
309
310    #[test]
311    fn busy_word_in_model_reply_above_status_region_does_not_block() {
312        // A normal reply mentioning "Working" / "Thinking" scrolls ABOVE the
313        // composer; only the bottom status region gates readiness (Codex P1).
314        let d = CodexReadinessDetector;
315        let screen = "I am Working on the Thinking task you asked about.\n\
316                      Here is a long reply that mentions Working again.\n\
317                      filler line\n\
318                      another filler\n\
319                      \u{276f} ";
320        assert_eq!(d.is_ready(&view(screen.trim_end())), Ok(true));
321    }
322}