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#[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 pub last_response_at: Option<String>,
24 pub user_turns: usize,
26 pub assistant_turns: usize,
28 pub rounds: usize,
30 pub first_user_message: Option<String>,
31}
32
33#[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#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SessionTranscriptMessage {
44 pub timestamp: Option<String>,
45 pub role: String,
46 pub text: String,
47}
48
49#[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 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
221pub 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
268pub 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
325pub 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
331pub 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
340pub 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
571pub 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
626pub 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
671pub 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
707pub 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
721pub 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 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 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 mtime_ms: u64,
885 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 chosen.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
1113 if chosen.len() > limit {
1114 chosen.truncate(limit);
1115 }
1116 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 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 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(¤t)
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 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 let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1406
1407 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}