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    /// Latest OSC window title (OSC 0/2), captured from the PTY byte stream
37    /// (E6.1). The manifest engine's `osc_title` region reads this; it survives
38    /// scrollback/wrap/resize where grid text does not. `None` if no title OSC
39    /// has been seen (e.g. the grid-pane scanner, which does not capture OSC).
40    pub osc_title: Option<&'a str>,
41    /// Latest OSC 9;4 progress payload (E6.1); the engine's `osc_progress`
42    /// region. `None` if none seen.
43    pub osc_progress: Option<&'a str>,
44}
45
46#[derive(Debug, thiserror::Error, PartialEq, Eq)]
47pub enum ReadinessError {
48    /// No per-CLI readiness signal is available for this provider. Surfaced as
49    /// a runtime error rather than a guessed readiness (Open Question #9).
50    #[error("no readiness signal available for provider '{provider}'; refusing to guess")]
51    UnknownReadinessSignal { provider: String },
52}
53
54/// Per-CLI readiness signal. Implementations inspect a [`ScreenView`] and
55/// return whether the CLI is waiting for input. Implementations MUST NOT infer
56/// readiness from raw byte counts.
57pub trait ReadinessDetector: Send + Sync {
58    /// `&str` (not `&'static str`) so a detector can name a provider known only
59    /// at runtime (the daemon's wildcard NoSignalDetector carries the real
60    /// provider name rather than the literal "unknown"; cv-789fdba0).
61    fn provider_name(&self) -> &str;
62
63    /// `Ok(true)` when the CLI is ready for input, `Ok(false)` when not yet,
64    /// `Err(UnknownReadinessSignal)` when this provider has no usable signal.
65    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError>;
66}
67
68/// Detector for providers that genuinely have no readiness signal yet. Always
69/// errors; exists so the daemon can register a placeholder and get the
70/// fail-loud behavior instead of a silent false-positive. (A previous design
71/// would have returned `Ok(true)` here; that is the bug Open Question #9 bans.)
72pub struct NoSignalDetector {
73    pub provider: String,
74}
75
76impl ReadinessDetector for NoSignalDetector {
77    fn provider_name(&self) -> &str {
78        &self.provider
79    }
80
81    fn is_ready(&self, _screen: &ScreenView) -> Result<bool, ReadinessError> {
82        Err(ReadinessError::UnknownReadinessSignal {
83            provider: self.provider.clone(),
84        })
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Per-CLI detectors (Wave 2). Codex = Open Question #2; Gemini = Open Question
90// #3. Both follow the same discipline: never claim readiness from byte counts;
91// reject known false-ready walls (auth / trust prompts) and mid-work states;
92// claim ready only on a positive prompt-glyph signal.
93//
94// SMOKE-PINNING NOTE: the exact prompt glyphs each CLI draws when idle are
95// best-known from the providers' documentation and the US4 captures, but have
96// not been pinned against a live interactive TUI in this PR (running an
97// interactive CLI headlessly risks an auth/trust hang). The detectors are
98// CONSERVATIVE by construction: the only failure mode of a wrong glyph is a
99// false-NOT-ready (the daemon waits/retries), never a false-ready (input sent
100// into the wrong UI state) - which is exactly the bias Open Question #9
101// mandates. `cli/scripts/smoke/capture-readiness-grid.sh` regenerates the grid
102// fixtures against the live CLIs; tune `PROMPT_GLYPHS` from a capture.
103// ---------------------------------------------------------------------------
104
105/// Prompt indicators a modern CLI composer draws on its idle input line. A
106/// match on the last non-blank line's trailing glyph is the positive readiness
107/// signal. Centralized so a smoke capture tunes one place.
108pub const PROMPT_GLYPHS: &[char] = &['\u{276f}', '\u{203a}', '\u{2595}']; // ❯ › ▌
109
110/// Visible substrings that mean the CLI is mid-work and NOT accepting input,
111/// even if a prompt glyph is also on screen.
112const BUSY_MARKERS: &[&str] = &[
113    "esc to interrupt",
114    "Esc to interrupt",
115    "Working",
116    "Thinking",
117];
118
119/// Visible substrings for a blocking wall the operator must clear first (auth,
120/// trust). The Gemini "Waiting for auth" false-ready (Open Question #3) lives
121/// here.
122const WALL_MARKERS: &[&str] = &[
123    "Waiting for auth",
124    "waiting for auth",
125    "Do you trust",
126    "Login required",
127];
128
129/// How many trailing non-blank lines form the "status region" the busy/wall
130/// markers are matched against. These CLIs draw their composer + status bar
131/// (the spinner, "esc to interrupt", auth wall) in the bottom few rows; model
132/// reply text scrolls ABOVE it. Scoping the marker match to this region stops a
133/// normal reply that happens to contain "Working"/"Thinking" from pinning the
134/// detector permanently not-ready (Codex review P1).
135const STATUS_REGION_LINES: usize = 3;
136
137/// Shared readiness decision: not-ready under any wall or busy marker *in the
138/// bottom status region*; otherwise ready only when the last non-blank line
139/// ends with a recognized prompt glyph. Never guesses from byte counts (Open
140/// Question #9).
141fn prompt_ready(screen: &ScreenView, glyphs: &[char]) -> bool {
142    let nonblank: Vec<&str> = screen
143        .visible_text
144        .lines()
145        .filter(|l| !l.trim().is_empty())
146        .collect();
147    let Some(last) = nonblank.last() else {
148        return false; // blank screen: nothing drawn yet, not ready
149    };
150    // Status region = the last few non-blank lines (where the composer/status
151    // bar lives), NOT the whole scrollback.
152    let region_start = nonblank.len().saturating_sub(STATUS_REGION_LINES);
153    let region = nonblank[region_start..].join("\n");
154    if WALL_MARKERS.iter().any(|m| region.contains(m)) {
155        return false;
156    }
157    if BUSY_MARKERS.iter().any(|m| region.contains(m)) {
158        return false;
159    }
160    let tail = last.trim_end();
161    glyphs.iter().any(|g| tail.ends_with(*g))
162}
163
164/// Provider-agnostic readiness check for the grid attention scanner
165/// (fu-grid-pagination / ab-82dddd5f). codex + gemini share [`PROMPT_GLYPHS`]
166/// and the same [`prompt_ready`] logic, and the grid only ever hosts those
167/// two (claude is excluded, grid Locked Decision 6), so a single shared check
168/// is correct here. Run client-side on a pane's `Term` snapshot to flag an
169/// off-screen agent waiting for input. Inherits the same wall / busy-marker
170/// discipline as the per-CLI detectors: never a byte-count guess, and a
171/// "Waiting for auth" wall reads as not-waiting. A brief false / late badge
172/// is tolerable for an awareness hint, but a false-ready into a busy state is
173/// not - hence the shared `prompt_ready` bias.
174pub fn screen_is_waiting(screen: &ScreenView) -> bool {
175    prompt_ready(screen, PROMPT_GLYPHS)
176}
177
178/// Codex interactive-composer readiness (Open Question #2).
179pub struct CodexReadinessDetector;
180
181impl ReadinessDetector for CodexReadinessDetector {
182    fn provider_name(&self) -> &str {
183        "codex"
184    }
185
186    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
187        Ok(prompt_ready(screen, PROMPT_GLYPHS))
188    }
189}
190
191/// Gemini interactive-composer readiness (Open Question #3). Shares the prompt
192/// glyph set with codex; the load-bearing difference is rejecting the
193/// "Waiting for auth" wall, handled by the shared `WALL_MARKERS`.
194pub struct GeminiReadinessDetector;
195
196impl ReadinessDetector for GeminiReadinessDetector {
197    fn provider_name(&self) -> &str {
198        "gemini"
199    }
200
201    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
202        Ok(prompt_ready(screen, PROMPT_GLYPHS))
203    }
204}
205
206/// Agy interactive-composer readiness (Phase C, agy harness). agy runs Gemini
207/// models under the hood and draws the same prompt-glyph family as the other
208/// CLIs, so the shared [`prompt_ready`] logic applies unchanged. The
209/// conservative bias (a wrong glyph is false-NOT-ready, never false-ready) holds
210/// here as for the others. Glyphs tune against a live agy capture (deferred).
211pub struct AgyReadinessDetector;
212
213impl ReadinessDetector for AgyReadinessDetector {
214    fn provider_name(&self) -> &str {
215        "agy"
216    }
217
218    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
219        Ok(prompt_ready(screen, PROMPT_GLYPHS))
220    }
221}
222
223/// Claude interactive-composer readiness (inside-out-multiplexer E1). Claude's
224/// TUI composer draws the same prompt-glyph family as codex/gemini and its
225/// mid-turn "esc to interrupt" status line is already in [`BUSY_MARKERS`], so
226/// the shared [`prompt_ready`] logic applies unchanged. Threshold tuning against
227/// a live capture is Claude's Discretion (the design's readiness-detector
228/// bullet); the conservative bias (a wrong glyph is false-NOT-ready, never
229/// false-ready) holds here as for the other CLIs.
230pub struct ClaudeReadinessDetector;
231
232impl ReadinessDetector for ClaudeReadinessDetector {
233    fn provider_name(&self) -> &str {
234        "claude"
235    }
236
237    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
238        Ok(prompt_ready(screen, PROMPT_GLYPHS))
239    }
240}
241
242/// opencode TUI readiness (x-51f6 US3). Markers pinned from a LIVE capture
243/// (opencode 1.14.50, mux-pane hosted), not guessed:
244///
245/// - **idle**: the composer box draws a `┃` (U+2503) left edge; the footer
246///   hint row reads "tab agents  ctrl+p commands". The LAST line is a status
247///   bar (`~/path:branch … 1.14.50`), so the shared trailing-glyph
248///   [`prompt_ready`] can never fire for opencode — a positive signal needs
249///   the composer edge instead.
250/// - **working**: the footer swaps to a progress fill (`⬝⬝⬝⬝■■■■`) plus an
251///   "esc interrupt" hint. NOTE: live 1.14.50 says "esc interrupt" (no "to"),
252///   which the shared `BUSY_MARKERS` miss — matched here on "interrupt".
253/// - **blocked**: the "Permission required" dialog (same marker family as
254///   `manifests/opencode.toml`) and the shared auth [`WALL_MARKERS`].
255///
256/// Conservative bias holds (readiness OQ#9): the composer edge is required
257/// AND every busy/blocked marker vetoes, so a UI change degrades to
258/// false-NOT-ready (footnote waits), never a false-ready dispatch.
259pub struct OpencodeReadinessDetector;
260
261/// Bottom non-blank lines forming opencode's status region: the composer box
262/// (edge rows + model line), the hint row, and the status bar — deeper than
263/// the shared 3-line region because opencode's composer is a multi-row box.
264const OPENCODE_STATUS_REGION_LINES: usize = 8;
265
266impl ReadinessDetector for OpencodeReadinessDetector {
267    fn provider_name(&self) -> &str {
268        "opencode"
269    }
270
271    fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
272        let nonblank: Vec<&str> = screen
273            .visible_text
274            .lines()
275            .filter(|l| !l.trim().is_empty())
276            .collect();
277        if nonblank.is_empty() {
278            return Ok(false); // blank screen: nothing drawn yet
279        }
280        let start = nonblank.len().saturating_sub(OPENCODE_STATUS_REGION_LINES);
281        let region = nonblank[start..].join("\n");
282        if WALL_MARKERS.iter().any(|m| region.contains(m)) {
283            return Ok(false);
284        }
285        // "interrupt" covers "esc interrupt" (live 1.14.50) and the "esc to
286        // interrupt"/"ctrl+c to interrupt" variants; the ⬝/■ runs are the
287        // progress fill; "Permission required" is the blocked dialog.
288        const OPENCODE_NOT_READY: &[&str] = &[
289            "interrupt",
290            "Permission required",
291            "\u{25a0}\u{25a0}\u{25a0}\u{25a0}",
292            "\u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}",
293        ];
294        if OPENCODE_NOT_READY.iter().any(|m| region.contains(m)) {
295            return Ok(false);
296        }
297        // Positive signal: the composer's `┃` left edge in the status region.
298        // A dialog overlay or half-drawn screen has no edge -> not ready.
299        Ok(nonblank[start..]
300            .iter()
301            .any(|l| l.trim_start().starts_with('\u{2503}')))
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// A minimal grid-pattern detector to exercise the trait. Real Codex/Gemini
310    /// detectors (Wave 2) match smoke-captured prompts; this stand-in matches a
311    /// trailing prompt glyph on the cursor row and rejects a known false-ready
312    /// auth banner, mirroring the discipline the real impls must follow.
313    struct PromptGlyphDetector;
314    impl ReadinessDetector for PromptGlyphDetector {
315        fn provider_name(&self) -> &str {
316            "test-cli"
317        }
318        fn is_ready(&self, screen: &ScreenView) -> Result<bool, ReadinessError> {
319            if screen.visible_text.contains("Waiting for auth") {
320                return Ok(false); // known false-ready (the Gemini trap)
321            }
322            Ok(screen.visible_text.trim_end().ends_with('\u{276f}')) // matches "❯"
323        }
324    }
325
326    fn view(text: &str) -> ScreenView<'_> {
327        ScreenView {
328            visible_text: text,
329            cursor_row: 0,
330            cursor_col: 0,
331            osc_title: None,
332            osc_progress: None,
333        }
334    }
335
336    #[test]
337    fn ready_on_prompt_glyph() {
338        let d = PromptGlyphDetector;
339        assert_eq!(d.is_ready(&view("project \u{276f}")), Ok(true));
340        assert_eq!(d.is_ready(&view("loading...")), Ok(false));
341    }
342
343    #[test]
344    fn auth_wall_is_not_ready() {
345        let d = PromptGlyphDetector;
346        // Even with a trailing glyph, the auth banner must report not-ready.
347        assert_eq!(d.is_ready(&view("Waiting for auth \u{276f}")), Ok(false));
348    }
349
350    // ---- opencode (x-51f6 US3): fixtures condensed from a live 1.14.50
351    // mux-pane capture (capture method: `fno mux pane run -- opencode` +
352    // `pane read`), per the markers-from-capture locked decision. ----
353
354    fn opencode_screen(footer_hint: &str) -> String {
355        format!(
356            "   \u{2503}\n   \u{2503}  Ask anything... \"Fix a TODO in the codebase\"\n   \u{2503}\n   \u{2503}  Sisyphus - Ultraworker \u{b7} model \u{b7} medium\n   \u{2579}\u{2580}\u{2580}\u{2580}\u{2580}\n{footer_hint}\n  ~/proj:main  \u{2299} 6 MCP /status   1.14.50"
357        )
358    }
359
360    #[test]
361    fn opencode_idle_composer_is_ready() {
362        let d = OpencodeReadinessDetector;
363        let screen = opencode_screen("                 tab agents  ctrl+p commands");
364        assert_eq!(d.is_ready(&view(&screen)), Ok(true));
365        assert_eq!(d.provider_name(), "opencode");
366    }
367
368    #[test]
369    fn opencode_progress_fill_or_interrupt_hint_is_busy() {
370        let d = OpencodeReadinessDetector;
371        // Live 1.14.50 working footer: fill run + "esc interrupt" (no "to").
372        let working = opencode_screen(
373            "   \u{2b1d}\u{2b1d}\u{2b1d}\u{2b1d}\u{25a0}\u{25a0}\u{25a0}\u{25a0}  esc interrupt      tab agents  ctrl+p commands",
374        );
375        assert_eq!(d.is_ready(&view(&working)), Ok(false));
376        // The shared "esc to interrupt" spelling stays busy too.
377        let alt = opencode_screen("   esc to interrupt");
378        assert_eq!(d.is_ready(&view(&alt)), Ok(false));
379    }
380
381    #[test]
382    fn opencode_permission_dialog_and_auth_wall_are_not_ready() {
383        let d = OpencodeReadinessDetector;
384        let perm = opencode_screen("   \u{25b3} Permission required");
385        assert_eq!(d.is_ready(&view(&perm)), Ok(false));
386        let wall = opencode_screen("   Login required");
387        assert_eq!(d.is_ready(&view(&wall)), Ok(false));
388    }
389
390    #[test]
391    fn opencode_stale_blocker_above_the_8_line_window_does_not_block() {
392        // A "Permission required" / progress-fill marker that has scrolled
393        // ABOVE the trailing OPENCODE_STATUS_REGION_LINES(8) window (into old
394        // reply text) must not veto readiness - only the bottom composer/
395        // status region gates it (mirrors busy_word_in_model_reply_above_
396        // status_region_does_not_block for the shared 3-line window). This
397        // fixture has 11 non-blank lines; the last 8 exclude both noise lines.
398        let d = OpencodeReadinessDetector;
399        let screen = "Permission required somewhere in old reply\n\
400                      \u{25a0}\u{25a0}\u{25a0}\u{25a0} progress marker from old scrollback\n\
401                      filler line one\n\
402                      filler line two\n\
403                      \u{2503}\n\
404                      \u{2503}  Ask anything... \"Fix a TODO in the codebase\"\n\
405                      \u{2503}\n\
406                      \u{2503}  Sisyphus - Ultraworker \u{b7} model \u{b7} medium\n\
407                      \u{2579}\u{2580}\u{2580}\u{2580}\u{2580}\n\
408                                       tab agents  ctrl+p commands\n\
409                      ~/proj:main  \u{2299} 6 MCP /status   1.14.50";
410        assert_eq!(d.is_ready(&view(screen)), Ok(true));
411    }
412
413    #[test]
414    fn opencode_no_composer_edge_is_not_ready() {
415        let d = OpencodeReadinessDetector;
416        // A half-drawn screen / full-screen dialog with no `┃` edge must read
417        // not-ready (conservative bias), and a blank screen too.
418        assert_eq!(d.is_ready(&view("loading opencode...")), Ok(false));
419        assert_eq!(d.is_ready(&view("")), Ok(false));
420    }
421
422    #[test]
423    fn no_signal_detector_errors_never_guesses() {
424        // "aider" is the canonical genuinely-unhosted CLI (opencode graduated
425        // to a real detector at x-51f6).
426        let d = NoSignalDetector {
427            provider: "aider".to_string(),
428        };
429        // The detector reports the real provider name (not the literal
430        // "unknown") in both provider_name() and the error (cv-789fdba0).
431        assert_eq!(d.provider_name(), "aider");
432        assert_eq!(
433            d.is_ready(&view("anything at all, 9999 bytes of banner")),
434            Err(ReadinessError::UnknownReadinessSignal {
435                provider: "aider".into()
436            })
437        );
438    }
439
440    #[test]
441    fn codex_detector_ready_on_idle_prompt() {
442        let d = CodexReadinessDetector;
443        assert_eq!(d.provider_name(), "codex");
444        // Idle composer: last non-blank line ends with a prompt glyph.
445        assert_eq!(
446            d.is_ready(&view(
447                "codex 0.130\n\n  build feature X\n\u{276f} ".trim_end()
448            )),
449            Ok(true)
450        );
451    }
452
453    #[test]
454    fn codex_detector_not_ready_while_working() {
455        let d = CodexReadinessDetector;
456        // Busy marker overrides any prompt glyph also on screen.
457        assert_eq!(
458            d.is_ready(&view("running tool...\nEsc to interrupt\n\u{276f}")),
459            Ok(false)
460        );
461    }
462
463    #[test]
464    fn codex_detector_not_ready_without_prompt_signal() {
465        let d = CodexReadinessDetector;
466        // No prompt glyph anywhere: refuse to claim ready (no byte-count guess).
467        assert_eq!(
468            d.is_ready(&view("loading a 5000 byte banner of text")),
469            Ok(false)
470        );
471    }
472
473    #[test]
474    fn gemini_detector_rejects_waiting_for_auth_false_ready() {
475        let d = GeminiReadinessDetector;
476        assert_eq!(d.provider_name(), "gemini");
477        // The documented Gemini trap: prompt glyph present but auth wall up.
478        assert_eq!(
479            d.is_ready(&view("Waiting for auth...\n\u{276f}")),
480            Ok(false)
481        );
482    }
483
484    #[test]
485    fn gemini_detector_ready_on_idle_prompt() {
486        let d = GeminiReadinessDetector;
487        assert_eq!(
488            d.is_ready(&view("Gemini ready\n\u{203a} ".trim_end())),
489            Ok(true)
490        );
491    }
492
493    #[test]
494    fn busy_word_in_model_reply_above_status_region_does_not_block() {
495        // A normal reply mentioning "Working" / "Thinking" scrolls ABOVE the
496        // composer; only the bottom status region gates readiness (Codex P1).
497        let d = CodexReadinessDetector;
498        let screen = "I am Working on the Thinking task you asked about.\n\
499                      Here is a long reply that mentions Working again.\n\
500                      filler line\n\
501                      another filler\n\
502                      \u{276f} ";
503        assert_eq!(d.is_ready(&view(screen.trim_end())), Ok(true));
504    }
505}