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
68#[derive(Default)]
69struct HeadScan {
70 lines_scanned: usize,
71 session_id: Option<String>,
72 first_user_message: Option<String>,
73 last_summary: Option<String>,
74 last_summary_sid: Option<String>,
75 automation: Option<String>,
76 saw_off_chain_summary: bool,
77}
78
79fn build_latest_chain(path: &Path) -> Option<HashSet<String>> {
80 let file = File::open(path).ok()?;
81 let reader = BufReader::new(file);
82 let mut parents: HashMap<String, Option<String>> = HashMap::new();
83 let mut last_uuid: Option<String> = None;
84
85 for line in reader.lines() {
86 let line = match line {
87 Ok(line) => line,
88 Err(_) => continue,
89 };
90
91 if !line.contains("\"uuid\"") {
92 continue;
93 }
94
95 let json: serde_json::Value = match serde_json::from_str(&line) {
96 Ok(v) => v,
97 Err(_) => continue,
98 };
99
100 if session::is_synthetic_linear_record(&json) {
101 continue;
102 }
103
104 let Some(uuid) = session::extract_uuid(&json) else {
105 continue;
106 };
107 parents.insert(uuid.clone(), session::extract_parent_uuid(&json));
108 last_uuid = Some(uuid);
109 }
110
111 let mut chain = HashSet::new();
112 let mut current = last_uuid?;
113 loop {
114 if !chain.insert(current.clone()) {
115 break;
116 }
117 let Some(parent_uuid) = parents.get(¤t).cloned().flatten() else {
118 break;
119 };
120 current = parent_uuid;
121 }
122
123 Some(chain)
124}
125
126fn summary_is_on_latest_chain(
127 json: &serde_json::Value,
128 latest_chain: Option<&HashSet<String>>,
129) -> bool {
130 let Some(latest_chain) = latest_chain else {
131 return true;
132 };
133 let Some(leaf_uuid) = session::extract_leaf_uuid(json) else {
134 return true;
135 };
136 latest_chain.contains(&leaf_uuid)
137}
138
139fn scan_head_with_chain(
140 path: &Path,
141 max_lines: usize,
142 latest_chain: Option<&HashSet<String>>,
143) -> Option<HeadScan> {
144 let file = File::open(path).ok()?;
145 let reader = BufReader::new(file);
146 let mut scan = HeadScan::default();
147
148 for (i, line) in reader.lines().enumerate() {
149 if i >= max_lines {
150 break;
151 }
152 scan.lines_scanned = i + 1;
153
154 let line = match line {
155 Ok(l) => l,
156 Err(_) => continue,
157 };
158
159 let json: serde_json::Value = match serde_json::from_str(&line) {
160 Ok(v) => v,
161 Err(_) => continue,
162 };
163
164 if session::is_synthetic_linear_record(&json) {
165 continue;
166 }
167
168 if scan.session_id.is_none() {
169 scan.session_id = session::extract_session_id(&json);
170 }
171
172 if session::extract_record_type(&json) == Some("summary") {
173 if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
174 let trimmed = summary_text.trim();
175 if !trimmed.is_empty() {
176 if summary_is_on_latest_chain(&json, latest_chain) {
177 scan.last_summary = Some(truncate_summary(trimmed, 100));
178 scan.last_summary_sid = session::extract_session_id(&json);
179 } else {
180 scan.saw_off_chain_summary = true;
181 }
182 }
183 }
184 }
185
186 if session::extract_record_type(&json) == Some("user") {
187 let is_meta = json
188 .get("isMeta")
189 .and_then(|v| v.as_bool())
190 .unwrap_or(false);
191
192 if !is_meta {
193 if let Some(message) = json.get("message") {
194 if let Some(content) = message.get("content") {
195 if let Some(text) = extract_text_content(content) {
196 if scan.automation.is_none() {
197 scan.automation =
198 session::detect_automation(&text).map(|s| s.to_string());
199 }
200 if scan.first_user_message.is_none()
201 && !text.starts_with("<system-reminder>")
202 {
203 scan.first_user_message = Some(truncate_summary(&text, 100));
204 }
205 }
206 }
207 }
208 }
209 }
210
211 if scan.first_user_message.is_some()
212 && scan.session_id.is_some()
213 && scan.last_summary.is_some()
214 {
215 break;
216 }
217 }
218
219 Some(scan)
220}
221
222#[cfg(test)]
223fn scan_head(path: &Path, max_lines: usize) -> Option<HeadScan> {
224 scan_head_with_chain(path, max_lines, None)
225}
226
227#[derive(Default)]
228struct TailSummaryScan {
229 summary: Option<(Option<String>, String, Option<String>)>,
230 saw_off_chain_summary: bool,
231}
232
233fn find_summary_from_tail_with_chain(
240 path: &Path,
241 max_bytes: u64,
242 latest_chain: Option<&HashSet<String>>,
243) -> Option<TailSummaryScan> {
244 let mut file = File::open(path).ok()?;
245 let file_len = file.metadata().ok()?.len();
246 let start = file_len.saturating_sub(max_bytes);
247
248 let read_start = if start > 0 { start - 1 } else { 0 };
251 if read_start > 0 {
252 file.seek(SeekFrom::Start(read_start)).ok()?;
253 }
254 let mut buf = Vec::new();
255 file.read_to_end(&mut buf).ok()?;
256
257 let data = if start > 0 {
262 let at_line_boundary = buf[0] == b'\n';
264 let tail_buf = &buf[1..];
265 if at_line_boundary {
266 tail_buf
267 } else if tail_buf.first() == Some(&b'\n') {
268 &tail_buf[1..]
269 } else if let Some(pos) = tail_buf.iter().position(|&b| b == b'\n') {
270 &tail_buf[pos + 1..]
271 } else {
272 return None;
273 }
274 } else {
275 &buf
276 };
277 let tail = String::from_utf8_lossy(data);
278
279 let mut last_summary: Option<(Option<String>, String)> = None;
283 let mut any_sid: Option<String> = None;
284 let mut tail_automation: Option<String> = None;
285 let mut saw_off_chain_summary = false;
286 for line in tail.lines() {
287 let json: serde_json::Value = match serde_json::from_str(line) {
288 Ok(v) => v,
289 Err(_) => continue,
290 };
291 if session::is_synthetic_linear_record(&json) {
292 continue;
293 }
294 if any_sid.is_none() {
295 any_sid = session::extract_session_id(&json);
296 }
297 if tail_automation.is_none() && session::extract_record_type(&json) == Some("user") {
298 let is_meta = json
299 .get("isMeta")
300 .and_then(|v| v.as_bool())
301 .unwrap_or(false);
302 if !is_meta {
303 if let Some(message) = json.get("message") {
304 if let Some(content) = message.get("content") {
305 if let Some(text) = extract_text_content(content) {
306 tail_automation =
307 session::detect_automation(&text).map(|s| s.to_string());
308 }
309 }
310 }
311 }
312 }
313 if session::extract_record_type(&json) == Some("summary") {
314 if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
315 let trimmed = summary_text.trim();
316 if !trimmed.is_empty() {
317 if summary_is_on_latest_chain(&json, latest_chain) {
318 let sid = session::extract_session_id(&json);
319 last_summary = Some((sid, truncate_summary(trimmed, 100)));
320 } else {
321 saw_off_chain_summary = true;
322 }
323 }
324 }
325 }
326 }
327
328 Some(TailSummaryScan {
329 summary: last_summary.map(|(sid, text)| (sid.or(any_sid), text, tail_automation)),
330 saw_off_chain_summary,
331 })
332}
333
334#[cfg(test)]
335fn find_summary_from_tail(
336 path: &Path,
337 max_bytes: u64,
338) -> Option<(Option<String>, String, Option<String>)> {
339 find_summary_from_tail_with_chain(path, max_bytes, None)?.summary
340}
341
342fn extract_latest_user_message_on_chain(
343 path: &Path,
344 latest_chain: &HashSet<String>,
345) -> Option<String> {
346 let file = File::open(path).ok()?;
347 let reader = BufReader::new(file);
348 let mut latest_user_message: Option<String> = None;
349
350 for line in reader.lines() {
351 let line = match line {
352 Ok(line) => line,
353 Err(_) => continue,
354 };
355
356 let json: serde_json::Value = match serde_json::from_str(&line) {
357 Ok(v) => v,
358 Err(_) => continue,
359 };
360
361 if session::is_synthetic_linear_record(&json)
362 || session::extract_record_type(&json) != Some("user")
363 {
364 continue;
365 }
366
367 let is_meta = json
368 .get("isMeta")
369 .and_then(|v| v.as_bool())
370 .unwrap_or(false);
371 if is_meta {
372 continue;
373 }
374
375 let Some(uuid) = session::extract_uuid(&json) else {
376 continue;
377 };
378 if !latest_chain.contains(&uuid) {
379 continue;
380 }
381
382 let Some(message) = json.get("message") else {
383 continue;
384 };
385 let Some(content) = message.get("content") else {
386 continue;
387 };
388 let Some(text) = extract_text_content(content) else {
389 continue;
390 };
391 if text.starts_with("<system-reminder>") {
392 continue;
393 }
394
395 latest_user_message = Some(truncate_summary(&text, 100));
396 }
397
398 latest_user_message
399}
400
401pub(crate) fn detect_session_automation(path: &Path) -> Option<String> {
402 let file = File::open(path).ok()?;
403 let reader = BufReader::new(file);
404
405 for line in reader.lines() {
406 let line = match line {
407 Ok(line) => line,
408 Err(_) => continue,
409 };
410
411 let json: serde_json::Value = match serde_json::from_str(&line) {
412 Ok(v) => v,
413 Err(_) => continue,
414 };
415
416 if session::is_synthetic_linear_record(&json)
417 || session::extract_record_type(&json) != Some("user")
418 {
419 continue;
420 }
421
422 let is_meta = json
423 .get("isMeta")
424 .and_then(|v| v.as_bool())
425 .unwrap_or(false);
426 if is_meta {
427 continue;
428 }
429
430 let Some(message) = json.get("message") else {
431 continue;
432 };
433 let Some(content) = message.get("content") else {
434 continue;
435 };
436 let Some(text) = extract_text_content(content) else {
437 continue;
438 };
439
440 if let Some(automation) = session::detect_automation(&text) {
441 return Some(automation.to_string());
442 }
443 }
444
445 None
446}
447
448pub fn extract_summary(path: &Path) -> Option<RecentSession> {
461 let path_str = path.to_str().unwrap_or("");
462 let source = SessionSource::from_path(path_str);
463 let project = extract_project_from_path(path_str);
464 const TAIL_BYTES: u64 = 256 * 1024;
465 let latest_chain = build_latest_chain(path);
466
467 let mtime = fs::metadata(path).and_then(|m| m.modified()).ok()?;
469 let mtime_timestamp: DateTime<Utc> = mtime.into();
470 let head_scan = scan_head_with_chain(path, HEAD_SCAN_LINES, latest_chain.as_ref())?;
471
472 let file_len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
474 let tail_start = file_len.saturating_sub(TAIL_BYTES);
475 let tail_scan = find_summary_from_tail_with_chain(path, TAIL_BYTES, latest_chain.as_ref())
476 .unwrap_or_default();
477 let tail_summary = tail_scan.summary;
478 let tail_scanned_user_fields = tail_summary.is_some();
479
480 let mut session_id = head_scan.session_id.clone();
482 let mut first_user_message = head_scan.first_user_message.clone();
483 let mut last_summary = head_scan.last_summary.clone();
484 let mut last_summary_sid = head_scan.last_summary_sid.clone();
485 let mut automation = head_scan.automation.clone();
486 let mut saw_off_chain_summary =
487 head_scan.saw_off_chain_summary || tail_scan.saw_off_chain_summary;
488 let lines_scanned = head_scan.lines_scanned;
489
490 if let Some((tail_sid, summary_text, tail_automation)) = tail_summary {
491 session_id = tail_sid.clone().or(session_id);
492 last_summary = Some(summary_text);
493 last_summary_sid = tail_sid;
494 automation = tail_automation.or(automation);
495 }
496
497 let need_summary = last_summary.is_none();
507 let need_user_msg = last_summary.is_none() && first_user_message.is_none();
508 let need_sid = last_summary_sid.is_none() && session_id.is_none();
509 let need_automation = automation.is_none();
510 let should_scan_middle =
514 (need_summary && tail_start > 0) || need_user_msg || need_sid || need_automation;
515 if should_scan_middle && lines_scanned >= HEAD_SCAN_LINES {
516 let file = File::open(path).ok()?;
517 let reader = BufReader::new(file);
518 let mut bytes_read: u64 = 0;
519
520 for (i, line) in reader.lines().enumerate() {
521 if i < lines_scanned {
523 if let Ok(ref l) = line {
524 bytes_read += l.len() as u64 + 1; }
526 continue;
527 }
528
529 let in_tail_region = tail_start > 0 && bytes_read >= tail_start;
533 let still_need_user_msg = need_user_msg && first_user_message.is_none();
534 let still_need_sid = need_sid && session_id.is_none();
535 let still_need_auto = need_automation && automation.is_none();
536 let need_tail_user_scan = !tail_scanned_user_fields
537 && (still_need_user_msg || still_need_sid || still_need_auto);
538 if in_tail_region && !need_tail_user_scan {
539 break;
540 }
541
542 let line = match line {
543 Ok(l) => l,
544 Err(_) => continue,
545 };
546 bytes_read += line.len() as u64 + 1;
547
548 let have_summary = !need_summary || last_summary.is_some();
550 let have_user_msg = !need_user_msg || first_user_message.is_some();
551 let have_sid = !need_sid || session_id.is_some();
552 let have_auto = !need_automation || automation.is_some();
553 if have_summary && have_user_msg && have_sid && have_auto {
554 break;
555 }
556
557 let could_be_summary = need_summary && !in_tail_region && line.contains("\"summary\"");
558 let could_be_user =
559 (still_need_user_msg || still_need_auto) && line.contains("\"user\"");
560
561 let could_have_sid = still_need_sid
562 && (line.contains("\"sessionId\"") || line.contains("\"session_id\""));
563
564 if !could_be_summary && !could_be_user && !could_have_sid {
565 continue;
566 }
567
568 let json: serde_json::Value = match serde_json::from_str(&line) {
569 Ok(v) => v,
570 Err(_) => continue,
571 };
572 if session::is_synthetic_linear_record(&json) {
573 continue;
574 }
575
576 if session_id.is_none() {
578 session_id = session::extract_session_id(&json);
579 }
580
581 if could_be_summary && session::extract_record_type(&json) == Some("summary") {
582 if let Some(summary_text) = json.get("summary").and_then(|v| v.as_str()) {
583 let trimmed = summary_text.trim();
584 if !trimmed.is_empty() {
585 if summary_is_on_latest_chain(&json, latest_chain.as_ref()) {
586 last_summary = Some(truncate_summary(trimmed, 100));
587 last_summary_sid = session::extract_session_id(&json);
588 } else {
589 saw_off_chain_summary = true;
590 }
591 }
592 }
593 }
594
595 if could_be_user && session::extract_record_type(&json) == Some("user") {
596 let is_meta = json
597 .get("isMeta")
598 .and_then(|v| v.as_bool())
599 .unwrap_or(false);
600 if !is_meta {
601 if let Some(message) = json.get("message") {
602 if let Some(content) = message.get("content") {
603 if let Some(text) = extract_text_content(content) {
604 if automation.is_none() {
605 automation =
606 session::detect_automation(&text).map(|s| s.to_string());
607 }
608 if first_user_message.is_none()
609 && !text.starts_with("<system-reminder>")
610 {
611 first_user_message = Some(truncate_summary(&text, 100));
612 }
613 }
614 }
615 }
616 }
617 }
618 }
619 }
620
621 if let Some(summary_text) = last_summary {
623 let sid = last_summary_sid.or(session_id)?;
624 return Some(RecentSession {
625 session_id: sid,
626 file_path: path_str.to_string(),
627 project,
628 source,
629 timestamp: mtime_timestamp,
630 summary: summary_text,
631 automation,
632 });
633 }
634
635 let session_id = session_id?;
636 let summary = if saw_off_chain_summary {
637 latest_chain
638 .as_ref()
639 .and_then(|chain| extract_latest_user_message_on_chain(path, chain))
640 .or(first_user_message)
641 .unwrap_or_default()
642 } else {
643 first_user_message.unwrap_or_default()
644 };
645
646 if summary.is_empty() {
647 return None;
648 }
649
650 Some(RecentSession {
651 session_id,
652 file_path: path_str.to_string(),
653 project,
654 source,
655 timestamp: mtime_timestamp,
656 summary,
657 automation,
658 })
659}
660
661#[cfg(test)]
665fn extract_session_id_from_head(path: &Path) -> Option<String> {
666 scan_head(path, HEAD_SCAN_LINES).and_then(|scan| scan.session_id)
667}
668
669fn find_jsonl_files(search_paths: &[String]) -> Vec<PathBuf> {
671 let mut files: Vec<PathBuf> = Vec::new();
672 for base in search_paths {
673 let base_path = Path::new(base);
674 if !base_path.is_dir() {
675 continue;
676 }
677 collect_jsonl_recursive(base_path, &mut files);
678 }
679 files
680}
681
682fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
683 let entries = match fs::read_dir(dir) {
684 Ok(e) => e,
685 Err(_) => return,
686 };
687 for entry in entries.flatten() {
688 let path = entry.path();
689 if path.is_dir() {
690 if path.is_symlink() {
692 continue;
693 }
694 collect_jsonl_recursive(&path, files);
695 } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
696 if name.ends_with(".jsonl") && !name.starts_with("agent-") {
697 files.push(path);
698 }
699 }
700 }
701}
702
703pub fn collect_recent_sessions(search_paths: &[String], limit: usize) -> Vec<RecentSession> {
710 let files = find_jsonl_files(search_paths);
711
712 let mut files_with_mtime: Vec<(PathBuf, std::time::SystemTime)> = files
714 .into_iter()
715 .filter_map(|p| {
716 fs::metadata(&p)
717 .and_then(|m| m.modified())
718 .ok()
719 .map(|t| (p, t))
720 })
721 .collect();
722 files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1));
723
724 let files: Vec<PathBuf> = files_with_mtime.into_iter().map(|(p, _)| p).collect();
725
726 let mut sessions: Vec<RecentSession> = Vec::new();
731 let batch_multiplier = 4;
732 let mut offset = 0;
733
734 loop {
735 let batch_size = if offset == 0 {
736 (limit * batch_multiplier).max(limit)
737 } else {
738 files.len().saturating_sub(offset)
740 };
741 let end = (offset + batch_size).min(files.len());
742 if offset >= end {
743 break;
744 }
745
746 let batch = &files[offset..end];
747 let batch_sessions: Vec<RecentSession> = batch
748 .par_iter()
749 .filter_map(|path| extract_summary(path))
750 .collect();
751 sessions.extend(batch_sessions);
752 offset = end;
753
754 if sessions.len() >= limit {
756 break;
757 }
758 }
759
760 sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
762 sessions.truncate(limit);
763
764 sessions
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use chrono::TimeZone;
771 use std::io::Write;
772 use tempfile::NamedTempFile;
773
774 #[test]
775 fn test_recent_session_creation() {
776 let ts = Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap();
777 let session = RecentSession {
778 session_id: "sess-linear-001".to_string(),
779 file_path: "/Users/user/.claude/projects/-Users-user-myproject/abc.jsonl".to_string(),
780 project: "myproject".to_string(),
781 source: SessionSource::ClaudeCodeCLI,
782 timestamp: ts,
783 summary: "How do I sort a list in Python?".to_string(),
784 automation: None,
785 };
786 assert_eq!(session.session_id, "sess-linear-001");
787 assert_eq!(session.project, "myproject");
788 assert_eq!(session.source, SessionSource::ClaudeCodeCLI);
789 assert_eq!(session.timestamp, ts);
790 assert_eq!(session.summary, "How do I sort a list in Python?");
791 }
792
793 #[test]
794 fn test_recent_session_desktop_source() {
795 let ts = Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap();
796 let session = RecentSession {
797 session_id: "desktop-uuid".to_string(),
798 file_path: "/Users/user/Library/Application Support/Claude/local-agent-mode-sessions/uuid1/uuid2/local_session/audit.jsonl".to_string(),
799 project: "uuid1".to_string(),
800 source: SessionSource::ClaudeDesktop,
801 timestamp: ts,
802 summary: "Desktop session summary".to_string(),
803 automation: None,
804 };
805 assert_eq!(session.source, SessionSource::ClaudeDesktop);
806 assert_eq!(session.project, "uuid1");
807 }
808
809 #[test]
810 fn test_extract_summary_returns_first_user_message() {
811 let mut f = NamedTempFile::new().unwrap();
812 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();
813 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Use sorted()"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
814
815 let result = extract_summary(f.path()).unwrap();
816 assert_eq!(result.summary, "How do I sort a list in Python?");
817 assert_eq!(result.session_id, "sess-001");
818 }
819
820 #[test]
821 fn test_extract_summary_prefers_summary_record() {
822 let mut f = NamedTempFile::new().unwrap();
823 writeln!(f, r#"{{"type":"summary","summary":"This session discussed Python sorting","sessionId":"sess-001","timestamp":"2025-06-01T09:59:00Z"}}"#).unwrap();
824 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();
825
826 let result = extract_summary(f.path()).unwrap();
827 assert_eq!(result.summary, "This session discussed Python sorting");
828 }
829
830 #[test]
831 fn test_extract_summary_returns_none_for_empty_file() {
832 let f = NamedTempFile::new().unwrap();
833 let result = extract_summary(f.path());
834 assert!(result.is_none());
835 }
836
837 #[test]
838 fn test_extract_summary_returns_none_for_assistant_only() {
839 let mut f = NamedTempFile::new().unwrap();
840 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hello"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
841
842 let result = extract_summary(f.path());
843 assert!(result.is_none());
844 }
845
846 #[test]
847 fn test_extract_summary_returns_none_for_synthetic_linear_bootstrap_only() {
848 let mut f = NamedTempFile::new().unwrap();
849 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();
850 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();
851
852 let result = extract_summary(f.path());
853 assert!(result.is_none());
854 }
855
856 #[test]
857 fn test_extract_summary_keeps_resumed_synthetic_branch_visible() {
858 let mut f = NamedTempFile::new().unwrap();
859 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();
860 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();
861 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();
862 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Continuing"}}]}},"sessionId":"sess-synth","timestamp":"2025-06-01T10:03:00Z"}}"#).unwrap();
863
864 let result = extract_summary(f.path()).unwrap();
865 assert_eq!(result.session_id, "sess-synth");
866 assert_eq!(result.summary, "Continue working on this branch");
867 }
868
869 #[test]
870 fn test_extract_summary_truncates_long_messages() {
871 let long_msg = "a".repeat(200);
872 let mut f = NamedTempFile::new().unwrap();
873 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-001","timestamp":"2025-06-01T10:00:00Z"}}"#, long_msg).unwrap();
874
875 let result = extract_summary(f.path()).unwrap();
876 assert_eq!(result.summary.len(), 100); assert!(result.summary.ends_with("..."));
878 }
879
880 #[test]
881 fn test_extract_summary_handles_desktop_format() {
882 let mut f = NamedTempFile::new().unwrap();
883 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();
884
885 let result = extract_summary(f.path()).unwrap();
886 assert_eq!(result.summary, "Explain Docker networking");
887 assert_eq!(result.session_id, "desktop-001");
888 }
889
890 #[test]
891 fn test_extract_summary_skips_meta_messages() {
892 let mut f = NamedTempFile::new().unwrap();
893 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();
894 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();
895
896 let result = extract_summary(f.path()).unwrap();
897 assert_eq!(result.summary, "Real question here");
898 }
899
900 #[test]
901 fn test_extract_summary_skips_system_reminder_messages() {
902 let mut f = NamedTempFile::new().unwrap();
903 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();
904 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();
905
906 let result = extract_summary(f.path()).unwrap();
907 assert_eq!(result.summary, "Real user question");
908 }
909
910 #[test]
911 fn test_extract_summary_none_when_only_system_reminders() {
912 let mut f = NamedTempFile::new().unwrap();
913 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();
914
915 let result = extract_summary(f.path());
916 assert!(result.is_none());
917 }
918
919 #[test]
920 fn test_extract_summary_prefers_summary_after_user_message() {
921 let mut f = NamedTempFile::new().unwrap();
924 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();
925 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();
926 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();
927 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();
928
929 let result = extract_summary(f.path()).unwrap();
930 assert_eq!(
931 result.summary,
932 "The conversation covered initial setup and greetings."
933 );
934 }
935
936 #[test]
937 fn test_extract_summary_from_fixture_compaction() {
938 let path = Path::new("tests/fixtures/compaction_session.jsonl");
939 let result = extract_summary(path).unwrap();
940 assert_eq!(
941 result.summary,
942 "The conversation covered initial setup and greetings."
943 );
944 assert_eq!(result.session_id, "sess-compact-001");
945 }
946
947 #[test]
948 fn test_extract_summary_ignores_off_chain_summary_and_uses_live_branch_prompt() {
949 let mut f = NamedTempFile::new().unwrap();
950 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();
951 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();
952 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();
953 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();
954 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();
955 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();
956 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();
957
958 let result = extract_summary(f.path()).unwrap();
959 assert_eq!(result.session_id, "sess-branch");
960 assert_eq!(result.summary, "Live branch prompt");
961 }
962
963 #[test]
964 fn test_collect_recent_sessions_not_crowded_by_nonsession_files() {
965 let dir = tempfile::TempDir::new().unwrap();
968 let proj = dir.path().join("projects").join("-Users-user-proj");
969 std::fs::create_dir_all(&proj).unwrap();
970
971 for i in 0..3 {
973 write_test_session(
974 &proj,
975 &format!("real{}.jsonl", i),
976 &format!("sess-{}", i),
977 &format!("Real question {}", i),
978 );
979 }
980
981 std::thread::sleep(std::time::Duration::from_millis(50));
983
984 for i in 0..5 {
986 let aux_path = proj.join(format!("aux{}.jsonl", i));
987 let mut f = std::fs::File::create(&aux_path).unwrap();
988 writeln!(f, r#"{{"some_metadata":"value{}"}}"#, i).unwrap();
989 }
990
991 let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
992 let result = collect_recent_sessions(&paths, 3);
995 assert_eq!(result.len(), 3);
996 }
997
998 #[test]
999 fn test_extract_summary_from_fixture_linear() {
1000 let path = Path::new("tests/fixtures/linear_session.jsonl");
1001 let result = extract_summary(path).unwrap();
1002 assert_eq!(result.summary, "How do I sort a list in Python?");
1003 assert_eq!(result.session_id, "sess-linear-001");
1004 }
1005
1006 #[test]
1007 fn test_extract_summary_from_fixture_desktop() {
1008 let path = Path::new("tests/fixtures/desktop_audit_session.jsonl");
1009 let result = extract_summary(path).unwrap();
1010 assert_eq!(result.summary, "Explain Docker networking");
1011 }
1012
1013 #[test]
1014 fn test_truncate_summary_short() {
1015 assert_eq!(truncate_summary("short text", 100), "short text");
1016 }
1017
1018 #[test]
1019 fn test_truncate_summary_exact() {
1020 let s = "a".repeat(100);
1021 assert_eq!(truncate_summary(&s, 100), s);
1022 }
1023
1024 #[test]
1025 fn test_truncate_summary_long() {
1026 let s = "a".repeat(150);
1027 let result = truncate_summary(&s, 100);
1028 assert_eq!(result.chars().count(), 100); assert!(result.ends_with("..."));
1030 }
1031
1032 fn write_test_session(dir: &std::path::Path, filename: &str, session_id: &str, msg: &str) {
1035 let path = dir.join(filename);
1036 let mut f = std::fs::File::create(&path).unwrap();
1037 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"{}","timestamp":"2025-06-01T10:00:00Z"}}"#, msg, session_id).unwrap();
1038 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"reply"}}]}},"sessionId":"{}","timestamp":"2025-06-01T10:01:00Z"}}"#, session_id).unwrap();
1039 }
1040
1041 #[test]
1042 fn test_collect_recent_sessions_finds_across_dirs() {
1043 let dir = tempfile::TempDir::new().unwrap();
1044 let proj1 = dir.path().join("projects").join("-Users-user-proj1");
1045 let proj2 = dir.path().join("projects").join("-Users-user-proj2");
1046 std::fs::create_dir_all(&proj1).unwrap();
1047 std::fs::create_dir_all(&proj2).unwrap();
1048
1049 write_test_session(&proj1, "sess1.jsonl", "sess-1", "Question one");
1050 write_test_session(&proj2, "sess2.jsonl", "sess-2", "Question two");
1051
1052 let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1053 let result = collect_recent_sessions(&paths, 50);
1054 assert_eq!(result.len(), 2);
1055
1056 let ids: Vec<&str> = result.iter().map(|s| s.session_id.as_str()).collect();
1057 assert!(ids.contains(&"sess-1"));
1058 assert!(ids.contains(&"sess-2"));
1059 }
1060
1061 #[test]
1062 fn test_collect_recent_sessions_skips_agent_files() {
1063 let dir = tempfile::TempDir::new().unwrap();
1064 let proj = dir.path().join("projects").join("-Users-user-proj");
1065 std::fs::create_dir_all(&proj).unwrap();
1066
1067 write_test_session(&proj, "sess1.jsonl", "sess-1", "Normal session");
1068 write_test_session(&proj, "agent-abc123.jsonl", "sess-1", "Agent session");
1069
1070 let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1071 let result = collect_recent_sessions(&paths, 50);
1072 assert_eq!(result.len(), 1);
1073 assert_eq!(result[0].session_id, "sess-1");
1074 }
1075
1076 #[test]
1077 fn test_collect_recent_sessions_sorts_by_timestamp_desc() {
1078 let dir = tempfile::TempDir::new().unwrap();
1079 let proj = dir.path().join("projects").join("-Users-user-proj");
1080 std::fs::create_dir_all(&proj).unwrap();
1081
1082 let older_path = proj.join("older.jsonl");
1084 let mut f = std::fs::File::create(&older_path).unwrap();
1085 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Old question"}}]}},"sessionId":"sess-old","timestamp":"2025-01-01T10:00:00Z"}}"#).unwrap();
1086
1087 let newer_path = proj.join("newer.jsonl");
1088 let mut f = std::fs::File::create(&newer_path).unwrap();
1089 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"New question"}}]}},"sessionId":"sess-new","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1090
1091 let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1092 let result = collect_recent_sessions(&paths, 50);
1093 assert_eq!(result.len(), 2);
1094 assert_eq!(result[0].session_id, "sess-new");
1096 assert_eq!(result[1].session_id, "sess-old");
1097 }
1098
1099 #[test]
1100 fn test_extract_summary_finds_late_summary_record() {
1101 let mut f = NamedTempFile::new().unwrap();
1104 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1106 for i in 0..50 {
1108 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-long","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1109 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();
1110 }
1111 writeln!(f, r#"{{"type":"summary","summary":"Session about Rust performance optimization","sessionId":"sess-long","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1113
1114 let result = extract_summary(f.path()).unwrap();
1115 assert_eq!(
1116 result.summary,
1117 "Session about Rust performance optimization"
1118 );
1119 assert_eq!(result.session_id, "sess-long");
1120 }
1121
1122 #[test]
1123 fn test_extract_summary_finds_summary_in_middle_of_large_file() {
1124 let mut f = NamedTempFile::new().unwrap();
1127 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1129 for i in 0..40 {
1131 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1132 }
1133 writeln!(f, r#"{{"type":"summary","summary":"Mid-file compaction summary","sessionId":"sess-mid","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1135 let padding_line = format!(
1137 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-mid","timestamp":"2025-06-01T12:00:00Z"}}"#,
1138 "x".repeat(500)
1139 );
1140 for _ in 0..500 {
1142 writeln!(f, "{}", padding_line).unwrap();
1143 }
1144
1145 let result = extract_summary(f.path()).unwrap();
1146 assert_eq!(result.summary, "Mid-file compaction summary");
1147 assert_eq!(result.session_id, "sess-mid");
1148 }
1149
1150 #[test]
1151 fn test_find_summary_from_tail_handles_multibyte_utf8_boundary() {
1152 let mut f = NamedTempFile::new().unwrap();
1155 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Привет мир 🌍🌍🌍🌍🌍"}}]}},"sessionId":"sess-utf8","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1158 writeln!(f, r#"{{"type":"summary","summary":"UTF-8 session summary","sessionId":"sess-utf8","timestamp":"2025-06-01T11:00:00Z"}}"#).unwrap();
1160
1161 let result = find_summary_from_tail(f.path(), 200);
1165 assert!(result.is_some());
1166 let (sid, text, _auto) = result.unwrap();
1167 assert_eq!(text, "UTF-8 session summary");
1168 assert_eq!(sid, Some("sess-utf8".to_string()));
1169 }
1170
1171 #[test]
1172 fn test_extract_summary_finds_summary_in_head_when_tail_misses() {
1173 let mut f = NamedTempFile::new().unwrap();
1176 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-head","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1177 writeln!(f, r#"{{"type":"summary","summary":"Summary found in head scan","sessionId":"sess-head","timestamp":"2025-06-01T10:01:00Z"}}"#).unwrap();
1178 for i in 0..5 {
1180 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();
1181 }
1182
1183 let result = extract_summary(f.path()).unwrap();
1184 assert_eq!(result.summary, "Summary found in head scan");
1186 assert_eq!(result.session_id, "sess-head");
1187 }
1188
1189 #[test]
1190 fn test_extract_session_id_from_head_skips_non_session_records() {
1191 let mut f = NamedTempFile::new().unwrap();
1195 for i in 0..8 {
1197 writeln!(
1198 f,
1199 r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1200 i
1201 )
1202 .unwrap();
1203 }
1204 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();
1206
1207 let result = extract_session_id_from_head(f.path());
1208 assert_eq!(result, Some("sess-late-id".to_string()));
1209 }
1210
1211 #[test]
1212 fn test_extract_summary_finds_summary_after_user_in_large_file() {
1213 let mut f = NamedTempFile::new().unwrap();
1217 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Initial question"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1219 for i in 0..5 {
1221 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Response {}"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:01:00Z"}}"#, i).unwrap();
1222 }
1223 writeln!(f, r#"{{"type":"summary","summary":"Summary after user message","sessionId":"sess-big","timestamp":"2025-06-01T10:02:00Z"}}"#).unwrap();
1225 let padding_line = format!(
1227 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-big","timestamp":"2025-06-01T10:03:00Z"}}"#,
1228 "x".repeat(1024)
1229 );
1230 for _ in 0..300 {
1231 writeln!(f, "{}", padding_line).unwrap();
1232 }
1233
1234 let result = extract_summary(f.path()).unwrap();
1235 assert_eq!(result.summary, "Summary after user message");
1237 assert_eq!(result.session_id, "sess-big");
1238 }
1239
1240 #[test]
1241 fn test_extract_summary_user_message_beyond_head_scan() {
1242 let mut f = NamedTempFile::new().unwrap();
1247 writeln!(f, r#"{{"type":"file-history-snapshot","files":["a.rs"],"sessionId":"sess-deep-user","timestamp":"2025-06-01T10:00:00Z"}}"#).unwrap();
1249 for i in 1..35 {
1250 writeln!(
1251 f,
1252 r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1253 i
1254 )
1255 .unwrap();
1256 }
1257 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();
1259 for i in 0..3 {
1261 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();
1262 }
1263
1264 let result = extract_summary(f.path());
1265 assert!(
1266 result.is_some(),
1267 "Session with user message beyond 30-line head scan should not be dropped"
1268 );
1269 let session = result.unwrap();
1270 assert_eq!(session.summary, "Deep user question");
1271 assert_eq!(session.session_id, "sess-deep-user");
1272 }
1273
1274 #[test]
1275 fn test_extract_summary_session_id_only_beyond_head() {
1276 let mut f = NamedTempFile::new().unwrap();
1280 for i in 0..35 {
1282 writeln!(
1283 f,
1284 r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1285 i
1286 )
1287 .unwrap();
1288 }
1289 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();
1291
1292 let result = extract_summary(f.path());
1293 assert!(
1294 result.is_some(),
1295 "Session with session_id only beyond 30-line head scan should not be dropped"
1296 );
1297 let session = result.unwrap();
1298 assert_eq!(session.summary, "Late session ID question");
1299 assert_eq!(session.session_id, "sess-late-id");
1300 }
1301
1302 #[test]
1303 fn test_find_summary_from_tail_exact_line_boundary() {
1304 use std::io::Write;
1309
1310 let mut f = NamedTempFile::new().unwrap();
1311 let prefix_line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"padding"}]},"sessionId":"sess-boundary","timestamp":"2025-06-01T10:00:00Z"}"#;
1313 writeln!(f, "{}", prefix_line).unwrap();
1314 let summary_line = r#"{"type":"summary","summary":"Boundary summary found","sessionId":"sess-boundary","timestamp":"2025-06-01T11:00:00Z"}"#;
1316 writeln!(f, "{}", summary_line).unwrap();
1317
1318 let file_len = f.as_file().metadata().unwrap().len();
1319 let summary_offset = prefix_line.len() as u64 + 1; let max_bytes = file_len - summary_offset;
1323
1324 let result = find_summary_from_tail(f.path(), max_bytes);
1325 assert!(
1326 result.is_some(),
1327 "Summary at exact tail boundary should not be skipped"
1328 );
1329 let (sid, text, _auto) = result.unwrap();
1330 assert_eq!(text, "Boundary summary found");
1331 assert_eq!(sid, Some("sess-boundary".to_string()));
1332 }
1333
1334 #[test]
1335 fn test_collect_recent_sessions_respects_limit() {
1336 let dir = tempfile::TempDir::new().unwrap();
1337 let proj = dir.path().join("projects").join("-Users-user-proj");
1338 std::fs::create_dir_all(&proj).unwrap();
1339
1340 for i in 0..5 {
1341 write_test_session(
1342 &proj,
1343 &format!("sess{}.jsonl", i),
1344 &format!("sess-{}", i),
1345 &format!("Question {}", i),
1346 );
1347 }
1348
1349 let paths = vec![dir.path().join("projects").to_str().unwrap().to_string()];
1350 let result = collect_recent_sessions(&paths, 3);
1351 assert_eq!(result.len(), 3);
1352 }
1353
1354 #[test]
1355 fn test_extract_summary_detects_ralphex_automation() {
1356 let mut f = NamedTempFile::new().unwrap();
1357 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();
1358 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();
1359
1360 let result = extract_summary(f.path()).unwrap();
1361 assert_eq!(result.automation, Some("ralphex".to_string()));
1362 }
1363
1364 #[test]
1365 fn test_extract_summary_manual_session_no_automation() {
1366 let mut f = NamedTempFile::new().unwrap();
1367 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();
1368
1369 let result = extract_summary(f.path()).unwrap();
1370 assert_eq!(result.automation, None);
1371 }
1372
1373 #[test]
1374 fn test_extract_summary_ralphex_fixture_file() {
1375 let path = std::path::Path::new("tests/fixtures/ralphex_session.jsonl");
1376 let result = extract_summary(path).unwrap();
1377 assert_eq!(result.session_id, "sess-ralphex-001");
1378 assert_eq!(result.automation, Some("ralphex".to_string()));
1379 }
1380
1381 #[test]
1382 fn test_extract_summary_linear_fixture_no_automation() {
1383 let path = std::path::Path::new("tests/fixtures/linear_session.jsonl");
1384 let result = extract_summary(path).unwrap();
1385 assert_eq!(result.session_id, "sess-linear-001");
1386 assert_eq!(result.automation, None);
1387 }
1388
1389 #[test]
1390 fn test_extract_summary_marker_in_assistant_not_detected() {
1391 let mut f = NamedTempFile::new().unwrap();
1392 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();
1393 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();
1394
1395 let result = extract_summary(f.path()).unwrap();
1396 assert_eq!(result.automation, None);
1397 }
1398
1399 #[test]
1400 fn test_extract_summary_tail_summary_uses_head_automation() {
1401 let mut f = NamedTempFile::new().unwrap();
1402 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Bootstrap run. <<<RALPHEX:ALL_TASKS_DONE>>>"}}]}},"sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:00:00Z"}}"#).unwrap();
1403
1404 let padding_line = format!(
1405 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-tail-auto","timestamp":"2026-03-28T08:01:00Z"}}"#,
1406 "x".repeat(1024)
1407 );
1408 for _ in 0..300 {
1409 writeln!(f, "{}", padding_line).unwrap();
1410 }
1411
1412 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();
1413
1414 let result = extract_summary(f.path()).unwrap();
1415 assert_eq!(
1416 result.summary,
1417 "Tail summary with automation marker only in head"
1418 );
1419 assert_eq!(result.automation, Some("ralphex".to_string()));
1420 }
1421
1422 #[test]
1423 fn test_extract_summary_tail_summary_scans_middle_for_automation() {
1424 let mut f = NamedTempFile::new().unwrap();
1425
1426 for i in 0..35 {
1427 writeln!(
1428 f,
1429 r#"{{"type":"file-history-snapshot","files":["file{}.rs"]}}"#,
1430 i
1431 )
1432 .unwrap();
1433 }
1434
1435 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();
1436
1437 let padding_line = format!(
1438 r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{}"}}]}},"sessionId":"sess-mid-auto","timestamp":"2026-03-28T08:01:00Z"}}"#,
1439 "x".repeat(1024)
1440 );
1441 for _ in 0..300 {
1442 writeln!(f, "{}", padding_line).unwrap();
1443 }
1444
1445 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();
1446
1447 let result = extract_summary(f.path()).unwrap();
1448 assert_eq!(
1449 result.summary,
1450 "Tail summary with automation marker only in middle"
1451 );
1452 assert_eq!(result.automation, Some("ralphex".to_string()));
1453 }
1454}