Skip to main content

ccs/
recent.rs

1use chrono::{DateTime, Utc};
2use rayon::prelude::*;
3use std::collections::{HashMap, HashSet};
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader, Read as _, Seek, SeekFrom};
6use std::path::{Path, PathBuf};
7
8use crate::search::extract_project_from_path;
9use crate::session::{self, SessionSource};
10
11const HEAD_SCAN_LINES: usize = 30;
12
13/// A recently accessed Claude session with summary metadata.
14#[derive(Debug, Clone)]
15pub struct RecentSession {
16    pub session_id: String,
17    pub file_path: String,
18    pub project: String,
19    pub source: SessionSource,
20    pub timestamp: DateTime<Utc>,
21    pub summary: String,
22    pub automation: Option<String>,
23}
24
25/// Truncate a string to `max_len` characters, appending "..." if truncated.
26fn truncate_summary(s: &str, max_len: usize) -> String {
27    let trimmed = s.trim();
28    if trimmed.chars().count() <= max_len {
29        trimmed.to_string()
30    } else {
31        let truncated: String = trimmed.chars().take(max_len.saturating_sub(3)).collect();
32        format!("{}...", truncated)
33    }
34}
35
36/// Extract text content from a message's content field.
37/// Handles both array format [{"type":"text","text":"..."}] and plain string.
38fn extract_text_content(content: &serde_json::Value) -> Option<String> {
39    if let Some(s) = content.as_str() {
40        let trimmed = s.trim();
41        if !trimmed.is_empty() {
42            return Some(trimmed.to_string());
43        }
44        return None;
45    }
46
47    if let Some(arr) = content.as_array() {
48        let mut parts: Vec<String> = Vec::new();
49        for item in arr {
50            let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
51            if item_type == "text" {
52                if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
53                    let trimmed = text.trim();
54                    if !trimmed.is_empty() {
55                        parts.push(trimmed.to_string());
56                    }
57                }
58            }
59        }
60        if !parts.is_empty() {
61            return Some(parts.join(" "));
62        }
63    }
64
65    None
66}
67
68fn extract_non_meta_user_text(json: &serde_json::Value) -> Option<String> {
69    if session::extract_record_type(json) != Some("user") {
70        return None;
71    }
72
73    if json
74        .get("isMeta")
75        .and_then(|v| v.as_bool())
76        .unwrap_or(false)
77    {
78        return None;
79    }
80
81    let message = json.get("message")?;
82    let content = message.get("content")?;
83    extract_text_content(content)
84}
85
86fn is_real_user_prompt(text: &str) -> bool {
87    !text.starts_with("<system-reminder>")
88}
89
90#[derive(Default)]
91struct HeadScan {
92    lines_scanned: usize,
93    session_id: Option<String>,
94    first_user_message: Option<String>,
95    last_summary: Option<String>,
96    last_summary_sid: Option<String>,
97    automation: Option<String>,
98    saw_off_chain_summary: bool,
99}
100
101fn build_latest_chain(path: &Path) -> Option<HashSet<String>> {
102    let file = File::open(path).ok()?;
103    let reader = BufReader::new(file);
104    let mut parents: HashMap<String, Option<String>> = HashMap::new();
105    let mut last_uuid: Option<String> = None;
106
107    for line in reader.lines() {
108        let line = match line {
109            Ok(line) => line,
110            Err(_) => continue,
111        };
112
113        if !line.contains("\"uuid\"") {
114            continue;
115        }
116
117        let json: serde_json::Value = match serde_json::from_str(&line) {
118            Ok(v) => v,
119            Err(_) => continue,
120        };
121
122        if session::is_synthetic_linear_record(&json) {
123            continue;
124        }
125
126        let Some(uuid) = session::extract_uuid(&json) else {
127            continue;
128        };
129        parents.insert(uuid.clone(), session::extract_parent_uuid(&json));
130        last_uuid = Some(uuid);
131    }
132
133    let mut chain = HashSet::new();
134    let mut current = last_uuid?;
135    loop {
136        if !chain.insert(current.clone()) {
137            break;
138        }
139        let Some(parent_uuid) = parents.get(&current).cloned().flatten() else {
140            break;
141        };
142        current = parent_uuid;
143    }
144
145    Some(chain)
146}
147
148fn summary_is_on_latest_chain(
149    json: &serde_json::Value,
150    latest_chain: Option<&HashSet<String>>,
151) -> bool {
152    let Some(latest_chain) = latest_chain else {
153        return true;
154    };
155    let Some(leaf_uuid) = session::extract_leaf_uuid(json) else {
156        return true;
157    };
158    latest_chain.contains(&leaf_uuid)
159}
160
161fn scan_head_with_chain(
162    path: &Path,
163    max_lines: usize,
164    latest_chain: Option<&HashSet<String>>,
165) -> Option<HeadScan> {
166    let file = File::open(path).ok()?;
167    let reader = BufReader::new(file);
168    let mut scan = HeadScan::default();
169
170    for (i, line) in reader.lines().enumerate() {
171        if i >= max_lines {
172            break;
173        }
174        scan.lines_scanned = i + 1;
175
176        let line = match line {
177            Ok(l) => l,
178            Err(_) => continue,
179        };
180
181        let json: serde_json::Value = match serde_json::from_str(&line) {
182            Ok(v) => v,
183            Err(_) => continue,
184        };
185
186        if session::is_synthetic_linear_record(&json) {
187            continue;
188        }
189
190        if scan.session_id.is_none() {
191            scan.session_id = session::extract_session_id(&json);
192        }
193
194        if session::extract_record_type(&json) == Some("summary") {
195            if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
196                let trimmed = summary_text.trim();
197                if !trimmed.is_empty() {
198                    if summary_is_on_latest_chain(&json, latest_chain) {
199                        scan.last_summary = Some(truncate_summary(trimmed, 100));
200                        scan.last_summary_sid = session::extract_session_id(&json);
201                    } else {
202                        scan.saw_off_chain_summary = true;
203                    }
204                }
205            }
206        }
207
208        if let Some(text) = extract_non_meta_user_text(&json) {
209            if scan.first_user_message.is_none() && is_real_user_prompt(&text) {
210                scan.automation = session::detect_automation(&text).map(|s| s.to_string());
211                scan.first_user_message = Some(truncate_summary(&text, 100));
212            }
213        }
214
215        if scan.first_user_message.is_some()
216            && scan.session_id.is_some()
217            && scan.last_summary.is_some()
218        {
219            break;
220        }
221    }
222
223    Some(scan)
224}
225
226#[cfg(test)]
227fn scan_head(path: &Path, max_lines: usize) -> Option<HeadScan> {
228    scan_head_with_chain(path, max_lines, None)
229}
230
231#[derive(Default)]
232struct TailSummaryScan {
233    summary: Option<(Option<String>, String)>,
234    saw_off_chain_summary: bool,
235}
236
237/// Read the last `max_bytes` of a file and search for the last `type=summary` record.
238/// Compaction summaries are appended during context compaction, so they appear near
239/// the end of long session files. Returns (session_id, summary_text) if found.
240///
241/// Reads into a byte buffer and skips to the first newline after the seek offset
242/// to avoid splitting multibyte UTF-8 characters or partial JSONL lines.
243fn find_summary_from_tail_with_chain(
244    path: &Path,
245    max_bytes: u64,
246    latest_chain: Option<&HashSet<String>>,
247) -> Option<TailSummaryScan> {
248    let mut file = File::open(path).ok()?;
249    let file_len = file.metadata().ok()?.len();
250    let start = file_len.saturating_sub(max_bytes);
251
252    // When seeking into the middle, start one byte earlier so we can check
253    // whether the seek position falls on a line boundary without reopening the file.
254    let read_start = if start > 0 { start - 1 } else { 0 };
255    if read_start > 0 {
256        file.seek(SeekFrom::Start(read_start)).ok()?;
257    }
258    let mut buf = Vec::new();
259    file.read_to_end(&mut buf).ok()?;
260
261    // If we seeked into the middle of the file, skip to the first complete line
262    // to avoid partial lines and mid-UTF-8 character issues.
263    // The buffer includes one extra byte before `start` (when start > 0) so we
264    // can check for a line boundary without a second file open.
265    let data = if start > 0 {
266        // buf[0] is the byte at read_start (= start - 1). The actual tail starts at buf[1..].
267        let at_line_boundary = buf[0] == b'\n';
268        let tail_buf = &buf[1..];
269        if at_line_boundary {
270            tail_buf
271        } else if tail_buf.first() == Some(&b'\n') {
272            &tail_buf[1..]
273        } else if let Some(pos) = tail_buf.iter().position(|&b| b == b'\n') {
274            &tail_buf[pos + 1..]
275        } else {
276            return None;
277        }
278    } else {
279        &buf
280    };
281    let tail = String::from_utf8_lossy(data);
282
283    // Find the last summary record in the tail, and track any sessionId from any record
284    // so we have a fallback if the summary record itself lacks a sessionId.
285    let mut last_summary: Option<(Option<String>, String)> = None;
286    let mut any_sid: Option<String> = None;
287    let mut saw_off_chain_summary = false;
288    for line in tail.lines() {
289        let json: serde_json::Value = match serde_json::from_str(line) {
290            Ok(v) => v,
291            Err(_) => continue,
292        };
293        if session::is_synthetic_linear_record(&json) {
294            continue;
295        }
296        if any_sid.is_none() {
297            any_sid = session::extract_session_id(&json);
298        }
299        if session::extract_record_type(&json) == Some("summary") {
300            if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
301                let trimmed = summary_text.trim();
302                if !trimmed.is_empty() {
303                    if summary_is_on_latest_chain(&json, latest_chain) {
304                        let sid = session::extract_session_id(&json);
305                        last_summary = Some((sid, truncate_summary(trimmed, 100)));
306                    } else {
307                        saw_off_chain_summary = true;
308                    }
309                }
310            }
311        }
312    }
313
314    Some(TailSummaryScan {
315        summary: last_summary.map(|(sid, text)| (sid.or(any_sid), text)),
316        saw_off_chain_summary,
317    })
318}
319
320#[cfg(test)]
321fn find_summary_from_tail(path: &Path, max_bytes: u64) -> Option<(Option<String>, String)> {
322    find_summary_from_tail_with_chain(path, max_bytes, None)?.summary
323}
324
325fn extract_latest_user_message_on_chain(
326    path: &Path,
327    latest_chain: &HashSet<String>,
328) -> Option<String> {
329    let file = File::open(path).ok()?;
330    let reader = BufReader::new(file);
331    let mut latest_user_message: Option<String> = None;
332
333    for line in reader.lines() {
334        let line = match line {
335            Ok(line) => line,
336            Err(_) => continue,
337        };
338
339        let json: serde_json::Value = match serde_json::from_str(&line) {
340            Ok(v) => v,
341            Err(_) => continue,
342        };
343
344        if session::is_synthetic_linear_record(&json)
345            || session::extract_record_type(&json) != Some("user")
346        {
347            continue;
348        }
349
350        let is_meta = json
351            .get("isMeta")
352            .and_then(|v| v.as_bool())
353            .unwrap_or(false);
354        if is_meta {
355            continue;
356        }
357
358        let Some(uuid) = session::extract_uuid(&json) else {
359            continue;
360        };
361        if !latest_chain.contains(&uuid) {
362            continue;
363        }
364
365        let Some(message) = json.get("message") else {
366            continue;
367        };
368        let Some(content) = message.get("content") else {
369            continue;
370        };
371        let Some(text) = extract_text_content(content) else {
372            continue;
373        };
374        if text.starts_with("<system-reminder>") {
375            continue;
376        }
377
378        latest_user_message = Some(truncate_summary(&text, 100));
379    }
380
381    latest_user_message
382}
383
384pub(crate) fn detect_session_automation(path: &Path) -> Option<String> {
385    let file = File::open(path).ok()?;
386    let reader = BufReader::new(file);
387
388    for line in reader.lines() {
389        let line = match line {
390            Ok(line) => line,
391            Err(_) => continue,
392        };
393
394        let json: serde_json::Value = match serde_json::from_str(&line) {
395            Ok(v) => v,
396            Err(_) => continue,
397        };
398
399        if session::is_synthetic_linear_record(&json) {
400            continue;
401        }
402
403        let Some(text) = extract_non_meta_user_text(&json) else {
404            continue;
405        };
406
407        if is_real_user_prompt(&text) {
408            return session::detect_automation(&text).map(|s| s.to_string());
409        }
410    }
411
412    None
413}
414
415/// Extract a `RecentSession` from a JSONL session file.
416///
417/// Priority for summary:
418/// 1. `type=summary` record -> use `.summary` field (scans file tail, head, then middle)
419/// 2. First `type=user` where `isMeta` is not true -> extract text content
420///
421/// Uses a three-pass approach:
422/// 1. Check the last 256KB for summary records (compaction summaries at file end)
423/// 2. Scan first 30 lines for session_id, first user message, and summary records
424/// 3. Scan the remaining lines for any missing summary/session-id/first-user metadata
425///
426/// Uses file mtime as timestamp for accurate recency sorting.
427pub fn extract_summary(path: &Path) -> Option<RecentSession> {
428    let path_str = path.to_str().unwrap_or("");
429    let source = SessionSource::from_path(path_str);
430    let project = extract_project_from_path(path_str);
431    const TAIL_BYTES: u64 = 256 * 1024;
432    let latest_chain = build_latest_chain(path);
433
434    // Use file mtime as timestamp — more accurate for recency than first JSONL record
435    let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
436    let mtime_timestamp: DateTime<Utc> = mtime.into();
437    let head_scan = scan_head_with_chain(path, HEAD_SCAN_LINES, latest_chain.as_ref())?;
438
439    // Calculate where the tail scan started (in bytes) so pass 3 knows when to stop
440    let file_len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
441    let tail_start = file_len.saturating_sub(TAIL_BYTES);
442    let tail_scan = find_summary_from_tail_with_chain(path, TAIL_BYTES, latest_chain.as_ref())
443        .unwrap_or_default();
444    let tail_summary = tail_scan.summary;
445
446    // Pass 2: reuse the head scan, then let the tail summary override it when present.
447    let mut session_id = head_scan.session_id.clone();
448    let mut first_user_message = head_scan.first_user_message.clone();
449    let mut last_summary = head_scan.last_summary.clone();
450    let mut last_summary_sid = head_scan.last_summary_sid.clone();
451    let mut automation = head_scan.automation.clone();
452    let mut saw_off_chain_summary =
453        head_scan.saw_off_chain_summary || tail_scan.saw_off_chain_summary;
454    let lines_scanned = head_scan.lines_scanned;
455
456    if let Some((tail_sid, summary_text)) = tail_summary {
457        session_id = tail_sid.clone().or(session_id);
458        last_summary = Some(summary_text);
459        last_summary_sid = tail_sid;
460    }
461
462    // Pass 3: scan the middle region for any metadata the head/tail passes still
463    // could not find. This handles three cases:
464    // - Compaction wrote a summary early, then many messages pushed it out of both windows
465    // - The file starts with >30 non-message records (e.g., file-history-snapshot) so the
466    //   first user message was beyond the head scan
467    // - Automation markers or session IDs only appear in the unscanned middle of a large file
468    //
469    let need_summary = last_summary.is_none();
470    let need_user_msg = first_user_message.is_none();
471    let need_sid = last_summary_sid.is_none() && session_id.is_none();
472    let need_automation = automation.is_none() && first_user_message.is_none();
473    // For summaries, only scan the middle if tail_start > 0 (otherwise tail covered the whole
474    // file). For the first user prompt, session_id, and automation, always scan beyond the head
475    // since the tail pass never looks for them.
476    let should_scan_middle =
477        (need_summary && tail_start > 0) || need_user_msg || need_sid || need_automation;
478    if should_scan_middle && lines_scanned >= HEAD_SCAN_LINES {
479        let file = File::open(path).ok()?;
480        let reader = BufReader::new(file);
481        let mut bytes_read: u64 = 0;
482
483        for (i, line) in reader.lines().enumerate() {
484            // Skip lines already covered by pass 2
485            if i < lines_scanned {
486                if let Ok(ref l) = line {
487                    bytes_read += l.len() as u64 + 1; // +1 for newline
488                }
489                continue;
490            }
491
492            // For summary scanning, stop before the tail region (already covered by pass 1).
493            // For the first real user prompt, continue through the tail since pass 1 never
494            // looks for it.
495            let in_tail_region = tail_start > 0 && bytes_read >= tail_start;
496            let still_need_user_msg = need_user_msg && first_user_message.is_none();
497            let still_need_sid = need_sid && session_id.is_none();
498            if in_tail_region && !(still_need_user_msg || still_need_sid) {
499                break;
500            }
501
502            let line = match line {
503                Ok(l) => l,
504                Err(_) => continue,
505            };
506            bytes_read += line.len() as u64 + 1;
507
508            // Stop once we have everything we need from the middle scan
509            let have_summary = !need_summary || last_summary.is_some();
510            let have_user_msg = !need_user_msg || first_user_message.is_some();
511            let have_sid = !need_sid || session_id.is_some();
512            let have_auto = !need_automation || first_user_message.is_some();
513            if have_summary && have_user_msg && have_sid && have_auto {
514                break;
515            }
516
517            let could_be_summary = need_summary && !in_tail_region && line.contains("\"summary\"");
518            let could_be_user = still_need_user_msg && line.contains("\"user\"");
519
520            let could_have_sid = still_need_sid
521                && (line.contains("\"sessionId\"") || line.contains("\"session_id\""));
522
523            if !could_be_summary && !could_be_user && !could_have_sid {
524                continue;
525            }
526
527            let json: serde_json::Value = match serde_json::from_str(&line) {
528                Ok(v) => v,
529                Err(_) => continue,
530            };
531            if session::is_synthetic_linear_record(&json) {
532                continue;
533            }
534
535            // Extract session_id from any parsed record if still missing
536            if session_id.is_none() {
537                session_id = session::extract_session_id(&json);
538            }
539
540            if could_be_summary && session::extract_record_type(&json) == Some("summary") {
541                if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
542                    let trimmed = summary_text.trim();
543                    if !trimmed.is_empty() {
544                        if summary_is_on_latest_chain(&json, latest_chain.as_ref()) {
545                            last_summary = Some(truncate_summary(trimmed, 100));
546                            last_summary_sid = session::extract_session_id(&json);
547                        } else {
548                            saw_off_chain_summary = true;
549                        }
550                    }
551                }
552            }
553
554            if could_be_user {
555                if let Some(text) = extract_non_meta_user_text(&json) {
556                    if first_user_message.is_none() && is_real_user_prompt(&text) {
557                        automation = session::detect_automation(&text).map(|s| s.to_string());
558                        first_user_message = Some(truncate_summary(&text, 100));
559                    }
560                }
561            }
562        }
563    }
564
565    // After pass 3, check if we found a summary in the middle region
566    if let Some(summary_text) = last_summary {
567        let sid = last_summary_sid.or(session_id)?;
568        return Some(RecentSession {
569            session_id: sid,
570            file_path: path_str.to_string(),
571            project,
572            source,
573            timestamp: mtime_timestamp,
574            summary: summary_text,
575            automation,
576        });
577    }
578
579    let session_id = session_id?;
580    let summary = if saw_off_chain_summary {
581        latest_chain
582            .as_ref()
583            .and_then(|chain| extract_latest_user_message_on_chain(path, chain))
584            .or(first_user_message)
585            .unwrap_or_default()
586    } else {
587        first_user_message.unwrap_or_default()
588    };
589
590    if summary.is_empty() {
591        return None;
592    }
593
594    Some(RecentSession {
595        session_id,
596        file_path: path_str.to_string(),
597        project,
598        source,
599        timestamp: mtime_timestamp,
600        summary,
601        automation,
602    })
603}
604
605/// Read the first few lines to extract session_id.
606/// Scans up to 30 lines to handle files that start with non-session records
607/// (e.g., file-history-snapshot, summary) before the first record with a session_id.
608#[cfg(test)]
609fn extract_session_id_from_head(path: &Path) -> Option<String> {
610    scan_head(path, HEAD_SCAN_LINES).and_then(|scan| scan.session_id)
611}
612
613/// Walk directories and find `*.jsonl` files, skipping `agent-*` files.
614fn find_jsonl_files(search_paths: &[String]) -> Vec<PathBuf> {
615    let mut files: Vec<PathBuf> = Vec::new();
616    for base in search_paths {
617        let base_path = Path::new(base);
618        if !base_path.is_dir() {
619            continue;
620        }
621        collect_jsonl_recursive(base_path, &mut files);
622    }
623    files
624}
625
626fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
627    let entries = match fs::read_dir(dir) {
628        Ok(e) => e,
629        Err(_) => return,
630    };
631    for entry in entries.flatten() {
632        let path = entry.path();
633        if path.is_dir() {
634            // Skip symlinks to avoid infinite loops from cyclic directory structures
635            if path.is_symlink() {
636                continue;
637            }
638            collect_jsonl_recursive(&path, files);
639        } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
640            if name.ends_with(".jsonl") && !name.starts_with("agent-") {
641                files.push(path);
642            }
643        }
644    }
645}
646
647/// Collect recent sessions from search paths.
648///
649/// Walks directories, finds `*.jsonl` files (skipping `agent-*`),
650/// sorts by filesystem mtime descending, extracts summaries in parallel
651/// with rayon (filtering out non-session files), and returns the top
652/// `limit` results sorted by session timestamp descending.
653pub fn collect_recent_sessions(search_paths: &[String], limit: usize) -> Vec<RecentSession> {
654    let files = find_jsonl_files(search_paths);
655
656    // Collect mtime once per file, then sort — avoids O(n log n) repeated stat() calls
657    let mut files_with_mtime: Vec<(PathBuf, std::time::SystemTime)> = files
658        .into_iter()
659        .filter_map(|p| {
660            fs::metadata(&p)
661                .and_then(|m| m.modified())
662                .ok()
663                .map(|t| (p, t))
664        })
665        .collect();
666    files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1));
667
668    let files: Vec<PathBuf> = files_with_mtime.into_iter().map(|(p, _)| p).collect();
669
670    // Process files in batches to avoid reading the entire corpus when only `limit`
671    // sessions are needed. Non-session JSONL files (metadata, auxiliary) return None
672    // from extract_summary(), so we process extra files per batch to compensate.
673    // Start with 4x the limit and expand if needed.
674    let mut sessions: Vec<RecentSession> = Vec::new();
675    let batch_multiplier = 4;
676    let mut offset = 0;
677
678    loop {
679        let batch_size = if offset == 0 {
680            (limit * batch_multiplier).max(limit)
681        } else {
682            // Subsequent batches: process remaining files
683            files.len().saturating_sub(offset)
684        };
685        let end = (offset + batch_size).min(files.len());
686        if offset >= end {
687            break;
688        }
689
690        let batch = &files[offset..end];
691        let batch_sessions: Vec<RecentSession> = batch
692            .par_iter()
693            .filter_map(|path| extract_summary(path))
694            .collect();
695        sessions.extend(batch_sessions);
696        offset = end;
697
698        // If we have enough sessions, stop processing more files
699        if sessions.len() >= limit {
700            break;
701        }
702    }
703
704    // Sort by timestamp descending and apply limit
705    sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
706    sessions.truncate(limit);
707
708    sessions
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use chrono::TimeZone;
715    use std::io::Write;
716    use tempfile::NamedTempFile;
717
718    #[test]
719    fn test_recent_session_creation() {
720        let ts = Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap();
721        let session = RecentSession {
722            session_id: "sess-linear-001".to_string(),
723            file_path: "/Users/user/.claude/projects/-Users-user-myproject/abc.jsonl".to_string(),
724            project: "myproject".to_string(),
725            source: SessionSource::ClaudeCodeCLI,
726            timestamp: ts,
727            summary: "How do I sort a list in Python?".to_string(),
728            automation: None,
729        };
730        assert_eq!(session.session_id, "sess-linear-001");
731        assert_eq!(session.project, "myproject");
732        assert_eq!(session.source, SessionSource::ClaudeCodeCLI);
733        assert_eq!(session.timestamp, ts);
734        assert_eq!(session.summary, "How do I sort a list in Python?");
735    }
736
737    #[test]
738    fn test_recent_session_desktop_source() {
739        let ts = Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap();
740        let session = RecentSession {
741            session_id: "desktop-uuid".to_string(),
742            file_path: "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_session/audit.jsonl".to_string(),
743            project: "uuid1".to_string(),
744            source: SessionSource::ClaudeDesktop,
745            timestamp: ts,
746            summary: "Desktop session summary".to_string(),
747            automation: None,
748        };
749        assert_eq!(session.source, SessionSource::ClaudeDesktop);
750        assert_eq!(session.project, "uuid1");
751    }
752
753    #[test]
754    fn test_extract_summary_returns_first_user_message() {
755        let mut f = NamedTempFile::new().unwrap();
756        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How do I sort a list in Python?"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
757        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Use sorted()"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
758
759        let result = extract_summary(f.path()).unwrap();
760        assert_eq!(result.summary, "How do I sort a list in Python?");
761        assert_eq!(result.session_id, "sess-001");
762    }
763
764    #[test]
765    fn test_extract_summary_prefers_summary_record() {
766        let mut f = NamedTempFile::new().unwrap();
767        writeln!(f, r#"{{"type":"summary","summary":"This session discussed Python sorting","sessionId":"sess-001","timestamp":"2025-06-01T09:59:00Z"}}"#).unwrap();
768        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How do I sort a list?"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
769
770        let result = extract_summary(f.path()).unwrap();
771        assert_eq!(result.summary, "This session discussed Python sorting");
772    }
773
774    #[test]
775    fn test_extract_summary_returns_none_for_empty_file() {
776        let f = NamedTempFile::new().unwrap();
777        let result = extract_summary(f.path());
778        assert!(result.is_none());
779    }
780
781    #[test]
782    fn test_extract_summary_returns_none_for_assistant_only() {
783        let mut f = NamedTempFile::new().unwrap();
784        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hello"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
785
786        let result = extract_summary(f.path());
787        assert!(result.is_none());
788    }
789
790    #[test]
791    fn test_extract_summary_returns_none_for_synthetic_linear_bootstrap_only() {
792        let mut f = NamedTempFile::new().unwrap();
793        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Synthetic linear resume"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:00:00Z","ccsSyntheticLinear":true}}"#).unwrap();
794        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"reply"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:01:00Z","ccsSyntheticLinear":true}}"#).unwrap();
795
796        let result = extract_summary(f.path());
797        assert!(result.is_none());
798    }
799
800    #[test]
801    fn test_extract_summary_keeps_resumed_synthetic_branch_visible() {
802        let mut f = NamedTempFile::new().unwrap();
803        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Synthetic linear resume"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:00:00Z","ccsSyntheticLinear":true}}"#).unwrap();
804        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"bootstrap reply"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:01:00Z","ccsSyntheticLinear":true}}"#).unwrap();
805        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Continue working on this branch"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
806        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Continuing"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
807
808        let result = extract_summary(f.path()).unwrap();
809        assert_eq!(result.session_id, "sess-synth");
810        assert_eq!(result.summary, "Continue working on this branch");
811    }
812
813    #[test]
814    fn test_extract_summary_truncates_long_messages() {
815        let long_msg = "a".repeat(200);
816        let mut f = NamedTempFile::new().unwrap();
817        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#, long_msg).unwrap();
818
819        let result = extract_summary(f.path()).unwrap();
820        assert_eq!(result.summary.len(), 100); // 97 chars + "..."
821        assert!(result.summary.ends_with("..."));
822    }
823
824    #[test]
825    fn test_extract_summary_handles_desktop_format() {
826        let mut f = NamedTempFile::new().unwrap();
827        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":"Explain Docker networking"}},"session_id":"desktop-001","_audit_timestamp":"2026-01-13T10:00:00.000Z"}}"#).unwrap();
828
829        let result = extract_summary(f.path()).unwrap();
830        assert_eq!(result.summary, "Explain Docker networking");
831        assert_eq!(result.session_id, "desktop-001");
832    }
833
834    #[test]
835    fn test_extract_summary_skips_meta_messages() {
836        let mut f = NamedTempFile::new().unwrap();
837        writeln!(f, r#"{{"type":"user","isMeta":true,"message":{{"role":"user","content":[{{"type":"text","text":"init message"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
838        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Real question here"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
839
840        let result = extract_summary(f.path()).unwrap();
841        assert_eq!(result.summary, "Real question here");
842    }
843
844    #[test]
845    fn test_extract_summary_skips_system_reminder_messages() {
846        let mut f = NamedTempFile::new().unwrap();
847        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"<system-reminder>Some system context</system-reminder>"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
848        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Real user question"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
849
850        let result = extract_summary(f.path()).unwrap();
851        assert_eq!(result.summary, "Real user question");
852    }
853
854    #[test]
855    fn test_extract_summary_none_when_only_system_reminders() {
856        let mut f = NamedTempFile::new().unwrap();
857        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"<system-reminder>System context only"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
858
859        let result = extract_summary(f.path());
860        assert!(result.is_none());
861    }
862
863    #[test]
864    fn test_extract_summary_prefers_summary_after_user_message() {
865        // Compaction layout: user message first, then summary record later.
866        // The summary record should be preferred over the first user message.
867        let mut f = NamedTempFile::new().unwrap();
868        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Start a long conversation"}}]}},"uuid":"c1","sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
869        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Sure, ready."}}]}},"uuid":"c2","parentUuid":"c1","sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
870        writeln!(f, r#"{{"type":"summary","summary":"The conversation covered initial setup and greetings.","uuid":"c3","parentUuid":"c2","sessionId":"sess-001","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
871        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Continue after compaction"}}]}},"uuid":"c4","parentUuid":"c3","sessionId":"sess-001","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
872
873        let result = extract_summary(f.path()).unwrap();
874        assert_eq!(
875            result.summary,
876            "The conversation covered initial setup and greetings."
877        );
878    }
879
880    #[test]
881    fn test_extract_summary_from_fixture_compaction() {
882        let path = Path::new("tests/fixtures/compaction_session.jsonl");
883        let result = extract_summary(path).unwrap();
884        assert_eq!(
885            result.summary,
886            "The conversation covered initial setup and greetings."
887        );
888        assert_eq!(result.session_id, "sess-compact-001");
889    }
890
891    #[test]
892    fn test_extract_summary_ignores_off_chain_summary_and_uses_live_branch_prompt() {
893        let mut f = NamedTempFile::new().unwrap();
894        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Original prompt"}}]}},"uuid":"u1","sessionId":"sess-branch","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
895        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Initial reply"}}]}},"uuid":"a1","parentUuid":"u1","sessionId":"sess-branch","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
896        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Abandoned branch prompt"}}]}},"uuid":"u2","parentUuid":"a1","sessionId":"sess-branch","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
897        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Abandoned branch reply"}}]}},"uuid":"a2","parentUuid":"u2","sessionId":"sess-branch","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
898        writeln!(f, r#"{{"type":"summary","summary":"Stale abandoned branch summary","leafUuid":"a2","uuid":"s1","parentUuid":"a2","sessionId":"sess-branch","timestamp":"2025-06-01T10:04:00Z"}}"#).unwrap();
899        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Live branch prompt"}}]}},"uuid":"u3","parentUuid":"a1","sessionId":"sess-branch","timestamp":"2025-06-01T10:05:00Z"}}"#).unwrap();
900        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Live branch reply"}}]}},"uuid":"a3","parentUuid":"u3","sessionId":"sess-branch","timestamp":"2025-06-01T10:06:00Z"}}"#).unwrap();
901
902        let result = extract_summary(f.path()).unwrap();
903        assert_eq!(result.session_id, "sess-branch");
904        assert_eq!(result.summary, "Live branch prompt");
905    }
906
907    #[test]
908    fn test_collect_recent_sessions_not_crowded_by_nonsession_files() {
909        // Auxiliary JSONL files (no user messages, no session_id) should not
910        // crowd out real sessions from the result when limit is applied.
911        let dir = tempfile::TempDir::new().unwrap();
912        let proj = dir.path().join("projects").join("-Users-user-proj");
913        std::fs::create_dir_all(&proj).unwrap();
914
915        // Create 3 real sessions first (older mtime)
916        for i in 0..3 {
917            write_test_session(
918                &proj,
919                &format!("real{}.jsonl", i),
920                &format!("sess-{}", i),
921                &format!("Real question {}", i),
922            );
923        }
924
925        // Brief pause so auxiliary files get newer mtime
926        std::thread::sleep(std::time::Duration::from_millis(50));
927
928        // Create 5 auxiliary JSONL files (no valid session data) — newer mtime
929        for i in 0..5 {
930            let aux_path = proj.join(format!("aux{}.jsonl", i));
931            let mut f = std::fs::File::create(&aux_path).unwrap();
932            writeln!(f, r#"{{"some_metadata":"value{}"}}"#, i).unwrap();
933        }
934
935        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
936        // With limit=3, if we only took top 3 by mtime (all aux files), we'd get 0 sessions.
937        // The over-fetch (limit*2=6) ensures we reach real sessions.
938        let result = collect_recent_sessions(&paths, 3);
939        assert_eq!(result.len(), 3);
940    }
941
942    #[test]
943    fn test_extract_summary_from_fixture_linear() {
944        let path = Path::new("tests/fixtures/linear_session.jsonl");
945        let result = extract_summary(path).unwrap();
946        assert_eq!(result.summary, "How do I sort a list in Python?");
947        assert_eq!(result.session_id, "sess-linear-001");
948    }
949
950    #[test]
951    fn test_extract_summary_from_fixture_desktop() {
952        let path = Path::new("tests/fixtures/desktop_audit_session.jsonl");
953        let result = extract_summary(path).unwrap();
954        assert_eq!(result.summary, "Explain Docker networking");
955    }
956
957    #[test]
958    fn test_truncate_summary_short() {
959        assert_eq!(truncate_summary("short text", 100), "short text");
960    }
961
962    #[test]
963    fn test_truncate_summary_exact() {
964        let s = "a".repeat(100);
965        assert_eq!(truncate_summary(&s, 100), s);
966    }
967
968    #[test]
969    fn test_truncate_summary_long() {
970        let s = "a".repeat(150);
971        let result = truncate_summary(&s, 100);
972        assert_eq!(result.chars().count(), 100); // 97 chars + "..."
973        assert!(result.ends_with("..."));
974    }
975
976    // --- collect_recent_sessions tests ---
977
978    fn write_test_session(dir: &std::path::Path, filename: &str, session_id: &str, msg: &str) {
979        let path = dir.join(filename);
980        let mut f = std::fs::File::create(&path).unwrap();
981        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"{}","timestamp":"2025-06-01T10:00:00Z"}}"#, msg, session_id).unwrap();
982        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"reply"}}]}},"sessionId":"{}","timestamp":"2025-06-01T10:01:00Z"}}"#, session_id).unwrap();
983    }
984
985    #[test]
986    fn test_collect_recent_sessions_finds_across_dirs() {
987        let dir = tempfile::TempDir::new().unwrap();
988        let proj1 = dir.path().join("projects").join("-Users-user-proj1");
989        let proj2 = dir.path().join("projects").join("-Users-user-proj2");
990        std::fs::create_dir_all(&proj1).unwrap();
991        std::fs::create_dir_all(&proj2).unwrap();
992
993        write_test_session(&proj1, "sess1.jsonl", "sess-1", "Question one");
994        write_test_session(&proj2, "sess2.jsonl", "sess-2", "Question two");
995
996        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
997        let result = collect_recent_sessions(&paths, 50);
998        assert_eq!(result.len(), 2);
999
1000        let ids: Vec<&str> = result.iter().map(|s| s.session_id.as_str()).collect();
1001        assert!(ids.contains(&"sess-1"));
1002        assert!(ids.contains(&"sess-2"));
1003    }
1004
1005    #[test]
1006    fn test_collect_recent_sessions_skips_agent_files() {
1007        let dir = tempfile::TempDir::new().unwrap();
1008        let proj = dir.path().join("projects").join("-Users-user-proj");
1009        std::fs::create_dir_all(&proj).unwrap();
1010
1011        write_test_session(&proj, "sess1.jsonl", "sess-1", "Normal session");
1012        write_test_session(&proj, "agent-abc123.jsonl", "sess-1", "Agent session");
1013
1014        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1015        let result = collect_recent_sessions(&paths, 50);
1016        assert_eq!(result.len(), 1);
1017        assert_eq!(result[0].session_id, "sess-1");
1018    }
1019
1020    #[test]
1021    fn test_collect_recent_sessions_sorts_by_timestamp_desc() {
1022        let dir = tempfile::TempDir::new().unwrap();
1023        let proj = dir.path().join("projects").join("-Users-user-proj");
1024        std::fs::create_dir_all(&proj).unwrap();
1025
1026        // Create files with different JSONL timestamps
1027        let older_path = proj.join("older.jsonl");
1028        let mut f = std::fs::File::create(&older_path).unwrap();
1029        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Old question"}}]}},"sessionId":"sess-old","timestamp":"2025-01-01T10:00:00Z"}}"#).unwrap();
1030
1031        let newer_path = proj.join("newer.jsonl");
1032        let mut f = std::fs::File::create(&newer_path).unwrap();
1033        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"New question"}}]}},"sessionId":"sess-new","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1034
1035        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1036        let result = collect_recent_sessions(&paths, 50);
1037        assert_eq!(result.len(), 2);
1038        // Final sort is by session timestamp descending
1039        assert_eq!(result[0].session_id, "sess-new");
1040        assert_eq!(result[1].session_id, "sess-old");
1041    }
1042
1043    #[test]
1044    fn test_extract_summary_finds_late_summary_record() {
1045        // Simulates a long compacted session where the summary record appears
1046        // far from the beginning (beyond any fixed forward-scan limit).
1047        let mut f = NamedTempFile::new().unwrap();
1048        // First user message at line 1
1049        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1050        // 50 assistant/user exchanges (100 lines) pushing summary far away
1051        for i in 0..50 {
1052            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1053            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Follow-up {}"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:02:00Z"}}"#, i).unwrap();
1054        }
1055        // Summary record at line ~102 — well beyond any forward-scan limit
1056        writeln!(f, r#"{{"type":"summary","summary":"Session about Rust performance optimization","sessionId":"sess-long","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1057
1058        let result = extract_summary(f.path()).unwrap();
1059        assert_eq!(
1060            result.summary,
1061            "Session about Rust performance optimization"
1062        );
1063        assert_eq!(result.session_id, "sess-long");
1064    }
1065
1066    #[test]
1067    fn test_extract_summary_finds_summary_in_middle_of_large_file() {
1068        // Regression test: summary record sits between the head scan (30 lines)
1069        // and the tail scan (last 256KB). Without pass 3, this summary is invisible.
1070        let mut f = NamedTempFile::new().unwrap();
1071        // First user message at line 1
1072        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1073        // 40 lines of messages to push past the 30-line head scan
1074        for i in 0..40 {
1075            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1076        }
1077        // Summary record at line ~42 — beyond head scan
1078        writeln!(f, r#"{{"type":"summary","summary":"Mid-file compaction summary","sessionId":"sess-mid","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1079        // Add >256KB of content after the summary to push it out of the tail scan
1080        let padding_line = format!(
1081            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T12:00:00Z"}}"#,
1082            "x".repeat(500)
1083        );
1084        // Each line is ~600 bytes; need ~430 lines to exceed 256KB
1085        for _ in 0..500 {
1086            writeln!(f, "{}", padding_line).unwrap();
1087        }
1088
1089        let result = extract_summary(f.path()).unwrap();
1090        assert_eq!(result.summary, "Mid-file compaction summary");
1091        assert_eq!(result.session_id, "sess-mid");
1092    }
1093
1094    #[test]
1095    fn test_find_summary_from_tail_handles_multibyte_utf8_boundary() {
1096        // If the tail-scan byte offset lands in the middle of a multibyte UTF-8
1097        // character, the function should still find the summary (not silently fail).
1098        let mut f = NamedTempFile::new().unwrap();
1099        // Write a line with multibyte characters (Cyrillic = 2 bytes each, emoji = 4 bytes each)
1100        // This line is ~200 bytes total
1101        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Привет мир 🌍🌍🌍🌍🌍"}}]}},"sessionId":"sess-utf8","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1102        // Summary line is ~120 bytes
1103        writeln!(f, r#"{{"type":"summary","summary":"UTF-8 session summary","sessionId":"sess-utf8","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1104
1105        // Use a tail size that includes the summary line but starts mid-way through
1106        // the first line's multibyte characters. The file is ~320 bytes total;
1107        // reading the last 200 bytes should land inside the Cyrillic/emoji text.
1108        let result = find_summary_from_tail(f.path(), 200);
1109        assert!(result.is_some());
1110        let (sid, text) = result.unwrap();
1111        assert_eq!(text, "UTF-8 session summary");
1112        assert_eq!(sid, Some("sess-utf8".to_string()));
1113    }
1114
1115    #[test]
1116    fn test_extract_summary_finds_summary_in_head_when_tail_misses() {
1117        // Simulates a session where the summary record is near the beginning
1118        // (within the first 30 lines) and the tail window doesn't contain it.
1119        let mut f = NamedTempFile::new().unwrap();
1120        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-head","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1121        writeln!(f, r#"{{"type":"summary","summary":"Summary found in head scan","sessionId":"sess-head","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1122        // Add a few more user messages after the summary
1123        for i in 0..5 {
1124            writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Follow-up {}"}}]}},"sessionId":"sess-head","timestamp":"2025-06-01T10:02:00Z"}}"#, i).unwrap();
1125        }
1126
1127        let result = extract_summary(f.path()).unwrap();
1128        // Should prefer the summary record over the first user message
1129        assert_eq!(result.summary, "Summary found in head scan");
1130        assert_eq!(result.session_id, "sess-head");
1131    }
1132
1133    #[test]
1134    fn test_extract_session_id_from_head_skips_non_session_records() {
1135        // Simulates a file where the first several lines are non-session records
1136        // (e.g., file-history-snapshot) without sessionId, followed by a user record
1137        // with a sessionId beyond the old 5-line limit.
1138        let mut f = NamedTempFile::new().unwrap();
1139        // 8 lines of metadata without sessionId
1140        for i in 0..8 {
1141            writeln!(
1142                f,
1143                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1144                i
1145            )
1146            .unwrap();
1147        }
1148        // User record with sessionId on line 9
1149        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Real question"}}]}},"sessionId":"sess-late-id","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1150
1151        let result = extract_session_id_from_head(f.path());
1152        assert_eq!(result, Some("sess-late-id".to_string()));
1153    }
1154
1155    #[test]
1156    fn test_extract_summary_finds_summary_after_user_in_large_file() {
1157        // Simulates a file larger than TAIL_BYTES where a summary record appears
1158        // AFTER the first user message in the head. The forward scan must not
1159        // break early before finding the summary.
1160        let mut f = NamedTempFile::new().unwrap();
1161        // User message on line 1
1162        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1163        // Some assistant/user exchanges
1164        for i in 0..5 {
1165            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1166        }
1167        // Summary record on line 7 — after the first user message
1168        writeln!(f, r#"{{"type":"summary","summary":"Summary after user message","sessionId":"sess-big","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
1169        // Pad the file to exceed TAIL_BYTES (256KB) so the tail scan doesn't cover the head
1170        let padding_line = format!(
1171            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:03:00Z"}}"#,
1172            "x".repeat(1024)
1173        );
1174        for _ in 0..300 {
1175            writeln!(f, "{}", padding_line).unwrap();
1176        }
1177
1178        let result = extract_summary(f.path()).unwrap();
1179        // Must find the summary record, not fall back to the first user message
1180        assert_eq!(result.summary, "Summary after user message");
1181        assert_eq!(result.session_id, "sess-big");
1182    }
1183
1184    #[test]
1185    fn test_extract_summary_user_message_beyond_head_scan() {
1186        // Regression test: session starts with >30 non-message records (e.g.,
1187        // file-history-snapshot) so the first user message is beyond the 30-line
1188        // head scan. Without scanning the middle for user messages, the session
1189        // would be silently dropped.
1190        let mut f = NamedTempFile::new().unwrap();
1191        // 35 lines of metadata records (with sessionId on the first one)
1192        writeln!(f, r#"{{"type":"file-history-snapshot","files":["a.rs"],"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1193        for i in 1..35 {
1194            writeln!(
1195                f,
1196                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1197                i
1198            )
1199            .unwrap();
1200        }
1201        // First user message at line 36 — beyond the 30-line head scan
1202        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Deep user question"}}]}},"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1203        // A few more messages
1204        for i in 0..3 {
1205            writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:02:00Z"}}"#, i).unwrap();
1206        }
1207
1208        let result = extract_summary(f.path());
1209        assert!(
1210            result.is_some(),
1211            "Session with user message beyond 30-line head scan should not be dropped"
1212        );
1213        let session = result.unwrap();
1214        assert_eq!(session.summary, "Deep user question");
1215        assert_eq!(session.session_id, "sess-deep-user");
1216    }
1217
1218    #[test]
1219    fn test_extract_summary_session_id_only_beyond_head() {
1220        // Regression test: session_id is only present on records beyond the 30-line
1221        // head scan. Pass 3 must extract session_id from those records rather than
1222        // relying solely on the head scan.
1223        let mut f = NamedTempFile::new().unwrap();
1224        // 35 lines of metadata records WITHOUT sessionId
1225        for i in 0..35 {
1226            writeln!(
1227                f,
1228                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1229                i
1230            )
1231            .unwrap();
1232        }
1233        // First user message at line 36 — has sessionId, beyond the head scan
1234        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Late session ID question"}}]}},"sessionId":"sess-late-id","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1235
1236        let result = extract_summary(f.path());
1237        assert!(
1238            result.is_some(),
1239            "Session with session_id only beyond 30-line head scan should not be dropped"
1240        );
1241        let session = result.unwrap();
1242        assert_eq!(session.summary, "Late session ID question");
1243        assert_eq!(session.session_id, "sess-late-id");
1244    }
1245
1246    #[test]
1247    fn test_find_summary_from_tail_exact_line_boundary() {
1248        // Regression test: if the tail-scan offset lands exactly on the first byte
1249        // of a JSONL record (i.e., the preceding byte is '\n'), the function must
1250        // NOT skip that line. Without checking the preceding byte, the code would
1251        // see '{' as the first byte, skip to the next newline, and drop the record.
1252        use std::io::Write;
1253
1254        let mut f = NamedTempFile::new().unwrap();
1255        // Write a "prefix" line that we'll use to calculate exact offset
1256        let prefix_line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"padding"}]},"sessionId":"sess-boundary","timestamp":"2025-06-01T10:00:00Z"}"#;
1257        writeln!(f, "{}", prefix_line).unwrap();
1258        // The summary line starts right after the newline of the prefix line
1259        let summary_line = r#"{"type":"summary","summary":"Boundary summary found","sessionId":"sess-boundary","timestamp":"2025-06-01T11:00:00Z"}"#;
1260        writeln!(f, "{}", summary_line).unwrap();
1261
1262        let file_len = f.as_file().metadata().unwrap().len();
1263        // Set max_bytes so that start = file_len - max_bytes lands exactly on the
1264        // first byte of the summary line (right after the prefix line's newline).
1265        let summary_offset = prefix_line.len() as u64 + 1; // +1 for the newline
1266        let max_bytes = file_len - summary_offset;
1267
1268        let result = find_summary_from_tail(f.path(), max_bytes);
1269        assert!(
1270            result.is_some(),
1271            "Summary at exact tail boundary should not be skipped"
1272        );
1273        let (sid, text) = result.unwrap();
1274        assert_eq!(text, "Boundary summary found");
1275        assert_eq!(sid, Some("sess-boundary".to_string()));
1276    }
1277
1278    #[test]
1279    fn test_collect_recent_sessions_respects_limit() {
1280        let dir = tempfile::TempDir::new().unwrap();
1281        let proj = dir.path().join("projects").join("-Users-user-proj");
1282        std::fs::create_dir_all(&proj).unwrap();
1283
1284        for i in 0..5 {
1285            write_test_session(
1286                &proj,
1287                &format!("sess{}.jsonl", i),
1288                &format!("sess-{}", i),
1289                &format!("Question {}", i),
1290            );
1291        }
1292
1293        let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1294        let result = collect_recent_sessions(&paths, 3);
1295        assert_eq!(result.len(), 3);
1296    }
1297
1298    #[test]
1299    fn test_extract_summary_detects_ralphex_automation() {
1300        let mut f = NamedTempFile::new().unwrap();
1301        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Read the plan file. When done output <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"sess-rx-001","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1302        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Working on it."}}]}},"sessionId":"sess-rx-001","timestamp":"2026-03-28T08:01:00Z"}}"#).unwrap();
1303
1304        let result = extract_summary(f.path()).unwrap();
1305        assert_eq!(result.automation, Some("ralphex".to_string()));
1306    }
1307
1308    #[test]
1309    fn test_extract_summary_manual_session_no_automation() {
1310        let mut f = NamedTempFile::new().unwrap();
1311        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How do I sort a list?"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1312
1313        let result = extract_summary(f.path()).unwrap();
1314        assert_eq!(result.automation, None);
1315    }
1316
1317    #[test]
1318    fn test_extract_summary_ralphex_fixture_file() {
1319        let path = std::path::Path::new("tests/fixtures/ralphex_session.jsonl");
1320        let result = extract_summary(path).unwrap();
1321        assert_eq!(result.session_id, "sess-ralphex-001");
1322        assert_eq!(result.automation, Some("ralphex".to_string()));
1323    }
1324
1325    #[test]
1326    fn test_extract_summary_linear_fixture_no_automation() {
1327        let path = std::path::Path::new("tests/fixtures/linear_session.jsonl");
1328        let result = extract_summary(path).unwrap();
1329        assert_eq!(result.session_id, "sess-linear-001");
1330        assert_eq!(result.automation, None);
1331    }
1332
1333    #[test]
1334    fn test_extract_summary_marker_in_assistant_not_detected() {
1335        let mut f = NamedTempFile::new().unwrap();
1336        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Tell me about ralphex"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1337        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Ralphex uses <<<RALPHEX:ALL_TASKS_DONE>>> signals."}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1338
1339        let result = extract_summary(f.path()).unwrap();
1340        assert_eq!(result.automation, None);
1341    }
1342
1343    #[test]
1344    fn test_extract_summary_tail_summary_uses_head_automation() {
1345        let mut f = NamedTempFile::new().unwrap();
1346        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"When done output <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1347
1348        let padding_line = format!(
1349            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:01:00Z"}}"#,
1350            "x".repeat(1024)
1351        );
1352        for _ in 0..300 {
1353            writeln!(f, "{}", padding_line).unwrap();
1354        }
1355
1356        writeln!(f, r#"{{"type":"summary","summary":"Tail summary with automation marker only in head","sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:02:00Z"}}"#).unwrap();
1357
1358        let result = extract_summary(f.path()).unwrap();
1359        assert_eq!(
1360            result.summary,
1361            "Tail summary with automation marker only in head"
1362        );
1363        assert_eq!(result.automation, Some("ralphex".to_string()));
1364    }
1365
1366    #[test]
1367    fn test_extract_summary_ignores_later_quoted_scheduled_task_marker() {
1368        let mut f = NamedTempFile::new().unwrap();
1369        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"How can I distinguish ralphex transcripts from manual sessions?"}}]}},"sessionId":"sess-manual","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1370        writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Let's inspect the markers."}}]}},"sessionId":"sess-manual","timestamp":"2026-03-28T08:01:00Z"}}"#).unwrap();
1371        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"такие тоже надо детектить <scheduled-task name=\"chezmoi-sync\">"}}]}},"sessionId":"sess-manual","timestamp":"2026-03-28T08:02:00Z"}}"#).unwrap();
1372
1373        let result = extract_summary(f.path()).unwrap();
1374        assert_eq!(
1375            result.summary,
1376            "How can I distinguish ralphex transcripts from manual sessions?"
1377        );
1378        assert_eq!(result.automation, None);
1379    }
1380
1381    #[test]
1382    fn test_extract_summary_tail_summary_scans_middle_for_automation() {
1383        let mut f = NamedTempFile::new().unwrap();
1384
1385        for i in 0..35 {
1386            writeln!(
1387                f,
1388                r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1389                i
1390            )
1391            .unwrap();
1392        }
1393
1394        writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Follow the plan and emit <<<RALPHEX:ALL_TASKS_DONE>>> when complete"}}]}},"sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1395
1396        let padding_line = format!(
1397            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:01:00Z"}}"#,
1398            "x".repeat(1024)
1399        );
1400        for _ in 0..300 {
1401            writeln!(f, "{}", padding_line).unwrap();
1402        }
1403
1404        writeln!(f, r#"{{"type":"summary","summary":"Tail summary with automation marker only in middle","sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:02:00Z"}}"#).unwrap();
1405
1406        let result = extract_summary(f.path()).unwrap();
1407        assert_eq!(
1408            result.summary,
1409            "Tail summary with automation marker only in middle"
1410        );
1411        assert_eq!(result.automation, Some("ralphex".to_string()));
1412    }
1413}