1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::core::graph_context;
6use crate::core::intent_protocol::{IntentRecord, IntentSource};
7
8const MAX_FINDINGS: usize = 20;
9const MAX_DECISIONS: usize = 10;
10const MAX_FILES: usize = 50;
11const MAX_EVIDENCE: usize = 500;
12const BATCH_SAVE_INTERVAL: u32 = 5;
13
14#[derive(Serialize, Deserialize, Clone, Debug)]
16pub struct SessionState {
17 pub id: String,
18 pub version: u32,
19 pub started_at: DateTime<Utc>,
20 pub updated_at: DateTime<Utc>,
21 pub project_root: Option<String>,
22 #[serde(default)]
23 pub shell_cwd: Option<String>,
24 pub task: Option<TaskInfo>,
25 pub findings: Vec<Finding>,
26 pub decisions: Vec<Decision>,
27 pub files_touched: Vec<FileTouched>,
28 pub test_results: Option<TestSnapshot>,
29 pub progress: Vec<ProgressEntry>,
30 pub next_steps: Vec<String>,
31 #[serde(default)]
32 pub evidence: Vec<EvidenceRecord>,
33 #[serde(default)]
34 pub intents: Vec<IntentRecord>,
35 #[serde(default)]
36 pub active_structured_intent: Option<crate::core::intent_engine::StructuredIntent>,
37 pub stats: SessionStats,
38 #[serde(default)]
40 pub terse_mode: bool,
41 #[serde(default)]
43 pub compression_level: String,
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug)]
48pub struct TaskInfo {
49 pub description: String,
50 pub intent: Option<String>,
51 pub progress_pct: Option<u8>,
52}
53
54#[derive(Serialize, Deserialize, Clone, Debug)]
56pub struct Finding {
57 pub file: Option<String>,
58 pub line: Option<u32>,
59 pub summary: String,
60 pub timestamp: DateTime<Utc>,
61}
62
63#[derive(Serialize, Deserialize, Clone, Debug)]
65pub struct Decision {
66 pub summary: String,
67 pub rationale: Option<String>,
68 pub timestamp: DateTime<Utc>,
69}
70
71#[derive(Serialize, Deserialize, Clone, Debug)]
73pub struct FileTouched {
74 pub path: String,
75 pub file_ref: Option<String>,
76 pub read_count: u32,
77 pub modified: bool,
78 pub last_mode: String,
79 pub tokens: usize,
80 #[serde(default)]
81 pub stale: bool,
82 #[serde(default)]
83 pub context_item_id: Option<String>,
84}
85
86#[derive(Serialize, Deserialize, Clone, Debug)]
88pub struct TestSnapshot {
89 pub command: String,
90 pub passed: u32,
91 pub failed: u32,
92 pub total: u32,
93 pub timestamp: DateTime<Utc>,
94}
95
96#[derive(Serialize, Deserialize, Clone, Debug)]
98pub struct ProgressEntry {
99 pub action: String,
100 pub detail: Option<String>,
101 pub timestamp: DateTime<Utc>,
102}
103
104#[derive(Serialize, Deserialize, Clone, Debug)]
106#[serde(rename_all = "snake_case")]
107pub enum EvidenceKind {
108 ToolCall,
109 Manual,
110}
111
112#[derive(Serialize, Deserialize, Clone, Debug)]
114pub struct EvidenceRecord {
115 pub kind: EvidenceKind,
116 pub key: String,
117 pub value: Option<String>,
118 pub tool: Option<String>,
119 pub input_md5: Option<String>,
120 pub output_md5: Option<String>,
121 pub agent_id: Option<String>,
122 pub client_name: Option<String>,
123 pub timestamp: DateTime<Utc>,
124}
125
126#[derive(Serialize, Deserialize, Clone, Debug, Default)]
128#[serde(default)]
129pub struct SessionStats {
130 pub total_tool_calls: u32,
131 pub total_tokens_saved: u64,
132 pub total_tokens_input: u64,
133 pub cache_hits: u32,
134 pub files_read: u32,
135 pub commands_run: u32,
136 pub intents_inferred: u32,
137 pub intents_explicit: u32,
138 pub unsaved_changes: u32,
139}
140
141#[derive(Serialize, Deserialize, Clone, Debug)]
142struct LatestPointer {
143 id: String,
144}
145
146pub struct PreparedSave {
150 dir: PathBuf,
151 id: String,
152 json: String,
153 pointer_json: String,
154 compaction_snapshot: Option<String>,
155}
156
157impl PreparedSave {
158 pub fn write_to_disk(self) -> Result<(), String> {
161 if !self.dir.exists() {
162 std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
163 }
164 let path = self.dir.join(format!("{}.json", self.id));
165 let tmp = self.dir.join(format!(".{}.json.tmp", self.id));
166 std::fs::write(&tmp, &self.json).map_err(|e| e.to_string())?;
167 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
168
169 let latest_path = self.dir.join("latest.json");
170 let latest_tmp = self.dir.join(".latest.json.tmp");
171 std::fs::write(&latest_tmp, &self.pointer_json).map_err(|e| e.to_string())?;
172 std::fs::rename(&latest_tmp, &latest_path).map_err(|e| e.to_string())?;
173
174 if let Some(snapshot) = self.compaction_snapshot {
175 let snap_path = self.dir.join(format!("{}_snapshot.txt", self.id));
176 let _ = std::fs::write(&snap_path, &snapshot);
177 }
178 Ok(())
179 }
180}
181
182impl Default for SessionState {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188impl SessionState {
189 pub fn new() -> Self {
191 let now = Utc::now();
192 Self {
193 id: generate_session_id(),
194 version: 0,
195 started_at: now,
196 updated_at: now,
197 project_root: None,
198 shell_cwd: None,
199 task: None,
200 findings: Vec::new(),
201 decisions: Vec::new(),
202 files_touched: Vec::new(),
203 test_results: None,
204 progress: Vec::new(),
205 next_steps: Vec::new(),
206 evidence: Vec::new(),
207 intents: Vec::new(),
208 active_structured_intent: None,
209 stats: SessionStats::default(),
210 terse_mode: false,
211 compression_level: String::new(),
212 }
213 .with_compression_from_config()
214 }
215
216 fn with_compression_from_config(mut self) -> Self {
217 let level = if let Some(env_level) = crate::core::config::CompressionLevel::from_env() {
218 env_level
219 } else {
220 let profile = crate::core::profiles::active_profile();
221 let terse = profile.compression.terse_mode_effective();
222 let density = profile.compression.output_density_effective();
223 let od = match density {
224 "ultra" => crate::core::config::OutputDensity::Ultra,
225 "terse" => crate::core::config::OutputDensity::Terse,
226 _ => crate::core::config::OutputDensity::Normal,
227 };
228 let ta = if terse {
229 crate::core::config::TerseAgent::Full
230 } else {
231 crate::core::config::TerseAgent::Off
232 };
233 crate::core::config::CompressionLevel::from_legacy(&ta, &od)
234 };
235 self.compression_level = level.label().to_string();
236 self.terse_mode = level.is_active();
237 self
238 }
239
240 pub fn increment(&mut self) {
242 self.version += 1;
243 self.updated_at = Utc::now();
244 self.stats.unsaved_changes += 1;
245 }
246
247 pub fn should_save(&self) -> bool {
249 self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL
250 }
251
252 pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
254 self.task = Some(TaskInfo {
255 description: description.to_string(),
256 intent: intent.map(std::string::ToString::to_string),
257 progress_pct: None,
258 });
259
260 let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
261 let si = if touched.is_empty() {
262 crate::core::intent_engine::StructuredIntent::from_query(description)
263 } else {
264 crate::core::intent_engine::StructuredIntent::from_query_with_session(
265 description,
266 &touched,
267 )
268 };
269 if si.confidence >= 0.7 {
270 self.active_structured_intent = Some(si);
271 }
272
273 self.increment();
274 }
275
276 pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
278 self.findings.push(Finding {
279 file: file.map(std::string::ToString::to_string),
280 line,
281 summary: summary.to_string(),
282 timestamp: Utc::now(),
283 });
284 while self.findings.len() > MAX_FINDINGS {
285 self.findings.remove(0);
286 }
287 self.increment();
288 }
289
290 pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
292 self.decisions.push(Decision {
293 summary: summary.to_string(),
294 rationale: rationale.map(std::string::ToString::to_string),
295 timestamp: Utc::now(),
296 });
297 while self.decisions.len() > MAX_DECISIONS {
298 self.decisions.remove(0);
299 }
300 self.increment();
301 }
302
303 pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
305 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
306 existing.read_count += 1;
307 existing.last_mode = mode.to_string();
308 existing.tokens = tokens;
309 if let Some(r) = file_ref {
310 existing.file_ref = Some(r.to_string());
311 }
312 } else {
313 let item_id = crate::core::context_field::ContextItemId::from_file(path);
314 self.files_touched.push(FileTouched {
315 path: path.to_string(),
316 file_ref: file_ref.map(std::string::ToString::to_string),
317 read_count: 1,
318 modified: false,
319 last_mode: mode.to_string(),
320 tokens,
321 stale: false,
322 context_item_id: Some(item_id.to_string()),
323 });
324 while self.files_touched.len() > MAX_FILES {
325 self.files_touched.remove(0);
326 }
327 }
328 self.stats.files_read += 1;
329 self.increment();
330 }
331
332 pub fn mark_modified(&mut self, path: &str) {
334 if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
335 existing.modified = true;
336 }
337 self.increment();
338 }
339
340 pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
342 self.stats.total_tool_calls += 1;
343 self.stats.total_tokens_saved += tokens_saved;
344 self.stats.total_tokens_input += tokens_input;
345 }
346
347 pub fn record_intent(&mut self, mut intent: IntentRecord) {
349 if intent.occurrences == 0 {
350 intent.occurrences = 1;
351 }
352
353 if let Some(last) = self.intents.last_mut() {
354 if last.fingerprint() == intent.fingerprint() {
355 last.occurrences = last.occurrences.saturating_add(intent.occurrences);
356 last.timestamp = intent.timestamp;
357 match intent.source {
358 IntentSource::Inferred => self.stats.intents_inferred += 1,
359 IntentSource::Explicit => self.stats.intents_explicit += 1,
360 }
361 self.increment();
362 return;
363 }
364 }
365
366 match intent.source {
367 IntentSource::Inferred => self.stats.intents_inferred += 1,
368 IntentSource::Explicit => self.stats.intents_explicit += 1,
369 }
370
371 self.intents.push(intent);
372 while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
373 self.intents.remove(0);
374 }
375 self.increment();
376 }
377
378 pub fn record_tool_receipt(
380 &mut self,
381 tool: &str,
382 action: Option<&str>,
383 input_md5: &str,
384 output_md5: &str,
385 agent_id: Option<&str>,
386 client_name: Option<&str>,
387 ) {
388 let now = Utc::now();
389 let mut push = |key: String| {
390 self.evidence.push(EvidenceRecord {
391 kind: EvidenceKind::ToolCall,
392 key,
393 value: None,
394 tool: Some(tool.to_string()),
395 input_md5: Some(input_md5.to_string()),
396 output_md5: Some(output_md5.to_string()),
397 agent_id: agent_id.map(std::string::ToString::to_string),
398 client_name: client_name.map(std::string::ToString::to_string),
399 timestamp: now,
400 });
401 };
402
403 push(format!("tool:{tool}"));
404 if let Some(a) = action {
405 push(format!("tool:{tool}:{a}"));
406 }
407 while self.evidence.len() > MAX_EVIDENCE {
408 self.evidence.remove(0);
409 }
410 self.increment();
411 }
412
413 pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
415 self.evidence.push(EvidenceRecord {
416 kind: EvidenceKind::Manual,
417 key: key.to_string(),
418 value: value.map(std::string::ToString::to_string),
419 tool: None,
420 input_md5: None,
421 output_md5: None,
422 agent_id: None,
423 client_name: None,
424 timestamp: Utc::now(),
425 });
426 while self.evidence.len() > MAX_EVIDENCE {
427 self.evidence.remove(0);
428 }
429 self.increment();
430 }
431
432 pub fn has_evidence_key(&self, key: &str) -> bool {
434 self.evidence.iter().any(|e| e.key == key)
435 }
436
437 pub fn record_cache_hit(&mut self) {
439 self.stats.cache_hits += 1;
440 }
441
442 pub fn record_command(&mut self) {
444 self.stats.commands_run += 1;
445 }
446
447 pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
452 let root = self.project_root.as_deref().unwrap_or(".");
453 if let Some(cwd) = explicit_cwd {
454 if !cwd.is_empty() && cwd != "." {
455 return Self::jail_cwd(cwd, root);
456 }
457 }
458 if let Some(ref cwd) = self.shell_cwd {
459 return cwd.clone();
460 }
461 if let Some(ref r) = self.project_root {
462 return r.clone();
463 }
464 std::env::current_dir()
465 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
466 }
467
468 fn jail_cwd(candidate: &str, fallback_root: &str) -> String {
471 let p = std::path::Path::new(candidate);
472 match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
473 Ok(jailed) => jailed.to_string_lossy().to_string(),
474 Err(_) => fallback_root.to_string(),
475 }
476 }
477
478 pub fn update_shell_cwd(&mut self, command: &str) {
483 let base = self.effective_cwd(None);
484 if let Some(new_cwd) = extract_cd_target(command, &base) {
485 let path = std::path::Path::new(&new_cwd);
486 if path.exists() && path.is_dir() {
487 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
488 .to_string_lossy()
489 .to_string();
490 let root = self.project_root.as_deref().unwrap_or(".");
491 if crate::core::pathjail::jail_path(
492 std::path::Path::new(&canonical),
493 std::path::Path::new(root),
494 )
495 .is_ok()
496 {
497 self.shell_cwd = Some(canonical);
498 }
499 }
500 }
501 }
502
503 pub fn format_compact(&self) -> String {
505 let duration = self.updated_at - self.started_at;
506 let hours = duration.num_hours();
507 let mins = duration.num_minutes() % 60;
508 let duration_str = if hours > 0 {
509 format!("{hours}h {mins}m")
510 } else {
511 format!("{mins}m")
512 };
513
514 let mut lines = Vec::new();
515 lines.push(format!(
516 "SESSION v{} | {} | {} calls | {} tok saved",
517 self.version, duration_str, self.stats.total_tool_calls, self.stats.total_tokens_saved
518 ));
519
520 if let Some(ref task) = self.task {
521 let pct = task
522 .progress_pct
523 .map_or(String::new(), |p| format!(" [{p}%]"));
524 lines.push(format!("Task: {}{pct}", task.description));
525 }
526
527 if let Some(ref root) = self.project_root {
528 lines.push(format!("Root: {}", shorten_path(root)));
529 }
530
531 if !self.findings.is_empty() {
532 let items: Vec<String> = self
533 .findings
534 .iter()
535 .rev()
536 .take(5)
537 .map(|f| {
538 let loc = match (&f.file, f.line) {
539 (Some(file), Some(line)) => format!("{}:{line}", shorten_path(file)),
540 (Some(file), None) => shorten_path(file),
541 _ => String::new(),
542 };
543 if loc.is_empty() {
544 f.summary.clone()
545 } else {
546 format!("{loc} \u{2014} {}", f.summary)
547 }
548 })
549 .collect();
550 lines.push(format!(
551 "Findings ({}): {}",
552 self.findings.len(),
553 items.join(" | ")
554 ));
555 }
556
557 if !self.decisions.is_empty() {
558 let items: Vec<&str> = self
559 .decisions
560 .iter()
561 .rev()
562 .take(3)
563 .map(|d| d.summary.as_str())
564 .collect();
565 lines.push(format!("Decisions: {}", items.join(" | ")));
566 }
567
568 if !self.files_touched.is_empty() {
569 let items: Vec<String> = self
570 .files_touched
571 .iter()
572 .rev()
573 .take(10)
574 .map(|f| {
575 let status = if f.modified { "mod" } else { &f.last_mode };
576 let r = f.file_ref.as_deref().unwrap_or("?");
577 format!("[{r} {} {status}]", shorten_path(&f.path))
578 })
579 .collect();
580 lines.push(format!(
581 "Files ({}): {}",
582 self.files_touched.len(),
583 items.join(" ")
584 ));
585 }
586
587 if let Some(ref tests) = self.test_results {
588 lines.push(format!(
589 "Tests: {}/{} pass ({})",
590 tests.passed, tests.total, tests.command
591 ));
592 }
593
594 if !self.next_steps.is_empty() {
595 lines.push(format!("Next: {}", self.next_steps.join(" | ")));
596 }
597
598 lines.join("\n")
599 }
600
601 pub fn build_compaction_snapshot(&self) -> String {
603 const MAX_SNAPSHOT_BYTES: usize = 2048;
604
605 let mut sections: Vec<(u8, String)> = Vec::new();
606
607 let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
608 .unwrap_or_default();
609 if let Some(tag) = crate::core::terse::agent_prompts::session_context_tag(&level) {
610 sections.push((0, tag));
611 }
612
613 if let Some(ref task) = self.task {
614 let pct = task
615 .progress_pct
616 .map_or(String::new(), |p| format!(" [{p}%]"));
617 sections.push((1, format!("<task>{}{pct}</task>", task.description)));
618 }
619
620 if !self.files_touched.is_empty() {
621 let modified: Vec<&str> = self
622 .files_touched
623 .iter()
624 .filter(|f| f.modified)
625 .map(|f| f.path.as_str())
626 .collect();
627 let read_only: Vec<&str> = self
628 .files_touched
629 .iter()
630 .filter(|f| !f.modified)
631 .take(10)
632 .map(|f| f.path.as_str())
633 .collect();
634 let mut files_section = String::new();
635 if !modified.is_empty() {
636 files_section.push_str(&format!("Modified: {}", modified.join(", ")));
637 }
638 if !read_only.is_empty() {
639 if !files_section.is_empty() {
640 files_section.push_str(" | ");
641 }
642 files_section.push_str(&format!("Read: {}", read_only.join(", ")));
643 }
644 sections.push((1, format!("<files>{files_section}</files>")));
645 }
646
647 if !self.decisions.is_empty() {
648 let items: Vec<&str> = self.decisions.iter().map(|d| d.summary.as_str()).collect();
649 sections.push((2, format!("<decisions>{}</decisions>", items.join(" | "))));
650 }
651
652 if !self.findings.is_empty() {
653 let items: Vec<String> = self
654 .findings
655 .iter()
656 .rev()
657 .take(5)
658 .map(|f| f.summary.clone())
659 .collect();
660 sections.push((2, format!("<findings>{}</findings>", items.join(" | "))));
661 }
662
663 if !self.progress.is_empty() {
664 let items: Vec<String> = self
665 .progress
666 .iter()
667 .rev()
668 .take(5)
669 .map(|p| {
670 let detail = p.detail.as_deref().unwrap_or("");
671 if detail.is_empty() {
672 p.action.clone()
673 } else {
674 format!("{}: {detail}", p.action)
675 }
676 })
677 .collect();
678 sections.push((2, format!("<progress>{}</progress>", items.join(" | "))));
679 }
680
681 if let Some(ref tests) = self.test_results {
682 sections.push((
683 3,
684 format!(
685 "<tests>{}/{} pass ({})</tests>",
686 tests.passed, tests.total, tests.command
687 ),
688 ));
689 }
690
691 if !self.next_steps.is_empty() {
692 sections.push((
693 3,
694 format!("<next_steps>{}</next_steps>", self.next_steps.join(" | ")),
695 ));
696 }
697
698 sections.push((
699 4,
700 format!(
701 "<stats>calls={} saved={}tok</stats>",
702 self.stats.total_tool_calls, self.stats.total_tokens_saved
703 ),
704 ));
705
706 sections.sort_by_key(|(priority, _)| *priority);
707
708 const SNAPSHOT_HARD_CAP: usize = 2200;
709 const CLOSE_TAG: &str = "</session_snapshot>";
710 let open_len = "<session_snapshot>\n".len();
711 let reserve_body = SNAPSHOT_HARD_CAP.saturating_sub(open_len + CLOSE_TAG.len());
712
713 let mut snapshot = String::from("<session_snapshot>\n");
714 for (_, section) in §ions {
715 if snapshot.len() + section.len() + 25 > MAX_SNAPSHOT_BYTES {
716 break;
717 }
718 snapshot.push_str(section);
719 snapshot.push('\n');
720 }
721
722 let used = snapshot.len().saturating_sub(open_len);
723 let suffix_budget = reserve_body.saturating_sub(used).saturating_sub(1);
724 if suffix_budget > 64 {
725 let suffix = self.build_compaction_structured_suffix(suffix_budget);
726 if !suffix.is_empty() {
727 snapshot.push_str(&suffix);
728 if !suffix.ends_with('\n') {
729 snapshot.push('\n');
730 }
731 }
732 }
733
734 snapshot.push_str(CLOSE_TAG);
735 snapshot
736 }
737
738 fn build_compaction_structured_suffix(&self, max_bytes: usize) -> String {
740 if max_bytes <= 64 {
741 return String::new();
742 }
743
744 let mut recovery_queries: Vec<String> = Vec::new();
745 for ft in self.files_touched.iter().rev().take(12) {
746 let path_esc = escape_xml_attr(&ft.path);
747 let mode = if ft.last_mode.is_empty() {
748 "map".to_string()
749 } else {
750 escape_xml_attr(&ft.last_mode)
751 };
752 recovery_queries.push(format!(
753 r#"<query tool="ctx_read" path="{path_esc}" mode="{mode}" />"#,
754 ));
755 let pattern = file_stem_search_pattern(&ft.path);
756 if !pattern.is_empty() {
757 let search_dir = parent_dir_slash(&ft.path);
758 let pat_esc = escape_xml_attr(&pattern);
759 let dir_esc = escape_xml_attr(&search_dir);
760 recovery_queries.push(format!(
761 r#"<query tool="ctx_search" pattern="{pat_esc}" path="{dir_esc}" />"#,
762 ));
763 }
764 }
765
766 let mut parts: Vec<String> = Vec::new();
767 if !recovery_queries.is_empty() {
768 parts.push(format!(
769 "<recovery_queries>\n{}\n</recovery_queries>",
770 recovery_queries.join("\n")
771 ));
772 }
773
774 let knowledge_ok = !self.findings.is_empty() || !self.decisions.is_empty();
775 if knowledge_ok {
776 if let Some(q) = self.knowledge_recall_query_stem() {
777 let q_esc = escape_xml_attr(&q);
778 parts.push(format!(
779 "<knowledge_context>\n<recall query=\"{q_esc}\" />\n</knowledge_context>",
780 ));
781 }
782 }
783
784 if let Some(root) = self
785 .project_root
786 .as_deref()
787 .filter(|r| !r.trim().is_empty())
788 {
789 let root_trim = root.trim_end_matches('/');
790 let mut cluster_lines: Vec<String> = Vec::new();
791 for ft in self.files_touched.iter().rev().take(3) {
792 let primary_esc = escape_xml_attr(&ft.path);
793 let abs_primary = format!("{root_trim}/{}", ft.path.trim_start_matches('/'));
794 let related_csv =
795 graph_context::build_related_paths_csv(&abs_primary, root_trim, 8)
796 .map(|s| escape_xml_attr(&s))
797 .unwrap_or_default();
798 if related_csv.is_empty() {
799 continue;
800 }
801 cluster_lines.push(format!(
802 r#"<cluster primary="{primary_esc}" related="{related_csv}" />"#,
803 ));
804 }
805 if !cluster_lines.is_empty() {
806 parts.push(format!(
807 "<graph_context>\n{}\n</graph_context>",
808 cluster_lines.join("\n")
809 ));
810 }
811 }
812
813 Self::shrink_structured_suffix_parts(&mut parts, max_bytes)
814 }
815
816 fn shrink_structured_suffix_parts(parts: &mut Vec<String>, max_bytes: usize) -> String {
817 let mut out = parts.join("\n");
818 while out.len() > max_bytes && !parts.is_empty() {
819 parts.pop();
820 out = parts.join("\n");
821 }
822 if out.len() <= max_bytes {
823 return out;
824 }
825 if let Some(idx) = parts
826 .iter()
827 .position(|p| p.starts_with("<recovery_queries>"))
828 {
829 let mut lines: Vec<String> = parts[idx]
830 .lines()
831 .filter(|l| l.starts_with("<query "))
832 .map(str::to_string)
833 .collect();
834 while !lines.is_empty() && out.len() > max_bytes {
835 if lines.len() == 1 {
836 parts.remove(idx);
837 out = parts.join("\n");
838 break;
839 }
840 lines.truncate(lines.len().saturating_sub(2));
841 parts[idx] = format!(
842 "<recovery_queries>\n{}\n</recovery_queries>",
843 lines.join("\n")
844 );
845 out = parts.join("\n");
846 }
847 }
848 if out.len() > max_bytes {
849 return String::new();
850 }
851 out
852 }
853
854 fn knowledge_recall_query_stem(&self) -> Option<String> {
855 let mut bits: Vec<String> = Vec::new();
856 if let Some(ref t) = self.task {
857 bits.push(Self::task_keyword_stem(&t.description));
858 }
859 if bits.iter().all(std::string::String::is_empty) {
860 if let Some(f) = self.findings.last() {
861 bits.push(Self::task_keyword_stem(&f.summary));
862 } else if let Some(d) = self.decisions.last() {
863 bits.push(Self::task_keyword_stem(&d.summary));
864 }
865 }
866 let q = bits.join(" ").trim().to_string();
867 if q.is_empty() {
868 None
869 } else {
870 Some(q)
871 }
872 }
873
874 fn task_keyword_stem(text: &str) -> String {
875 const STOP: &[&str] = &[
876 "the", "a", "an", "and", "or", "to", "for", "of", "in", "on", "with", "is", "are",
877 "be", "this", "that", "it", "as", "at", "by", "from",
878 ];
879 text.split_whitespace()
880 .filter_map(|w| {
881 let w = w.trim_matches(|c: char| !c.is_alphanumeric());
882 if w.len() < 3 {
883 return None;
884 }
885 let lower = w.to_lowercase();
886 if STOP.contains(&lower.as_str()) {
887 return None;
888 }
889 Some(w.to_string())
890 })
891 .take(8)
892 .collect::<Vec<_>>()
893 .join(" ")
894 }
895
896 pub fn save_compaction_snapshot(&self) -> Result<String, String> {
898 let snapshot = self.build_compaction_snapshot();
899 let dir = sessions_dir().ok_or("cannot determine home directory")?;
900 if !dir.exists() {
901 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
902 }
903 let path = dir.join(format!("{}_snapshot.txt", self.id));
904 std::fs::write(&path, &snapshot).map_err(|e| e.to_string())?;
905 Ok(snapshot)
906 }
907
908 pub fn load_compaction_snapshot(session_id: &str) -> Option<String> {
910 let dir = sessions_dir()?;
911 let path = dir.join(format!("{session_id}_snapshot.txt"));
912 std::fs::read_to_string(&path).ok()
913 }
914
915 pub fn load_latest_snapshot() -> Option<String> {
921 let dir = sessions_dir()?;
922 let project_root = std::env::current_dir()
923 .ok()
924 .map(|p| p.to_string_lossy().to_string());
925
926 let mut snapshots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&dir)
927 .ok()?
928 .filter_map(std::result::Result::ok)
929 .filter(|e| e.path().to_string_lossy().ends_with("_snapshot.txt"))
930 .filter_map(|e| {
931 let meta = e.metadata().ok()?;
932 let modified = meta.modified().ok()?;
933
934 if let Some(ref root) = project_root {
935 let content = std::fs::read_to_string(e.path()).ok()?;
936 if !content.contains(root) {
937 return None;
938 }
939 }
940
941 Some((modified, e.path()))
942 })
943 .collect();
944
945 snapshots.sort_by_key(|x| std::cmp::Reverse(x.0));
946 snapshots
947 .first()
948 .and_then(|(_, path)| std::fs::read_to_string(path).ok())
949 }
950
951 pub fn build_resume_block(&self) -> String {
954 let mut parts: Vec<String> = Vec::new();
955
956 let level = crate::core::config::CompressionLevel::from_str_label(&self.compression_level)
957 .unwrap_or_default();
958 if let Some(hint) = crate::core::terse::agent_prompts::resume_block_hint(&level) {
959 parts.push(hint);
960 }
961
962 if let Some(ref root) = self.project_root {
963 let short = root.rsplit('/').next().unwrap_or(root);
964 parts.push(format!("Project: {short}"));
965 }
966
967 if let Some(ref task) = self.task {
968 let pct = task
969 .progress_pct
970 .map_or(String::new(), |p| format!(" [{p}%]"));
971 parts.push(format!("Task: {}{pct}", task.description));
972 }
973
974 if !self.decisions.is_empty() {
975 let items: Vec<&str> = self
976 .decisions
977 .iter()
978 .rev()
979 .take(5)
980 .map(|d| d.summary.as_str())
981 .collect();
982 parts.push(format!("Decisions: {}", items.join("; ")));
983 }
984
985 if !self.files_touched.is_empty() {
986 let modified: Vec<&str> = self
987 .files_touched
988 .iter()
989 .filter(|f| f.modified)
990 .take(10)
991 .map(|f| f.path.as_str())
992 .collect();
993 if !modified.is_empty() {
994 parts.push(format!("Modified: {}", modified.join(", ")));
995 }
996 }
997
998 if !self.next_steps.is_empty() {
999 let steps: Vec<&str> = self
1000 .next_steps
1001 .iter()
1002 .take(3)
1003 .map(std::string::String::as_str)
1004 .collect();
1005 parts.push(format!("Next: {}", steps.join("; ")));
1006 }
1007
1008 let archives = super::archive::list_entries(Some(&self.id));
1009 if !archives.is_empty() {
1010 let hints: Vec<String> = archives
1011 .iter()
1012 .take(5)
1013 .map(|a| format!("{}({})", a.id, a.tool))
1014 .collect();
1015 parts.push(format!("Archives: {}", hints.join(", ")));
1016 }
1017
1018 parts.push(format!(
1019 "Stats: {} calls, {} tok saved",
1020 self.stats.total_tool_calls, self.stats.total_tokens_saved
1021 ));
1022
1023 format!(
1024 "--- SESSION RESUME (post-compaction) ---\n{}\n---",
1025 parts.join("\n")
1026 )
1027 }
1028
1029 pub fn save(&mut self) -> Result<(), String> {
1031 let prepared = self.prepare_save()?;
1032 match prepared.write_to_disk() {
1033 Ok(()) => Ok(()),
1034 Err(e) => {
1035 self.stats.unsaved_changes = BATCH_SAVE_INTERVAL;
1036 Err(e)
1037 }
1038 }
1039 }
1040
1041 pub fn prepare_save(&mut self) -> Result<PreparedSave, String> {
1045 let dir = sessions_dir().ok_or("cannot determine home directory")?;
1046 let compaction_snapshot = if self.stats.total_tool_calls > 0 {
1047 Some(self.build_compaction_snapshot())
1048 } else {
1049 None
1050 };
1051 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
1052 let pointer_json = serde_json::to_string(&LatestPointer {
1053 id: self.id.clone(),
1054 })
1055 .map_err(|e| e.to_string())?;
1056 self.stats.unsaved_changes = 0;
1057 Ok(PreparedSave {
1058 dir,
1059 id: self.id.clone(),
1060 json,
1061 pointer_json,
1062 compaction_snapshot,
1063 })
1064 }
1065
1066 pub fn load_latest() -> Option<Self> {
1072 if let Some(project_root) = std::env::current_dir()
1073 .ok()
1074 .map(|p| p.to_string_lossy().to_string())
1075 {
1076 if let Some(session) = Self::load_latest_for_project_root(&project_root) {
1077 return Some(session);
1078 }
1079 }
1080 let dir = sessions_dir()?;
1081 let latest_path = dir.join("latest.json");
1082 let pointer_json = std::fs::read_to_string(&latest_path).ok()?;
1083 let pointer: LatestPointer = serde_json::from_str(&pointer_json).ok()?;
1084 Self::load_by_id(&pointer.id)
1085 }
1086
1087 pub fn load_latest_for_project_root(project_root: &str) -> Option<Self> {
1089 let dir = sessions_dir()?;
1090 let target_root =
1091 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(project_root));
1092 let mut latest_match: Option<Self> = None;
1093
1094 for entry in std::fs::read_dir(&dir).ok()?.flatten() {
1095 let path = entry.path();
1096 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1097 continue;
1098 }
1099 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1100 continue;
1101 }
1102
1103 let Some(id) = path.file_stem().and_then(|n| n.to_str()) else {
1104 continue;
1105 };
1106 let Some(session) = Self::load_by_id(id) else {
1107 continue;
1108 };
1109
1110 if !session_matches_project_root(&session, &target_root) {
1111 continue;
1112 }
1113
1114 if latest_match
1115 .as_ref()
1116 .is_none_or(|existing| session.updated_at > existing.updated_at)
1117 {
1118 latest_match = Some(session);
1119 }
1120 }
1121
1122 latest_match
1123 }
1124
1125 pub fn load_by_id(id: &str) -> Option<Self> {
1127 let dir = sessions_dir()?;
1128 let path = dir.join(format!("{id}.json"));
1129 let json = std::fs::read_to_string(&path).ok()?;
1130 let session: Self = serde_json::from_str(&json).ok()?;
1131 Some(normalize_loaded_session(session))
1132 }
1133
1134 pub fn list_sessions() -> Vec<SessionSummary> {
1136 let Some(dir) = sessions_dir() else {
1137 return Vec::new();
1138 };
1139
1140 let mut summaries = Vec::new();
1141 if let Ok(entries) = std::fs::read_dir(&dir) {
1142 for entry in entries.flatten() {
1143 let path = entry.path();
1144 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1145 continue;
1146 }
1147 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
1148 continue;
1149 }
1150 if let Ok(json) = std::fs::read_to_string(&path) {
1151 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1152 summaries.push(SessionSummary {
1153 id: session.id,
1154 started_at: session.started_at,
1155 updated_at: session.updated_at,
1156 version: session.version,
1157 task: session.task.as_ref().map(|t| t.description.clone()),
1158 tool_calls: session.stats.total_tool_calls,
1159 tokens_saved: session.stats.total_tokens_saved,
1160 });
1161 }
1162 }
1163 }
1164 }
1165
1166 summaries.sort_by_key(|x| std::cmp::Reverse(x.updated_at));
1167 summaries
1168 }
1169
1170 pub fn cleanup_old_sessions(max_age_days: i64) -> u32 {
1172 let Some(dir) = sessions_dir() else { return 0 };
1173
1174 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
1175 let latest = Self::load_latest().map(|s| s.id);
1176 let mut removed = 0u32;
1177
1178 if let Ok(entries) = std::fs::read_dir(&dir) {
1179 for entry in entries.flatten() {
1180 let path = entry.path();
1181 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1182 continue;
1183 }
1184 let filename = path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
1185 if filename == "latest" || filename.starts_with('.') {
1186 continue;
1187 }
1188 if latest.as_deref() == Some(filename) {
1189 continue;
1190 }
1191 if let Ok(json) = std::fs::read_to_string(&path) {
1192 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
1193 if session.updated_at < cutoff && std::fs::remove_file(&path).is_ok() {
1194 removed += 1;
1195 }
1196 }
1197 }
1198 }
1199 }
1200
1201 removed
1202 }
1203}
1204
1205#[derive(Debug, Clone)]
1207pub struct SessionSummary {
1208 pub id: String,
1209 pub started_at: DateTime<Utc>,
1210 pub updated_at: DateTime<Utc>,
1211 pub version: u32,
1212 pub task: Option<String>,
1213 pub tool_calls: u32,
1214 pub tokens_saved: u64,
1215}
1216
1217fn escape_xml_attr(value: &str) -> String {
1218 value
1219 .replace('&', "&")
1220 .replace('<', "<")
1221 .replace('>', ">")
1222 .replace('"', """)
1223}
1224
1225fn file_stem_search_pattern(path: &str) -> String {
1226 Path::new(path)
1227 .file_stem()
1228 .and_then(|s| s.to_str())
1229 .map(str::trim)
1230 .filter(|s| !s.is_empty() && s.chars().any(char::is_alphanumeric))
1231 .unwrap_or("")
1232 .to_string()
1233}
1234
1235fn parent_dir_slash(path: &str) -> String {
1236 Path::new(path)
1237 .parent()
1238 .and_then(|p| p.to_str())
1239 .map_or_else(
1240 || "./".to_string(),
1241 |p| {
1242 let norm = p.replace('\\', "/");
1243 let trimmed = norm.trim_end_matches('/');
1244 if trimmed.is_empty() {
1245 "./".to_string()
1246 } else {
1247 format!("{trimmed}/")
1248 }
1249 },
1250 )
1251}
1252
1253fn sessions_dir() -> Option<PathBuf> {
1254 crate::core::data_dir::lean_ctx_data_dir()
1255 .ok()
1256 .map(|d| d.join("sessions"))
1257}
1258
1259fn generate_session_id() -> String {
1260 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1261 let now = Utc::now();
1262 let ts = now.format("%Y%m%d-%H%M%S").to_string();
1263 let nanos = now.timestamp_subsec_micros();
1264 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1265 format!("{ts}-{nanos:06}s{seq}")
1266}
1267
1268fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
1271 let first_cmd = command
1272 .split("&&")
1273 .next()
1274 .unwrap_or(command)
1275 .split(';')
1276 .next()
1277 .unwrap_or(command)
1278 .trim();
1279
1280 if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
1281 return None;
1282 }
1283
1284 let target = first_cmd.strip_prefix("cd")?.trim();
1285 if target.is_empty() || target == "~" {
1286 return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
1287 }
1288
1289 let target = target.trim_matches('"').trim_matches('\'');
1290 let path = std::path::Path::new(target);
1291
1292 if path.is_absolute() {
1293 Some(target.to_string())
1294 } else {
1295 let base = std::path::Path::new(base_cwd);
1296 let joined = base.join(target).to_string_lossy().to_string();
1297 Some(joined.replace('\\', "/"))
1298 }
1299}
1300
1301fn shorten_path(path: &str) -> String {
1302 let parts: Vec<&str> = path.split('/').collect();
1303 if parts.len() <= 2 {
1304 return path.to_string();
1305 }
1306 let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
1307 format!("…/{}/{}", last_two[1], last_two[0])
1308}
1309
1310fn normalize_loaded_session(mut session: SessionState) -> SessionState {
1311 if matches!(session.project_root.as_deref(), Some(r) if r.trim().is_empty()) {
1312 session.project_root = None;
1313 }
1314 if matches!(session.shell_cwd.as_deref(), Some(c) if c.trim().is_empty()) {
1315 session.shell_cwd = None;
1316 }
1317
1318 if let (Some(ref root), Some(ref cwd)) = (&session.project_root, &session.shell_cwd) {
1321 let root_p = std::path::Path::new(root);
1322 let cwd_p = std::path::Path::new(cwd);
1323 let root_looks_real = has_project_marker(root_p);
1324 let cwd_looks_real = has_project_marker(cwd_p);
1325
1326 if !root_looks_real && cwd_looks_real && is_agent_or_temp_dir(root_p) {
1327 session.project_root = Some(cwd.clone());
1328 }
1329 }
1330
1331 if session.compression_level.is_empty() {
1333 if session.terse_mode {
1334 session.compression_level = "lite".to_string();
1335 } else if let Some(env_level) = crate::core::config::CompressionLevel::from_env() {
1336 session.compression_level = env_level.label().to_string();
1337 session.terse_mode = env_level.is_active();
1338 } else {
1339 let profile = crate::core::profiles::active_profile();
1340 if profile.compression.terse_mode_effective() {
1341 session.compression_level = "lite".to_string();
1342 session.terse_mode = true;
1343 } else {
1344 session.compression_level = "off".to_string();
1345 }
1346 }
1347 } else if !session.terse_mode {
1348 let level =
1349 crate::core::config::CompressionLevel::from_str_label(&session.compression_level)
1350 .unwrap_or_default();
1351 session.terse_mode = level.is_active();
1352 }
1353
1354 session
1355}
1356
1357fn session_matches_project_root(session: &SessionState, target_root: &std::path::Path) -> bool {
1358 if let Some(root) = session.project_root.as_deref() {
1359 let root_path =
1360 crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(root));
1361 if root_path == target_root {
1362 return true;
1363 }
1364 if has_project_marker(&root_path) {
1365 return false;
1366 }
1367 }
1368
1369 if let Some(cwd) = session.shell_cwd.as_deref() {
1370 let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(cwd));
1371 return cwd_path == target_root || cwd_path.starts_with(target_root);
1372 }
1373
1374 false
1375}
1376
1377fn has_project_marker(dir: &std::path::Path) -> bool {
1378 const MARKERS: &[&str] = &[
1379 ".git",
1380 ".lean-ctx.toml",
1381 "Cargo.toml",
1382 "package.json",
1383 "go.mod",
1384 "pyproject.toml",
1385 ".planning",
1386 ];
1387 MARKERS.iter().any(|m| dir.join(m).exists())
1388}
1389
1390fn is_agent_or_temp_dir(dir: &std::path::Path) -> bool {
1391 let s = dir.to_string_lossy();
1392 s.contains("/.claude")
1393 || s.contains("/.codex")
1394 || s.contains("/var/folders/")
1395 || s.contains("/tmp/")
1396 || s.contains("\\.claude")
1397 || s.contains("\\.codex")
1398 || s.contains("\\AppData\\Local\\Temp")
1399 || s.contains("\\Temp\\")
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404 use super::*;
1405
1406 #[test]
1407 fn extract_cd_absolute_path() {
1408 let result = extract_cd_target("cd /usr/local/bin", "/home/user");
1409 assert_eq!(result, Some("/usr/local/bin".to_string()));
1410 }
1411
1412 #[test]
1413 fn extract_cd_relative_path() {
1414 let result = extract_cd_target("cd subdir", "/home/user");
1415 assert_eq!(result, Some("/home/user/subdir".to_string()));
1416 }
1417
1418 #[test]
1419 fn extract_cd_with_chained_command() {
1420 let result = extract_cd_target("cd /tmp && ls", "/home/user");
1421 assert_eq!(result, Some("/tmp".to_string()));
1422 }
1423
1424 #[test]
1425 fn extract_cd_with_semicolon() {
1426 let result = extract_cd_target("cd /tmp; ls", "/home/user");
1427 assert_eq!(result, Some("/tmp".to_string()));
1428 }
1429
1430 #[test]
1431 fn extract_cd_parent_dir() {
1432 let result = extract_cd_target("cd ..", "/home/user/project");
1433 assert_eq!(result, Some("/home/user/project/..".to_string()));
1434 }
1435
1436 #[test]
1437 fn extract_cd_no_cd_returns_none() {
1438 let result = extract_cd_target("ls -la", "/home/user");
1439 assert!(result.is_none());
1440 }
1441
1442 #[test]
1443 fn extract_cd_bare_cd_goes_home() {
1444 let result = extract_cd_target("cd", "/home/user");
1445 assert!(result.is_some());
1446 }
1447
1448 #[test]
1449 fn effective_cwd_explicit_takes_priority() {
1450 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-explicit");
1451 let sub = tmp.join("sub");
1452 let _ = std::fs::create_dir_all(&sub);
1453 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1454 .to_string_lossy()
1455 .to_string();
1456 let sub_canon = crate::core::pathutil::safe_canonicalize_or_self(&sub)
1457 .to_string_lossy()
1458 .to_string();
1459
1460 let mut session = SessionState::new();
1461 session.project_root = Some(root_canon);
1462 let result = session.effective_cwd(Some(&sub_canon));
1463 assert_eq!(result, sub_canon);
1464 let _ = std::fs::remove_dir_all(&tmp);
1465 }
1466
1467 #[test]
1468 fn effective_cwd_explicit_outside_root_is_jailed() {
1469 let tmp = std::env::temp_dir().join("lean-ctx-test-cwd-jail");
1470 let _ = std::fs::create_dir_all(&tmp);
1471 let root_canon = crate::core::pathutil::safe_canonicalize_or_self(&tmp)
1472 .to_string_lossy()
1473 .to_string();
1474
1475 let mut session = SessionState::new();
1476 session.project_root = Some(root_canon.clone());
1477 let result = session.effective_cwd(Some("/nonexistent-outside-path"));
1478 assert_eq!(result, root_canon);
1479 let _ = std::fs::remove_dir_all(&tmp);
1480 }
1481
1482 #[test]
1483 fn effective_cwd_shell_cwd_second_priority() {
1484 let mut session = SessionState::new();
1485 session.project_root = Some("/project".to_string());
1486 session.shell_cwd = Some("/project/src".to_string());
1487 assert_eq!(session.effective_cwd(None), "/project/src");
1488 }
1489
1490 #[test]
1491 fn effective_cwd_project_root_third_priority() {
1492 let mut session = SessionState::new();
1493 session.project_root = Some("/project".to_string());
1494 assert_eq!(session.effective_cwd(None), "/project");
1495 }
1496
1497 #[test]
1498 fn effective_cwd_dot_ignored() {
1499 let mut session = SessionState::new();
1500 session.project_root = Some("/project".to_string());
1501 assert_eq!(session.effective_cwd(Some(".")), "/project");
1502 }
1503
1504 #[test]
1505 fn compaction_snapshot_includes_compression_config_when_enabled() {
1506 let mut session = SessionState::new();
1507 session.compression_level = "standard".to_string();
1508 session.terse_mode = true;
1509 session.set_task("x", None);
1510 let snapshot = session.build_compaction_snapshot();
1511 assert!(snapshot.contains("<config compression=\"standard\" />"));
1512 }
1513
1514 #[test]
1515 fn resume_block_prefixes_compression_hint_when_enabled() {
1516 let mut session = SessionState::new();
1517 session.compression_level = "lite".to_string();
1518 session.terse_mode = true;
1519 let block = session.build_resume_block();
1520 assert!(block.contains("[COMPRESSION: lite]"));
1521 }
1522
1523 #[test]
1524 fn compaction_snapshot_includes_task() {
1525 let mut session = SessionState::new();
1526 session.set_task("fix auth bug", None);
1527 let snapshot = session.build_compaction_snapshot();
1528 assert!(snapshot.contains("<task>fix auth bug</task>"));
1529 assert!(snapshot.contains("<session_snapshot>"));
1530 assert!(snapshot.contains("</session_snapshot>"));
1531 }
1532
1533 #[test]
1534 fn compaction_snapshot_includes_files() {
1535 let mut session = SessionState::new();
1536 session.touch_file("src/auth.rs", None, "full", 500);
1537 session.files_touched[0].modified = true;
1538 session.touch_file("src/main.rs", None, "map", 100);
1539 let snapshot = session.build_compaction_snapshot();
1540 assert!(snapshot.contains("auth.rs"));
1541 assert!(snapshot.contains("<files>"));
1542 }
1543
1544 #[test]
1545 fn compaction_snapshot_includes_decisions() {
1546 let mut session = SessionState::new();
1547 session.add_decision("Use JWT RS256", None);
1548 let snapshot = session.build_compaction_snapshot();
1549 assert!(snapshot.contains("JWT RS256"));
1550 assert!(snapshot.contains("<decisions>"));
1551 }
1552
1553 #[test]
1554 fn compaction_snapshot_respects_size_limit() {
1555 let mut session = SessionState::new();
1556 session.set_task("a]task", None);
1557 for i in 0..100 {
1558 session.add_finding(
1559 Some(&format!("file{i}.rs")),
1560 Some(i),
1561 &format!("Finding number {i} with some detail text here"),
1562 );
1563 }
1564 let snapshot = session.build_compaction_snapshot();
1565 assert!(snapshot.len() <= 2200);
1566 }
1567
1568 #[test]
1569 fn compaction_snapshot_includes_stats() {
1570 let mut session = SessionState::new();
1571 session.stats.total_tool_calls = 42;
1572 session.stats.total_tokens_saved = 10000;
1573 let snapshot = session.build_compaction_snapshot();
1574 assert!(snapshot.contains("calls=42"));
1575 assert!(snapshot.contains("saved=10000"));
1576 }
1577}