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                if self.agent_type == AgentType::Codex {
89                    self.session_id = codex_session_resume_id(path)?;
90                }
91                // Bind at EOF — ignore history
92                self.offset = file_len(path)?;
93                self.last_verdict = TrackerVerdict::Unknown;
94            }
95            return Ok(self.last_verdict);
96        }
97
98        // Check for newer session file (rebind)
99        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    /// The path of the currently tracked session file, if any.
122    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
159// ---------------------------------------------------------------------------
160// Session file discovery
161// ---------------------------------------------------------------------------
162
163fn 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), // Kiro / Generic don't have JSONL sessions
173    }
174}
175
176/// Claude: `~/.claude/projects/<cwd_encoded>/<session_id>.jsonl`
177fn 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    // Exact session ID lookup
189    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    // Newest JSONL in preferred directory
198    if preferred_dir.is_dir() {
199        if let Some(path) = newest_jsonl_in(&preferred_dir)? {
200            return Ok(Some(path));
201        }
202    }
203
204    // Fall back: scan all project dirs for matching cwd
205    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
228/// Codex: `~/.codex/sessions/<year>/<month>/<day>/<session_id>.jsonl`
229fn 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    // Walk year/month/day hierarchy
239    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
291// ---------------------------------------------------------------------------
292// JSONL log entry classification
293// ---------------------------------------------------------------------------
294
295fn 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        // Incomplete line — rewind and wait for next poll
320        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
342/// Classify a Claude JSONL log entry.
343///
344/// - `type: "assistant"` with `stop_reason: "tool_use"` → Working
345/// - `type: "assistant"` with `stop_reason: "end_turn"` → Idle
346/// - `type: "progress"` → Working
347/// - `type: "user"` with `toolUseResult` → Working
348/// - `type: "user"` with interrupt text → Idle
349fn 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
399/// Classify a Codex JSONL log entry.
400///
401/// - `type: "event_msg"` with `payload.type: "task_complete"` → Idle
402/// - Any other new event → Working (handled by caller via "had new events")
403fn 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    // Any parseable JSONL line means the agent is active
414    TrackerVerdict::Working
415}
416
417// ---------------------------------------------------------------------------
418// Merge logic — combine screen + tracker verdicts
419// ---------------------------------------------------------------------------
420
421use super::classifier::ScreenVerdict;
422
423/// Final merged state after combining screen and tracker verdicts.
424///
425/// Merge priority per spec:
426/// - Claude: Screen > Tracker (screen is more reliable for live state)
427/// - Codex: Tracker > Screen (task_complete is ground truth for completion)
428/// - Other types: Screen only (no JSONL tracker)
429pub fn merge_verdicts(
430    agent_type: AgentType,
431    screen: ScreenVerdict,
432    tracker: TrackerVerdict,
433) -> ScreenVerdict {
434    match agent_type {
435        AgentType::Claude => {
436            // Screen takes priority — fall back to tracker when screen is Unknown
437            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            // Tracker takes priority for completion detection
450            match tracker {
451                TrackerVerdict::Idle => ScreenVerdict::AgentIdle, // task_complete
452                TrackerVerdict::Working => {
453                    // Tracker says active — screen can override to idle only
454                    // if the prompt is visible (unlikely during work)
455                    match screen {
456                        ScreenVerdict::ContextExhausted => ScreenVerdict::ContextExhausted,
457                        _ => ScreenVerdict::AgentWorking,
458                    }
459                }
460                TrackerVerdict::Unknown => screen, // No tracker info, use screen
461            }
462        }
463        // Kiro / Generic: no tracker, screen only
464        _ => screen,
465    }
466}
467
468// ---------------------------------------------------------------------------
469// Helpers
470// ---------------------------------------------------------------------------
471
472fn 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// ---------------------------------------------------------------------------
575// Tests
576// ---------------------------------------------------------------------------
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use std::io::Write;
582
583    // -- Claude entry classification --
584
585    #[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    // -- Codex entry classification --
647
648    #[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    // -- Merge verdicts --
666
667    #[test]
668    fn claude_screen_takes_priority() {
669        // Screen idle wins over tracker working
670        assert_eq!(
671            merge_verdicts(
672                AgentType::Claude,
673                ScreenVerdict::AgentIdle,
674                TrackerVerdict::Working
675            ),
676            ScreenVerdict::AgentIdle,
677        );
678        // Screen working wins over tracker idle
679        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        // Tracker idle (task_complete) wins over screen unknown
712        assert_eq!(
713            merge_verdicts(
714                AgentType::Codex,
715                ScreenVerdict::Unknown,
716                TrackerVerdict::Idle
717            ),
718            ScreenVerdict::AgentIdle,
719        );
720        // Tracker working wins, shows working even if screen says idle
721        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    // -- Session discovery (Claude) --
780
781    #[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    // -- Session discovery (Codex) --
822
823    #[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    // -- Session tracker poll --
906
907    #[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        // First poll discovers + binds at EOF, returns Unknown
928        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        // Append new events
951        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        // Append end_turn
962        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(); // bind
989
990        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        // No new events — verdict stays Idle (sticky)
1000        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(); // bind
1023
1024        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(); // bind
1052        assert!(tracker.session_file.is_some());
1053
1054        // Delete the file
1055        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        // No session discovered for non-supported types
1068        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(); // bind to old
1088        assert_eq!(tracker.session_file.as_deref(), Some(old_session.as_path()));
1089
1090        // Create a newer file after a brief delay
1091        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        // Poll should rebind to the newer file
1100        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    // -- parse_session_tail --
1144
1145    #[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        // Start offset beyond file length → resets to 0
1156        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        // Write a complete line followed by an incomplete one (no trailing newline)
1165        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        // Offset should stop before the incomplete line
1176        let complete_line = "{\"type\":\"assistant\",\"message\":{\"stop_reason\":\"tool_use\"}}\n";
1177        assert_eq!(offset, complete_line.len() as u64);
1178    }
1179
1180    // -- Graceful degradation --
1181
1182    #[test]
1183    fn tracker_graceful_no_session_file() {
1184        let tmp = tempfile::tempdir().unwrap();
1185        // Empty root — no session files
1186        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        // Should return Unknown without error
1193        assert_eq!(tracker.poll().unwrap(), TrackerVerdict::Unknown);
1194        assert!(tracker.session_file.is_none());
1195    }
1196}