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#[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
25fn 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
36fn 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(¤t).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
237fn 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 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 let data = if start > 0 {
266 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 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
415pub 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 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 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 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 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 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 if i < lines_scanned {
486 if let Ok(ref l) = line {
487 bytes_read += l.len() as u64 + 1; }
489 continue;
490 }
491
492 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 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 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 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#[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
613fn 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 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
647pub fn collect_recent_sessions(search_paths: &[String], limit: usize) -> Vec<RecentSession> {
654 let files = find_jsonl_files(search_paths);
655
656 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 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 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 sessions.len() >= limit {
700 break;
701 }
702 }
703
704 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); 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(50));
927
928 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 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); assert!(result.ends_with("..."));
974 }
975
976 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 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 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 let mut f = NamedTempFile::new().unwrap();
1048 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 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 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 let mut f = NamedTempFile::new().unwrap();
1071 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 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 writeln!(f, r#"{{"type":"summary","summary":"Mid-file compaction summary","sessionId":"sess-mid","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1079 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 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 let mut f = NamedTempFile::new().unwrap();
1099 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Привет мир 🌍🌍🌍🌍🌍"}}]}},"sessionId":"sess-utf8","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1102 writeln!(f, r#"{{"type":"summary","summary":"UTF-8 session summary","sessionId":"sess-utf8","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1104
1105 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 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 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 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 let mut f = NamedTempFile::new().unwrap();
1139 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 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 let mut f = NamedTempFile::new().unwrap();
1161 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 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 writeln!(f, r#"{{"type":"summary","summary":"Summary after user message","sessionId":"sess-big","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
1169 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 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 let mut f = NamedTempFile::new().unwrap();
1191 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 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 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 let mut f = NamedTempFile::new().unwrap();
1224 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 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 use std::io::Write;
1253
1254 let mut f = NamedTempFile::new().unwrap();
1255 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 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 let summary_offset = prefix_line.len() as u64 + 1; 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}