Skip to main content

batty_cli/shim/
tracker.rs

1//! JSONL session file tracking for shim classifiers.
2//!
3//! Tails agent session files for higher-fidelity state detection.
4//! Claude tracker: `~/.claude/projects/<cwd_encoded>/<session_id>.jsonl`
5//! Codex tracker: `~/.codex/sessions/<year>/<month>/<day>/<session_id>.jsonl`
6
7use 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// ---------------------------------------------------------------------------
16// Tracker verdict — what the JSONL log says the agent is doing
17// ---------------------------------------------------------------------------
18
19/// Verdict from the JSONL session tracker.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum TrackerVerdict {
22    /// Agent is actively processing (tool use, progress events).
23    Working,
24    /// Agent finished its turn (end_turn, task_complete).
25    Idle,
26    /// No new information from the session file.
27    Unknown,
28}
29
30// ---------------------------------------------------------------------------
31// Session tracker — unified state for tailing a JSONL file
32// ---------------------------------------------------------------------------
33
34/// Tracks an agent's JSONL session file, tailing from EOF.
35pub struct SessionTracker {
36    agent_type: AgentType,
37    /// Root directory for session discovery.
38    root: PathBuf,
39    /// Working directory to match sessions against.
40    cwd: PathBuf,
41    /// Optional pre-known session ID.
42    session_id: Option<String>,
43    /// Currently tracked session file.
44    session_file: Option<PathBuf>,
45    /// Read offset in the session file.
46    offset: u64,
47    /// Last known verdict (sticky until overridden).
48    last_verdict: TrackerVerdict,
49}
50
51impl SessionTracker {
52    /// Create a new tracker. Does not discover the session file until `poll()`.
53    ///
54    /// - For Claude: `root` = `~/.claude/projects`
55    /// - For Codex: `root` = `~/.codex/sessions`
56    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    /// Poll the session file for new events.
74    ///
75    /// On first call, discovers the session file and binds at EOF so that
76    /// historical entries don't produce false state transitions.
77    /// Returns the current sticky verdict.
78    pub fn poll(&mut self) -> Result<TrackerVerdict> {
79        // First-time discovery
80        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                // Bind at EOF — ignore history
89                self.offset = file_len(path)?;
90                self.last_verdict = TrackerVerdict::Unknown;
91            }
92            return Ok(self.last_verdict);
93        }
94
95        // Check for newer session file (rebind)
96        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    /// The path of the currently tracked session file, if any.
119    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
152// ---------------------------------------------------------------------------
153// Session file discovery
154// ---------------------------------------------------------------------------
155
156fn 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), // Kiro / Generic don't have JSONL sessions
166    }
167}
168
169/// Claude: `~/.claude/projects/<cwd_encoded>/<session_id>.jsonl`
170fn 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    // Exact session ID lookup
182    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    // Newest JSONL in preferred directory
191    if preferred_dir.is_dir() {
192        if let Some(path) = newest_jsonl_in(&preferred_dir)? {
193            return Ok(Some(path));
194        }
195    }
196
197    // Fall back: scan all project dirs for matching cwd
198    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
221/// Codex: `~/.codex/sessions/<year>/<month>/<day>/<session_id>.jsonl`
222fn 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    // Walk year/month/day hierarchy
232    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
272// ---------------------------------------------------------------------------
273// JSONL log entry classification
274// ---------------------------------------------------------------------------
275
276fn 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        // Incomplete line — rewind and wait for next poll
301        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
323/// Classify a Claude JSONL log entry.
324///
325/// - `type: "assistant"` with `stop_reason: "tool_use"` → Working
326/// - `type: "assistant"` with `stop_reason: "end_turn"` → Idle
327/// - `type: "progress"` → Working
328/// - `type: "user"` with `toolUseResult` → Working
329/// - `type: "user"` with interrupt text → Idle
330fn 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
380/// Classify a Codex JSONL log entry.
381///
382/// - `type: "event_msg"` with `payload.type: "task_complete"` → Idle
383/// - Any other new event → Working (handled by caller via "had new events")
384fn 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    // Any parseable JSONL line means the agent is active
395    TrackerVerdict::Working
396}
397
398// ---------------------------------------------------------------------------
399// Merge logic — combine screen + tracker verdicts
400// ---------------------------------------------------------------------------
401
402use super::classifier::ScreenVerdict;
403
404/// Final merged state after combining screen and tracker verdicts.
405///
406/// Merge priority per spec:
407/// - Claude: Screen > Tracker (screen is more reliable for live state)
408/// - Codex: Tracker > Screen (task_complete is ground truth for completion)
409/// - Other types: Screen only (no JSONL tracker)
410pub fn merge_verdicts(
411    agent_type: AgentType,
412    screen: ScreenVerdict,
413    tracker: TrackerVerdict,
414) -> ScreenVerdict {
415    match agent_type {
416        AgentType::Claude => {
417            // Screen takes priority — fall back to tracker when screen is Unknown
418            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            // Tracker takes priority for completion detection
431            match tracker {
432                TrackerVerdict::Idle => ScreenVerdict::AgentIdle, // task_complete
433                TrackerVerdict::Working => {
434                    // Tracker says active — screen can override to idle only
435                    // if the prompt is visible (unlikely during work)
436                    match screen {
437                        ScreenVerdict::ContextExhausted => ScreenVerdict::ContextExhausted,
438                        _ => ScreenVerdict::AgentWorking,
439                    }
440                }
441                TrackerVerdict::Unknown => screen, // No tracker info, use screen
442            }
443        }
444        // Kiro / Generic: no tracker, screen only
445        _ => screen,
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Helpers
451// ---------------------------------------------------------------------------
452
453fn 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// ---------------------------------------------------------------------------
538// Tests
539// ---------------------------------------------------------------------------
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use std::io::Write;
545
546    // -- Claude entry classification --
547
548    #[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    // -- Codex entry classification --
610
611    #[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    // -- Merge verdicts --
629
630    #[test]
631    fn claude_screen_takes_priority() {
632        // Screen idle wins over tracker working
633        assert_eq!(
634            merge_verdicts(
635                AgentType::Claude,
636                ScreenVerdict::AgentIdle,
637                TrackerVerdict::Working
638            ),
639            ScreenVerdict::AgentIdle,
640        );
641        // Screen working wins over tracker idle
642        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        // Tracker idle (task_complete) wins over screen unknown
675        assert_eq!(
676            merge_verdicts(
677                AgentType::Codex,
678                ScreenVerdict::Unknown,
679                TrackerVerdict::Idle
680            ),
681            ScreenVerdict::AgentIdle,
682        );
683        // Tracker working wins, shows working even if screen says idle
684        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    // -- Session discovery (Claude) --
743
744    #[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    // -- Session discovery (Codex) --
785
786    #[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    // -- Session tracker poll --
841
842    #[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        // First poll discovers + binds at EOF, returns Unknown
863        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        // Append new events
886        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        // Append end_turn
897        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(); // bind
924
925        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        // No new events — verdict stays Idle (sticky)
935        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(); // bind
958
959        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(); // bind
987        assert!(tracker.session_file.is_some());
988
989        // Delete the file
990        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        // No session discovered for non-supported types
1003        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(); // bind to old
1023        assert_eq!(tracker.session_file.as_deref(), Some(old_session.as_path()));
1024
1025        // Create a newer file after a brief delay
1026        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        // Poll should rebind to the newer file
1035        tracker.poll().unwrap();
1036        assert_eq!(tracker.session_file.as_deref(), Some(new_session.as_path()));
1037    }
1038
1039    // -- parse_session_tail --
1040
1041    #[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        // Start offset beyond file length → resets to 0
1052        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        // Write a complete line followed by an incomplete one (no trailing newline)
1061        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        // Offset should stop before the incomplete line
1072        let complete_line = "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n";
1073        assert_eq!(offset, complete_line.len() as u64);
1074    }
1075
1076    // -- Graceful degradation --
1077
1078    #[test]
1079    fn tracker_graceful_no_session_file() {
1080        let tmp = tempfile::tempdir().unwrap();
1081        // Empty root — no session files
1082        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        // Should return Unknown without error
1089        assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1090        assert!(tracker.session_file.is_none());
1091    }
1092}