Skip to main content

tam_daemon/
provider.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::time::Duration;
4
5use tam_proto::AgentState;
6
7/// Context window usage for an agent.
8pub struct ContextUsage {
9    pub used_tokens: u64,
10    pub limit_tokens: u64,
11}
12
13impl ContextUsage {
14    pub fn percent(&self) -> u8 {
15        if self.limit_tokens == 0 {
16            return 0;
17        }
18        let pct = (self.used_tokens as f64 / self.limit_tokens as f64 * 100.0).round() as u64;
19        pct.min(100) as u8
20    }
21}
22
23/// Adapter for a specific agent tool (claude, codex, etc.).
24///
25/// Providers know how to launch the agent and how to detect its state.
26/// Hook-based providers (e.g. Claude) return `None` from `detect_state_from_output`
27/// and push state via hooks instead. PTY-heuristic providers analyze output directly.
28pub trait Provider: Send + Sync {
29    /// Unique name for this provider (e.g. "claude", "codex").
30    fn name(&self) -> &str;
31
32    /// Build the command to launch the agent in a directory.
33    fn build_command(
34        &self,
35        dir: &Path,
36        args: &[String],
37        resume_session: Option<&str>,
38        prompt: Option<&str>,
39    ) -> Command;
40
41    /// Analyze agent state from recent PTY output and time since last output.
42    /// Returns `None` if this provider doesn't do output-based detection (e.g. uses hooks).
43    fn detect_state_from_output(
44        &self,
45        recent_output: &[u8],
46        idle_duration: Duration,
47    ) -> Option<AgentState>;
48
49    /// Map a hook event name to an agent state.
50    /// Returns `None` if this provider doesn't handle hooks or doesn't recognize the event.
51    fn map_hook_event(&self, event: &str) -> Option<AgentState>;
52
53    /// Read context window usage for an agent. Returns `None` if not supported
54    /// or if the data isn't available.
55    fn context_usage(&self, _pid: u32, _dir: &Path) -> Option<ContextUsage> {
56        None
57    }
58}
59
60/// Claude Code provider.
61///
62/// State detection will use hooks (configured at spawn time). Output-based
63/// detection returns None — state is pushed via hook callbacks.
64pub struct ClaudeProvider;
65
66impl Provider for ClaudeProvider {
67    fn name(&self) -> &str {
68        "claude"
69    }
70
71    fn build_command(
72        &self,
73        dir: &Path,
74        args: &[String],
75        resume_session: Option<&str>,
76        prompt: Option<&str>,
77    ) -> Command {
78        let mut cmd = Command::new("claude");
79        cmd.current_dir(dir);
80        if let Some(id) = resume_session {
81            cmd.arg("--resume").arg(id);
82        }
83        cmd.args(args);
84        if let Some(text) = prompt {
85            cmd.arg(text);
86        }
87        cmd
88    }
89
90    fn detect_state_from_output(
91        &self,
92        _recent_output: &[u8],
93        _idle_duration: Duration,
94    ) -> Option<AgentState> {
95        // Claude uses hooks for state detection, not output parsing
96        None
97    }
98
99    fn map_hook_event(&self, event: &str) -> Option<AgentState> {
100        match event {
101            // User submitted a prompt, Claude is about to work
102            "user_prompt_submit" => Some(AgentState::Working),
103            // Claude finished responding, waiting for new prompt
104            "stop" | "notification:idle_prompt" => Some(AgentState::Input),
105            // Claude needs user action (permission approval)
106            "notification:permission_prompt" => Some(AgentState::Blocked),
107            _ => None,
108        }
109    }
110
111    fn context_usage(&self, pid: u32, dir: &Path) -> Option<ContextUsage> {
112        claude_context_usage(pid, dir)
113    }
114}
115
116// --- Claude context usage parsing ---
117
118use serde::Deserialize;
119
120#[derive(Deserialize)]
121struct ClaudeSessionFile {
122    #[serde(rename = "sessionId")]
123    session_id: String,
124}
125
126#[derive(Deserialize)]
127struct ClaudeJournalLine {
128    #[serde(rename = "type")]
129    line_type: Option<String>,
130    message: Option<ClaudeJournalMessage>,
131}
132
133#[derive(Deserialize)]
134struct ClaudeJournalMessage {
135    model: Option<String>,
136    usage: Option<ClaudeUsage>,
137}
138
139#[derive(Deserialize)]
140struct ClaudeUsage {
141    #[serde(default)]
142    input_tokens: u64,
143    #[serde(default)]
144    cache_creation_input_tokens: u64,
145    #[serde(default)]
146    cache_read_input_tokens: u64,
147}
148
149fn claude_context_usage(pid: u32, dir: &Path) -> Option<ContextUsage> {
150    let home = std::env::var("HOME").ok()?;
151    let claude_dir = PathBuf::from(&home).join(".claude");
152
153    // Encode directory path the way Claude does: /home/user/foo → -home-user-foo
154    let encoded_dir = encode_claude_path(dir);
155    let project_dir = claude_dir.join("projects").join(&encoded_dir);
156
157    // Try to find the JSONL via the PID session file first, then fall back
158    // to the most recently modified JSONL in the project directory.
159    // Claude's sessionId in the PID file doesn't always match the JSONL filename
160    // (e.g. when resuming a session, the JSONL keeps its original name).
161    let jsonl_path = claude_session_jsonl_from_pid(pid, &claude_dir, &project_dir)
162        .or_else(|| claude_most_recent_jsonl(&project_dir))?;
163
164    // Read JSONL, find last assistant message with usage
165    let content = std::fs::read_to_string(&jsonl_path).ok()?;
166    let (usage, model) = find_last_usage(&content)?;
167
168    let used =
169        usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens;
170
171    // Context limit: 1M if model has [1m] suffix or tokens already exceed 200k
172    let limit = if model.as_deref().is_some_and(|m| m.contains("[1m]")) || used > 180_000 {
173        1_000_000
174    } else {
175        200_000
176    };
177
178    Some(ContextUsage {
179        used_tokens: used,
180        limit_tokens: limit,
181    })
182}
183
184/// Try to resolve the JSONL path from the PID session file.
185fn claude_session_jsonl_from_pid(
186    pid: u32,
187    claude_dir: &Path,
188    project_dir: &Path,
189) -> Option<PathBuf> {
190    let session_path = claude_dir.join("sessions").join(format!("{pid}.json"));
191    let session_content = std::fs::read_to_string(&session_path).ok()?;
192    let session: ClaudeSessionFile = serde_json::from_str(&session_content).ok()?;
193    let path = project_dir.join(format!("{}.jsonl", session.session_id));
194    if path.exists() {
195        Some(path)
196    } else {
197        None
198    }
199}
200
201/// Fall back to the most recently modified JSONL in the project directory.
202fn claude_most_recent_jsonl(project_dir: &Path) -> Option<PathBuf> {
203    let entries = std::fs::read_dir(project_dir).ok()?;
204    entries
205        .filter_map(|e| e.ok())
206        .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
207        .max_by_key(|e| {
208            e.metadata()
209                .and_then(|m| m.modified())
210                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
211        })
212        .map(|e| e.path())
213}
214
215fn encode_claude_path(dir: &Path) -> String {
216    let s = dir.to_string_lossy();
217    s.replace('/', "-")
218}
219
220fn find_last_usage(content: &str) -> Option<(ClaudeUsage, Option<String>)> {
221    for line in content.lines().rev() {
222        let entry: ClaudeJournalLine = match serde_json::from_str(line) {
223            Ok(e) => e,
224            Err(_) => continue,
225        };
226        if entry.line_type.as_deref() != Some("assistant") {
227            continue;
228        }
229        if let Some(msg) = entry.message {
230            if let Some(usage) = msg.usage {
231                return Some((usage, msg.model));
232            }
233        }
234    }
235    None
236}
237
238/// Codex CLI provider.
239///
240/// State detection uses PTY idle heuristic (no useful hooks for per-turn detection).
241/// Context tracking reads Codex's JSONL session files.
242pub struct CodexProvider;
243
244impl Provider for CodexProvider {
245    fn name(&self) -> &str {
246        "codex"
247    }
248
249    fn build_command(
250        &self,
251        dir: &Path,
252        args: &[String],
253        resume_session: Option<&str>,
254        prompt: Option<&str>,
255    ) -> Command {
256        let mut cmd = Command::new("codex");
257        if let Some(id) = resume_session {
258            cmd.arg("resume").arg(id);
259        }
260        cmd.arg("-C").arg(dir);
261        cmd.args(args);
262        if let Some(text) = prompt {
263            cmd.arg(text);
264        }
265        cmd
266    }
267
268    fn detect_state_from_output(
269        &self,
270        _recent_output: &[u8],
271        idle_duration: Duration,
272    ) -> Option<AgentState> {
273        if idle_duration >= Duration::from_secs(5) {
274            Some(AgentState::Idle)
275        } else {
276            Some(AgentState::Working)
277        }
278    }
279
280    fn map_hook_event(&self, _event: &str) -> Option<AgentState> {
281        None
282    }
283
284    fn context_usage(&self, _pid: u32, dir: &Path) -> Option<ContextUsage> {
285        codex_context_usage(dir)
286    }
287}
288
289// --- Codex context usage parsing ---
290
291#[derive(Deserialize)]
292struct CodexJournalLine {
293    #[serde(rename = "type")]
294    line_type: Option<String>,
295    payload: Option<CodexPayload>,
296}
297
298#[derive(Deserialize)]
299struct CodexPayload {
300    #[serde(rename = "type")]
301    payload_type: Option<String>,
302    info: Option<CodexTokenInfo>,
303    // session_meta fields (flattened into payload)
304    cwd: Option<String>,
305}
306
307#[derive(Deserialize)]
308struct CodexTokenInfo {
309    last_token_usage: Option<CodexTokenUsage>,
310    model_context_window: Option<u64>,
311}
312
313#[derive(Deserialize)]
314struct CodexTokenUsage {
315    #[serde(default)]
316    input_tokens: u64,
317}
318
319/// Find the most recent Codex session file matching the given working directory.
320fn find_codex_session(codex_dir: &Path, agent_dir: &Path) -> Option<PathBuf> {
321    let sessions_dir = codex_dir.join("sessions");
322    if !sessions_dir.is_dir() {
323        return None;
324    }
325
326    // Walk YYYY/MM/DD directories in reverse chronological order
327    let mut year_dirs: Vec<_> = std::fs::read_dir(&sessions_dir)
328        .ok()?
329        .filter_map(|e| e.ok())
330        .filter(|e| e.path().is_dir())
331        .collect();
332    year_dirs.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
333
334    for year in year_dirs {
335        let mut month_dirs: Vec<_> = std::fs::read_dir(year.path())
336            .ok()?
337            .filter_map(|e| e.ok())
338            .filter(|e| e.path().is_dir())
339            .collect();
340        month_dirs.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
341
342        for month in month_dirs {
343            let mut day_dirs: Vec<_> = std::fs::read_dir(month.path())
344                .ok()?
345                .filter_map(|e| e.ok())
346                .filter(|e| e.path().is_dir())
347                .collect();
348            day_dirs.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
349
350            for day in day_dirs {
351                // List JSONL files in this day, sorted by name descending (most recent first)
352                let mut files: Vec<_> = std::fs::read_dir(day.path())
353                    .ok()?
354                    .filter_map(|e| e.ok())
355                    .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
356                    .collect();
357                files.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
358
359                for file in files {
360                    // Read first line to check CWD
361                    if let Ok(content) = std::fs::read_to_string(file.path()) {
362                        if let Some(first_line) = content.lines().next() {
363                            if let Ok(meta) = serde_json::from_str::<CodexJournalLine>(first_line) {
364                                let cwd = meta.payload.as_ref().and_then(|p| p.cwd.as_deref());
365                                if cwd == Some(&agent_dir.to_string_lossy()) {
366                                    return Some(file.path());
367                                }
368                            }
369                        }
370                    }
371                }
372            }
373        }
374    }
375
376    None
377}
378
379fn codex_context_usage(dir: &Path) -> Option<ContextUsage> {
380    let home = std::env::var("HOME").ok()?;
381    let codex_dir = PathBuf::from(&home).join(".codex");
382
383    let session_path = find_codex_session(&codex_dir, dir)?;
384    let content = std::fs::read_to_string(&session_path).ok()?;
385
386    // Find last token_count event
387    for line in content.lines().rev() {
388        let entry: CodexJournalLine = match serde_json::from_str(line) {
389            Ok(e) => e,
390            Err(_) => continue,
391        };
392        if entry.line_type.as_deref() != Some("event_msg") {
393            continue;
394        }
395        let payload = entry.payload?;
396        if payload.payload_type.as_deref() != Some("token_count") {
397            continue;
398        }
399        let info = payload.info?;
400        let usage = info.last_token_usage?;
401        let limit = info.model_context_window?;
402
403        return Some(ContextUsage {
404            used_tokens: usage.input_tokens,
405            limit_tokens: limit,
406        });
407    }
408
409    None
410}
411
412/// Generic provider for any CLI agent.
413///
414/// Uses the provider name as the command and PTY activity heuristic for state detection.
415pub struct GenericProvider {
416    command: String,
417    idle_timeout: Duration,
418}
419
420impl GenericProvider {
421    pub fn new(command: &str) -> Self {
422        Self {
423            command: command.to_string(),
424            idle_timeout: Duration::from_secs(5),
425        }
426    }
427}
428
429impl Provider for GenericProvider {
430    fn name(&self) -> &str {
431        &self.command
432    }
433
434    fn build_command(
435        &self,
436        dir: &Path,
437        args: &[String],
438        _resume_session: Option<&str>,
439        _prompt: Option<&str>,
440    ) -> Command {
441        let mut cmd = Command::new(&self.command);
442        cmd.current_dir(dir);
443        cmd.args(args);
444        cmd
445    }
446
447    fn detect_state_from_output(
448        &self,
449        _recent_output: &[u8],
450        idle_duration: Duration,
451    ) -> Option<AgentState> {
452        if idle_duration >= self.idle_timeout {
453            Some(AgentState::Idle)
454        } else {
455            Some(AgentState::Working)
456        }
457    }
458
459    fn map_hook_event(&self, _event: &str) -> Option<AgentState> {
460        // Generic provider doesn't use hooks
461        None
462    }
463}
464
465/// Resolve a provider name to a concrete provider.
466pub fn resolve(name: &str) -> Box<dyn Provider> {
467    match name {
468        "claude" => Box::new(ClaudeProvider),
469        "codex" => Box::new(CodexProvider),
470        other => Box::new(GenericProvider::new(other)),
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use std::path::PathBuf;
478
479    #[test]
480    fn claude_provider_basics() {
481        let p = ClaudeProvider;
482        assert_eq!(p.name(), "claude");
483
484        let cmd = p.build_command(&PathBuf::from("/tmp"), &["--verbose".into()], None, None);
485        assert_eq!(cmd.get_program(), "claude");
486        assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp")));
487        let args: Vec<_> = cmd.get_args().collect();
488        assert_eq!(args, &["--verbose"]);
489    }
490
491    #[test]
492    fn claude_resume_session() {
493        let p = ClaudeProvider;
494        let cmd = p.build_command(&PathBuf::from("/tmp"), &[], Some("abc-123"), None);
495        let args: Vec<_> = cmd.get_args().collect();
496        assert_eq!(args, &["--resume", "abc-123"]);
497    }
498
499    #[test]
500    fn claude_prompt_arg() {
501        let p = ClaudeProvider;
502        let cmd = p.build_command(&PathBuf::from("/tmp"), &[], None, Some("fix the bug"));
503        let args: Vec<_> = cmd.get_args().collect();
504        assert_eq!(args, &["fix the bug"]);
505    }
506
507    #[test]
508    fn claude_resume_session_and_prompt() {
509        let p = ClaudeProvider;
510        let cmd = p.build_command(
511            &PathBuf::from("/tmp"),
512            &[],
513            Some("abc-123"),
514            Some("fix the bug"),
515        );
516        let args: Vec<_> = cmd.get_args().collect();
517        assert_eq!(args, &["--resume", "abc-123", "fix the bug"]);
518    }
519
520    #[test]
521    fn claude_returns_none_for_output_detection() {
522        let p = ClaudeProvider;
523        assert_eq!(
524            p.detect_state_from_output(b"anything", Duration::from_secs(0)),
525            None
526        );
527    }
528
529    #[test]
530    fn generic_provider_basics() {
531        let p = GenericProvider::new("codex");
532        assert_eq!(p.name(), "codex");
533
534        let cmd = p.build_command(&PathBuf::from("/home"), &[], None, None);
535        assert_eq!(cmd.get_program(), "codex");
536    }
537
538    #[test]
539    fn generic_working_when_active() {
540        let p = GenericProvider::new("test");
541        let state = p.detect_state_from_output(b"output", Duration::from_secs(1));
542        assert_eq!(state, Some(AgentState::Working));
543    }
544
545    #[test]
546    fn generic_idle_after_timeout() {
547        let p = GenericProvider::new("test");
548        let state = p.detect_state_from_output(b"", Duration::from_secs(6));
549        assert_eq!(state, Some(AgentState::Idle));
550    }
551
552    #[test]
553    fn resolve_claude() {
554        let p = resolve("claude");
555        assert_eq!(p.name(), "claude");
556    }
557
558    #[test]
559    fn resolve_unknown_gives_generic() {
560        let p = resolve("my-agent");
561        assert_eq!(p.name(), "my-agent");
562    }
563
564    #[test]
565    fn claude_hook_stop_maps_to_input() {
566        let p = ClaudeProvider;
567        assert_eq!(p.map_hook_event("stop"), Some(AgentState::Input));
568        assert_eq!(
569            p.map_hook_event("notification:idle_prompt"),
570            Some(AgentState::Input)
571        );
572    }
573
574    #[test]
575    fn claude_hook_permission_maps_to_blocked() {
576        let p = ClaudeProvider;
577        assert_eq!(
578            p.map_hook_event("notification:permission_prompt"),
579            Some(AgentState::Blocked)
580        );
581    }
582
583    #[test]
584    fn claude_hook_user_prompt_maps_to_working() {
585        let p = ClaudeProvider;
586        assert_eq!(
587            p.map_hook_event("user_prompt_submit"),
588            Some(AgentState::Working)
589        );
590    }
591
592    #[test]
593    fn claude_hook_unknown_returns_none() {
594        let p = ClaudeProvider;
595        assert_eq!(p.map_hook_event("something_else"), None);
596    }
597
598    #[test]
599    fn generic_hook_always_none() {
600        let p = GenericProvider::new("test");
601        assert_eq!(p.map_hook_event("stop"), None);
602    }
603
604    #[test]
605    fn generic_context_usage_returns_none() {
606        let p = GenericProvider::new("test");
607        assert!(p.context_usage(1234, Path::new("/tmp")).is_none());
608    }
609
610    #[test]
611    fn context_usage_percent() {
612        let cu = ContextUsage {
613            used_tokens: 150_000,
614            limit_tokens: 200_000,
615        };
616        assert_eq!(cu.percent(), 75);
617    }
618
619    #[test]
620    fn context_usage_percent_zero_limit() {
621        let cu = ContextUsage {
622            used_tokens: 100,
623            limit_tokens: 0,
624        };
625        assert_eq!(cu.percent(), 0);
626    }
627
628    #[test]
629    fn context_usage_percent_clamped() {
630        let cu = ContextUsage {
631            used_tokens: 250_000,
632            limit_tokens: 200_000,
633        };
634        assert_eq!(cu.percent(), 100);
635    }
636
637    #[test]
638    fn encode_claude_path_basic() {
639        assert_eq!(
640            encode_claude_path(Path::new("/home/user/Workspace/tam")),
641            "-home-user-Workspace-tam"
642        );
643    }
644
645    #[test]
646    fn encode_claude_path_root() {
647        assert_eq!(encode_claude_path(Path::new("/")), "-");
648    }
649
650    #[test]
651    fn find_last_usage_basic() {
652        let jsonl = r#"{"type":"user","message":{"content":"hello"}}
653{"type":"assistant","message":{"model":"claude-opus-4-6[1m]","usage":{"input_tokens":100,"cache_creation_input_tokens":200,"cache_read_input_tokens":300}}}
654{"type":"user","message":{"content":"bye"}}"#;
655        let (usage, model) = find_last_usage(jsonl).unwrap();
656        assert_eq!(usage.input_tokens, 100);
657        assert_eq!(usage.cache_creation_input_tokens, 200);
658        assert_eq!(usage.cache_read_input_tokens, 300);
659        assert_eq!(model.as_deref(), Some("claude-opus-4-6[1m]"));
660    }
661
662    #[test]
663    fn find_last_usage_returns_last() {
664        let jsonl = r#"{"type":"assistant","message":{"model":"m1","usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
665{"type":"assistant","message":{"model":"m2","usage":{"input_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#;
666        let (usage, model) = find_last_usage(jsonl).unwrap();
667        assert_eq!(usage.input_tokens, 50);
668        assert_eq!(model.as_deref(), Some("m2"));
669    }
670
671    #[test]
672    fn find_last_usage_none_when_empty() {
673        assert!(find_last_usage("").is_none());
674    }
675
676    #[test]
677    fn find_last_usage_skips_malformed() {
678        let jsonl = "not json\n{\"type\":\"assistant\",\"message\":{\"usage\":{\"input_tokens\":42,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}";
679        let (usage, _) = find_last_usage(jsonl).unwrap();
680        assert_eq!(usage.input_tokens, 42);
681    }
682
683    // --- Codex provider tests ---
684
685    #[test]
686    fn codex_provider_basics() {
687        let p = CodexProvider;
688        assert_eq!(p.name(), "codex");
689
690        let cmd = p.build_command(&PathBuf::from("/tmp/project"), &[], None, None);
691        assert_eq!(cmd.get_program(), "codex");
692        let args: Vec<_> = cmd.get_args().collect();
693        assert_eq!(args, &["-C", "/tmp/project"]);
694    }
695
696    #[test]
697    fn codex_resume_session() {
698        let p = CodexProvider;
699        let cmd = p.build_command(&PathBuf::from("/tmp"), &[], Some("sess-456"), None);
700        let args: Vec<_> = cmd.get_args().collect();
701        assert_eq!(args, &["resume", "sess-456", "-C", "/tmp"]);
702    }
703
704    #[test]
705    fn codex_prompt_arg() {
706        let p = CodexProvider;
707        let cmd = p.build_command(&PathBuf::from("/tmp"), &[], None, Some("fix the bug"));
708        let args: Vec<_> = cmd.get_args().collect();
709        assert_eq!(args, &["-C", "/tmp", "fix the bug"]);
710    }
711
712    #[test]
713    fn codex_resume_session_and_prompt() {
714        let p = CodexProvider;
715        let cmd = p.build_command(
716            &PathBuf::from("/tmp"),
717            &[],
718            Some("sess-456"),
719            Some("fix the bug"),
720        );
721        let args: Vec<_> = cmd.get_args().collect();
722        assert_eq!(args, &["resume", "sess-456", "-C", "/tmp", "fix the bug"]);
723    }
724
725    #[test]
726    fn codex_pty_heuristic_working() {
727        let p = CodexProvider;
728        assert_eq!(
729            p.detect_state_from_output(b"output", Duration::from_secs(1)),
730            Some(AgentState::Working)
731        );
732    }
733
734    #[test]
735    fn codex_pty_heuristic_idle() {
736        let p = CodexProvider;
737        assert_eq!(
738            p.detect_state_from_output(b"", Duration::from_secs(6)),
739            Some(AgentState::Idle)
740        );
741    }
742
743    #[test]
744    fn codex_hook_always_none() {
745        let p = CodexProvider;
746        assert_eq!(p.map_hook_event("stop"), None);
747    }
748
749    #[test]
750    fn resolve_codex() {
751        let p = resolve("codex");
752        assert_eq!(p.name(), "codex");
753    }
754
755    #[test]
756    fn codex_find_last_token_count() {
757        // Simulate a Codex JSONL with a token_count event
758        let jsonl = r#"{"type":"session_meta","payload":{"cwd":"/tmp/project"}}
759{"type":"response_item","payload":{"type":"message","role":"user"}}
760{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":50000},"model_context_window":258400}}}"#;
761
762        // Parse the last token_count event directly (unit test for the parsing logic)
763        for line in jsonl.lines().rev() {
764            let entry: CodexJournalLine = match serde_json::from_str(line) {
765                Ok(e) => e,
766                Err(_) => continue,
767            };
768            if entry.line_type.as_deref() != Some("event_msg") {
769                continue;
770            }
771            let payload = entry.payload.unwrap();
772            if payload.payload_type.as_deref() != Some("token_count") {
773                continue;
774            }
775            let info = payload.info.unwrap();
776            let usage = info.last_token_usage.unwrap();
777            assert_eq!(usage.input_tokens, 50000);
778            assert_eq!(info.model_context_window.unwrap(), 258400);
779            return;
780        }
781        panic!("token_count event not found");
782    }
783}