1use serde::{Deserialize, Serialize};
2
3use crate::error::{Result, SqzError};
4use crate::session_store::SessionStore;
5use crate::token_counter::TokenCounter;
6use crate::types::SessionState;
7
8const DEFAULT_MAX_SNAPSHOT_BYTES: usize = 2048;
10
11const MAX_GUIDE_CHARS: usize = 2000;
13
14#[cfg(test)]
16const MAX_GUIDE_TOKENS: u32 = 500;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23pub enum SnapshotEventType {
24 LastPrompt,
26 Error,
28 Decision,
30 PendingTask,
32 ActiveFile,
34 GitOp,
36 Rule,
38 ToolUse,
40 Environment,
42 Dependency,
44 Progress,
46 Warning,
48 Context,
50 Learning,
52 Summary,
54}
55
56impl SnapshotEventType {
57 pub fn priority(&self) -> u8 {
59 match self {
60 Self::LastPrompt => 1,
61 Self::Error => 2,
62 Self::Decision => 3,
63 Self::PendingTask => 4,
64 Self::ActiveFile => 5,
65 Self::GitOp => 6,
66 Self::Rule => 7,
67 Self::ToolUse => 8,
68 Self::Warning => 9,
69 Self::Learning => 10,
70 Self::Progress => 11,
71 Self::Context => 12,
72 Self::Environment => 13,
73 Self::Dependency => 14,
74 Self::Summary => 15,
75 }
76 }
77
78 pub fn label(&self) -> &'static str {
80 match self {
81 Self::LastPrompt => "last_request",
82 Self::Error => "errors",
83 Self::Decision => "decisions",
84 Self::PendingTask => "tasks",
85 Self::ActiveFile => "files",
86 Self::GitOp => "git",
87 Self::Rule => "rules",
88 Self::ToolUse => "tools",
89 Self::Warning => "warnings",
90 Self::Learning => "learnings",
91 Self::Progress => "progress",
92 Self::Context => "context",
93 Self::Environment => "environment",
94 Self::Dependency => "dependencies",
95 Self::Summary => "summary",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SnapshotEvent {
103 pub content: String,
104 pub priority: u8,
105 pub event_type: SnapshotEventType,
106}
107
108impl SnapshotEvent {
109 pub fn new(event_type: SnapshotEventType, content: String) -> Self {
110 Self {
111 priority: event_type.priority(),
112 event_type,
113 content,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Snapshot {
123 pub events: Vec<SnapshotEvent>,
124}
125
126impl Snapshot {
127 pub fn size_bytes(&self) -> usize {
129 serde_json::to_string(self).map(|s| s.len()).unwrap_or(0)
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SessionGuide {
140 pub text: String,
142 pub token_count: u32,
144}
145
146pub struct SessionContinuityManager<'a> {
151 store: &'a SessionStore,
152 max_snapshot_bytes: usize,
153}
154
155impl<'a> SessionContinuityManager<'a> {
156 pub fn new(store: &'a SessionStore) -> Self {
157 Self {
158 store,
159 max_snapshot_bytes: DEFAULT_MAX_SNAPSHOT_BYTES,
160 }
161 }
162
163 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
164 self.max_snapshot_bytes = max_bytes;
165 self
166 }
167
168 pub fn build_snapshot(&self, events: Vec<SnapshotEvent>) -> Result<Snapshot> {
176 let mut sorted = events;
178 sorted.sort_by_key(|e| e.priority);
179
180 let mut included: Vec<SnapshotEvent> = Vec::new();
181
182 for event in sorted {
183 included.push(event.clone());
185 let candidate = Snapshot { events: included.clone() };
186 if candidate.size_bytes() > self.max_snapshot_bytes {
187 included.pop();
189 break;
192 }
193 }
194
195 Ok(Snapshot { events: included })
196 }
197
198 pub fn build_snapshot_from_session(&self, session: &SessionState) -> Result<Snapshot> {
201 let mut events = Vec::new();
202
203 if let Some(turn) = session.conversation.iter().rev().find(|t| {
205 matches!(t.role, crate::types::Role::User)
206 }) {
207 events.push(SnapshotEvent::new(
208 SnapshotEventType::LastPrompt,
209 truncate(&turn.content, 512),
210 ));
211 }
212
213 let mut seen_files = std::collections::HashSet::new();
215 for record in &session.tool_usage {
216 if record.tool_name.contains("file") || record.tool_name.contains("read") {
217 if seen_files.insert(record.tool_name.clone()) {
218 events.push(SnapshotEvent::new(
219 SnapshotEventType::ActiveFile,
220 record.tool_name.clone(),
221 ));
222 }
223 }
224 }
225
226 for learning in &session.learnings {
228 events.push(SnapshotEvent::new(
229 SnapshotEventType::Decision,
230 format!("{}: {}", learning.key, learning.value),
231 ));
232 }
233
234 for turn in session.conversation.iter().rev().take(5) {
236 if matches!(turn.role, crate::types::Role::Assistant) {
237 let lower = turn.content.to_lowercase();
238 if lower.contains("error") || lower.contains("failed") || lower.contains("panic") {
239 events.push(SnapshotEvent::new(
240 SnapshotEventType::Error,
241 truncate(&turn.content, 256),
242 ));
243 }
244 }
245 }
246
247 self.build_snapshot(events)
248 }
249
250 pub fn store_snapshot(&self, session_id: &str, snapshot: &Snapshot) -> Result<()> {
253 let json = serde_json::to_string(snapshot)?;
254 let compressed = crate::types::CompressedContent {
255 data: json,
256 tokens_compressed: 0,
257 tokens_original: 0,
258 stages_applied: vec!["snapshot".to_string()],
259 compression_ratio: 1.0,
260 provenance: crate::types::Provenance::default(),
261 verify: None,
262 };
263 let hash = format!("snapshot:{session_id}");
264 self.store.save_cache_entry(&hash, &compressed)?;
265 Ok(())
266 }
267
268 pub fn load_snapshot(&self, session_id: &str) -> Result<Option<Snapshot>> {
270 let hash = format!("snapshot:{session_id}");
271 match self.store.get_cache_entry(&hash)? {
272 Some(entry) => {
273 let snapshot: Snapshot = serde_json::from_str(&entry.data)
274 .map_err(|e| SqzError::Other(format!("failed to parse snapshot: {e}")))?;
275 Ok(Some(snapshot))
276 }
277 None => Ok(None),
278 }
279 }
280
281 pub fn generate_guide(&self, snapshot: &Snapshot) -> SessionGuide {
291 let category_order: &[SnapshotEventType] = &[
294 SnapshotEventType::LastPrompt,
295 SnapshotEventType::Error,
296 SnapshotEventType::Decision,
297 SnapshotEventType::PendingTask,
298 SnapshotEventType::ActiveFile,
299 SnapshotEventType::GitOp,
300 SnapshotEventType::Rule,
301 SnapshotEventType::ToolUse,
302 SnapshotEventType::Warning,
303 SnapshotEventType::Learning,
304 SnapshotEventType::Progress,
305 SnapshotEventType::Context,
306 SnapshotEventType::Environment,
307 SnapshotEventType::Dependency,
308 SnapshotEventType::Summary,
309 ];
310
311 let mut groups: std::collections::HashMap<SnapshotEventType, Vec<&str>> =
313 std::collections::HashMap::new();
314 for event in &snapshot.events {
315 groups
316 .entry(event.event_type)
317 .or_default()
318 .push(&event.content);
319 }
320
321 let wrapper_overhead = "<session_knowledge>\n</session_knowledge>\n".len();
324 let body_budget = MAX_GUIDE_CHARS.saturating_sub(wrapper_overhead);
325 let mut body = String::with_capacity(body_budget);
326
327 for &cat in category_order {
328 if let Some(entries) = groups.get(&cat) {
329 let label = cat.label();
330 let mut line = format!("{label}:");
332 for (i, entry) in entries.iter().enumerate() {
333 if i > 0 {
334 line.push(';');
335 }
336 line.push(' ');
337 line.push_str(entry);
338 }
339 line.push('\n');
340
341 if body.len() + line.len() > body_budget {
343 let remaining = body_budget.saturating_sub(body.len());
345 if remaining > label.len() + 5 {
346 let cut = remaining.min(line.len()).saturating_sub(2);
348 let trunc = crate::text_boundary::truncate_str(&line, cut);
349 body.push_str(trunc);
350 body.push_str("…\n");
351 }
352 break;
353 }
354 body.push_str(&line);
355 }
356 }
357
358 let text = if body.is_empty() {
360 "<session_knowledge>\n</session_knowledge>\n".to_string()
361 } else {
362 format!("<session_knowledge>\n{body}</session_knowledge>\n")
363 };
364
365 let token_count = TokenCounter::count_fast(&text);
369
370 SessionGuide { text, token_count }
371 }
372}
373
374fn truncate(s: &str, max_len: usize) -> String {
377 if s.len() <= max_len {
378 s.to_string()
379 } else {
380 let mut result = crate::text_boundary::truncate_str(s, max_len).to_string();
381 result.push('…');
382 result
383 }
384}
385
386#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::session_store::{apply_schema, SessionStore};
392 use rusqlite::Connection;
393
394 fn in_memory_store() -> SessionStore {
395 let conn = Connection::open_in_memory().unwrap();
396 apply_schema(&conn).unwrap();
397 SessionStore::from_connection(conn)
398 }
399
400 #[test]
401 fn test_snapshot_event_priorities() {
402 assert!(SnapshotEventType::LastPrompt.priority() < SnapshotEventType::GitOp.priority());
403 assert!(SnapshotEventType::Error.priority() < SnapshotEventType::ActiveFile.priority());
404 assert!(SnapshotEventType::Decision.priority() < SnapshotEventType::PendingTask.priority());
405 }
406
407 #[test]
408 fn test_build_snapshot_empty_events() {
409 let store = in_memory_store();
410 let mgr = SessionContinuityManager::new(&store);
411 let snapshot = mgr.build_snapshot(vec![]).unwrap();
412 assert!(snapshot.events.is_empty());
413 assert!(snapshot.size_bytes() <= DEFAULT_MAX_SNAPSHOT_BYTES);
414 }
415
416 #[test]
417 fn test_build_snapshot_fits_budget() {
418 let store = in_memory_store();
419 let mgr = SessionContinuityManager::new(&store);
420
421 let events = vec![
422 SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix the auth bug".into()),
423 SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
424 SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
425 SnapshotEvent::new(SnapshotEventType::Decision, "Use JWT tokens".into()),
426 SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123".into()),
427 ];
428
429 let snapshot = mgr.build_snapshot(events).unwrap();
430 assert!(snapshot.size_bytes() <= DEFAULT_MAX_SNAPSHOT_BYTES);
431 assert!(!snapshot.events.is_empty());
432 }
433
434 #[test]
435 fn test_build_snapshot_drops_low_priority_when_tight() {
436 let store = in_memory_store();
437 let mgr = SessionContinuityManager::new(&store).with_max_bytes(300);
439
440 let events = vec![
441 SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix the auth bug".into()),
442 SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
443 SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
444 SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123 with a long message".into()),
445 SnapshotEvent::new(SnapshotEventType::PendingTask, "Refactor the module".into()),
446 ];
447
448 let snapshot = mgr.build_snapshot(events).unwrap();
449 assert!(snapshot.size_bytes() <= 300);
450
451 let types: Vec<_> = snapshot.events.iter().map(|e| e.event_type).collect();
453 assert!(types.contains(&SnapshotEventType::LastPrompt));
454 }
455
456 #[test]
457 fn test_build_snapshot_preserves_priority_order() {
458 let store = in_memory_store();
459 let mgr = SessionContinuityManager::new(&store);
460
461 let events = vec![
462 SnapshotEvent::new(SnapshotEventType::GitOp, "commit".into()),
463 SnapshotEvent::new(SnapshotEventType::LastPrompt, "prompt".into()),
464 SnapshotEvent::new(SnapshotEventType::Error, "err".into()),
465 ];
466
467 let snapshot = mgr.build_snapshot(events).unwrap();
468 let priorities: Vec<u8> = snapshot.events.iter().map(|e| e.priority).collect();
469 let mut sorted = priorities.clone();
471 sorted.sort();
472 assert_eq!(priorities, sorted);
473 }
474
475 #[test]
476 fn test_store_and_load_snapshot() {
477 let store = in_memory_store();
478 let mgr = SessionContinuityManager::new(&store);
479
480 let events = vec![
481 SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix bug".into()),
482 SnapshotEvent::new(SnapshotEventType::Error, "segfault".into()),
483 ];
484 let snapshot = mgr.build_snapshot(events).unwrap();
485
486 mgr.store_snapshot("sess-1", &snapshot).unwrap();
487 let loaded = mgr.load_snapshot("sess-1").unwrap().unwrap();
488
489 assert_eq!(loaded.events.len(), snapshot.events.len());
490 assert_eq!(loaded.events[0].content, snapshot.events[0].content);
491 assert_eq!(loaded.events[1].content, snapshot.events[1].content);
492 }
493
494 #[test]
495 fn test_load_nonexistent_snapshot_returns_none() {
496 let store = in_memory_store();
497 let mgr = SessionContinuityManager::new(&store);
498 let result = mgr.load_snapshot("nonexistent").unwrap();
499 assert!(result.is_none());
500 }
501
502 #[test]
503 fn test_truncate_helper() {
504 assert_eq!(truncate("hello", 10), "hello");
505 assert_eq!(truncate("hello world", 5), "hello…");
506 }
507
508 #[test]
511 fn test_generate_guide_empty_snapshot() {
512 let store = in_memory_store();
513 let mgr = SessionContinuityManager::new(&store);
514 let snapshot = Snapshot { events: vec![] };
515
516 let guide = mgr.generate_guide(&snapshot);
517 assert!(guide.text.contains("<session_knowledge>"));
518 assert!(guide.text.contains("</session_knowledge>"));
519 assert!(guide.token_count <= MAX_GUIDE_TOKENS);
520 }
521
522 #[test]
523 fn test_generate_guide_contains_session_knowledge_directive() {
524 let store = in_memory_store();
525 let mgr = SessionContinuityManager::new(&store);
526 let snapshot = Snapshot {
527 events: vec![
528 SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix auth bug".into()),
529 ],
530 };
531
532 let guide = mgr.generate_guide(&snapshot);
533 assert!(guide.text.starts_with("<session_knowledge>\n"));
534 assert!(guide.text.ends_with("</session_knowledge>\n"));
535 }
536
537 #[test]
538 fn test_generate_guide_organizes_by_category() {
539 let store = in_memory_store();
540 let mgr = SessionContinuityManager::new(&store);
541 let snapshot = Snapshot {
542 events: vec![
543 SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix auth".into()),
544 SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
545 SnapshotEvent::new(SnapshotEventType::Decision, "Use JWT".into()),
546 SnapshotEvent::new(SnapshotEventType::PendingTask, "Refactor module".into()),
547 SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
548 SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123".into()),
549 SnapshotEvent::new(SnapshotEventType::Rule, "No unwrap in prod".into()),
550 SnapshotEvent::new(SnapshotEventType::ToolUse, "read_file".into()),
551 SnapshotEvent::new(SnapshotEventType::Warning, "Deprecated API".into()),
552 SnapshotEvent::new(SnapshotEventType::Learning, "Project uses ESM".into()),
553 SnapshotEvent::new(SnapshotEventType::Progress, "Auth module done".into()),
554 SnapshotEvent::new(SnapshotEventType::Context, "REST API project".into()),
555 SnapshotEvent::new(SnapshotEventType::Environment, "Rust 1.75".into()),
556 SnapshotEvent::new(SnapshotEventType::Dependency, "serde 1.0".into()),
557 SnapshotEvent::new(SnapshotEventType::Summary, "Working on auth".into()),
558 ],
559 };
560
561 let guide = mgr.generate_guide(&snapshot);
562
563 assert!(guide.text.contains("last_request:"));
565 assert!(guide.text.contains("errors:"));
566 assert!(guide.text.contains("decisions:"));
567 assert!(guide.text.contains("tasks:"));
568 assert!(guide.text.contains("files:"));
569 assert!(guide.text.contains("git:"));
570 assert!(guide.text.contains("rules:"));
571 assert!(guide.text.contains("tools:"));
572 assert!(guide.text.contains("warnings:"));
573 assert!(guide.text.contains("learnings:"));
574 assert!(guide.text.contains("progress:"));
575 assert!(guide.text.contains("context:"));
576 assert!(guide.text.contains("environment:"));
577 assert!(guide.text.contains("dependencies:"));
578 assert!(guide.text.contains("summary:"));
579 }
580
581 #[test]
582 fn test_generate_guide_token_count_within_budget() {
583 let store = in_memory_store();
584 let mgr = SessionContinuityManager::new(&store);
585
586 let mut events = Vec::new();
588 for i in 0..20 {
589 events.push(SnapshotEvent::new(
590 SnapshotEventType::ActiveFile,
591 format!("src/module_{i}/handler.rs"),
592 ));
593 }
594 events.push(SnapshotEvent::new(
595 SnapshotEventType::LastPrompt,
596 "Implement the session continuity feature with all 15 categories".into(),
597 ));
598 events.push(SnapshotEvent::new(
599 SnapshotEventType::Error,
600 "thread 'main' panicked at 'index out of bounds'".into(),
601 ));
602
603 let snapshot = Snapshot { events };
604 let guide = mgr.generate_guide(&snapshot);
605
606 assert!(
607 guide.token_count <= MAX_GUIDE_TOKENS,
608 "token count {} exceeds budget {}",
609 guide.token_count,
610 MAX_GUIDE_TOKENS
611 );
612 assert!(
613 guide.text.len() <= MAX_GUIDE_CHARS + 100, "guide length {} exceeds char budget",
615 guide.text.len()
616 );
617 }
618
619 #[test]
620 fn test_generate_guide_multiple_events_same_category() {
621 let store = in_memory_store();
622 let mgr = SessionContinuityManager::new(&store);
623 let snapshot = Snapshot {
624 events: vec![
625 SnapshotEvent::new(SnapshotEventType::Error, "error 1".into()),
626 SnapshotEvent::new(SnapshotEventType::Error, "error 2".into()),
627 ],
628 };
629
630 let guide = mgr.generate_guide(&snapshot);
631 assert!(guide.text.contains("errors: error 1; error 2"));
633 }
634
635 #[test]
636 fn test_generate_guide_performance_under_50ms() {
637 let store = in_memory_store();
638 let mgr = SessionContinuityManager::new(&store);
639
640 let mut events = Vec::new();
642 for i in 0..50 {
643 events.push(SnapshotEvent::new(
644 SnapshotEventType::ActiveFile,
645 format!("src/deep/nested/path/module_{i}.rs"),
646 ));
647 }
648 events.push(SnapshotEvent::new(
649 SnapshotEventType::LastPrompt,
650 "A".repeat(512),
651 ));
652 let snapshot = Snapshot { events };
653
654 let start = std::time::Instant::now();
655 let _guide = mgr.generate_guide(&snapshot);
656 let elapsed = start.elapsed();
657
658 assert!(
659 elapsed.as_millis() < 50,
660 "generate_guide took {}ms, expected <50ms",
661 elapsed.as_millis()
662 );
663 }
664
665 #[test]
666 fn test_generate_guide_category_order_matches_priority() {
667 let store = in_memory_store();
668 let mgr = SessionContinuityManager::new(&store);
669 let snapshot = Snapshot {
670 events: vec![
671 SnapshotEvent::new(SnapshotEventType::Summary, "sum".into()),
673 SnapshotEvent::new(SnapshotEventType::LastPrompt, "prompt".into()),
674 SnapshotEvent::new(SnapshotEventType::GitOp, "commit".into()),
675 SnapshotEvent::new(SnapshotEventType::Error, "err".into()),
676 ],
677 };
678
679 let guide = mgr.generate_guide(&snapshot);
680 let pos_prompt = guide.text.find("last_request:").unwrap();
682 let pos_error = guide.text.find("errors:").unwrap();
683 let pos_git = guide.text.find("git:").unwrap();
684 let pos_summary = guide.text.find("summary:").unwrap();
685 assert!(pos_prompt < pos_error);
686 assert!(pos_error < pos_git);
687 assert!(pos_git < pos_summary);
688 }
689
690 #[test]
691 fn test_snapshot_event_type_labels_are_unique() {
692 use std::collections::HashSet;
693 let all_types = [
694 SnapshotEventType::LastPrompt,
695 SnapshotEventType::Error,
696 SnapshotEventType::Decision,
697 SnapshotEventType::PendingTask,
698 SnapshotEventType::ActiveFile,
699 SnapshotEventType::GitOp,
700 SnapshotEventType::Rule,
701 SnapshotEventType::ToolUse,
702 SnapshotEventType::Warning,
703 SnapshotEventType::Learning,
704 SnapshotEventType::Progress,
705 SnapshotEventType::Context,
706 SnapshotEventType::Environment,
707 SnapshotEventType::Dependency,
708 SnapshotEventType::Summary,
709 ];
710 let labels: HashSet<&str> = all_types.iter().map(|t| t.label()).collect();
711 assert_eq!(labels.len(), 15, "expected 15 unique category labels");
712 }
713
714 #[test]
715 fn test_snapshot_event_type_has_15_categories() {
716 use std::collections::HashSet;
718 let all_types = [
719 SnapshotEventType::LastPrompt,
720 SnapshotEventType::Error,
721 SnapshotEventType::Decision,
722 SnapshotEventType::PendingTask,
723 SnapshotEventType::ActiveFile,
724 SnapshotEventType::GitOp,
725 SnapshotEventType::Rule,
726 SnapshotEventType::ToolUse,
727 SnapshotEventType::Warning,
728 SnapshotEventType::Learning,
729 SnapshotEventType::Progress,
730 SnapshotEventType::Context,
731 SnapshotEventType::Environment,
732 SnapshotEventType::Dependency,
733 SnapshotEventType::Summary,
734 ];
735 let priorities: HashSet<u8> = all_types.iter().map(|t| t.priority()).collect();
736 assert_eq!(priorities.len(), 15, "expected 15 unique priorities");
737 }
738
739 use proptest::prelude::*;
742
743 fn arb_event_type() -> impl Strategy<Value = SnapshotEventType> {
745 prop_oneof![
746 Just(SnapshotEventType::LastPrompt),
747 Just(SnapshotEventType::Error),
748 Just(SnapshotEventType::Decision),
749 Just(SnapshotEventType::PendingTask),
750 Just(SnapshotEventType::ActiveFile),
751 Just(SnapshotEventType::GitOp),
752 Just(SnapshotEventType::Rule),
753 Just(SnapshotEventType::ToolUse),
754 Just(SnapshotEventType::Warning),
755 Just(SnapshotEventType::Learning),
756 Just(SnapshotEventType::Progress),
757 Just(SnapshotEventType::Context),
758 Just(SnapshotEventType::Environment),
759 Just(SnapshotEventType::Dependency),
760 Just(SnapshotEventType::Summary),
761 ]
762 }
763
764 fn arb_snapshot_event() -> impl Strategy<Value = SnapshotEvent> {
767 (arb_event_type(), "[a-zA-Z0-9 _/\\-.]{1,300}")
768 .prop_map(|(et, content)| SnapshotEvent::new(et, content))
769 }
770
771 proptest! {
772 #[test]
781 fn prop40_snapshot_budget_compliance(
782 events in proptest::collection::vec(arb_snapshot_event(), 0..=50),
783 budget in 128usize..=4096,
784 ) {
785 let store = in_memory_store();
786 let mgr = SessionContinuityManager::new(&store).with_max_bytes(budget);
787 let snapshot = mgr.build_snapshot(events).unwrap();
788
789 prop_assert!(
791 snapshot.size_bytes() <= budget,
792 "snapshot size {} exceeds budget {}",
793 snapshot.size_bytes(),
794 budget,
795 );
796
797 let priorities: Vec<u8> = snapshot.events.iter().map(|e| e.priority).collect();
800 let mut sorted = priorities.clone();
801 sorted.sort();
802 prop_assert_eq!(
803 priorities,
804 sorted,
805 "snapshot events are not sorted by priority"
806 );
807
808 if !snapshot.events.is_empty() {
813 let max_included_priority = snapshot
814 .events
815 .iter()
816 .map(|e| e.priority)
817 .max()
818 .unwrap();
819
820 prop_assert!(
826 max_included_priority <= 15,
827 "included priority {} out of valid range",
828 max_included_priority,
829 );
830 }
831 }
832 }
833}