1use anyhow::Result;
8use serde_json::Value;
9use std::fs::{self, File};
10use std::io::{BufRead, BufReader, Seek, SeekFrom};
11use std::path::{Path, PathBuf};
12
13use super::classifier::AgentType;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum TrackerVerdict {
22 Working,
24 Idle,
26 Unknown,
28}
29
30pub struct SessionTracker {
36 agent_type: AgentType,
37 root: PathBuf,
39 cwd: PathBuf,
41 session_id: Option<String>,
43 session_file: Option<PathBuf>,
45 offset: u64,
47 last_verdict: TrackerVerdict,
49}
50
51impl SessionTracker {
52 pub fn new(
57 agent_type: AgentType,
58 root: PathBuf,
59 cwd: PathBuf,
60 session_id: Option<String>,
61 ) -> Self {
62 Self {
63 agent_type,
64 root,
65 cwd,
66 session_id,
67 session_file: None,
68 offset: 0,
69 last_verdict: TrackerVerdict::Unknown,
70 }
71 }
72
73 pub fn poll(&mut self) -> Result<TrackerVerdict> {
79 if self.session_file.is_none() {
81 self.session_file = discover_session_file(
82 self.agent_type,
83 &self.root,
84 &self.cwd,
85 self.session_id.as_deref(),
86 )?;
87 if let Some(ref path) = self.session_file {
88 self.offset = file_len(path)?;
90 self.last_verdict = TrackerVerdict::Unknown;
91 }
92 return Ok(self.last_verdict);
93 }
94
95 self.maybe_rebind()?;
97
98 let Some(ref path) = self.session_file else {
99 return Ok(TrackerVerdict::Unknown);
100 };
101
102 if !path.exists() {
103 self.session_file = None;
104 self.offset = 0;
105 self.last_verdict = TrackerVerdict::Unknown;
106 return Ok(TrackerVerdict::Unknown);
107 }
108
109 let path = path.clone();
110 let (verdict, new_offset) = parse_session_tail(self.agent_type, &path, self.offset)?;
111 self.offset = new_offset;
112 if verdict != TrackerVerdict::Unknown {
113 self.last_verdict = verdict;
114 }
115 Ok(self.last_verdict)
116 }
117
118 pub fn session_file(&self) -> Option<&Path> {
120 self.session_file.as_deref()
121 }
122
123 fn maybe_rebind(&mut self) -> Result<()> {
124 let Some(ref current) = self.session_file else {
125 return Ok(());
126 };
127
128 let Some(newest) = discover_session_file(self.agent_type, &self.root, &self.cwd, None)?
129 else {
130 return Ok(());
131 };
132
133 if newest == *current {
134 return Ok(());
135 }
136
137 let current_modified = file_modified(current);
138 let newest_modified = file_modified(&newest);
139
140 if newest_modified <= current_modified {
141 return Ok(());
142 }
143
144 self.session_file = Some(newest.clone());
145 self.session_id = file_stem_id(&newest);
146 self.offset = file_len(&newest)?;
147 self.last_verdict = TrackerVerdict::Unknown;
148 Ok(())
149 }
150}
151
152fn discover_session_file(
157 agent_type: AgentType,
158 root: &Path,
159 cwd: &Path,
160 session_id: Option<&str>,
161) -> Result<Option<PathBuf>> {
162 match agent_type {
163 AgentType::Claude => discover_claude_session(root, cwd, session_id),
164 AgentType::Codex => discover_codex_session(root, cwd, session_id),
165 _ => Ok(None), }
167}
168
169fn discover_claude_session(
171 projects_root: &Path,
172 cwd: &Path,
173 session_id: Option<&str>,
174) -> Result<Option<PathBuf>> {
175 if !projects_root.exists() {
176 return Ok(None);
177 }
178
179 let preferred_dir = projects_root.join(cwd.to_string_lossy().replace('/', "-"));
180
181 if let Some(sid) = session_id {
183 let exact = preferred_dir.join(format!("{sid}.jsonl"));
184 if exact.is_file() {
185 return Ok(Some(exact));
186 }
187 return Ok(None);
188 }
189
190 if preferred_dir.is_dir() {
192 if let Some(path) = newest_jsonl_in(&preferred_dir)? {
193 return Ok(Some(path));
194 }
195 }
196
197 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
199 for project_dir in read_dir_sorted(projects_root)? {
200 if !project_dir.is_dir() {
201 continue;
202 }
203 for entry in read_dir_sorted(&project_dir)? {
204 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
205 continue;
206 }
207 if claude_session_cwd(&entry)?.as_deref() != Some(cwd.as_os_str()) {
208 continue;
209 }
210 let modified = file_modified(&entry);
211 match &newest {
212 Some((t, _)) if modified <= *t => {}
213 _ => newest = Some((modified, entry)),
214 }
215 }
216 }
217
218 Ok(newest.map(|(_, p)| p))
219}
220
221fn discover_codex_session(
223 sessions_root: &Path,
224 cwd: &Path,
225 session_id: Option<&str>,
226) -> Result<Option<PathBuf>> {
227 if !sessions_root.exists() {
228 return Ok(None);
229 }
230
231 if let Some(sid) = session_id {
233 for year in read_dir_sorted(sessions_root)? {
234 for month in read_dir_sorted(&year)? {
235 for day in read_dir_sorted(&month)? {
236 let entry = day.join(format!("{sid}.jsonl"));
237 if entry.is_file()
238 && codex_session_cwd(&entry)?.as_deref() == Some(cwd.as_os_str())
239 {
240 return Ok(Some(entry));
241 }
242 }
243 }
244 }
245 return Ok(None);
246 }
247
248 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
249 for year in read_dir_sorted(sessions_root)? {
250 for month in read_dir_sorted(&year)? {
251 for day in read_dir_sorted(&month)? {
252 for entry in read_dir_sorted(&day)? {
253 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
254 continue;
255 }
256 if codex_session_cwd(&entry)?.as_deref() != Some(cwd.as_os_str()) {
257 continue;
258 }
259 let modified = file_modified(&entry);
260 match &newest {
261 Some((t, _)) if modified <= *t => {}
262 _ => newest = Some((modified, entry)),
263 }
264 }
265 }
266 }
267 }
268
269 Ok(newest.map(|(_, p)| p))
270}
271
272fn parse_session_tail(
277 agent_type: AgentType,
278 path: &Path,
279 start_offset: u64,
280) -> Result<(TrackerVerdict, u64)> {
281 let file_len = fs::metadata(path)?.len();
282 let mut offset = if file_len < start_offset {
283 0
284 } else {
285 start_offset
286 };
287
288 let file = File::open(path)?;
289 let mut reader = BufReader::new(file);
290 reader.seek(SeekFrom::Start(offset))?;
291
292 let mut verdict = TrackerVerdict::Unknown;
293 loop {
294 let line_start = reader.stream_position()?;
295 let mut line = String::new();
296 let bytes = reader.read_line(&mut line)?;
297 if bytes == 0 {
298 break;
299 }
300 if !line.ends_with('\n') {
302 reader.seek(SeekFrom::Start(line_start))?;
303 break;
304 }
305
306 if let Ok(entry) = serde_json::from_str::<Value>(&line) {
307 let v = match agent_type {
308 AgentType::Claude => classify_claude_entry(&entry),
309 AgentType::Codex => classify_codex_entry(&entry),
310 _ => TrackerVerdict::Unknown,
311 };
312 if v != TrackerVerdict::Unknown {
313 verdict = v;
314 }
315 }
316
317 offset = reader.stream_position()?;
318 }
319
320 Ok((verdict, offset))
321}
322
323fn classify_claude_entry(entry: &Value) -> TrackerVerdict {
331 match entry.get("type").and_then(Value::as_str) {
332 Some("assistant") => {
333 let stop_reason = entry
334 .get("message")
335 .and_then(|m| m.get("stop_reason"))
336 .and_then(Value::as_str);
337 match stop_reason {
338 Some("tool_use") => TrackerVerdict::Working,
339 Some("end_turn") => TrackerVerdict::Idle,
340 _ => TrackerVerdict::Unknown,
341 }
342 }
343 Some("progress") => TrackerVerdict::Working,
344 Some("user") => {
345 if entry
346 .get("toolUseResult")
347 .and_then(Value::as_object)
348 .is_some()
349 {
350 return TrackerVerdict::Working;
351 }
352 if let Some(content) = entry.get("message").and_then(|m| m.get("content")) {
353 if let Some(text) = content.as_str() {
354 if text == "[Request interrupted by user]" {
355 return TrackerVerdict::Idle;
356 }
357 return TrackerVerdict::Working;
358 }
359 if let Some(items) = content.as_array() {
360 for item in items {
361 if item.get("tool_use_id").is_some() {
362 return TrackerVerdict::Working;
363 }
364 if item.get("type").and_then(Value::as_str) == Some("text")
365 && item.get("text").and_then(Value::as_str)
366 == Some("[Request interrupted by user]")
367 {
368 return TrackerVerdict::Idle;
369 }
370 }
371 return TrackerVerdict::Working;
372 }
373 }
374 TrackerVerdict::Unknown
375 }
376 _ => TrackerVerdict::Unknown,
377 }
378}
379
380fn classify_codex_entry(entry: &Value) -> TrackerVerdict {
385 if entry.get("type").and_then(Value::as_str) == Some("event_msg")
386 && entry
387 .get("payload")
388 .and_then(|p| p.get("type"))
389 .and_then(Value::as_str)
390 == Some("task_complete")
391 {
392 return TrackerVerdict::Idle;
393 }
394 TrackerVerdict::Working
396}
397
398use super::classifier::ScreenVerdict;
403
404pub fn merge_verdicts(
411 agent_type: AgentType,
412 screen: ScreenVerdict,
413 tracker: TrackerVerdict,
414) -> ScreenVerdict {
415 match agent_type {
416 AgentType::Claude => {
417 match screen {
419 ScreenVerdict::AgentIdle
420 | ScreenVerdict::AgentWorking
421 | ScreenVerdict::ContextExhausted => screen,
422 ScreenVerdict::Unknown => match tracker {
423 TrackerVerdict::Working => ScreenVerdict::AgentWorking,
424 TrackerVerdict::Idle => ScreenVerdict::AgentIdle,
425 TrackerVerdict::Unknown => ScreenVerdict::Unknown,
426 },
427 }
428 }
429 AgentType::Codex => {
430 match tracker {
432 TrackerVerdict::Idle => ScreenVerdict::AgentIdle, TrackerVerdict::Working => {
434 match screen {
437 ScreenVerdict::ContextExhausted => ScreenVerdict::ContextExhausted,
438 _ => ScreenVerdict::AgentWorking,
439 }
440 }
441 TrackerVerdict::Unknown => screen, }
443 }
444 _ => screen,
446 }
447}
448
449fn claude_session_cwd(path: &Path) -> Result<Option<std::ffi::OsString>> {
454 let file = File::open(path)?;
455 let reader = BufReader::new(file);
456 for line in reader.lines() {
457 let line = line?;
458 if line.trim().is_empty() {
459 continue;
460 }
461 let Ok(entry) = serde_json::from_str::<Value>(&line) else {
462 continue;
463 };
464 if let Some(cwd) = entry.get("cwd").and_then(Value::as_str) {
465 return Ok(Some(std::ffi::OsString::from(cwd)));
466 }
467 }
468 Ok(None)
469}
470
471fn codex_session_cwd(path: &Path) -> Result<Option<std::ffi::OsString>> {
472 let file = File::open(path)?;
473 let reader = BufReader::new(file);
474 for line in reader.lines() {
475 let line = line?;
476 if line.trim().is_empty() {
477 continue;
478 }
479 let Ok(entry) = serde_json::from_str::<Value>(&line) else {
480 continue;
481 };
482 if entry.get("type").and_then(Value::as_str) != Some("session_meta") {
483 continue;
484 }
485 return Ok(entry
486 .get("payload")
487 .and_then(|p| p.get("cwd"))
488 .and_then(Value::as_str)
489 .map(std::ffi::OsString::from));
490 }
491 Ok(None)
492}
493
494fn newest_jsonl_in(dir: &Path) -> Result<Option<PathBuf>> {
495 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
496 for entry in read_dir_sorted(dir)? {
497 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
498 continue;
499 }
500 let modified = file_modified(&entry);
501 match &newest {
502 Some((t, _)) if modified <= *t => {}
503 _ => newest = Some((modified, entry)),
504 }
505 }
506 Ok(newest.map(|(_, p)| p))
507}
508
509fn read_dir_sorted(dir: &Path) -> Result<Vec<PathBuf>> {
510 if !dir.is_dir() {
511 return Ok(Vec::new());
512 }
513 let mut entries: Vec<PathBuf> = fs::read_dir(dir)?
514 .filter_map(|e| e.ok())
515 .map(|e| e.path())
516 .collect();
517 entries.sort();
518 Ok(entries)
519}
520
521fn file_len(path: &Path) -> Result<u64> {
522 Ok(fs::metadata(path)?.len())
523}
524
525fn file_modified(path: &Path) -> std::time::SystemTime {
526 fs::metadata(path)
527 .and_then(|m| m.modified())
528 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
529}
530
531fn file_stem_id(path: &Path) -> Option<String> {
532 path.file_stem()
533 .and_then(|s| s.to_str())
534 .map(|s| s.to_string())
535}
536
537#[cfg(test)]
542mod tests {
543 use super::*;
544 use std::io::Write;
545
546 #[test]
549 fn claude_tool_use_is_working() {
550 let entry: Value =
551 serde_json::from_str(r#"{"type":"assistant","message":{"stop_reason":"tool_use"}}"#)
552 .unwrap();
553 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
554 }
555
556 #[test]
557 fn claude_end_turn_is_idle() {
558 let entry: Value =
559 serde_json::from_str(r#"{"type":"assistant","message":{"stop_reason":"end_turn"}}"#)
560 .unwrap();
561 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Idle);
562 }
563
564 #[test]
565 fn claude_progress_is_working() {
566 let entry: Value = serde_json::from_str(r#"{"type":"progress","data":"chunk"}"#).unwrap();
567 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
568 }
569
570 #[test]
571 fn claude_tool_result_is_working() {
572 let entry: Value =
573 serde_json::from_str(r#"{"type":"user","toolUseResult":{"stdout":"ok"}}"#).unwrap();
574 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
575 }
576
577 #[test]
578 fn claude_user_text_is_working() {
579 let entry: Value =
580 serde_json::from_str(r#"{"type":"user","message":{"content":"do something"}}"#)
581 .unwrap();
582 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
583 }
584
585 #[test]
586 fn claude_interrupt_is_idle() {
587 let entry: Value = serde_json::from_str(
588 r#"{"type":"user","message":{"content":"[Request interrupted by user]"}}"#,
589 )
590 .unwrap();
591 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Idle);
592 }
593
594 #[test]
595 fn claude_interrupt_in_array_is_idle() {
596 let entry: Value = serde_json::from_str(
597 r#"{"type":"user","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#,
598 )
599 .unwrap();
600 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Idle);
601 }
602
603 #[test]
604 fn claude_unknown_type() {
605 let entry: Value = serde_json::from_str(r#"{"type":"system","message":"init"}"#).unwrap();
606 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Unknown);
607 }
608
609 #[test]
612 fn codex_task_complete_is_idle() {
613 let entry: Value =
614 serde_json::from_str(r#"{"type":"event_msg","payload":{"type":"task_complete"}}"#)
615 .unwrap();
616 assert_eq!(classify_codex_entry(&entry), TrackerVerdict::Idle);
617 }
618
619 #[test]
620 fn codex_other_event_is_working() {
621 let entry: Value = serde_json::from_str(
622 r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[]}}"#,
623 )
624 .unwrap();
625 assert_eq!(classify_codex_entry(&entry), TrackerVerdict::Working);
626 }
627
628 #[test]
631 fn claude_screen_takes_priority() {
632 assert_eq!(
634 merge_verdicts(
635 AgentType::Claude,
636 ScreenVerdict::AgentIdle,
637 TrackerVerdict::Working
638 ),
639 ScreenVerdict::AgentIdle,
640 );
641 assert_eq!(
643 merge_verdicts(
644 AgentType::Claude,
645 ScreenVerdict::AgentWorking,
646 TrackerVerdict::Idle
647 ),
648 ScreenVerdict::AgentWorking,
649 );
650 }
651
652 #[test]
653 fn claude_tracker_fills_unknown_screen() {
654 assert_eq!(
655 merge_verdicts(
656 AgentType::Claude,
657 ScreenVerdict::Unknown,
658 TrackerVerdict::Working
659 ),
660 ScreenVerdict::AgentWorking,
661 );
662 assert_eq!(
663 merge_verdicts(
664 AgentType::Claude,
665 ScreenVerdict::Unknown,
666 TrackerVerdict::Idle
667 ),
668 ScreenVerdict::AgentIdle,
669 );
670 }
671
672 #[test]
673 fn codex_tracker_takes_priority() {
674 assert_eq!(
676 merge_verdicts(
677 AgentType::Codex,
678 ScreenVerdict::Unknown,
679 TrackerVerdict::Idle
680 ),
681 ScreenVerdict::AgentIdle,
682 );
683 assert_eq!(
685 merge_verdicts(
686 AgentType::Codex,
687 ScreenVerdict::AgentIdle,
688 TrackerVerdict::Working
689 ),
690 ScreenVerdict::AgentWorking,
691 );
692 }
693
694 #[test]
695 fn codex_context_exhausted_overrides_tracker() {
696 assert_eq!(
697 merge_verdicts(
698 AgentType::Codex,
699 ScreenVerdict::ContextExhausted,
700 TrackerVerdict::Working,
701 ),
702 ScreenVerdict::ContextExhausted,
703 );
704 }
705
706 #[test]
707 fn codex_no_tracker_falls_to_screen() {
708 assert_eq!(
709 merge_verdicts(
710 AgentType::Codex,
711 ScreenVerdict::AgentIdle,
712 TrackerVerdict::Unknown
713 ),
714 ScreenVerdict::AgentIdle,
715 );
716 }
717
718 #[test]
719 fn kiro_ignores_tracker() {
720 assert_eq!(
721 merge_verdicts(
722 AgentType::Kiro,
723 ScreenVerdict::AgentWorking,
724 TrackerVerdict::Idle
725 ),
726 ScreenVerdict::AgentWorking,
727 );
728 }
729
730 #[test]
731 fn generic_ignores_tracker() {
732 assert_eq!(
733 merge_verdicts(
734 AgentType::Generic,
735 ScreenVerdict::Unknown,
736 TrackerVerdict::Working
737 ),
738 ScreenVerdict::Unknown,
739 );
740 }
741
742 #[test]
745 fn discovers_claude_session_in_preferred_dir() {
746 let tmp = tempfile::tempdir().unwrap();
747 let root = tmp.path().join("projects");
748 let cwd = PathBuf::from("/Users/test/myproject");
749 let project_dir = root.join("-Users-test-myproject");
750 fs::create_dir_all(&project_dir).unwrap();
751
752 let session = project_dir.join("abc123.jsonl");
753 fs::write(&session, "{\"cwd\":\"/Users/test/myproject\"}\n").unwrap();
754
755 let found = discover_claude_session(&root, &cwd, None).unwrap();
756 assert_eq!(found.as_deref(), Some(session.as_path()));
757 }
758
759 #[test]
760 fn claude_exact_session_id_lookup() {
761 let tmp = tempfile::tempdir().unwrap();
762 let root = tmp.path().join("projects");
763 let cwd = PathBuf::from("/Users/test/myproject");
764 let project_dir = root.join("-Users-test-myproject");
765 fs::create_dir_all(&project_dir).unwrap();
766
767 let session = project_dir.join("exact-id.jsonl");
768 fs::write(&session, "{}\n").unwrap();
769
770 let found = discover_claude_session(&root, &cwd, Some("exact-id")).unwrap();
771 assert_eq!(found.as_deref(), Some(session.as_path()));
772
773 let missing = discover_claude_session(&root, &cwd, Some("nonexistent")).unwrap();
774 assert!(missing.is_none());
775 }
776
777 #[test]
778 fn claude_nonexistent_root_returns_none() {
779 let found =
780 discover_claude_session(Path::new("/nonexistent"), Path::new("/foo"), None).unwrap();
781 assert!(found.is_none());
782 }
783
784 #[test]
787 fn discovers_codex_session_by_cwd() {
788 let tmp = tempfile::tempdir().unwrap();
789 let root = tmp.path().join("sessions");
790 let day_dir = root.join("2026").join("03").join("23");
791 fs::create_dir_all(&day_dir).unwrap();
792
793 let cwd = PathBuf::from("/Users/test/repo");
794 let session = day_dir.join("sess1.jsonl");
795 fs::write(
796 &session,
797 format!(
798 "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
799 cwd.display()
800 ),
801 )
802 .unwrap();
803
804 let found = discover_codex_session(&root, &cwd, None).unwrap();
805 assert_eq!(found.as_deref(), Some(session.as_path()));
806 }
807
808 #[test]
809 fn codex_exact_session_id_lookup() {
810 let tmp = tempfile::tempdir().unwrap();
811 let root = tmp.path().join("sessions");
812 let day_dir = root.join("2026").join("03").join("23");
813 fs::create_dir_all(&day_dir).unwrap();
814
815 let cwd = PathBuf::from("/Users/test/repo");
816 let session = day_dir.join("my-session.jsonl");
817 fs::write(
818 &session,
819 format!(
820 "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
821 cwd.display()
822 ),
823 )
824 .unwrap();
825
826 let found = discover_codex_session(&root, &cwd, Some("my-session")).unwrap();
827 assert_eq!(found.as_deref(), Some(session.as_path()));
828
829 let missing = discover_codex_session(&root, &cwd, Some("nope")).unwrap();
830 assert!(missing.is_none());
831 }
832
833 #[test]
834 fn codex_nonexistent_root_returns_none() {
835 let found =
836 discover_codex_session(Path::new("/nonexistent"), Path::new("/foo"), None).unwrap();
837 assert!(found.is_none());
838 }
839
840 #[test]
843 fn tracker_binds_at_eof_ignoring_history() {
844 let tmp = tempfile::tempdir().unwrap();
845 let root = tmp.path().join("projects");
846 let cwd = PathBuf::from("/Users/test/proj");
847 let project_dir = root.join("-Users-test-proj");
848 fs::create_dir_all(&project_dir).unwrap();
849
850 let session = project_dir.join("s1.jsonl");
851 fs::write(
852 &session,
853 concat!(
854 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj\",\"message\":{\"content\":\"hi\"}}\n",
855 "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n",
856 ),
857 )
858 .unwrap();
859
860 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
861
862 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
864 assert!(tracker.session_file.is_some());
865 }
866
867 #[test]
868 fn tracker_reports_new_events_after_bind() {
869 let tmp = tempfile::tempdir().unwrap();
870 let root = tmp.path().join("projects");
871 let cwd = PathBuf::from("/Users/test/proj2");
872 let project_dir = root.join("-Users-test-proj2");
873 fs::create_dir_all(&project_dir).unwrap();
874
875 let session = project_dir.join("s2.jsonl");
876 fs::write(
877 &session,
878 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj2\"}\n",
879 )
880 .unwrap();
881
882 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
883 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
884
885 let mut f = fs::OpenOptions::new().append(true).open(&session).unwrap();
887 writeln!(
888 f,
889 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"tool_use\"}}}}"
890 )
891 .unwrap();
892 f.flush().unwrap();
893
894 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Working);
895
896 writeln!(
898 f,
899 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"end_turn\"}}}}"
900 )
901 .unwrap();
902 f.flush().unwrap();
903
904 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
905 }
906
907 #[test]
908 fn tracker_sticky_verdict_on_no_new_events() {
909 let tmp = tempfile::tempdir().unwrap();
910 let root = tmp.path().join("projects");
911 let cwd = PathBuf::from("/Users/test/proj3");
912 let project_dir = root.join("-Users-test-proj3");
913 fs::create_dir_all(&project_dir).unwrap();
914
915 let session = project_dir.join("s3.jsonl");
916 fs::write(
917 &session,
918 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj3\"}\n",
919 )
920 .unwrap();
921
922 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
923 tracker.poll().unwrap(); let mut f = fs::OpenOptions::new().append(true).open(&session).unwrap();
926 writeln!(
927 f,
928 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"end_turn\"}}}}"
929 )
930 .unwrap();
931 f.flush().unwrap();
932
933 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
934 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
936 }
937
938 #[test]
939 fn codex_tracker_detects_task_complete() {
940 let tmp = tempfile::tempdir().unwrap();
941 let root = tmp.path().join("sessions");
942 let day_dir = root.join("2026").join("03").join("23");
943 fs::create_dir_all(&day_dir).unwrap();
944
945 let cwd = PathBuf::from("/Users/test/codex-proj");
946 let session = day_dir.join("cx1.jsonl");
947 fs::write(
948 &session,
949 format!(
950 "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
951 cwd.display()
952 ),
953 )
954 .unwrap();
955
956 let mut tracker = SessionTracker::new(AgentType::Codex, root, cwd, None);
957 tracker.poll().unwrap(); let mut f = fs::OpenOptions::new().append(true).open(&session).unwrap();
960 writeln!(
961 f,
962 "{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"task_complete\"}}}}"
963 )
964 .unwrap();
965 f.flush().unwrap();
966
967 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
968 }
969
970 #[test]
971 fn tracker_handles_deleted_session_file() {
972 let tmp = tempfile::tempdir().unwrap();
973 let root = tmp.path().join("projects");
974 let cwd = PathBuf::from("/Users/test/proj4");
975 let project_dir = root.join("-Users-test-proj4");
976 fs::create_dir_all(&project_dir).unwrap();
977
978 let session = project_dir.join("s4.jsonl");
979 fs::write(
980 &session,
981 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj4\"}\n",
982 )
983 .unwrap();
984
985 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
986 tracker.poll().unwrap(); assert!(tracker.session_file.is_some());
988
989 fs::remove_file(&session).unwrap();
991 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
992 assert!(tracker.session_file.is_none());
993 }
994
995 #[test]
996 fn kiro_tracker_always_unknown() {
997 let tmp = tempfile::tempdir().unwrap();
998 let root = tmp.path().to_path_buf();
999 let cwd = PathBuf::from("/Users/test/kiro");
1000
1001 let mut tracker = SessionTracker::new(AgentType::Kiro, root, cwd, None);
1002 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1004 }
1005
1006 #[test]
1007 fn tracker_rebinds_to_newer_file() {
1008 let tmp = tempfile::tempdir().unwrap();
1009 let root = tmp.path().join("projects");
1010 let cwd = PathBuf::from("/Users/test/proj5");
1011 let project_dir = root.join("-Users-test-proj5");
1012 fs::create_dir_all(&project_dir).unwrap();
1013
1014 let old_session = project_dir.join("old.jsonl");
1015 fs::write(
1016 &old_session,
1017 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj5\"}\n",
1018 )
1019 .unwrap();
1020
1021 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
1022 tracker.poll().unwrap(); assert_eq!(tracker.session_file.as_deref(), Some(old_session.as_path()));
1024
1025 std::thread::sleep(std::time::Duration::from_millis(20));
1027 let new_session = project_dir.join("new.jsonl");
1028 fs::write(
1029 &new_session,
1030 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj5\"}\n",
1031 )
1032 .unwrap();
1033
1034 tracker.poll().unwrap();
1036 assert_eq!(tracker.session_file.as_deref(), Some(new_session.as_path()));
1037 }
1038
1039 #[test]
1042 fn parse_tail_handles_truncated_file() {
1043 let tmp = tempfile::tempdir().unwrap();
1044 let session = tmp.path().join("truncated.jsonl");
1045 fs::write(
1046 &session,
1047 "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"end_turn\"}}\n",
1048 )
1049 .unwrap();
1050
1051 let (verdict, _) = parse_session_tail(AgentType::Claude, &session, 9999).unwrap();
1053 assert_eq!(verdict, TrackerVerdict::Idle);
1054 }
1055
1056 #[test]
1057 fn parse_tail_skips_incomplete_lines() {
1058 let tmp = tempfile::tempdir().unwrap();
1059 let session = tmp.path().join("incomplete.jsonl");
1060 let mut f = File::create(&session).unwrap();
1062 write!(
1063 f,
1064 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"tool_use\"}}}}\n{{\"type\":\"partial"
1065 )
1066 .unwrap();
1067 f.flush().unwrap();
1068
1069 let (verdict, offset) = parse_session_tail(AgentType::Claude, &session, 0).unwrap();
1070 assert_eq!(verdict, TrackerVerdict::Working);
1071 let complete_line = "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n";
1073 assert_eq!(offset, complete_line.len() as u64);
1074 }
1075
1076 #[test]
1079 fn tracker_graceful_no_session_file() {
1080 let tmp = tempfile::tempdir().unwrap();
1081 let root = tmp.path().join("empty_projects");
1083 fs::create_dir_all(&root).unwrap();
1084
1085 let mut tracker =
1086 SessionTracker::new(AgentType::Claude, root, PathBuf::from("/no/match"), None);
1087
1088 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1090 assert!(tracker.session_file.is_none());
1091 }
1092}