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}