Skip to main content

codex_helper_core/
sessions.rs

1use std::cmp::{Ordering, Reverse};
2use std::collections::{HashMap, VecDeque};
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use tokio::fs;
10use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, BufReader};
11
12use crate::config::codex_sessions_dir;
13
14/// Summary information for a Codex conversation session.
15#[derive(Debug, Clone)]
16pub struct SessionSummary {
17    pub id: String,
18    pub path: PathBuf,
19    pub cwd: Option<String>,
20    pub created_at: Option<String>,
21    pub updated_at: Option<String>,
22    /// RFC3339 timestamp string for the most recent assistant message, if available.
23    pub last_response_at: Option<String>,
24    /// Number of user turns (from `event_msg` user_message).
25    pub user_turns: usize,
26    /// Number of assistant messages (from `response_item` message role=assistant).
27    pub assistant_turns: usize,
28    /// Conversation rounds (best-effort; currently `min(user_turns, assistant_turns)`).
29    pub rounds: usize,
30    pub first_user_message: Option<String>,
31}
32
33/// Basic metadata for a Codex session (best-effort parsed from JSONL).
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SessionMeta {
36    pub id: String,
37    pub cwd: Option<String>,
38    pub created_at: Option<String>,
39}
40
41/// A single transcript message extracted from a Codex session JSONL.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SessionTranscriptMessage {
44    pub timestamp: Option<String>,
45    pub role: String,
46    pub text: String,
47}
48
49/// Minimal data for printing `project_root session_id` style lists.
50#[derive(Debug, Clone)]
51pub struct RecentSession {
52    pub id: String,
53    pub cwd: Option<String>,
54    pub mtime_ms: u64,
55}
56
57#[cfg(feature = "gui")]
58#[derive(Debug, Clone)]
59pub struct SessionDayDir {
60    pub date: String,
61    pub path: PathBuf,
62}
63
64#[cfg(feature = "gui")]
65#[derive(Debug, Clone)]
66pub struct SessionIndexItem {
67    pub id: String,
68    pub path: PathBuf,
69    pub cwd: Option<String>,
70    pub created_at: Option<String>,
71    pub updated_hint: Option<String>,
72    pub mtime_ms: u64,
73    pub first_user_message: Option<String>,
74}
75
76pub fn infer_project_root_from_cwd(cwd: &str) -> String {
77    let path = std::path::PathBuf::from(cwd);
78    if !path.is_absolute() {
79        return cwd.to_string();
80    }
81
82    let canonical = std::fs::canonicalize(&path).unwrap_or(path);
83    let mut cur = canonical.clone();
84    loop {
85        if cur.join(".git").exists() {
86            return cur.to_string_lossy().to_string();
87        }
88        if !cur.pop() {
89            break;
90        }
91    }
92    canonical.to_string_lossy().to_string()
93}
94
95const MAX_SCAN_FILES: usize = 10_000;
96const HEAD_SCAN_LINES: usize = 512;
97const IO_CHUNK_SIZE: usize = 64 * 1024;
98const TAIL_SCAN_MAX_BYTES: usize = 1024 * 1024;
99
100const SESSION_STATS_CACHE_VERSION: u32 = 1;
101const MAX_STATS_CACHE_ENTRIES: usize = 20_000;
102const MAX_SCAN_FILES_RECENT: usize = 200_000;
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105struct CachedSessionStats {
106    mtime_ms: u64,
107    size: u64,
108    user_turns: usize,
109    assistant_turns: usize,
110    last_response_at: Option<String>,
111}
112
113#[derive(Debug, Default, Serialize, Deserialize)]
114struct SessionStatsCacheFile {
115    version: u32,
116    entries: HashMap<String, CachedSessionStats>,
117}
118
119struct SessionStatsCache {
120    path: PathBuf,
121    data: SessionStatsCacheFile,
122    dirty: bool,
123}
124
125impl SessionStatsCache {
126    async fn load_default() -> Self {
127        let path = crate::config::proxy_home_dir()
128            .join("cache")
129            .join("session_stats.json");
130        let mut cache = Self {
131            path,
132            data: SessionStatsCacheFile {
133                version: SESSION_STATS_CACHE_VERSION,
134                entries: HashMap::new(),
135            },
136            dirty: false,
137        };
138        let bytes = match fs::read(&cache.path).await {
139            Ok(b) => b,
140            Err(_) => return cache,
141        };
142        let parsed = serde_json::from_slice::<SessionStatsCacheFile>(&bytes);
143        if let Ok(mut data) = parsed {
144            if data.version != SESSION_STATS_CACHE_VERSION {
145                data.version = SESSION_STATS_CACHE_VERSION;
146                data.entries.clear();
147                cache.dirty = true;
148            }
149            cache.data = data;
150        }
151        cache
152    }
153
154    async fn save_if_dirty(&mut self) -> Result<()> {
155        if !self.dirty {
156            return Ok(());
157        }
158        if self.data.entries.len() > MAX_STATS_CACHE_ENTRIES {
159            // Best-effort bounding: drop everything to avoid unbounded growth.
160            self.data.entries.clear();
161        }
162
163        if let Some(parent) = self.path.parent() {
164            fs::create_dir_all(parent).await.ok();
165        }
166
167        let tmp = self.path.with_extension("json.tmp");
168        let bytes = serde_json::to_vec_pretty(&self.data)?;
169        fs::write(&tmp, bytes).await?;
170        fs::rename(&tmp, &self.path).await?;
171        self.dirty = false;
172        Ok(())
173    }
174
175    async fn get_or_compute(&mut self, path: &Path) -> Result<(usize, usize, Option<String>)> {
176        let key = path.to_string_lossy().to_string();
177        let meta = fs::metadata(path)
178            .await
179            .with_context(|| format!("failed to stat session file {:?}", path))?;
180        let size = meta.len();
181        let mtime_ms = meta
182            .modified()
183            .ok()
184            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
185            .map(|d| d.as_millis() as u64)
186            .unwrap_or(0);
187
188        if mtime_ms > 0
189            && let Some(cached) = self.data.entries.get(&key)
190            && cached.mtime_ms == mtime_ms
191            && cached.size == size
192        {
193            return Ok((
194                cached.user_turns,
195                cached.assistant_turns,
196                cached.last_response_at.clone(),
197            ));
198        }
199
200        let (user_turns, assistant_turns) = count_turns_in_file(path).await?;
201        let last_response_at = read_last_assistant_timestamp_from_tail(path).await?;
202
203        if mtime_ms > 0 {
204            self.data.entries.insert(
205                key,
206                CachedSessionStats {
207                    mtime_ms,
208                    size,
209                    user_turns,
210                    assistant_turns,
211                    last_response_at: last_response_at.clone(),
212                },
213            );
214            self.dirty = true;
215        }
216
217        Ok((user_turns, assistant_turns, last_response_at))
218    }
219}
220
221/// Find recent Codex sessions for a given directory, preferring sessions whose cwd matches that directory
222/// (or one of its ancestors/descendants). Results are ordered newest-first by updated_at.
223pub async fn find_codex_sessions_for_dir(
224    root_dir: &Path,
225    limit: usize,
226) -> Result<Vec<SessionSummary>> {
227    let root = codex_sessions_dir();
228    if !root.exists() {
229        return Ok(Vec::new());
230    }
231
232    let mut matched: Vec<SessionHeader> = Vec::new();
233    let mut others: Vec<SessionHeader> = Vec::new();
234    let mut scanned_files: usize = 0;
235
236    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
237
238    'outer: for (_year, year_path) in year_dirs {
239        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
240        for (_month, month_path) in month_dirs {
241            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
242            for (_day, day_path) in day_dirs {
243                let day_files = collect_rollout_files_sorted(&day_path).await?;
244                for path in day_files {
245                    if scanned_files >= MAX_SCAN_FILES {
246                        break 'outer;
247                    }
248                    scanned_files += 1;
249
250                    let header_opt = read_session_header(&path, root_dir).await?;
251                    let Some(header) = header_opt else {
252                        continue;
253                    };
254
255                    if header.is_cwd_match {
256                        matched.push(header);
257                    } else {
258                        others.push(header);
259                    }
260                }
261            }
262        }
263    }
264
265    select_and_expand_headers(matched, others, limit).await
266}
267
268/// Search Codex sessions for user messages containing the given substring.
269/// Matching is case-insensitive and only considers the first user message per session.
270pub async fn search_codex_sessions_for_dir(
271    root_dir: &Path,
272    query: &str,
273    limit: usize,
274) -> Result<Vec<SessionSummary>> {
275    let needle = query.to_lowercase();
276
277    let root = codex_sessions_dir();
278    if !root.exists() {
279        return Ok(Vec::new());
280    }
281
282    let mut matched: Vec<SessionHeader> = Vec::new();
283    let mut others: Vec<SessionHeader> = Vec::new();
284    let mut scanned_files: usize = 0;
285
286    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
287
288    'outer: for (_year, year_path) in year_dirs {
289        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
290        for (_month, month_path) in month_dirs {
291            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
292            for (_day, day_path) in day_dirs {
293                let day_files = collect_rollout_files_sorted(&day_path).await?;
294                for path in day_files {
295                    if scanned_files >= MAX_SCAN_FILES {
296                        break 'outer;
297                    }
298                    scanned_files += 1;
299
300                    let header_opt = read_session_header(&path, root_dir).await?;
301                    let Some(header) = header_opt else {
302                        continue;
303                    };
304                    if !header
305                        .first_user_message
306                        .to_lowercase()
307                        .contains(needle.as_str())
308                    {
309                        continue;
310                    }
311
312                    if header.is_cwd_match {
313                        matched.push(header);
314                    } else {
315                        others.push(header);
316                    }
317                }
318            }
319        }
320    }
321
322    select_and_expand_headers(matched, others, limit).await
323}
324
325/// Convenience wrapper that uses the current working directory as the root for session matching.
326pub async fn find_codex_sessions_for_current_dir(limit: usize) -> Result<Vec<SessionSummary>> {
327    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
328    find_codex_sessions_for_dir(&cwd, limit).await
329}
330
331/// Convenience wrapper to search sessions under the current working directory.
332pub async fn search_codex_sessions_for_current_dir(
333    query: &str,
334    limit: usize,
335) -> Result<Vec<SessionSummary>> {
336    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
337    search_codex_sessions_for_dir(&cwd, query, limit).await
338}
339
340/// List recent Codex sessions across all projects, filtered by session file mtime.
341///
342/// This is optimized for "resume" workflows: it avoids counting turns/timestamps and only reads the
343/// `session_meta` header for sessions that pass the recency filter.
344pub async fn find_recent_codex_sessions(
345    since: Duration,
346    limit: usize,
347) -> Result<Vec<RecentSession>> {
348    let root = codex_sessions_dir();
349    find_recent_codex_sessions_in_dir(&root, since, limit).await
350}
351
352#[cfg(feature = "gui")]
353pub async fn find_recent_codex_session_summaries(
354    since: Duration,
355    limit: usize,
356) -> Result<Vec<SessionSummary>> {
357    if limit == 0 {
358        return Ok(Vec::new());
359    }
360    let sessions_dir = codex_sessions_dir();
361    if !sessions_dir.exists() {
362        return Ok(Vec::new());
363    }
364
365    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
366
367    let now_ms = SystemTime::now()
368        .duration_since(UNIX_EPOCH)
369        .unwrap_or_default()
370        .as_millis()
371        .min(u64::MAX as u128) as u64;
372    let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
373    let threshold_ms = now_ms.saturating_sub(since_ms);
374
375    let mut headers: Vec<SessionHeader> = Vec::new();
376    let mut scanned_files: usize = 0;
377
378    let year_dirs = collect_dirs_desc(&sessions_dir, |s| s.parse::<u32>().ok()).await?;
379    'outer: for (_year, year_path) in year_dirs {
380        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
381        for (_month, month_path) in month_dirs {
382            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
383            for (_day, day_path) in day_dirs {
384                let day_files = collect_rollout_files_sorted(&day_path).await?;
385                for path in day_files {
386                    if scanned_files >= MAX_SCAN_FILES_RECENT {
387                        break 'outer;
388                    }
389                    scanned_files += 1;
390
391                    let meta = match fs::metadata(&path).await {
392                        Ok(m) => m,
393                        Err(_) => continue,
394                    };
395                    let mtime_ms = meta
396                        .modified()
397                        .ok()
398                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
399                        .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
400                        .unwrap_or(0);
401                    if mtime_ms < threshold_ms {
402                        continue;
403                    }
404
405                    let header_opt = read_session_header(&path, &cwd).await?;
406                    let Some(header) = header_opt else {
407                        continue;
408                    };
409                    headers.push(header);
410                }
411            }
412        }
413    }
414
415    select_and_expand_headers(Vec::new(), headers, limit).await
416}
417
418#[cfg(feature = "gui")]
419pub async fn list_codex_session_day_dirs(limit: usize) -> Result<Vec<SessionDayDir>> {
420    if limit == 0 {
421        return Ok(Vec::new());
422    }
423    let root = codex_sessions_dir();
424    if !root.exists() {
425        return Ok(Vec::new());
426    }
427
428    let mut out: Vec<SessionDayDir> = Vec::new();
429    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
430    'outer: for (year, year_path) in year_dirs {
431        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
432        for (month, month_path) in month_dirs {
433            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
434            for (day, day_path) in day_dirs {
435                out.push(SessionDayDir {
436                    date: format!("{year:04}-{month:02}-{day:02}"),
437                    path: day_path,
438                });
439                if out.len() >= limit {
440                    break 'outer;
441                }
442            }
443        }
444    }
445    Ok(out)
446}
447
448#[cfg(feature = "gui")]
449pub async fn list_codex_sessions_in_day_dir(
450    day_dir: &Path,
451    limit: usize,
452) -> Result<Vec<SessionIndexItem>> {
453    if limit == 0 {
454        return Ok(Vec::new());
455    }
456    if !day_dir.exists() {
457        return Ok(Vec::new());
458    }
459
460    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
461    let day_files = collect_rollout_files_sorted(day_dir).await?;
462    let mut out: Vec<SessionIndexItem> = Vec::new();
463    for path in day_files {
464        if out.len() >= limit {
465            break;
466        }
467        let header_opt = read_session_header(&path, &cwd).await?;
468        let Some(mut header) = header_opt else {
469            continue;
470        };
471        header.updated_hint = read_last_timestamp_from_tail(&header.path)
472            .await?
473            .or_else(|| header.created_at.clone());
474        out.push(SessionIndexItem {
475            id: header.id,
476            path: header.path,
477            cwd: header.cwd,
478            created_at: header.created_at,
479            updated_hint: header.updated_hint,
480            mtime_ms: header.mtime_ms,
481            first_user_message: Some(header.first_user_message),
482        });
483    }
484
485    out.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
486    Ok(out)
487}
488
489async fn find_recent_codex_sessions_in_dir(
490    sessions_dir: &Path,
491    since: Duration,
492    limit: usize,
493) -> Result<Vec<RecentSession>> {
494    if limit == 0 {
495        return Ok(Vec::new());
496    }
497    if since.is_zero() {
498        return Ok(Vec::new());
499    }
500    if !sessions_dir.exists() {
501        return Ok(Vec::new());
502    }
503
504    let now_ms = SystemTime::now()
505        .duration_since(UNIX_EPOCH)
506        .unwrap_or_default()
507        .as_millis()
508        .min(u64::MAX as u128) as u64;
509    let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
510    let threshold_ms = now_ms.saturating_sub(since_ms);
511
512    let mut out: Vec<RecentSession> = Vec::new();
513    let mut scanned_files: usize = 0;
514
515    let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
516    'outer: for (_year, year_path) in year_dirs {
517        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
518        for (_month, month_path) in month_dirs {
519            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
520            for (_day, day_path) in day_dirs {
521                let day_files = collect_rollout_files_sorted(&day_path).await?;
522                for path in day_files {
523                    if scanned_files >= MAX_SCAN_FILES_RECENT {
524                        break 'outer;
525                    }
526                    scanned_files += 1;
527
528                    let meta = match fs::metadata(&path).await {
529                        Ok(m) => m,
530                        Err(_) => continue,
531                    };
532                    let mtime_ms = meta
533                        .modified()
534                        .ok()
535                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
536                        .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
537                        .unwrap_or(0);
538                    if mtime_ms < threshold_ms {
539                        continue;
540                    }
541
542                    let file_id = path
543                        .file_name()
544                        .and_then(|s| s.to_str())
545                        .and_then(parse_timestamp_and_uuid)
546                        .map(|(_, uuid)| uuid);
547
548                    let meta = read_codex_session_meta(&path).await?;
549                    let (id, cwd) = if let Some(meta) = meta {
550                        (meta.id, meta.cwd)
551                    } else if let Some(id) = file_id {
552                        (id, None)
553                    } else {
554                        continue;
555                    };
556
557                    out.push(RecentSession { id, cwd, mtime_ms });
558                }
559            }
560        }
561    }
562
563    out.sort_by(|a, b| match b.mtime_ms.cmp(&a.mtime_ms) {
564        Ordering::Equal => b.id.cmp(&a.id),
565        other => other,
566    });
567    out.truncate(limit);
568    Ok(out)
569}
570
571/// Find a Codex session's cwd by its session id (UUID suffix in rollout filename).
572///
573/// This is best-effort and scans session files from newest to oldest until it finds a match.
574pub async fn find_codex_session_cwd_by_id(session_id: &str) -> Result<Option<String>> {
575    let root = codex_sessions_dir();
576    if !root.exists() {
577        return Ok(None);
578    }
579
580    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
581    for (_year, year_path) in year_dirs {
582        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
583        for (_month, month_path) in month_dirs {
584            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
585            for (_day, day_path) in day_dirs {
586                let day_files = collect_rollout_files_sorted(&day_path).await?;
587                for path in day_files {
588                    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
589                        continue;
590                    };
591                    let Some((_ts, uuid)) = parse_timestamp_and_uuid(name) else {
592                        continue;
593                    };
594                    if uuid != session_id {
595                        continue;
596                    }
597
598                    let file = fs::File::open(&path)
599                        .await
600                        .with_context(|| format!("failed to open session file {:?}", path))?;
601                    let reader = BufReader::new(file);
602                    let mut lines = reader.lines();
603                    while let Some(line) = lines.next_line().await? {
604                        let line = line.trim();
605                        if line.is_empty() {
606                            continue;
607                        }
608                        let value: Value = match serde_json::from_str(line) {
609                            Ok(v) => v,
610                            Err(_) => continue,
611                        };
612                        if let Some(meta) = parse_session_meta(&value) {
613                            return Ok(meta.cwd);
614                        }
615                    }
616
617                    return Ok(None);
618                }
619            }
620        }
621    }
622
623    Ok(None)
624}
625
626/// Best-effort: locate a Codex session JSONL file by session id.
627///
628/// We first try to match the UUID suffix in the `rollout-...-<uuid>.jsonl` filename (fast path),
629/// then fall back to scanning session_meta records to match `payload.id`.
630pub async fn find_codex_session_file_by_id(session_id: &str) -> Result<Option<PathBuf>> {
631    let root = codex_sessions_dir();
632    if !root.exists() {
633        return Ok(None);
634    }
635
636    let mut scanned_files: usize = 0;
637    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
638
639    'outer: for (_year, year_path) in year_dirs {
640        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
641        for (_month, month_path) in month_dirs {
642            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
643            for (_day, day_path) in day_dirs {
644                let day_files = collect_rollout_files_sorted(&day_path).await?;
645                for path in day_files {
646                    if scanned_files >= MAX_SCAN_FILES {
647                        break 'outer;
648                    }
649                    scanned_files += 1;
650
651                    if let Some(name) = path.file_name().and_then(|s| s.to_str())
652                        && let Some((_ts, uuid)) = parse_timestamp_and_uuid(name)
653                        && uuid == session_id
654                    {
655                        return Ok(Some(path));
656                    }
657
658                    if let Some(meta) = read_codex_session_meta(&path).await?
659                        && meta.id == session_id
660                    {
661                        return Ok(Some(path));
662                    }
663                }
664            }
665        }
666    }
667
668    Ok(None)
669}
670
671/// Read the `session_meta` record from a Codex session JSONL file (best-effort).
672pub async fn read_codex_session_meta(path: &Path) -> Result<Option<SessionMeta>> {
673    let file = fs::File::open(path)
674        .await
675        .with_context(|| format!("failed to open session file {:?}", path))?;
676    let reader = BufReader::new(file);
677    let mut lines = reader.lines();
678
679    let mut lines_scanned = 0usize;
680    while let Some(line) = lines.next_line().await? {
681        let trimmed = line.trim();
682        if trimmed.is_empty() {
683            continue;
684        }
685        lines_scanned += 1;
686        if lines_scanned > HEAD_SCAN_LINES {
687            break;
688        }
689
690        let value: Value = match serde_json::from_str(trimmed) {
691            Ok(v) => v,
692            Err(_) => continue,
693        };
694
695        if let Some(meta) = parse_session_meta(&value) {
696            return Ok(Some(SessionMeta {
697                id: meta.id,
698                cwd: meta.cwd,
699                created_at: meta.created_at,
700            }));
701        }
702    }
703
704    Ok(None)
705}
706
707/// Read a best-effort transcript from a Codex session JSONL file.
708///
709/// If `tail` is Some(N), only the last N extracted messages are returned.
710pub async fn read_codex_session_transcript(
711    path: &Path,
712    tail: Option<usize>,
713) -> Result<Vec<SessionTranscriptMessage>> {
714    match tail {
715        Some(0) => Ok(Vec::new()),
716        Some(n) => read_codex_session_transcript_tail(path, n).await,
717        None => read_codex_session_transcript_full(path).await,
718    }
719}
720
721/// Best-effort, case-insensitive substring search within the last `tail` transcript messages.
722///
723/// This is intended for interactive UIs (history/session manager). It trades completeness for speed:
724/// - Only scans the last N extracted messages (not the full file).
725/// - Returns `false` for empty queries or `tail == 0`.
726pub async fn codex_session_transcript_tail_contains_query(
727    path: &Path,
728    query: &str,
729    tail: usize,
730) -> Result<bool> {
731    let needle = query.trim();
732    if needle.is_empty() || tail == 0 {
733        return Ok(false);
734    }
735
736    let needle = needle.to_lowercase();
737    let msgs = read_codex_session_transcript(path, Some(tail)).await?;
738    Ok(msgs
739        .iter()
740        .any(|m| m.text.to_lowercase().contains(needle.as_str())))
741}
742
743async fn read_codex_session_transcript_full(path: &Path) -> Result<Vec<SessionTranscriptMessage>> {
744    let file = fs::File::open(path)
745        .await
746        .with_context(|| format!("failed to open session file {:?}", path))?;
747    let reader = BufReader::new(file);
748    let mut lines = reader.lines();
749
750    let mut out: Vec<SessionTranscriptMessage> = Vec::new();
751    while let Some(line) = lines.next_line().await? {
752        let trimmed = line.trim();
753        if trimmed.is_empty() {
754            continue;
755        }
756        let value: Value = match serde_json::from_str(trimmed) {
757            Ok(v) => v,
758            Err(_) => continue,
759        };
760
761        let Some(msg) = extract_transcript_message(&value) else {
762            continue;
763        };
764        if msg.text.trim().is_empty() {
765            continue;
766        }
767        out.push(msg);
768    }
769    Ok(out)
770}
771
772async fn read_codex_session_transcript_tail(
773    path: &Path,
774    n: usize,
775) -> Result<Vec<SessionTranscriptMessage>> {
776    // Best-effort optimization: read a bounded window from the file tail instead of scanning the
777    // whole JSONL. If we don't collect enough messages, expand the window a few times.
778    let mut max_bytes = TAIL_SCAN_MAX_BYTES;
779    let mut last: Vec<SessionTranscriptMessage> = Vec::new();
780    for _ in 0..5 {
781        let (bytes, started_mid) = read_file_tail_bytes(path, max_bytes).await?;
782        last = extract_transcript_messages_from_jsonl_bytes(&bytes, started_mid, n);
783        if last.len() >= n {
784            break;
785        }
786        max_bytes = max_bytes.saturating_mul(2).min(16 * 1024 * 1024);
787    }
788    Ok(last)
789}
790
791async fn read_file_tail_bytes(path: &Path, max_bytes: usize) -> Result<(Vec<u8>, bool)> {
792    let meta = fs::metadata(path)
793        .await
794        .with_context(|| format!("failed to stat session file {:?}", path))?;
795    let len = meta.len();
796    let start = len.saturating_sub(max_bytes as u64);
797    let started_mid = start > 0;
798
799    let mut file = fs::File::open(path)
800        .await
801        .with_context(|| format!("failed to open session file {:?}", path))?;
802    file.seek(std::io::SeekFrom::Start(start)).await?;
803
804    let mut buf = Vec::new();
805    file.read_to_end(&mut buf).await?;
806    Ok((buf, started_mid))
807}
808
809fn extract_transcript_messages_from_jsonl_bytes(
810    bytes: &[u8],
811    started_mid: bool,
812    tail_n: usize,
813) -> Vec<SessionTranscriptMessage> {
814    if tail_n == 0 {
815        return Vec::new();
816    }
817
818    let mut slice = bytes;
819    if started_mid {
820        // If we started mid-file, the first line might be partial; drop it.
821        if let Some(pos) = slice.iter().position(|&b| b == b'\n') {
822            slice = &slice[pos + 1..];
823        }
824    }
825
826    let mut ring: VecDeque<SessionTranscriptMessage> = VecDeque::with_capacity(tail_n.max(1));
827
828    for raw in slice.split(|&b| b == b'\n') {
829        if raw.is_empty() {
830            continue;
831        }
832        let line = match std::str::from_utf8(raw) {
833            Ok(s) => s.trim().trim_end_matches('\r'),
834            Err(_) => continue,
835        };
836        if line.is_empty() {
837            continue;
838        }
839        let value: Value = match serde_json::from_str(line) {
840            Ok(v) => v,
841            Err(_) => continue,
842        };
843        let Some(msg) = extract_transcript_message(&value) else {
844            continue;
845        };
846        if msg.text.trim().is_empty() {
847            continue;
848        }
849
850        ring.push_back(msg);
851        if ring.len() > tail_n {
852            ring.pop_front();
853        }
854    }
855
856    ring.into_iter().collect()
857}
858
859#[cfg(test)]
860async fn summarize_session_for_current_dir(
861    path: &Path,
862    cwd: &Path,
863) -> Result<Option<SessionSummary>> {
864    let header_opt = read_session_header(path, cwd).await?;
865    let Some(header) = header_opt else {
866        return Ok(None);
867    };
868    Ok(Some(expand_header_to_summary_uncached(header).await?))
869}
870
871struct SessionMetaInfo {
872    id: String,
873    cwd: Option<String>,
874    created_at: Option<String>,
875}
876
877#[derive(Debug, Clone)]
878struct SessionHeader {
879    id: String,
880    path: PathBuf,
881    cwd: Option<String>,
882    created_at: Option<String>,
883    /// File modified time in milliseconds since epoch (used for cheap recency sorting).
884    mtime_ms: u64,
885    /// Best-effort: timestamp of the most recent JSONL record (from the file tail; only computed for displayed rows).
886    updated_hint: Option<String>,
887    first_user_message: String,
888    is_cwd_match: bool,
889}
890
891fn parse_session_meta(value: &Value) -> Option<SessionMetaInfo> {
892    let obj = value.as_object()?;
893    let type_str = obj.get("type")?.as_str()?;
894    if type_str != "session_meta" {
895        return None;
896    }
897
898    let payload = obj.get("payload")?.as_object()?;
899    let id = payload.get("id").and_then(|v| v.as_str())?.to_string();
900    let cwd = payload
901        .get("cwd")
902        .and_then(|v| v.as_str())
903        .map(|s| s.to_string());
904    let created_at = payload
905        .get("timestamp")
906        .and_then(|v| v.as_str())
907        .map(|s| s.to_string())
908        .or_else(|| {
909            obj.get("timestamp")
910                .and_then(|v| v.as_str())
911                .map(|s| s.to_string())
912        });
913
914    Some(SessionMetaInfo {
915        id,
916        cwd,
917        created_at,
918    })
919}
920
921fn user_message_text(value: &Value) -> Option<&str> {
922    let obj = value.as_object()?;
923    let type_str = obj.get("type")?.as_str()?;
924    if type_str != "event_msg" {
925        return None;
926    }
927    let payload = obj.get("payload")?.as_object()?;
928    let payload_type = payload.get("type")?.as_str()?;
929    if payload_type != "user_message" {
930        return None;
931    }
932    payload.get("message").and_then(|v| v.as_str())
933}
934
935fn normalize_role(role: &str) -> String {
936    match role {
937        "user" => "User".to_string(),
938        "assistant" => "Assistant".to_string(),
939        "system" => "System".to_string(),
940        other => other.to_string(),
941    }
942}
943
944fn assistant_or_user_message_from_response_item(value: &Value) -> Option<(String, String)> {
945    let obj = value.as_object()?;
946    let type_str = obj.get("type")?.as_str()?;
947    if type_str != "response_item" {
948        return None;
949    }
950    let payload = obj.get("payload")?.as_object()?;
951    let payload_type = payload.get("type")?.as_str()?;
952    if payload_type != "message" {
953        return None;
954    }
955
956    let role = payload.get("role")?.as_str()?;
957    let text = payload
958        .get("content")
959        .and_then(|v| v.as_array())
960        .and_then(|items| extract_text_from_content_items(items))?;
961
962    Some((normalize_role(role), text))
963}
964
965fn extract_text_from_content_items(items: &[Value]) -> Option<String> {
966    let mut out = String::new();
967    for item in items {
968        let obj = match item.as_object() {
969            Some(o) => o,
970            None => continue,
971        };
972        let t = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
973        if !t.ends_with("_text") && t != "text" {
974            continue;
975        }
976        let Some(text) = obj.get("text").and_then(|v| v.as_str()) else {
977            continue;
978        };
979        out.push_str(text);
980    }
981    if out.is_empty() { None } else { Some(out) }
982}
983
984fn extract_transcript_message(value: &Value) -> Option<SessionTranscriptMessage> {
985    let timestamp = value
986        .get("timestamp")
987        .and_then(|v| v.as_str())
988        .map(|s| s.to_string());
989
990    if let Some(msg) = user_message_text(value) {
991        return Some(SessionTranscriptMessage {
992            timestamp,
993            role: "User".to_string(),
994            text: msg.to_string(),
995        });
996    }
997
998    if let Some((role, text)) = assistant_or_user_message_from_response_item(value) {
999        return Some(SessionTranscriptMessage {
1000            timestamp,
1001            role,
1002            text,
1003        });
1004    }
1005
1006    None
1007}
1008
1009fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
1010    if needle.is_empty() {
1011        return true;
1012    }
1013    if haystack.len() < needle.len() {
1014        return false;
1015    }
1016    haystack.windows(needle.len()).any(|w| w == needle)
1017}
1018
1019async fn read_session_header(path: &Path, cwd: &Path) -> Result<Option<SessionHeader>> {
1020    let meta = fs::metadata(path)
1021        .await
1022        .with_context(|| format!("failed to stat session file {:?}", path))?;
1023    let mtime_ms = meta
1024        .modified()
1025        .ok()
1026        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1027        .map(|d| d.as_millis() as u64)
1028        .unwrap_or(0);
1029
1030    let file = fs::File::open(path)
1031        .await
1032        .with_context(|| format!("failed to open session file {:?}", path))?;
1033    let reader = BufReader::new(file);
1034    let mut lines = reader.lines();
1035
1036    let mut session_id: Option<String> = None;
1037    let mut cwd_str: Option<String> = None;
1038    let mut created_at: Option<String> = None;
1039    let mut first_user_message: Option<String> = None;
1040
1041    let mut lines_scanned = 0usize;
1042    while let Some(line) = lines.next_line().await? {
1043        let trimmed = line.trim();
1044        if trimmed.is_empty() {
1045            continue;
1046        }
1047        lines_scanned += 1;
1048        if lines_scanned > HEAD_SCAN_LINES {
1049            break;
1050        }
1051        let value: Value = match serde_json::from_str(trimmed) {
1052            Ok(v) => v,
1053            Err(_) => continue,
1054        };
1055
1056        if session_id.is_none()
1057            && let Some(meta) = parse_session_meta(&value)
1058        {
1059            session_id = Some(meta.id);
1060            cwd_str = meta.cwd;
1061            created_at = meta.created_at;
1062        }
1063
1064        if first_user_message.is_none()
1065            && let Some(msg) = user_message_text(&value)
1066        {
1067            first_user_message = Some(msg.to_string());
1068        }
1069
1070        if session_id.is_some() && first_user_message.is_some() {
1071            break;
1072        }
1073    }
1074
1075    let Some(id) = session_id else {
1076        return Ok(None);
1077    };
1078    let Some(first_user_message) = first_user_message else {
1079        return Ok(None);
1080    };
1081
1082    let cwd_value = cwd_str.clone();
1083    let is_cwd_match = cwd_value
1084        .as_deref()
1085        .map(|s| path_matches_current_dir(s, cwd))
1086        .unwrap_or(false);
1087
1088    Ok(Some(SessionHeader {
1089        id,
1090        path: path.to_path_buf(),
1091        cwd: cwd_value,
1092        created_at,
1093        mtime_ms,
1094        updated_hint: None,
1095        first_user_message,
1096        is_cwd_match,
1097    }))
1098}
1099
1100async fn select_and_expand_headers(
1101    matched: Vec<SessionHeader>,
1102    others: Vec<SessionHeader>,
1103    limit: usize,
1104) -> Result<Vec<SessionSummary>> {
1105    if limit == 0 {
1106        return Ok(Vec::new());
1107    }
1108
1109    let mut chosen = if !matched.is_empty() { matched } else { others };
1110    // Use file mtime for cheap recency ordering; this correctly surfaces sessions that were resumed
1111    // (older filename timestamp but recently appended to).
1112    chosen.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
1113    if chosen.len() > limit {
1114        chosen.truncate(limit);
1115    }
1116    // Only for the rows we will display, compute a more precise timestamp from the JSONL tail.
1117    for header in &mut chosen {
1118        header.updated_hint = read_last_timestamp_from_tail(&header.path)
1119            .await?
1120            .or_else(|| header.created_at.clone());
1121    }
1122
1123    let mut cache = SessionStatsCache::load_default().await;
1124    let mut out: Vec<SessionSummary> = Vec::with_capacity(chosen.len().min(limit));
1125    for header in chosen {
1126        out.push(expand_header_to_summary(&mut cache, header).await?);
1127    }
1128    cache.save_if_dirty().await?;
1129    sort_by_updated_desc(&mut out);
1130    out.truncate(limit);
1131    Ok(out)
1132}
1133
1134fn build_summary_from_stats(
1135    header: SessionHeader,
1136    user_turns: usize,
1137    assistant_turns: usize,
1138    last_response_at: Option<String>,
1139) -> SessionSummary {
1140    let rounds = user_turns.min(assistant_turns);
1141    let updated_at = last_response_at
1142        .clone()
1143        .or_else(|| header.updated_hint.clone())
1144        .or_else(|| header.created_at.clone());
1145
1146    SessionSummary {
1147        id: header.id,
1148        path: header.path,
1149        cwd: header.cwd,
1150        created_at: header.created_at,
1151        updated_at,
1152        last_response_at,
1153        user_turns,
1154        assistant_turns,
1155        rounds,
1156        first_user_message: Some(header.first_user_message),
1157    }
1158}
1159
1160async fn expand_header_to_summary(
1161    cache: &mut SessionStatsCache,
1162    header: SessionHeader,
1163) -> Result<SessionSummary> {
1164    let (user_turns, assistant_turns, last_response_at) =
1165        cache.get_or_compute(&header.path).await?;
1166    Ok(build_summary_from_stats(
1167        header,
1168        user_turns,
1169        assistant_turns,
1170        last_response_at,
1171    ))
1172}
1173
1174#[cfg(test)]
1175async fn expand_header_to_summary_uncached(header: SessionHeader) -> Result<SessionSummary> {
1176    let (user_turns, assistant_turns) = count_turns_in_file(&header.path).await?;
1177    let last_response_at = read_last_assistant_timestamp_from_tail(&header.path).await?;
1178    Ok(build_summary_from_stats(
1179        header,
1180        user_turns,
1181        assistant_turns,
1182        last_response_at,
1183    ))
1184}
1185
1186async fn count_turns_in_file(path: &Path) -> Result<(usize, usize)> {
1187    const USER_TURN_NEEDLE: &[u8] = br#""payload":{"type":"user_message""#;
1188    const ASSISTANT_TURN_NEEDLE: &[u8] = br#""role":"assistant""#;
1189
1190    let mut file = fs::File::open(path)
1191        .await
1192        .with_context(|| format!("failed to open session file {:?}", path))?;
1193
1194    let mut buf = vec![0u8; IO_CHUNK_SIZE];
1195    let mut user_carry: Vec<u8> = Vec::new();
1196    let mut assistant_carry: Vec<u8> = Vec::new();
1197    let mut user_total = 0usize;
1198    let mut assistant_total = 0usize;
1199    let mut user_window: Vec<u8> = Vec::with_capacity(IO_CHUNK_SIZE + USER_TURN_NEEDLE.len());
1200    let mut assistant_window: Vec<u8> =
1201        Vec::with_capacity(IO_CHUNK_SIZE + ASSISTANT_TURN_NEEDLE.len());
1202
1203    loop {
1204        let n = file.read(&mut buf).await?;
1205        if n == 0 {
1206            break;
1207        }
1208
1209        user_window.clear();
1210        user_window.extend_from_slice(&user_carry);
1211        user_window.extend_from_slice(&buf[..n]);
1212        user_total = user_total.saturating_add(count_subslice(&user_window, USER_TURN_NEEDLE));
1213
1214        assistant_window.clear();
1215        assistant_window.extend_from_slice(&assistant_carry);
1216        assistant_window.extend_from_slice(&buf[..n]);
1217        assistant_total = assistant_total
1218            .saturating_add(count_subslice(&assistant_window, ASSISTANT_TURN_NEEDLE));
1219
1220        let user_keep = USER_TURN_NEEDLE.len().saturating_sub(1);
1221        user_carry = if user_keep > 0 && user_window.len() >= user_keep {
1222            user_window[user_window.len() - user_keep..].to_vec()
1223        } else {
1224            Vec::new()
1225        };
1226
1227        let assistant_keep = ASSISTANT_TURN_NEEDLE.len().saturating_sub(1);
1228        assistant_carry = if assistant_keep > 0 && assistant_window.len() >= assistant_keep {
1229            assistant_window[assistant_window.len() - assistant_keep..].to_vec()
1230        } else {
1231            Vec::new()
1232        };
1233    }
1234
1235    Ok((user_total, assistant_total))
1236}
1237
1238fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize {
1239    if needle.is_empty() {
1240        return 0;
1241    }
1242    if haystack.len() < needle.len() {
1243        return 0;
1244    }
1245    haystack
1246        .windows(needle.len())
1247        .filter(|w| *w == needle)
1248        .count()
1249}
1250
1251async fn read_last_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
1252    scan_tail_for_timestamp(path, None).await
1253}
1254
1255async fn read_last_assistant_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
1256    scan_tail_for_timestamp(path, Some(br#""role":"assistant""#)).await
1257}
1258
1259async fn scan_tail_for_timestamp(
1260    path: &Path,
1261    required_substring: Option<&[u8]>,
1262) -> Result<Option<String>> {
1263    let mut file = fs::File::open(path)
1264        .await
1265        .with_context(|| format!("failed to open session file {:?}", path))?;
1266    let meta = file
1267        .metadata()
1268        .await
1269        .with_context(|| format!("failed to stat session file {:?}", path))?;
1270    let mut pos = meta.len();
1271    if pos == 0 {
1272        return Ok(None);
1273    }
1274
1275    let mut scanned = 0usize;
1276    let mut carry: Vec<u8> = Vec::new();
1277    let chunk_size = IO_CHUNK_SIZE as u64;
1278
1279    while pos > 0 && scanned < TAIL_SCAN_MAX_BYTES {
1280        let start = pos.saturating_sub(chunk_size);
1281        let size = (pos - start) as usize;
1282        file.seek(std::io::SeekFrom::Start(start)).await?;
1283
1284        let mut chunk = vec![0u8; size];
1285        file.read_exact(&mut chunk).await?;
1286        scanned = scanned.saturating_add(size);
1287
1288        if !carry.is_empty() {
1289            chunk.extend_from_slice(&carry);
1290        }
1291
1292        // Iterate lines from the end.
1293        let mut end = chunk.len();
1294        while end > 0 {
1295            let mut begin = end;
1296            while begin > 0 && chunk[begin - 1] != b'\n' {
1297                begin -= 1;
1298            }
1299            let line = chunk[begin..end].trim_ascii();
1300            end = begin.saturating_sub(1);
1301
1302            if line.is_empty() {
1303                continue;
1304            }
1305            if let Some(needle) = required_substring
1306                && !contains_bytes(line, needle)
1307            {
1308                continue;
1309            }
1310
1311            let value: Value = match serde_json::from_slice(line) {
1312                Ok(v) => v,
1313                Err(_) => continue,
1314            };
1315            if let Some(ts) = value.get("timestamp").and_then(|v| v.as_str()) {
1316                return Ok(Some(ts.to_string()));
1317            }
1318        }
1319
1320        // Keep the partial first line for the next iteration.
1321        if let Some(first_nl) = chunk.iter().position(|b| *b == b'\n') {
1322            carry = chunk[..first_nl].to_vec();
1323        } else {
1324            carry = chunk;
1325        }
1326
1327        pos = start;
1328    }
1329
1330    Ok(None)
1331}
1332
1333fn path_matches_current_dir(session_cwd: &str, current_dir: &Path) -> bool {
1334    let session_path = PathBuf::from(session_cwd);
1335    if !session_path.is_absolute() {
1336        return false;
1337    }
1338
1339    let current = std::fs::canonicalize(current_dir).unwrap_or_else(|_| current_dir.to_path_buf());
1340    let cwd = std::fs::canonicalize(&session_path).unwrap_or(session_path);
1341
1342    current == cwd || current.starts_with(&cwd) || cwd.starts_with(&current)
1343}
1344
1345async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> std::io::Result<Vec<(T, PathBuf)>>
1346where
1347    T: Ord + Copy,
1348    F: Fn(&str) -> Option<T>,
1349{
1350    let mut dir = fs::read_dir(parent).await?;
1351    let mut vec: Vec<(T, PathBuf)> = Vec::new();
1352    while let Some(entry) = dir.next_entry().await? {
1353        if entry
1354            .file_type()
1355            .await
1356            .map(|ft| ft.is_dir())
1357            .unwrap_or(false)
1358            && let Some(s) = entry.file_name().to_str()
1359            && let Some(v) = parse(s)
1360        {
1361            vec.push((v, entry.path()));
1362        }
1363    }
1364    vec.sort_by_key(|(v, _)| Reverse(*v));
1365    Ok(vec)
1366}
1367
1368async fn collect_rollout_files_sorted(parent: &Path) -> std::io::Result<Vec<PathBuf>> {
1369    let mut dir = fs::read_dir(parent).await?;
1370    let mut records: Vec<(String, String, PathBuf)> = Vec::new();
1371
1372    while let Some(entry) = dir.next_entry().await? {
1373        if entry
1374            .file_type()
1375            .await
1376            .map(|ft| ft.is_file())
1377            .unwrap_or(false)
1378        {
1379            let name_os = entry.file_name();
1380            let Some(name) = name_os.to_str() else {
1381                continue;
1382            };
1383            if !name.starts_with("rollout-") || !name.ends_with(".jsonl") {
1384                continue;
1385            }
1386            if let Some((ts, uuid)) = parse_timestamp_and_uuid(name) {
1387                records.push((ts, uuid, entry.path()));
1388            }
1389        }
1390    }
1391
1392    records.sort_by(|a, b| {
1393        // Sort by timestamp desc, then UUID desc.
1394        match b.0.cmp(&a.0) {
1395            Ordering::Equal => b.1.cmp(&a.1),
1396            other => other,
1397        }
1398    });
1399
1400    Ok(records.into_iter().map(|(_, _, path)| path).collect())
1401}
1402
1403fn parse_timestamp_and_uuid(name: &str) -> Option<(String, String)> {
1404    // Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
1405    let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1406
1407    // Timestamp format is stable and has a fixed width: "YYYY-MM-DDThh-mm-ss" (19 chars).
1408    const TS_LEN: usize = 19;
1409    if core.len() <= TS_LEN + 1 {
1410        return None;
1411    }
1412    let (ts, rest) = core.split_at(TS_LEN);
1413    let uuid = rest.strip_prefix('-')?;
1414    if uuid.is_empty() {
1415        return None;
1416    }
1417    Some((ts.to_string(), uuid.to_string()))
1418}
1419
1420fn sort_by_updated_desc(vec: &mut [SessionSummary]) {
1421    vec.sort_by(|a, b| {
1422        let ta = a.updated_at.as_deref();
1423        let tb = b.updated_at.as_deref();
1424        match (ta, tb) {
1425            (Some(ta), Some(tb)) => tb.cmp(ta),
1426            (Some(_), None) => Ordering::Less,
1427            (None, Some(_)) => Ordering::Greater,
1428            (None, None) => Ordering::Equal,
1429        }
1430    });
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436
1437    use pretty_assertions::assert_eq;
1438
1439    #[test]
1440    fn session_cwd_parent_of_current_dir_matches() {
1441        let base = std::env::current_dir().expect("cwd");
1442        let project = base.join("codex_project_parent");
1443        let child = project.join("subdir");
1444        let session_cwd = project.to_str().expect("project path utf8").to_string();
1445
1446        assert!(
1447            path_matches_current_dir(&session_cwd, &child),
1448            "session cwd should match when it is a parent of current dir"
1449        );
1450    }
1451
1452    #[test]
1453    fn session_cwd_child_of_current_dir_matches() {
1454        let base = std::env::current_dir().expect("cwd");
1455        let project = base.join("codex_project_child");
1456        let child = project.join("subdir");
1457        let session_cwd = child.to_str().expect("child path utf8").to_string();
1458
1459        assert!(
1460            path_matches_current_dir(&session_cwd, &project),
1461            "session cwd should match when it is a child of current dir"
1462        );
1463    }
1464
1465    #[test]
1466    fn unrelated_paths_do_not_match() {
1467        let base = std::env::current_dir().expect("cwd");
1468        let project = base.join("codex_project_main");
1469        let other = base.join("other_project_main");
1470        let session_cwd = other.to_str().expect("other path utf8").to_string();
1471
1472        assert!(
1473            !path_matches_current_dir(&session_cwd, &project),
1474            "unrelated paths should not match"
1475        );
1476    }
1477
1478    #[test]
1479    fn parse_rollout_filename_splits_uuid_correctly() {
1480        let name = "rollout-2025-12-20T16-01-02-550e8400-e29b-41d4-a716-446655440000.jsonl";
1481        let (ts, uuid) = parse_timestamp_and_uuid(name).expect("should parse");
1482        assert_eq!(ts, "2025-12-20T16-01-02");
1483        assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000");
1484    }
1485
1486    #[tokio::test]
1487    async fn summarize_session_tracks_rounds_and_last_response() {
1488        let dir = std::env::temp_dir().join(format!("codex-helper-test-{}", uuid::Uuid::new_v4()));
1489        std::fs::create_dir_all(&dir).expect("create tmp dir");
1490        let path =
1491            dir.join("rollout-2025-12-22T00-00-00-00000000-0000-0000-0000-000000000000.jsonl");
1492        let cwd = dir.join("project");
1493        std::fs::create_dir_all(&cwd).expect("create cwd dir");
1494        let cwd_str = cwd.to_str().expect("cwd utf8");
1495
1496        let meta_line = serde_json::json!({
1497            "timestamp": "2025-12-22T00:00:00.000Z",
1498            "type": "session_meta",
1499            "payload": {
1500                "id": "sid-1",
1501                "cwd": cwd_str,
1502                "timestamp": "2025-12-22T00:00:00.000Z"
1503            }
1504        })
1505        .to_string();
1506        let lines = [
1507            meta_line,
1508            r#"{"timestamp":"2025-12-22T00:00:01.000Z","type":"event_msg","payload":{"type":"user_message","message":"hi"}}"#.to_string(),
1509            r#"{"timestamp":"2025-12-22T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}"#.to_string(),
1510            r#"{"timestamp":"2025-12-22T00:00:03.000Z","type":"event_msg","payload":{"type":"user_message","message":"next"}}"#.to_string(),
1511            r#"{"timestamp":"2025-12-22T00:00:04.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}"#.to_string(),
1512        ]
1513        .join("\n");
1514        std::fs::write(&path, lines).expect("write session file");
1515
1516        let summary = summarize_session_for_current_dir(&path, &cwd)
1517            .await
1518            .expect("summarize ok")
1519            .expect("some summary");
1520
1521        assert_eq!(
1522            summary.user_turns, 2,
1523            "should count user_message events as user turns"
1524        );
1525        assert_eq!(
1526            summary.assistant_turns, 2,
1527            "should count assistant response_item messages"
1528        );
1529        assert_eq!(summary.rounds, 2, "rounds should match assistant turns");
1530        assert_eq!(
1531            summary.last_response_at.as_deref(),
1532            Some("2025-12-22T00:00:04.000Z")
1533        );
1534        assert_eq!(
1535            summary.updated_at.as_deref(),
1536            Some("2025-12-22T00:00:04.000Z"),
1537            "updated_at should prefer last_response_at"
1538        );
1539    }
1540
1541    #[tokio::test]
1542    async fn read_codex_session_transcript_extracts_messages_and_tail() {
1543        let dir = std::env::temp_dir().join(format!("codex-helper-test-{}", uuid::Uuid::new_v4()));
1544        std::fs::create_dir_all(&dir).expect("create tmp dir");
1545        let path =
1546            dir.join("rollout-2025-12-22T00-00-00-00000000-0000-0000-0000-000000000000.jsonl");
1547
1548        let meta_line = serde_json::json!({
1549            "timestamp": "2025-12-22T00:00:00.000Z",
1550            "type": "session_meta",
1551            "payload": {
1552                "id": "00000000-0000-0000-0000-000000000000",
1553                "cwd": "G:/code/project",
1554                "timestamp": "2025-12-22T00:00:00.000Z"
1555            }
1556        })
1557        .to_string();
1558
1559        let lines = [
1560            meta_line,
1561            r#"{"timestamp":"2025-12-22T00:00:01.000Z","type":"event_msg","payload":{"type":"user_message","message":"hi"}}"#.to_string(),
1562            r#"{"timestamp":"2025-12-22T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}"#.to_string(),
1563            r#"{"timestamp":"2025-12-22T00:00:03.000Z","type":"event_msg","payload":{"type":"user_message","message":"next"}}"#.to_string(),
1564            r#"{"timestamp":"2025-12-22T00:00:04.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}"#.to_string(),
1565        ]
1566        .join("\n");
1567        std::fs::write(&path, lines).expect("write session file");
1568
1569        let all = read_codex_session_transcript(&path, None)
1570            .await
1571            .expect("read transcript ok");
1572        assert_eq!(all.len(), 4);
1573        assert_eq!(all[0].role, "User");
1574        assert_eq!(all[0].text, "hi");
1575        assert_eq!(all[1].role, "Assistant");
1576        assert_eq!(all[1].text, "hello");
1577
1578        let tail = read_codex_session_transcript(&path, Some(2))
1579            .await
1580            .expect("read tail ok");
1581        assert_eq!(tail.len(), 2);
1582        assert_eq!(tail[0].text, "next");
1583        assert_eq!(tail[1].text, "ok");
1584
1585        assert!(
1586            codex_session_transcript_tail_contains_query(&path, "HELLO", 3)
1587                .await
1588                .expect("search ok"),
1589            "should match case-insensitively within tail"
1590        );
1591        assert!(
1592            !codex_session_transcript_tail_contains_query(&path, "missing", 10)
1593                .await
1594                .expect("search ok"),
1595            "should return false when not found"
1596        );
1597    }
1598
1599    #[tokio::test]
1600    async fn recent_sessions_filters_by_mtime_and_prefers_meta_id() {
1601        let tmp = std::env::temp_dir().join(format!("codex-helper-test-{}", uuid::Uuid::new_v4()));
1602        std::fs::create_dir_all(&tmp).expect("create tmp dir");
1603
1604        let sessions = tmp.join("sessions").join("2026").join("02").join("01");
1605        std::fs::create_dir_all(&sessions).expect("create sessions dir");
1606
1607        let file1 =
1608            sessions.join("rollout-2026-02-01T00-00-00-11111111-1111-1111-1111-111111111111.jsonl");
1609        let file2 =
1610            sessions.join("rollout-2026-02-01T00-00-01-22222222-2222-2222-2222-222222222222.jsonl");
1611
1612        let meta1 = serde_json::json!({
1613            "timestamp": "2026-02-01T00:00:00.000Z",
1614            "type": "session_meta",
1615            "payload": {
1616                "id": "sid-old",
1617                "cwd": "G:/code/old",
1618                "timestamp": "2026-02-01T00:00:00.000Z"
1619            }
1620        })
1621        .to_string();
1622        std::fs::write(&file1, meta1).expect("write file1");
1623
1624        std::thread::sleep(std::time::Duration::from_millis(50));
1625
1626        let meta2 = serde_json::json!({
1627            "timestamp": "2026-02-01T00:00:01.000Z",
1628            "type": "session_meta",
1629            "payload": {
1630                "id": "sid-new",
1631                "cwd": "G:/code/new",
1632                "timestamp": "2026-02-01T00:00:01.000Z"
1633            }
1634        })
1635        .to_string();
1636        std::fs::write(&file2, meta2).expect("write file2");
1637
1638        let recent = find_recent_codex_sessions_in_dir(
1639            &tmp.join("sessions"),
1640            Duration::from_secs(24 * 3600),
1641            10,
1642        )
1643        .await
1644        .expect("recent ok");
1645        assert_eq!(recent.len(), 2);
1646        assert_eq!(recent[0].id, "sid-new");
1647        assert_eq!(recent[1].id, "sid-old");
1648
1649        let none =
1650            find_recent_codex_sessions_in_dir(&tmp.join("sessions"), Duration::from_secs(0), 10)
1651                .await
1652                .expect("recent ok");
1653        assert_eq!(none.len(), 0, "since=0 should filter everything out");
1654    }
1655}