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 if self.agent_type == AgentType::Codex {
89 self.session_id = codex_session_resume_id(path)?;
90 }
91 self.offset = file_len(path)?;
93 self.last_verdict = TrackerVerdict::Unknown;
94 }
95 return Ok(self.last_verdict);
96 }
97
98 self.maybe_rebind()?;
100
101 let Some(ref path) = self.session_file else {
102 return Ok(TrackerVerdict::Unknown);
103 };
104
105 if !path.exists() {
106 self.session_file = None;
107 self.offset = 0;
108 self.last_verdict = TrackerVerdict::Unknown;
109 return Ok(TrackerVerdict::Unknown);
110 }
111
112 let path = path.clone();
113 let (verdict, new_offset) = parse_session_tail(self.agent_type, &path, self.offset)?;
114 self.offset = new_offset;
115 if verdict != TrackerVerdict::Unknown {
116 self.last_verdict = verdict;
117 }
118 Ok(self.last_verdict)
119 }
120
121 pub fn session_file(&self) -> Option<&Path> {
123 self.session_file.as_deref()
124 }
125
126 fn maybe_rebind(&mut self) -> Result<()> {
127 let Some(ref current) = self.session_file else {
128 return Ok(());
129 };
130
131 let Some(newest) = discover_session_file(self.agent_type, &self.root, &self.cwd, None)?
132 else {
133 return Ok(());
134 };
135
136 if newest == *current {
137 return Ok(());
138 }
139
140 let current_modified = file_modified(current);
141 let newest_modified = file_modified(&newest);
142
143 if newest_modified <= current_modified {
144 return Ok(());
145 }
146
147 self.session_file = Some(newest.clone());
148 self.session_id = match self.agent_type {
149 AgentType::Codex => codex_session_resume_id(&newest)?,
150 AgentType::Claude => file_stem_id(&newest),
151 _ => file_stem_id(&newest),
152 };
153 self.offset = file_len(&newest)?;
154 self.last_verdict = TrackerVerdict::Unknown;
155 Ok(())
156 }
157}
158
159fn discover_session_file(
164 agent_type: AgentType,
165 root: &Path,
166 cwd: &Path,
167 session_id: Option<&str>,
168) -> Result<Option<PathBuf>> {
169 match agent_type {
170 AgentType::Claude => discover_claude_session(root, cwd, session_id),
171 AgentType::Codex => discover_codex_session(root, cwd, session_id),
172 _ => Ok(None), }
174}
175
176fn discover_claude_session(
178 projects_root: &Path,
179 cwd: &Path,
180 session_id: Option<&str>,
181) -> Result<Option<PathBuf>> {
182 if !projects_root.exists() {
183 return Ok(None);
184 }
185
186 let preferred_dir = projects_root.join(cwd.to_string_lossy().replace('/', "-"));
187
188 if let Some(sid) = session_id {
190 let exact = preferred_dir.join(format!("{sid}.jsonl"));
191 if exact.is_file() {
192 return Ok(Some(exact));
193 }
194 return Ok(None);
195 }
196
197 if preferred_dir.is_dir() {
199 if let Some(path) = newest_jsonl_in(&preferred_dir)? {
200 return Ok(Some(path));
201 }
202 }
203
204 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
206 for project_dir in read_dir_sorted(projects_root)? {
207 if !project_dir.is_dir() {
208 continue;
209 }
210 for entry in read_dir_sorted(&project_dir)? {
211 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
212 continue;
213 }
214 if claude_session_cwd(&entry)?.as_deref() != Some(cwd.as_os_str()) {
215 continue;
216 }
217 let modified = file_modified(&entry);
218 match &newest {
219 Some((t, _)) if modified <= *t => {}
220 _ => newest = Some((modified, entry)),
221 }
222 }
223 }
224
225 Ok(newest.map(|(_, p)| p))
226}
227
228fn discover_codex_session(
230 sessions_root: &Path,
231 cwd: &Path,
232 session_id: Option<&str>,
233) -> Result<Option<PathBuf>> {
234 if !sessions_root.exists() {
235 return Ok(None);
236 }
237
238 if let Some(sid) = session_id {
240 for year in read_dir_sorted(sessions_root)? {
241 for month in read_dir_sorted(&year)? {
242 for day in read_dir_sorted(&month)? {
243 for entry in read_dir_sorted(&day)? {
244 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
245 continue;
246 }
247 let Some(meta) = read_codex_session_meta(&entry)? else {
248 continue;
249 };
250 if meta.cwd.as_deref() != Some(cwd.as_os_str()) {
251 continue;
252 }
253 if file_stem_id(&entry).as_deref() == Some(sid)
254 || meta.id.as_deref() == Some(sid)
255 {
256 return Ok(Some(entry));
257 }
258 }
259 }
260 }
261 }
262 return Ok(None);
263 }
264
265 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
266 for year in read_dir_sorted(sessions_root)? {
267 for month in read_dir_sorted(&year)? {
268 for day in read_dir_sorted(&month)? {
269 for entry in read_dir_sorted(&day)? {
270 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
271 continue;
272 }
273 if read_codex_session_meta(&entry)?.and_then(|meta| meta.cwd)
274 != Some(cwd.as_os_str().to_os_string())
275 {
276 continue;
277 }
278 let modified = file_modified(&entry);
279 match &newest {
280 Some((t, _)) if modified <= *t => {}
281 _ => newest = Some((modified, entry)),
282 }
283 }
284 }
285 }
286 }
287
288 Ok(newest.map(|(_, p)| p))
289}
290
291fn parse_session_tail(
296 agent_type: AgentType,
297 path: &Path,
298 start_offset: u64,
299) -> Result<(TrackerVerdict, u64)> {
300 let file_len = fs::metadata(path)?.len();
301 let mut offset = if file_len < start_offset {
302 0
303 } else {
304 start_offset
305 };
306
307 let file = File::open(path)?;
308 let mut reader = BufReader::new(file);
309 reader.seek(SeekFrom::Start(offset))?;
310
311 let mut verdict = TrackerVerdict::Unknown;
312 loop {
313 let line_start = reader.stream_position()?;
314 let mut line = String::new();
315 let bytes = reader.read_line(&mut line)?;
316 if bytes == 0 {
317 break;
318 }
319 if !line.ends_with('\n') {
321 reader.seek(SeekFrom::Start(line_start))?;
322 break;
323 }
324
325 if let Ok(entry) = serde_json::from_str::<Value>(&line) {
326 let v = match agent_type {
327 AgentType::Claude => classify_claude_entry(&entry),
328 AgentType::Codex => classify_codex_entry(&entry),
329 _ => TrackerVerdict::Unknown,
330 };
331 if v != TrackerVerdict::Unknown {
332 verdict = v;
333 }
334 }
335
336 offset = reader.stream_position()?;
337 }
338
339 Ok((verdict, offset))
340}
341
342fn classify_claude_entry(entry: &Value) -> TrackerVerdict {
350 match entry.get("type").and_then(Value::as_str) {
351 Some("assistant") => {
352 let stop_reason = entry
353 .get("message")
354 .and_then(|m| m.get("stop_reason"))
355 .and_then(Value::as_str);
356 match stop_reason {
357 Some("tool_use") => TrackerVerdict::Working,
358 Some("end_turn") => TrackerVerdict::Idle,
359 _ => TrackerVerdict::Unknown,
360 }
361 }
362 Some("progress") => TrackerVerdict::Working,
363 Some("user") => {
364 if entry
365 .get("toolUseResult")
366 .and_then(Value::as_object)
367 .is_some()
368 {
369 return TrackerVerdict::Working;
370 }
371 if let Some(content) = entry.get("message").and_then(|m| m.get("content")) {
372 if let Some(text) = content.as_str() {
373 if text == "[Request interrupted by user]" {
374 return TrackerVerdict::Idle;
375 }
376 return TrackerVerdict::Working;
377 }
378 if let Some(items) = content.as_array() {
379 for item in items {
380 if item.get("tool_use_id").is_some() {
381 return TrackerVerdict::Working;
382 }
383 if item.get("type").and_then(Value::as_str) == Some("text")
384 && item.get("text").and_then(Value::as_str)
385 == Some("[Request interrupted by user]")
386 {
387 return TrackerVerdict::Idle;
388 }
389 }
390 return TrackerVerdict::Working;
391 }
392 }
393 TrackerVerdict::Unknown
394 }
395 _ => TrackerVerdict::Unknown,
396 }
397}
398
399fn classify_codex_entry(entry: &Value) -> TrackerVerdict {
404 if entry.get("type").and_then(Value::as_str) == Some("event_msg")
405 && entry
406 .get("payload")
407 .and_then(|p| p.get("type"))
408 .and_then(Value::as_str)
409 == Some("task_complete")
410 {
411 return TrackerVerdict::Idle;
412 }
413 TrackerVerdict::Working
415}
416
417use super::classifier::ScreenVerdict;
422
423pub fn merge_verdicts(
430 agent_type: AgentType,
431 screen: ScreenVerdict,
432 tracker: TrackerVerdict,
433) -> ScreenVerdict {
434 match agent_type {
435 AgentType::Claude => {
436 match screen {
438 ScreenVerdict::AgentIdle
439 | ScreenVerdict::AgentWorking
440 | ScreenVerdict::ContextExhausted => screen,
441 ScreenVerdict::Unknown => match tracker {
442 TrackerVerdict::Working => ScreenVerdict::AgentWorking,
443 TrackerVerdict::Idle => ScreenVerdict::AgentIdle,
444 TrackerVerdict::Unknown => ScreenVerdict::Unknown,
445 },
446 }
447 }
448 AgentType::Codex => {
449 match tracker {
451 TrackerVerdict::Idle => ScreenVerdict::AgentIdle, TrackerVerdict::Working => {
453 match screen {
456 ScreenVerdict::ContextExhausted => ScreenVerdict::ContextExhausted,
457 _ => ScreenVerdict::AgentWorking,
458 }
459 }
460 TrackerVerdict::Unknown => screen, }
462 }
463 _ => screen,
465 }
466}
467
468fn claude_session_cwd(path: &Path) -> Result<Option<std::ffi::OsString>> {
473 let file = File::open(path)?;
474 let reader = BufReader::new(file);
475 for line in reader.lines() {
476 let line = line?;
477 if line.trim().is_empty() {
478 continue;
479 }
480 let Ok(entry) = serde_json::from_str::<Value>(&line) else {
481 continue;
482 };
483 if let Some(cwd) = entry.get("cwd").and_then(Value::as_str) {
484 return Ok(Some(std::ffi::OsString::from(cwd)));
485 }
486 }
487 Ok(None)
488}
489
490#[derive(Debug, Clone, Default, PartialEq, Eq)]
491struct CodexSessionMeta {
492 id: Option<String>,
493 cwd: Option<std::ffi::OsString>,
494}
495
496fn read_codex_session_meta(path: &Path) -> Result<Option<CodexSessionMeta>> {
497 let file = File::open(path)?;
498 let reader = BufReader::new(file);
499 for line in reader.lines() {
500 let line = line?;
501 if line.trim().is_empty() {
502 continue;
503 }
504 let Ok(entry) = serde_json::from_str::<Value>(&line) else {
505 continue;
506 };
507 if entry.get("type").and_then(Value::as_str) != Some("session_meta") {
508 continue;
509 }
510 let payload = entry.get("payload");
511 return Ok(Some(CodexSessionMeta {
512 id: payload
513 .and_then(|payload| payload.get("id"))
514 .and_then(Value::as_str)
515 .map(str::to_string),
516 cwd: payload
517 .and_then(|payload| payload.get("cwd"))
518 .and_then(Value::as_str)
519 .map(std::ffi::OsString::from),
520 }));
521 }
522 Ok(None)
523}
524
525fn codex_session_resume_id(path: &Path) -> Result<Option<String>> {
526 Ok(read_codex_session_meta(path)?
527 .and_then(|meta| meta.id)
528 .or_else(|| file_stem_id(path)))
529}
530
531fn newest_jsonl_in(dir: &Path) -> Result<Option<PathBuf>> {
532 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
533 for entry in read_dir_sorted(dir)? {
534 if entry.extension().and_then(|e| e.to_str()) != Some("jsonl") {
535 continue;
536 }
537 let modified = file_modified(&entry);
538 match &newest {
539 Some((t, _)) if modified <= *t => {}
540 _ => newest = Some((modified, entry)),
541 }
542 }
543 Ok(newest.map(|(_, p)| p))
544}
545
546fn read_dir_sorted(dir: &Path) -> Result<Vec<PathBuf>> {
547 if !dir.is_dir() {
548 return Ok(Vec::new());
549 }
550 let mut entries: Vec<PathBuf> = fs::read_dir(dir)?
551 .filter_map(|e| e.ok())
552 .map(|e| e.path())
553 .collect();
554 entries.sort();
555 Ok(entries)
556}
557
558fn file_len(path: &Path) -> Result<u64> {
559 Ok(fs::metadata(path)?.len())
560}
561
562fn file_modified(path: &Path) -> std::time::SystemTime {
563 fs::metadata(path)
564 .and_then(|m| m.modified())
565 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
566}
567
568fn file_stem_id(path: &Path) -> Option<String> {
569 path.file_stem()
570 .and_then(|s| s.to_str())
571 .map(|s| s.to_string())
572}
573
574#[cfg(test)]
579mod tests {
580 use super::*;
581 use std::io::Write;
582
583 #[test]
586 fn claude_tool_use_is_working() {
587 let entry: Value =
588 serde_json::from_str(r#"{"type":"assistant","message":{"stop_reason":"tool_use"}}"#)
589 .unwrap();
590 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
591 }
592
593 #[test]
594 fn claude_end_turn_is_idle() {
595 let entry: Value =
596 serde_json::from_str(r#"{"type":"assistant","message":{"stop_reason":"end_turn"}}"#)
597 .unwrap();
598 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Idle);
599 }
600
601 #[test]
602 fn claude_progress_is_working() {
603 let entry: Value = serde_json::from_str(r#"{"type":"progress","data":"chunk"}"#).unwrap();
604 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
605 }
606
607 #[test]
608 fn claude_tool_result_is_working() {
609 let entry: Value =
610 serde_json::from_str(r#"{"type":"user","toolUseResult":{"stdout":"ok"}}"#).unwrap();
611 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
612 }
613
614 #[test]
615 fn claude_user_text_is_working() {
616 let entry: Value =
617 serde_json::from_str(r#"{"type":"user","message":{"content":"do something"}}"#)
618 .unwrap();
619 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Working);
620 }
621
622 #[test]
623 fn claude_interrupt_is_idle() {
624 let entry: Value = serde_json::from_str(
625 r#"{"type":"user","message":{"content":"[Request interrupted by user]"}}"#,
626 )
627 .unwrap();
628 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Idle);
629 }
630
631 #[test]
632 fn claude_interrupt_in_array_is_idle() {
633 let entry: Value = serde_json::from_str(
634 r#"{"type":"user","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#,
635 )
636 .unwrap();
637 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Idle);
638 }
639
640 #[test]
641 fn claude_unknown_type() {
642 let entry: Value = serde_json::from_str(r#"{"type":"system","message":"init"}"#).unwrap();
643 assert_eq!(classify_claude_entry(&entry), TrackerVerdict::Unknown);
644 }
645
646 #[test]
649 fn codex_task_complete_is_idle() {
650 let entry: Value =
651 serde_json::from_str(r#"{"type":"event_msg","payload":{"type":"task_complete"}}"#)
652 .unwrap();
653 assert_eq!(classify_codex_entry(&entry), TrackerVerdict::Idle);
654 }
655
656 #[test]
657 fn codex_other_event_is_working() {
658 let entry: Value = serde_json::from_str(
659 r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[]}}"#,
660 )
661 .unwrap();
662 assert_eq!(classify_codex_entry(&entry), TrackerVerdict::Working);
663 }
664
665 #[test]
668 fn claude_screen_takes_priority() {
669 assert_eq!(
671 merge_verdicts(
672 AgentType::Claude,
673 ScreenVerdict::AgentIdle,
674 TrackerVerdict::Working
675 ),
676 ScreenVerdict::AgentIdle,
677 );
678 assert_eq!(
680 merge_verdicts(
681 AgentType::Claude,
682 ScreenVerdict::AgentWorking,
683 TrackerVerdict::Idle
684 ),
685 ScreenVerdict::AgentWorking,
686 );
687 }
688
689 #[test]
690 fn claude_tracker_fills_unknown_screen() {
691 assert_eq!(
692 merge_verdicts(
693 AgentType::Claude,
694 ScreenVerdict::Unknown,
695 TrackerVerdict::Working
696 ),
697 ScreenVerdict::AgentWorking,
698 );
699 assert_eq!(
700 merge_verdicts(
701 AgentType::Claude,
702 ScreenVerdict::Unknown,
703 TrackerVerdict::Idle
704 ),
705 ScreenVerdict::AgentIdle,
706 );
707 }
708
709 #[test]
710 fn codex_tracker_takes_priority() {
711 assert_eq!(
713 merge_verdicts(
714 AgentType::Codex,
715 ScreenVerdict::Unknown,
716 TrackerVerdict::Idle
717 ),
718 ScreenVerdict::AgentIdle,
719 );
720 assert_eq!(
722 merge_verdicts(
723 AgentType::Codex,
724 ScreenVerdict::AgentIdle,
725 TrackerVerdict::Working
726 ),
727 ScreenVerdict::AgentWorking,
728 );
729 }
730
731 #[test]
732 fn codex_context_exhausted_overrides_tracker() {
733 assert_eq!(
734 merge_verdicts(
735 AgentType::Codex,
736 ScreenVerdict::ContextExhausted,
737 TrackerVerdict::Working,
738 ),
739 ScreenVerdict::ContextExhausted,
740 );
741 }
742
743 #[test]
744 fn codex_no_tracker_falls_to_screen() {
745 assert_eq!(
746 merge_verdicts(
747 AgentType::Codex,
748 ScreenVerdict::AgentIdle,
749 TrackerVerdict::Unknown
750 ),
751 ScreenVerdict::AgentIdle,
752 );
753 }
754
755 #[test]
756 fn kiro_ignores_tracker() {
757 assert_eq!(
758 merge_verdicts(
759 AgentType::Kiro,
760 ScreenVerdict::AgentWorking,
761 TrackerVerdict::Idle
762 ),
763 ScreenVerdict::AgentWorking,
764 );
765 }
766
767 #[test]
768 fn generic_ignores_tracker() {
769 assert_eq!(
770 merge_verdicts(
771 AgentType::Generic,
772 ScreenVerdict::Unknown,
773 TrackerVerdict::Working
774 ),
775 ScreenVerdict::Unknown,
776 );
777 }
778
779 #[test]
782 fn discovers_claude_session_in_preferred_dir() {
783 let tmp = tempfile::tempdir().unwrap();
784 let root = tmp.path().join("projects");
785 let cwd = PathBuf::from("/Users/test/myproject");
786 let project_dir = root.join("-Users-test-myproject");
787 fs::create_dir_all(&project_dir).unwrap();
788
789 let session = project_dir.join("abc123.jsonl");
790 fs::write(&session, "{\"cwd\":\"/Users/test/myproject\"}\n").unwrap();
791
792 let found = discover_claude_session(&root, &cwd, None).unwrap();
793 assert_eq!(found.as_deref(), Some(session.as_path()));
794 }
795
796 #[test]
797 fn claude_exact_session_id_lookup() {
798 let tmp = tempfile::tempdir().unwrap();
799 let root = tmp.path().join("projects");
800 let cwd = PathBuf::from("/Users/test/myproject");
801 let project_dir = root.join("-Users-test-myproject");
802 fs::create_dir_all(&project_dir).unwrap();
803
804 let session = project_dir.join("exact-id.jsonl");
805 fs::write(&session, "{}\n").unwrap();
806
807 let found = discover_claude_session(&root, &cwd, Some("exact-id")).unwrap();
808 assert_eq!(found.as_deref(), Some(session.as_path()));
809
810 let missing = discover_claude_session(&root, &cwd, Some("nonexistent")).unwrap();
811 assert!(missing.is_none());
812 }
813
814 #[test]
815 fn claude_nonexistent_root_returns_none() {
816 let found =
817 discover_claude_session(Path::new("/nonexistent"), Path::new("/foo"), None).unwrap();
818 assert!(found.is_none());
819 }
820
821 #[test]
824 fn discovers_codex_session_by_cwd() {
825 let tmp = tempfile::tempdir().unwrap();
826 let root = tmp.path().join("sessions");
827 let day_dir = root.join("2026").join("03").join("23");
828 fs::create_dir_all(&day_dir).unwrap();
829
830 let cwd = PathBuf::from("/Users/test/repo");
831 let session = day_dir.join("sess1.jsonl");
832 fs::write(
833 &session,
834 format!(
835 "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
836 cwd.display()
837 ),
838 )
839 .unwrap();
840
841 let found = discover_codex_session(&root, &cwd, None).unwrap();
842 assert_eq!(found.as_deref(), Some(session.as_path()));
843 }
844
845 #[test]
846 fn codex_exact_session_id_lookup() {
847 let tmp = tempfile::tempdir().unwrap();
848 let root = tmp.path().join("sessions");
849 let day_dir = root.join("2026").join("03").join("23");
850 fs::create_dir_all(&day_dir).unwrap();
851
852 let cwd = PathBuf::from("/Users/test/repo");
853 let session = day_dir.join("my-session.jsonl");
854 fs::write(
855 &session,
856 format!(
857 "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
858 cwd.display()
859 ),
860 )
861 .unwrap();
862
863 let found = discover_codex_session(&root, &cwd, Some("my-session")).unwrap();
864 assert_eq!(found.as_deref(), Some(session.as_path()));
865
866 let missing = discover_codex_session(&root, &cwd, Some("nope")).unwrap();
867 assert!(missing.is_none());
868 }
869
870 #[test]
871 fn codex_exact_session_id_lookup_matches_payload_id() {
872 let tmp = tempfile::tempdir().unwrap();
873 let root = tmp.path().join("sessions");
874 let day_dir = root.join("2026").join("03").join("23");
875 fs::create_dir_all(&day_dir).unwrap();
876
877 let cwd = PathBuf::from("/Users/test/repo");
878 let session = day_dir.join("rollout-2026-03-26T13-54-07-sample.jsonl");
879 fs::write(
880 &session,
881 format!(
882 "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d2b48-3d33-7613-bb3d-d0b4ecd45e2e\",\"cwd\":\"{}\"}}}}\n",
883 cwd.display()
884 ),
885 )
886 .unwrap();
887
888 let found =
889 discover_codex_session(&root, &cwd, Some("019d2b48-3d33-7613-bb3d-d0b4ecd45e2e"))
890 .unwrap();
891 assert_eq!(found.as_deref(), Some(session.as_path()));
892 assert_eq!(
893 codex_session_resume_id(&session).unwrap().as_deref(),
894 Some("019d2b48-3d33-7613-bb3d-d0b4ecd45e2e")
895 );
896 }
897
898 #[test]
899 fn codex_nonexistent_root_returns_none() {
900 let found =
901 discover_codex_session(Path::new("/nonexistent"), Path::new("/foo"), None).unwrap();
902 assert!(found.is_none());
903 }
904
905 #[test]
908 fn tracker_binds_at_eof_ignoring_history() {
909 let tmp = tempfile::tempdir().unwrap();
910 let root = tmp.path().join("projects");
911 let cwd = PathBuf::from("/Users/test/proj");
912 let project_dir = root.join("-Users-test-proj");
913 fs::create_dir_all(&project_dir).unwrap();
914
915 let session = project_dir.join("s1.jsonl");
916 fs::write(
917 &session,
918 concat!(
919 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj\",\"message\":{\"content\":\"hi\"}}\n",
920 "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n",
921 ),
922 )
923 .unwrap();
924
925 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
926
927 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
929 assert!(tracker.session_file.is_some());
930 }
931
932 #[test]
933 fn tracker_reports_new_events_after_bind() {
934 let tmp = tempfile::tempdir().unwrap();
935 let root = tmp.path().join("projects");
936 let cwd = PathBuf::from("/Users/test/proj2");
937 let project_dir = root.join("-Users-test-proj2");
938 fs::create_dir_all(&project_dir).unwrap();
939
940 let session = project_dir.join("s2.jsonl");
941 fs::write(
942 &session,
943 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj2\"}\n",
944 )
945 .unwrap();
946
947 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
948 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
949
950 let mut f = fs::OpenOptions::new().append(true).open(&session).unwrap();
952 writeln!(
953 f,
954 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"tool_use\"}}}}"
955 )
956 .unwrap();
957 f.flush().unwrap();
958
959 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Working);
960
961 writeln!(
963 f,
964 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"end_turn\"}}}}"
965 )
966 .unwrap();
967 f.flush().unwrap();
968
969 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
970 }
971
972 #[test]
973 fn tracker_sticky_verdict_on_no_new_events() {
974 let tmp = tempfile::tempdir().unwrap();
975 let root = tmp.path().join("projects");
976 let cwd = PathBuf::from("/Users/test/proj3");
977 let project_dir = root.join("-Users-test-proj3");
978 fs::create_dir_all(&project_dir).unwrap();
979
980 let session = project_dir.join("s3.jsonl");
981 fs::write(
982 &session,
983 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj3\"}\n",
984 )
985 .unwrap();
986
987 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
988 tracker.poll().unwrap(); let mut f = fs::OpenOptions::new().append(true).open(&session).unwrap();
991 writeln!(
992 f,
993 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"end_turn\"}}}}"
994 )
995 .unwrap();
996 f.flush().unwrap();
997
998 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
999 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
1001 }
1002
1003 #[test]
1004 fn codex_tracker_detects_task_complete() {
1005 let tmp = tempfile::tempdir().unwrap();
1006 let root = tmp.path().join("sessions");
1007 let day_dir = root.join("2026").join("03").join("23");
1008 fs::create_dir_all(&day_dir).unwrap();
1009
1010 let cwd = PathBuf::from("/Users/test/codex-proj");
1011 let session = day_dir.join("cx1.jsonl");
1012 fs::write(
1013 &session,
1014 format!(
1015 "{{\"type\":\"session_meta\",\"payload\":{{\"cwd\":\"{}\"}}}}\n",
1016 cwd.display()
1017 ),
1018 )
1019 .unwrap();
1020
1021 let mut tracker = SessionTracker::new(AgentType::Codex, root, cwd.clone(), None);
1022 tracker.poll().unwrap(); let mut f = fs::OpenOptions::new().append(true).open(&session).unwrap();
1025 writeln!(
1026 f,
1027 "{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"task_complete\"}}}}"
1028 )
1029 .unwrap();
1030 f.flush().unwrap();
1031
1032 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Idle);
1033 }
1034
1035 #[test]
1036 fn tracker_handles_deleted_session_file() {
1037 let tmp = tempfile::tempdir().unwrap();
1038 let root = tmp.path().join("projects");
1039 let cwd = PathBuf::from("/Users/test/proj4");
1040 let project_dir = root.join("-Users-test-proj4");
1041 fs::create_dir_all(&project_dir).unwrap();
1042
1043 let session = project_dir.join("s4.jsonl");
1044 fs::write(
1045 &session,
1046 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj4\"}\n",
1047 )
1048 .unwrap();
1049
1050 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
1051 tracker.poll().unwrap(); assert!(tracker.session_file.is_some());
1053
1054 fs::remove_file(&session).unwrap();
1056 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1057 assert!(tracker.session_file.is_none());
1058 }
1059
1060 #[test]
1061 fn kiro_tracker_always_unknown() {
1062 let tmp = tempfile::tempdir().unwrap();
1063 let root = tmp.path().to_path_buf();
1064 let cwd = PathBuf::from("/Users/test/kiro");
1065
1066 let mut tracker = SessionTracker::new(AgentType::Kiro, root, cwd, None);
1067 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1069 }
1070
1071 #[test]
1072 fn tracker_rebinds_to_newer_file() {
1073 let tmp = tempfile::tempdir().unwrap();
1074 let root = tmp.path().join("projects");
1075 let cwd = PathBuf::from("/Users/test/proj5");
1076 let project_dir = root.join("-Users-test-proj5");
1077 fs::create_dir_all(&project_dir).unwrap();
1078
1079 let old_session = project_dir.join("old.jsonl");
1080 fs::write(
1081 &old_session,
1082 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj5\"}\n",
1083 )
1084 .unwrap();
1085
1086 let mut tracker = SessionTracker::new(AgentType::Claude, root, cwd, None);
1087 tracker.poll().unwrap(); assert_eq!(tracker.session_file.as_deref(), Some(old_session.as_path()));
1089
1090 std::thread::sleep(std::time::Duration::from_millis(20));
1092 let new_session = project_dir.join("new.jsonl");
1093 fs::write(
1094 &new_session,
1095 "{\"type\":\"user\",\"cwd\":\"/Users/test/proj5\"}\n",
1096 )
1097 .unwrap();
1098
1099 tracker.poll().unwrap();
1101 assert_eq!(tracker.session_file.as_deref(), Some(new_session.as_path()));
1102 }
1103
1104 #[test]
1105 fn codex_tracker_rebind_keeps_payload_resume_id() {
1106 let tmp = tempfile::tempdir().unwrap();
1107 let root = tmp.path().join("sessions");
1108 let day_dir = root.join("2026").join("03").join("27");
1109 fs::create_dir_all(&day_dir).unwrap();
1110
1111 let cwd = PathBuf::from("/Users/test/repo");
1112 let old_session = day_dir.join("rollout-2026-03-27T10-00-00-old.jsonl");
1113 fs::write(
1114 &old_session,
1115 format!(
1116 "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"old-resume-id\",\"cwd\":\"{}\"}}}}\n",
1117 cwd.display()
1118 ),
1119 )
1120 .unwrap();
1121
1122 let mut tracker = SessionTracker::new(AgentType::Codex, root, cwd.clone(), None);
1123 tracker.poll().unwrap();
1124 assert_eq!(tracker.session_file.as_deref(), Some(old_session.as_path()));
1125 assert_eq!(tracker.session_id.as_deref(), Some("old-resume-id"));
1126
1127 std::thread::sleep(std::time::Duration::from_millis(20));
1128 let new_session = day_dir.join("rollout-2026-03-27T10-01-00-new.jsonl");
1129 fs::write(
1130 &new_session,
1131 format!(
1132 "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"new-resume-id\",\"cwd\":\"{}\"}}}}\n",
1133 cwd.display()
1134 ),
1135 )
1136 .unwrap();
1137
1138 tracker.poll().unwrap();
1139 assert_eq!(tracker.session_file.as_deref(), Some(new_session.as_path()));
1140 assert_eq!(tracker.session_id.as_deref(), Some("new-resume-id"));
1141 }
1142
1143 #[test]
1146 fn parse_tail_handles_truncated_file() {
1147 let tmp = tempfile::tempdir().unwrap();
1148 let session = tmp.path().join("truncated.jsonl");
1149 fs::write(
1150 &session,
1151 "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"end_turn\"}}\n",
1152 )
1153 .unwrap();
1154
1155 let (verdict, _) = parse_session_tail(AgentType::Claude, &session, 9999).unwrap();
1157 assert_eq!(verdict, TrackerVerdict::Idle);
1158 }
1159
1160 #[test]
1161 fn parse_tail_skips_incomplete_lines() {
1162 let tmp = tempfile::tempdir().unwrap();
1163 let session = tmp.path().join("incomplete.jsonl");
1164 let mut f = File::create(&session).unwrap();
1166 write!(
1167 f,
1168 "{{\"type\":\"assistant\",\"message\":{{\"stop_reason\":\"tool_use\"}}}}\n{{\"type\":\"partial"
1169 )
1170 .unwrap();
1171 f.flush().unwrap();
1172
1173 let (verdict, offset) = parse_session_tail(AgentType::Claude, &session, 0).unwrap();
1174 assert_eq!(verdict, TrackerVerdict::Working);
1175 let complete_line = "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n";
1177 assert_eq!(offset, complete_line.len() as u64);
1178 }
1179
1180 #[test]
1183 fn tracker_graceful_no_session_file() {
1184 let tmp = tempfile::tempdir().unwrap();
1185 let root = tmp.path().join("empty_projects");
1187 fs::create_dir_all(&root).unwrap();
1188
1189 let mut tracker =
1190 SessionTracker::new(AgentType::Claude, root, PathBuf::from("/no/match"), None);
1191
1192 assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1194 assert!(tracker.session_file.is_none());
1195 }
1196}