1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum WatcherState {
28 Active,
30 Ready,
33 Idle,
35 PaneDead,
37 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 last_output_changed_at: Instant,
50 tracker: Option<SessionTracker>,
51 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 pub fn poll(&mut self) -> Result<WatcherState> {
134 if !tmux::pane_exists(&self.pane_id) {
136 self.state = WatcherState::PaneDead;
137 return Ok(self.state);
138 }
139
140 if tmux::pane_dead(&self.pane_id).unwrap_or(false) {
142 self.state = WatcherState::PaneDead;
143 return Ok(self.state);
144 }
145
146 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 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 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 pub fn is_ready_for_delivery(&self) -> bool {
226 self.ready_confirmed
227 }
228
229 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 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 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 pub fn deactivate(&mut self) {
295 self.state = WatcherState::Idle;
296 self.completion_observed = false;
297 }
298
299 pub fn secs_since_last_output_change(&self) -> u64 {
301 self.last_output_changed_at.elapsed().as_secs()
302 }
303
304 pub fn last_output(&self) -> &str {
306 &self.last_capture
307 }
308
309 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 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
428pub(super) fn simple_hash(s: &str) -> u64 {
431 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 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 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 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 #[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}