Skip to main content

batty_cli/team/watcher/
mod.rs

1//! Disk-based session monitoring — polls agent output via tmux capture-pane.
2//!
3//! Detects agent completion, crashes, and staleness by periodically capturing
4//! pane output and checking for state changes. For Codex and Claude, this also
5//! tails their on-disk session JSONL data to reduce false classifications from
6//! stale pane text.
7
8mod claude;
9mod codex;
10mod screen;
11
12use anyhow::Result;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17use crate::tmux;
18
19pub use screen::is_at_agent_prompt;
20
21use claude::ClaudeSessionTracker;
22use codex::CodexSessionTracker;
23use screen::{classify_capture_state, detect_context_exhausted, next_state_after_capture};
24
25/// State of a watched agent session.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum WatcherState {
28    /// Agent is actively producing output.
29    Active,
30    /// Agent CLI has started and is showing its input prompt, ready for messages.
31    /// Transitional state between pane creation and first Idle classification.
32    Ready,
33    /// No agent running in pane (idle / waiting for assignment).
34    Idle,
35    /// The tmux pane no longer exists or its process has exited.
36    PaneDead,
37    /// Agent reported that its conversation/session is too large to continue.
38    ContextExhausted,
39}
40
41pub struct SessionWatcher {
42    pub pane_id: String,
43    pub member_name: String,
44    pub state: WatcherState,
45    completion_observed: bool,
46    last_output_hash: u64,
47    last_capture: String,
48    /// Timestamp of the last time pane output changed (hash differed from previous poll).
49    last_output_changed_at: Instant,
50    tracker: Option<SessionTracker>,
51    /// Whether the agent prompt has been observed at least once since creation
52    /// or last `activate()`. False means the pane may not be ready for messages.
53    ready_confirmed: bool,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct CodexQualitySignals {
58    pub last_response_chars: Option<usize>,
59    pub shortening_streak: u32,
60    pub repeated_output_streak: u32,
61    pub shrinking_responses: bool,
62    pub repeated_identical_outputs: bool,
63    pub tool_failure_message: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub enum SessionTrackerConfig {
68    Codex { cwd: PathBuf },
69    Claude { cwd: PathBuf },
70}
71
72enum SessionTracker {
73    Codex(CodexSessionTracker),
74    Claude(ClaudeSessionTracker),
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub(super) enum TrackerKind {
79    None,
80    Codex,
81    Claude,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(super) enum TrackerState {
86    Active,
87    Idle,
88    Completed,
89    Unknown,
90}
91
92impl SessionWatcher {
93    pub fn new(
94        pane_id: &str,
95        member_name: &str,
96        _stale_secs: u64,
97        tracker: Option<SessionTrackerConfig>,
98    ) -> Self {
99        Self {
100            pane_id: pane_id.to_string(),
101            member_name: member_name.to_string(),
102            state: WatcherState::Idle,
103            completion_observed: false,
104            last_output_hash: 0,
105            last_capture: String::new(),
106            last_output_changed_at: Instant::now(),
107            ready_confirmed: false,
108            tracker: tracker.map(|tracker| match tracker {
109                SessionTrackerConfig::Codex { cwd } => SessionTracker::Codex(CodexSessionTracker {
110                    sessions_root: default_codex_sessions_root(),
111                    cwd,
112                    session_id: None,
113                    session_file: None,
114                    offset: 0,
115                    quality: CodexQualitySignals::default(),
116                    last_response_hash: None,
117                }),
118                SessionTrackerConfig::Claude { cwd } => {
119                    SessionTracker::Claude(ClaudeSessionTracker {
120                        projects_root: default_claude_projects_root(),
121                        cwd,
122                        session_id: None,
123                        session_file: None,
124                        offset: 0,
125                        last_state: TrackerState::Unknown,
126                    })
127                }
128            }),
129        }
130    }
131
132    /// Poll the pane and update state.
133    pub fn poll(&mut self) -> Result<WatcherState> {
134        // Check if pane still exists
135        if !tmux::pane_exists(&self.pane_id) {
136            self.state = WatcherState::PaneDead;
137            return Ok(self.state);
138        }
139
140        // Check if pane process died
141        if tmux::pane_dead(&self.pane_id).unwrap_or(false) {
142            self.state = WatcherState::PaneDead;
143            return Ok(self.state);
144        }
145
146        // If idle or ready, peek at the pane to detect if the agent started working.
147        // This lets the watcher self-heal without requiring explicit activation
148        // from the daemon whenever a nudge, standup, or external input arrives.
149        if matches!(self.state, WatcherState::Idle | WatcherState::Ready) {
150            let capture = match tmux::capture_pane(&self.pane_id) {
151                Ok(capture) => capture,
152                Err(_) => {
153                    self.state = WatcherState::PaneDead;
154                    return Ok(self.state);
155                }
156            };
157            if detect_context_exhausted(&capture) {
158                self.last_capture = capture;
159                self.state = WatcherState::ContextExhausted;
160                return Ok(self.state);
161            }
162            let screen_state = classify_capture_state(&capture);
163            // When we first see the agent prompt, confirm readiness.
164            if screen_state == screen::ScreenState::Idle && !self.ready_confirmed {
165                self.ready_confirmed = true;
166                self.last_capture = capture;
167                self.state = WatcherState::Ready;
168                return Ok(self.state);
169            }
170            let tracker_state = self.poll_tracker().unwrap_or(TrackerState::Unknown);
171            self.completion_observed = tracker_state == TrackerState::Completed;
172            let tracker_kind = self.tracker_kind();
173            if !capture.is_empty() {
174                self.last_capture = capture;
175                let next_state =
176                    next_state_after_capture(tracker_kind, screen_state, tracker_state, self.state);
177                if next_state != WatcherState::Idle || self.ready_confirmed {
178                    self.last_output_hash = simple_hash(&self.last_capture);
179                    self.last_output_changed_at = Instant::now();
180                    self.state = next_state;
181                }
182            }
183            return Ok(self.state);
184        }
185
186        // Capture current pane content
187        let capture = match tmux::capture_pane(&self.pane_id) {
188            Ok(capture) => capture,
189            Err(_) => {
190                self.state = WatcherState::PaneDead;
191                return Ok(self.state);
192            }
193        };
194        if detect_context_exhausted(&capture) {
195            self.last_capture = capture;
196            self.state = WatcherState::ContextExhausted;
197            return Ok(self.state);
198        }
199        let hash = simple_hash(&capture);
200        let screen_state = classify_capture_state(&capture);
201        let tracker_state = self.poll_tracker().unwrap_or(TrackerState::Unknown);
202        self.completion_observed = tracker_state == TrackerState::Completed;
203        let tracker_kind = self.tracker_kind();
204
205        if hash != self.last_output_hash {
206            self.last_output_hash = hash;
207            self.last_output_changed_at = Instant::now();
208            self.last_capture = capture;
209            self.state =
210                next_state_after_capture(tracker_kind, screen_state, tracker_state, self.state);
211        } else {
212            self.last_capture = capture;
213            self.state =
214                next_state_after_capture(tracker_kind, screen_state, tracker_state, self.state);
215        }
216
217        Ok(self.state)
218    }
219
220    /// Whether this agent's pane has been confirmed ready for message delivery.
221    ///
222    /// Returns `true` once the agent prompt has been observed at least once since
223    /// the watcher was created or last activated. This prevents injecting messages
224    /// into panes where the agent CLI hasn't finished starting.
225    pub fn is_ready_for_delivery(&self) -> bool {
226        self.ready_confirmed
227    }
228
229    /// Externally confirm that the agent pane is ready (e.g. from a delivery
230    /// readiness check that observed the prompt). Updates state to Ready if it
231    /// was still in the initial Idle state before first readiness confirmation.
232    pub fn confirm_ready(&mut self) {
233        let was_unconfirmed = !self.ready_confirmed;
234        self.ready_confirmed = true;
235        if was_unconfirmed && self.state == WatcherState::Idle {
236            self.state = WatcherState::Ready;
237        }
238    }
239
240    /// Mark this watcher as actively working.
241    pub fn activate(&mut self) {
242        self.state = WatcherState::Active;
243        self.completion_observed = false;
244        self.last_output_hash = 0;
245        self.last_output_changed_at = Instant::now();
246        // A message was just injected so the pane was confirmed ready.
247        self.ready_confirmed = true;
248        if let Some(tracker) = self.tracker.as_mut() {
249            match tracker {
250                SessionTracker::Codex(codex) => {
251                    codex.session_file = None;
252                    codex.offset = 0;
253                    codex.quality = CodexQualitySignals::default();
254                    codex.last_response_hash = None;
255                }
256                SessionTracker::Claude(claude) => {
257                    claude.session_file = None;
258                    claude.offset = 0;
259                    claude.last_state = TrackerState::Unknown;
260                }
261            }
262        }
263    }
264
265    pub fn set_session_id(&mut self, session_id: Option<String>) {
266        if let Some(tracker) = self.tracker.as_mut() {
267            match tracker {
268                SessionTracker::Codex(codex) => {
269                    if codex.session_id == session_id {
270                        return;
271                    }
272                    self.completion_observed = false;
273                    codex.session_id = session_id;
274                    codex.session_file = None;
275                    codex.offset = 0;
276                    codex.quality = CodexQualitySignals::default();
277                    codex.last_response_hash = None;
278                }
279                SessionTracker::Claude(claude) => {
280                    if claude.session_id == session_id {
281                        return;
282                    }
283                    self.completion_observed = false;
284                    claude.session_id = session_id;
285                    claude.session_file = None;
286                    claude.offset = 0;
287                    claude.last_state = TrackerState::Unknown;
288                }
289            }
290        }
291    }
292
293    /// Mark this watcher as idle.
294    pub fn deactivate(&mut self) {
295        self.state = WatcherState::Idle;
296        self.completion_observed = false;
297    }
298
299    /// Seconds since the last time pane output changed.
300    pub fn secs_since_last_output_change(&self) -> u64 {
301        self.last_output_changed_at.elapsed().as_secs()
302    }
303
304    /// Get the last captured pane output.
305    pub fn last_output(&self) -> &str {
306        &self.last_capture
307    }
308
309    /// Get the last N lines of captured output.
310    pub fn last_lines(&self, n: usize) -> String {
311        let lines: Vec<&str> = self.last_capture.lines().collect();
312        let start = lines.len().saturating_sub(n);
313        lines[start..].join("\n")
314    }
315
316    pub fn current_session_id(&self) -> Option<String> {
317        match self.tracker.as_ref() {
318            Some(SessionTracker::Codex(codex)) => session_file_id(codex.session_file.as_ref()),
319            Some(SessionTracker::Claude(claude)) => session_file_id(claude.session_file.as_ref()),
320            None => None,
321        }
322    }
323
324    pub fn current_session_size_bytes(&self) -> Option<u64> {
325        let path = match self.tracker.as_ref() {
326            Some(SessionTracker::Codex(codex)) => codex.session_file.as_ref(),
327            Some(SessionTracker::Claude(claude)) => claude.session_file.as_ref(),
328            None => None,
329        }?;
330        fs::metadata(path).ok().map(|metadata| metadata.len())
331    }
332
333    pub fn codex_quality_signals(&self) -> Option<CodexQualitySignals> {
334        match self.tracker.as_ref() {
335            Some(SessionTracker::Codex(codex)) => Some(codex.quality.clone()),
336            _ => None,
337        }
338    }
339
340    pub fn take_completion_event(&mut self) -> bool {
341        let observed = self.completion_observed;
342        self.completion_observed = false;
343        observed
344    }
345
346    fn poll_tracker(&mut self) -> Result<TrackerState> {
347        let current_state = self.state;
348        let Some(tracker) = self.tracker.as_mut() else {
349            return Ok(TrackerState::Unknown);
350        };
351
352        match tracker {
353            SessionTracker::Codex(codex) => {
354                if codex.session_file.is_none() {
355                    codex.session_file = codex::discover_codex_session_file(
356                        &codex.sessions_root,
357                        &codex.cwd,
358                        codex.session_id.as_deref(),
359                    )?;
360                    if let Some(session_file) = codex.session_file.as_ref() {
361                        codex.offset = current_file_len(session_file)?;
362                    }
363                    codex.quality = CodexQualitySignals::default();
364                    codex.last_response_hash = None;
365                    return Ok(TrackerState::Unknown);
366                }
367
368                let Some(session_file) = codex.session_file.clone() else {
369                    return Ok(TrackerState::Unknown);
370                };
371
372                if !session_file.exists() {
373                    codex.session_file = None;
374                    codex.offset = 0;
375                    codex.quality = CodexQualitySignals::default();
376                    codex.last_response_hash = None;
377                    return Ok(TrackerState::Unknown);
378                }
379
380                let state = codex::poll_codex_session_file(
381                    &session_file,
382                    &mut codex.offset,
383                    &mut codex.quality,
384                    &mut codex.last_response_hash,
385                )?;
386
387                // When idle with no new events, check if a newer session file
388                // appeared (agent started a new task). Re-discover so the
389                // tracker picks up the latest session.
390                if state == TrackerState::Unknown
391                    && matches!(current_state, WatcherState::Idle | WatcherState::Ready)
392                {
393                    if let Some(latest) = codex::discover_codex_session_file(
394                        &codex.sessions_root,
395                        &codex.cwd,
396                        codex.session_id.as_deref(),
397                    )? {
398                        if latest != session_file {
399                            codex.session_file = Some(latest.clone());
400                            codex.offset = 0;
401                            codex.quality = CodexQualitySignals::default();
402                            codex.last_response_hash = None;
403                            return codex::poll_codex_session_file(
404                                &latest,
405                                &mut codex.offset,
406                                &mut codex.quality,
407                                &mut codex.last_response_hash,
408                            );
409                        }
410                    }
411                }
412
413                Ok(state)
414            }
415            SessionTracker::Claude(claude) => claude::poll_claude_session(claude),
416        }
417    }
418
419    fn tracker_kind(&self) -> TrackerKind {
420        match self.tracker {
421            Some(SessionTracker::Codex(_)) => TrackerKind::Codex,
422            Some(SessionTracker::Claude(_)) => TrackerKind::Claude,
423            None => TrackerKind::None,
424        }
425    }
426}
427
428// --- Shared utility functions used by submodules ---
429
430pub(super) fn simple_hash(s: &str) -> u64 {
431    // FNV-1a style hash, good enough for change detection
432    let mut hash: u64 = 0xcbf29ce484222325;
433    for byte in s.bytes() {
434        hash ^= byte as u64;
435        hash = hash.wrapping_mul(0x100000001b3);
436    }
437    hash
438}
439
440fn default_codex_sessions_root() -> PathBuf {
441    std::env::var_os("HOME")
442        .map(PathBuf::from)
443        .unwrap_or_else(|| PathBuf::from("/"))
444        .join(".codex")
445        .join("sessions")
446}
447
448fn default_claude_projects_root() -> PathBuf {
449    std::env::var_os("HOME")
450        .map(PathBuf::from)
451        .unwrap_or_else(|| PathBuf::from("/"))
452        .join(".claude")
453        .join("projects")
454}
455
456pub(super) fn current_file_len(path: &Path) -> Result<u64> {
457    Ok(fs::metadata(path)?.len())
458}
459
460pub(super) fn session_file_id(path: Option<&PathBuf>) -> Option<String> {
461    path.and_then(|path| {
462        path.file_stem()
463            .and_then(|stem| stem.to_str())
464            .map(|stem| stem.to_string())
465    })
466}
467
468pub(super) fn read_dir_paths(dir: &Path) -> Result<Vec<PathBuf>> {
469    let mut paths = Vec::new();
470    for entry in fs::read_dir(dir)? {
471        let entry = entry?;
472        paths.push(entry.path());
473    }
474    Ok(paths)
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use serial_test::serial;
481
482    #[test]
483    fn simple_hash_differs_for_different_input() {
484        assert_ne!(simple_hash("hello"), simple_hash("world"));
485        assert_eq!(simple_hash("same"), simple_hash("same"));
486    }
487
488    #[test]
489    fn new_watcher_starts_idle() {
490        let w = SessionWatcher::new("%0", "eng-1-1", 300, None);
491        assert_eq!(w.state, WatcherState::Idle);
492    }
493
494    #[test]
495    fn activate_sets_active() {
496        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
497        w.activate();
498        assert_eq!(w.state, WatcherState::Active);
499    }
500
501    #[test]
502    fn deactivate_sets_idle() {
503        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
504        w.activate();
505        w.deactivate();
506        assert_eq!(w.state, WatcherState::Idle);
507    }
508
509    #[test]
510    fn last_lines_returns_tail() {
511        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
512        w.last_capture = "line1\nline2\nline3\nline4\nline5".to_string();
513        assert_eq!(w.last_lines(3), "line3\nline4\nline5");
514        assert_eq!(w.last_lines(10), "line1\nline2\nline3\nline4\nline5");
515    }
516
517    #[test]
518    #[serial]
519    #[cfg_attr(not(feature = "integration"), ignore)]
520    fn idle_poll_consumes_non_empty_capture() {
521        let session = "batty-test-watcher-idle-poll";
522        let _ = crate::tmux::kill_session(session);
523
524        crate::tmux::create_session(
525            session,
526            "bash",
527            &[
528                "-lc".to_string(),
529                "printf 'watcher-idle-poll\\n'; sleep 3".to_string(),
530            ],
531            "/tmp",
532        )
533        .unwrap();
534        std::thread::sleep(std::time::Duration::from_millis(300));
535
536        let pane_id = crate::tmux::pane_id(session).unwrap();
537        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
538
539        assert_eq!(watcher.poll().unwrap(), WatcherState::Idle);
540        assert!(!watcher.last_output().is_empty());
541
542        crate::tmux::kill_session(session).unwrap();
543    }
544
545    #[test]
546    #[serial]
547    #[cfg_attr(not(feature = "integration"), ignore)]
548    fn active_poll_updates_state_when_capture_changes() {
549        let session = "batty-test-watcher-active-change";
550        let _ = crate::tmux::kill_session(session);
551
552        crate::tmux::create_session(
553            session,
554            "bash",
555            &[
556                "-lc".to_string(),
557                "printf 'watcher-active-change\\n'; sleep 3".to_string(),
558            ],
559            "/tmp",
560        )
561        .unwrap();
562        std::thread::sleep(std::time::Duration::from_millis(300));
563
564        let pane_id = crate::tmux::pane_id(session).unwrap();
565        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
566        watcher.state = WatcherState::Active;
567
568        assert_eq!(watcher.poll().unwrap(), WatcherState::Active);
569        assert_ne!(watcher.last_output_hash, 0);
570        assert!(!watcher.last_output().is_empty());
571
572        crate::tmux::kill_session(session).unwrap();
573    }
574
575    #[test]
576    #[serial]
577    #[cfg_attr(not(feature = "integration"), ignore)]
578    fn idle_poll_detects_context_exhaustion() {
579        let session = format!("batty-test-watcher-context-exhaust-{}", std::process::id());
580        let _ = crate::tmux::kill_session(&session);
581
582        crate::tmux::create_session(&session, "cat", &[], "/tmp").unwrap();
583        let pane_id = crate::tmux::pane_id(&session).unwrap();
584        std::thread::sleep(std::time::Duration::from_millis(100));
585        crate::tmux::send_keys(&pane_id, "Conversation is too long to continue.", true).unwrap();
586        std::thread::sleep(std::time::Duration::from_millis(150));
587
588        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
589
590        assert_eq!(watcher.poll().unwrap(), WatcherState::ContextExhausted);
591        assert!(watcher.last_output().contains("Conversation is too long"));
592
593        crate::tmux::kill_session(&session).unwrap();
594    }
595
596    #[test]
597    #[serial]
598    #[cfg_attr(not(feature = "integration"), ignore)]
599    fn active_poll_keeps_previous_state_when_capture_is_unchanged() {
600        let session = "batty-test-watcher-unchanged";
601        let _ = crate::tmux::kill_session(session);
602
603        crate::tmux::create_session(
604            session,
605            "bash",
606            &[
607                "-lc".to_string(),
608                "printf 'watcher-unchanged\\n'; sleep 3".to_string(),
609            ],
610            "/tmp",
611        )
612        .unwrap();
613        std::thread::sleep(std::time::Duration::from_millis(300));
614
615        let pane_id = crate::tmux::pane_id(session).unwrap();
616        let capture = crate::tmux::capture_pane(&pane_id).unwrap();
617        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 0, None);
618        watcher.state = WatcherState::Active;
619        watcher.last_capture = capture.clone();
620        watcher.last_output_hash = simple_hash(&capture);
621
622        assert_eq!(watcher.poll().unwrap(), WatcherState::Active);
623
624        crate::tmux::kill_session(session).unwrap();
625    }
626
627    #[test]
628    fn missing_pane_poll_reports_pane_dead() {
629        let mut watcher = SessionWatcher::new("%999999", "eng-1-1", 300, None);
630        assert_eq!(watcher.poll().unwrap(), WatcherState::PaneDead);
631    }
632
633    #[test]
634    #[serial]
635    #[cfg_attr(not(feature = "integration"), ignore)]
636    fn pane_dead_poll_reports_pane_dead() {
637        let session = format!("batty-test-watcher-pane-dead-{}", std::process::id());
638        let _ = crate::tmux::kill_session(&session);
639
640        crate::tmux::create_session(&session, "bash", &[], "/tmp").unwrap();
641        crate::tmux::create_window(&session, "keeper", "sleep", &["30".to_string()], "/tmp")
642            .unwrap();
643        let pane_id = crate::tmux::pane_id(&session).unwrap();
644        std::process::Command::new("tmux")
645            .args(["set-option", "-p", "-t", &pane_id, "remain-on-exit", "on"])
646            .output()
647            .unwrap();
648
649        crate::tmux::send_keys(&pane_id, "exit", true).unwrap();
650        for _ in 0..5 {
651            if crate::tmux::pane_dead(&pane_id).unwrap_or(false) {
652                break;
653            }
654            std::thread::sleep(std::time::Duration::from_millis(200));
655        }
656        assert!(crate::tmux::pane_dead(&pane_id).unwrap());
657
658        let mut watcher = SessionWatcher::new(&pane_id, "eng-1-1", 300, None);
659        assert_eq!(watcher.poll().unwrap(), WatcherState::PaneDead);
660
661        crate::tmux::kill_session(&session).unwrap();
662    }
663
664    #[test]
665    fn watcher_exposes_codex_quality_signals() {
666        let mut watcher = SessionWatcher::new("%0", "eng-1-1", 300, None);
667        watcher.tracker = Some(SessionTracker::Codex(CodexSessionTracker {
668            sessions_root: PathBuf::from("/tmp"),
669            cwd: PathBuf::from("/repo"),
670            session_id: None,
671            session_file: None,
672            offset: 0,
673            quality: CodexQualitySignals {
674                last_response_chars: Some(12),
675                shortening_streak: 2,
676                repeated_output_streak: 3,
677                shrinking_responses: true,
678                repeated_identical_outputs: true,
679                tool_failure_message: Some("exec_command failed".to_string()),
680            },
681            last_response_hash: Some(simple_hash("same response")),
682        }));
683
684        assert_eq!(
685            watcher.codex_quality_signals(),
686            Some(CodexQualitySignals {
687                last_response_chars: Some(12),
688                shortening_streak: 2,
689                repeated_output_streak: 3,
690                shrinking_responses: true,
691                repeated_identical_outputs: true,
692                tool_failure_message: Some("exec_command failed".to_string()),
693            })
694        );
695    }
696
697    #[test]
698    fn watcher_set_session_id_rebinds_codex_tracker() {
699        let mut watcher = SessionWatcher::new("%0", "eng-1", 300, None);
700        watcher.tracker = Some(SessionTracker::Codex(CodexSessionTracker {
701            sessions_root: PathBuf::from("/tmp"),
702            cwd: PathBuf::from("/repo"),
703            session_id: Some("old-session".to_string()),
704            session_file: Some(PathBuf::from("/tmp/old-session.jsonl")),
705            offset: 42,
706            quality: CodexQualitySignals {
707                last_response_chars: Some(12),
708                shortening_streak: 1,
709                repeated_output_streak: 2,
710                shrinking_responses: true,
711                repeated_identical_outputs: true,
712                tool_failure_message: Some("failure".to_string()),
713            },
714            last_response_hash: Some(simple_hash("old")),
715        }));
716
717        watcher.set_session_id(Some("new-session".to_string()));
718
719        let Some(SessionTracker::Codex(codex)) = watcher.tracker.as_ref() else {
720            panic!("expected codex tracker");
721        };
722        assert_eq!(codex.session_id.as_deref(), Some("new-session"));
723        assert!(codex.session_file.is_none());
724        assert_eq!(codex.offset, 0);
725        assert_eq!(codex.quality, CodexQualitySignals::default());
726        assert!(codex.last_response_hash.is_none());
727    }
728
729    #[test]
730    fn watcher_exposes_tracker_session_id_from_bound_file() {
731        let mut watcher = SessionWatcher::new("%0", "architect", 300, None);
732        watcher.tracker = Some(SessionTracker::Claude(ClaudeSessionTracker {
733            projects_root: PathBuf::from("/tmp"),
734            cwd: PathBuf::from("/repo"),
735            session_id: Some("1e94dc68-6004-402a-9a7b-1bfca674806e".to_string()),
736            session_file: Some(PathBuf::from(
737                "/tmp/-Users-zedmor-project/1e94dc68-6004-402a-9a7b-1bfca674806e.jsonl",
738            )),
739            offset: 0,
740            last_state: TrackerState::Unknown,
741        }));
742
743        assert_eq!(
744            watcher.current_session_id().as_deref(),
745            Some("1e94dc68-6004-402a-9a7b-1bfca674806e")
746        );
747    }
748
749    fn production_unwrap_expect_count(source: &str) -> usize {
750        let prod = if let Some(pos) = source.find("\n#[cfg(test)]\nmod tests") {
751            &source[..pos]
752        } else {
753            source
754        };
755        prod.lines()
756            .filter(|line| {
757                let trimmed = line.trim();
758                !trimmed.starts_with("#[cfg(test)]")
759                    && (trimmed.contains(".unwrap(") || trimmed.contains(".expect("))
760            })
761            .count()
762    }
763
764    #[test]
765    fn production_watcher_has_no_unwrap_or_expect_calls() {
766        // Check all submodule files as well as the main module.
767        let mod_src = include_str!("mod.rs");
768        assert_eq!(
769            production_unwrap_expect_count(mod_src),
770            0,
771            "production watcher/mod.rs should avoid unwrap/expect"
772        );
773        let screen_src = include_str!("screen.rs");
774        assert_eq!(
775            production_unwrap_expect_count(screen_src),
776            0,
777            "production watcher/screen.rs should avoid unwrap/expect"
778        );
779        let codex_src = include_str!("codex.rs");
780        assert_eq!(
781            production_unwrap_expect_count(codex_src),
782            0,
783            "production watcher/codex.rs should avoid unwrap/expect"
784        );
785        let claude_src = include_str!("claude.rs");
786        assert_eq!(
787            production_unwrap_expect_count(claude_src),
788            0,
789            "production watcher/claude.rs should avoid unwrap/expect"
790        );
791    }
792
793    #[test]
794    fn secs_since_last_output_change_starts_at_zero() {
795        let w = SessionWatcher::new("%0", "eng-1-1", 300, None);
796        // Freshly created watcher — elapsed should be near zero.
797        assert!(w.secs_since_last_output_change() < 2);
798    }
799
800    #[test]
801    fn activate_resets_last_output_changed_at() {
802        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
803        // Simulate time passing by backdating the field.
804        w.last_output_changed_at = Instant::now() - std::time::Duration::from_secs(600);
805        assert!(w.secs_since_last_output_change() >= 600);
806
807        w.activate();
808        assert!(w.secs_since_last_output_change() < 2);
809    }
810
811    // --- Readiness gate tests ---
812
813    #[test]
814    fn new_watcher_is_not_ready_for_delivery() {
815        let w = SessionWatcher::new("%0", "eng-1-1", 300, None);
816        assert!(!w.is_ready_for_delivery());
817        assert_eq!(w.state, WatcherState::Idle);
818    }
819
820    #[test]
821    fn confirm_ready_sets_ready_state() {
822        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
823        w.confirm_ready();
824        assert!(w.is_ready_for_delivery());
825        assert_eq!(w.state, WatcherState::Ready);
826    }
827
828    #[test]
829    fn activate_sets_ready_confirmed() {
830        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
831        assert!(!w.is_ready_for_delivery());
832        w.activate();
833        assert!(w.is_ready_for_delivery());
834        assert_eq!(w.state, WatcherState::Active);
835    }
836
837    #[test]
838    fn deactivate_preserves_readiness() {
839        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
840        w.activate();
841        assert!(w.is_ready_for_delivery());
842        w.deactivate();
843        assert!(w.is_ready_for_delivery());
844        assert_eq!(w.state, WatcherState::Idle);
845    }
846
847    #[test]
848    fn confirm_ready_on_already_idle_with_completion_does_not_override() {
849        let mut w = SessionWatcher::new("%0", "eng-1-1", 300, None);
850        w.activate();
851        w.deactivate();
852        w.confirm_ready();
853        assert_eq!(w.state, WatcherState::Idle);
854        assert!(w.is_ready_for_delivery());
855    }
856}