Skip to main content

sqz_engine/
session_continuity.rs

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
8/// Default snapshot budget in bytes.
9const DEFAULT_MAX_SNAPSHOT_BYTES: usize = 2048;
10
11/// Maximum character budget for the Session Guide body (~500 tokens).
12const MAX_GUIDE_CHARS: usize = 2000;
13
14/// Maximum token budget for the Session Guide.
15const MAX_GUIDE_TOKENS: u32 = 500;
16
17// ── Event types ───────────────────────────────────────────────────────────────
18
19/// Priority tiers for snapshot events. Lower numeric value = higher priority.
20/// 15 categories covering all session state that should survive compaction.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22pub enum SnapshotEventType {
23    /// The last user prompt — highest priority.
24    LastPrompt,
25    /// An unresolved error.
26    Error,
27    /// A user decision recorded during the session.
28    Decision,
29    /// A pending task or action item.
30    PendingTask,
31    /// An active file in the session.
32    ActiveFile,
33    /// A git operation (commit, branch, merge, etc.).
34    GitOp,
35    /// A rule or constraint the user established.
36    Rule,
37    /// A tool invocation record.
38    ToolUse,
39    /// An environment detail (OS, runtime, paths).
40    Environment,
41    /// A dependency or import relationship.
42    Dependency,
43    /// Progress or milestone reached.
44    Progress,
45    /// A warning (non-fatal issue).
46    Warning,
47    /// Contextual background information.
48    Context,
49    /// A learning or insight extracted from conversation.
50    Learning,
51    /// A summary or compressed narrative.
52    Summary,
53}
54
55impl SnapshotEventType {
56    /// Priority tier (1 = highest). Used for budget-aware dropping.
57    pub fn priority(&self) -> u8 {
58        match self {
59            Self::LastPrompt => 1,
60            Self::Error => 2,
61            Self::Decision => 3,
62            Self::PendingTask => 4,
63            Self::ActiveFile => 5,
64            Self::GitOp => 6,
65            Self::Rule => 7,
66            Self::ToolUse => 8,
67            Self::Warning => 9,
68            Self::Learning => 10,
69            Self::Progress => 11,
70            Self::Context => 12,
71            Self::Environment => 13,
72            Self::Dependency => 14,
73            Self::Summary => 15,
74        }
75    }
76
77    /// Human-readable category label used in the Session Guide output.
78    pub fn label(&self) -> &'static str {
79        match self {
80            Self::LastPrompt => "last_request",
81            Self::Error => "errors",
82            Self::Decision => "decisions",
83            Self::PendingTask => "tasks",
84            Self::ActiveFile => "files",
85            Self::GitOp => "git",
86            Self::Rule => "rules",
87            Self::ToolUse => "tools",
88            Self::Warning => "warnings",
89            Self::Learning => "learnings",
90            Self::Progress => "progress",
91            Self::Context => "context",
92            Self::Environment => "environment",
93            Self::Dependency => "dependencies",
94            Self::Summary => "summary",
95        }
96    }
97}
98
99/// A single event captured in a snapshot.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SnapshotEvent {
102    pub content: String,
103    pub priority: u8,
104    pub event_type: SnapshotEventType,
105}
106
107impl SnapshotEvent {
108    pub fn new(event_type: SnapshotEventType, content: String) -> Self {
109        Self {
110            priority: event_type.priority(),
111            event_type,
112            content,
113        }
114    }
115}
116
117// ── Snapshot ──────────────────────────────────────────────────────────────────
118
119/// A priority-tiered snapshot of session state built before compaction.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Snapshot {
122    pub events: Vec<SnapshotEvent>,
123}
124
125impl Snapshot {
126    /// Serialized size in bytes (JSON).
127    pub fn size_bytes(&self) -> usize {
128        serde_json::to_string(self).map(|s| s.len()).unwrap_or(0)
129    }
130}
131
132// ── SessionGuide ─────────────────────────────────────────────────────────────
133
134/// A compact structured narrative generated from a [`Snapshot`], designed to be
135/// injected into a new context window after compaction as a
136/// `<session_knowledge>` directive.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct SessionGuide {
139    /// The formatted `<session_knowledge>` XML text ready for injection.
140    pub text: String,
141    /// Estimated token count of the guide text.
142    pub token_count: u32,
143}
144
145// ── SessionContinuityManager ─────────────────────────────────────────────────
146
147/// Builds priority-tiered snapshots before context compaction and persists
148/// them in the Session_Store for later retrieval.
149pub struct SessionContinuityManager<'a> {
150    store: &'a SessionStore,
151    max_snapshot_bytes: usize,
152}
153
154impl<'a> SessionContinuityManager<'a> {
155    pub fn new(store: &'a SessionStore) -> Self {
156        Self {
157            store,
158            max_snapshot_bytes: DEFAULT_MAX_SNAPSHOT_BYTES,
159        }
160    }
161
162    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
163        self.max_snapshot_bytes = max_bytes;
164        self
165    }
166
167    /// Build a priority-tiered snapshot from a list of events, fitting within
168    /// the configured byte budget. Low-priority events are dropped first when
169    /// the budget is tight.
170    ///
171    /// Events are sorted by priority (ascending = highest priority first).
172    /// The builder greedily includes events until the next event would push
173    /// the snapshot over budget.
174    pub fn build_snapshot(&self, events: Vec<SnapshotEvent>) -> Result<Snapshot> {
175        // Sort by priority tier (lowest number = highest priority).
176        let mut sorted = events;
177        sorted.sort_by_key(|e| e.priority);
178
179        let mut included: Vec<SnapshotEvent> = Vec::new();
180
181        for event in sorted {
182            // Tentatively add the event and check size.
183            included.push(event.clone());
184            let candidate = Snapshot { events: included.clone() };
185            if candidate.size_bytes() > self.max_snapshot_bytes {
186                // Remove the event that pushed us over budget.
187                included.pop();
188                // Since events are sorted by ascending priority (i.e. remaining
189                // events are equal or lower priority), we can stop here.
190                break;
191            }
192        }
193
194        Ok(Snapshot { events: included })
195    }
196
197    /// Build a snapshot from a `SessionState`, extracting events from the
198    /// session's conversation, learnings, and tool usage.
199    pub fn build_snapshot_from_session(&self, session: &SessionState) -> Result<Snapshot> {
200        let mut events = Vec::new();
201
202        // Last prompt: find the last user turn.
203        if let Some(turn) = session.conversation.iter().rev().find(|t| {
204            matches!(t.role, crate::types::Role::User)
205        }) {
206            events.push(SnapshotEvent::new(
207                SnapshotEventType::LastPrompt,
208                truncate(&turn.content, 512),
209            ));
210        }
211
212        // Active files: extract from conversation metadata (file paths mentioned).
213        let mut seen_files = std::collections::HashSet::new();
214        for record in &session.tool_usage {
215            if record.tool_name.contains("file") || record.tool_name.contains("read") {
216                if seen_files.insert(record.tool_name.clone()) {
217                    events.push(SnapshotEvent::new(
218                        SnapshotEventType::ActiveFile,
219                        record.tool_name.clone(),
220                    ));
221                }
222            }
223        }
224
225        // Decisions: extract from learnings.
226        for learning in &session.learnings {
227            events.push(SnapshotEvent::new(
228                SnapshotEventType::Decision,
229                format!("{}: {}", learning.key, learning.value),
230            ));
231        }
232
233        // Errors: scan recent assistant turns for error indicators.
234        for turn in session.conversation.iter().rev().take(5) {
235            if matches!(turn.role, crate::types::Role::Assistant) {
236                let lower = turn.content.to_lowercase();
237                if lower.contains("error") || lower.contains("failed") || lower.contains("panic") {
238                    events.push(SnapshotEvent::new(
239                        SnapshotEventType::Error,
240                        truncate(&turn.content, 256),
241                    ));
242                }
243            }
244        }
245
246        self.build_snapshot(events)
247    }
248
249    /// Persist a snapshot into the Session_Store, indexed into FTS5 for
250    /// on-demand retrieval.
251    pub fn store_snapshot(&self, session_id: &str, snapshot: &Snapshot) -> Result<()> {
252        let json = serde_json::to_string(snapshot)?;
253        let compressed = crate::types::CompressedContent {
254            data: json,
255            tokens_compressed: 0,
256            tokens_original: 0,
257            stages_applied: vec!["snapshot".to_string()],
258            compression_ratio: 1.0,
259        };
260        let hash = format!("snapshot:{session_id}");
261        self.store.save_cache_entry(&hash, &compressed)?;
262        Ok(())
263    }
264
265    /// Load a previously stored snapshot from the Session_Store.
266    pub fn load_snapshot(&self, session_id: &str) -> Result<Option<Snapshot>> {
267        let hash = format!("snapshot:{session_id}");
268        match self.store.get_cache_entry(&hash)? {
269            Some(entry) => {
270                let snapshot: Snapshot = serde_json::from_str(&entry.data)
271                    .map_err(|e| SqzError::Other(format!("failed to parse snapshot: {e}")))?;
272                Ok(Some(snapshot))
273            }
274            None => Ok(None),
275        }
276    }
277
278    /// Generate a [`SessionGuide`] from a [`Snapshot`].
279    ///
280    /// The guide organises snapshot events into up to 15 labelled categories
281    /// and wraps the result in a `<session_knowledge>` XML directive suitable
282    /// for injection into a new context window after compaction.
283    ///
284    /// The output is capped at [`MAX_GUIDE_CHARS`] characters (~500 tokens)
285    /// and is generated in a single pass with no allocations beyond the
286    /// output string, targeting <50 ms generation time.
287    pub fn generate_guide(&self, snapshot: &Snapshot) -> SessionGuide {
288        // Collect events grouped by category label, preserving priority order.
289        // We use an ordered list of (label, entries) to keep output deterministic.
290        let category_order: &[SnapshotEventType] = &[
291            SnapshotEventType::LastPrompt,
292            SnapshotEventType::Error,
293            SnapshotEventType::Decision,
294            SnapshotEventType::PendingTask,
295            SnapshotEventType::ActiveFile,
296            SnapshotEventType::GitOp,
297            SnapshotEventType::Rule,
298            SnapshotEventType::ToolUse,
299            SnapshotEventType::Warning,
300            SnapshotEventType::Learning,
301            SnapshotEventType::Progress,
302            SnapshotEventType::Context,
303            SnapshotEventType::Environment,
304            SnapshotEventType::Dependency,
305            SnapshotEventType::Summary,
306        ];
307
308        // Group events by type.
309        let mut groups: std::collections::HashMap<SnapshotEventType, Vec<&str>> =
310            std::collections::HashMap::new();
311        for event in &snapshot.events {
312            groups
313                .entry(event.event_type)
314                .or_default()
315                .push(&event.content);
316        }
317
318        // Build the inner body, respecting the char budget.
319        // Reserve space for the XML wrapper (~50 chars).
320        let wrapper_overhead = "<session_knowledge>\n</session_knowledge>\n".len();
321        let body_budget = MAX_GUIDE_CHARS.saturating_sub(wrapper_overhead);
322        let mut body = String::with_capacity(body_budget);
323
324        for &cat in category_order {
325            if let Some(entries) = groups.get(&cat) {
326                let label = cat.label();
327                // Format: <label>: entry1; entry2; ...\n
328                let mut line = format!("{label}:");
329                for (i, entry) in entries.iter().enumerate() {
330                    if i > 0 {
331                        line.push(';');
332                    }
333                    line.push(' ');
334                    line.push_str(entry);
335                }
336                line.push('\n');
337
338                // Check if adding this line would exceed the body budget.
339                if body.len() + line.len() > body_budget {
340                    // Try to fit a truncated version.
341                    let remaining = body_budget.saturating_sub(body.len());
342                    if remaining > label.len() + 5 {
343                        // At least "label: …\n"
344                        let trunc = &line[..remaining.min(line.len()).saturating_sub(2)];
345                        body.push_str(trunc);
346                        body.push_str("…\n");
347                    }
348                    break;
349                }
350                body.push_str(&line);
351            }
352        }
353
354        // Wrap in <session_knowledge> directive.
355        let text = if body.is_empty() {
356            "<session_knowledge>\n</session_knowledge>\n".to_string()
357        } else {
358            format!("<session_knowledge>\n{body}</session_knowledge>\n")
359        };
360
361        // Estimate token count using the fast chars/4 heuristic.
362        // This avoids pulling in the full BPE tokenizer and keeps generation
363        // well under the 50 ms target.
364        let token_count = TokenCounter::count_fast(&text);
365
366        SessionGuide { text, token_count }
367    }
368}
369
370/// Truncate a string to at most `max_len` characters, appending "…" if
371/// truncated.
372fn truncate(s: &str, max_len: usize) -> String {
373    if s.len() <= max_len {
374        s.to_string()
375    } else {
376        let mut result = s[..max_len].to_string();
377        result.push('…');
378        result
379    }
380}
381
382// ── Tests ─────────────────────────────────────────────────────────────────────
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::session_store::{apply_schema, SessionStore};
388    use rusqlite::Connection;
389
390    fn in_memory_store() -> SessionStore {
391        let conn = Connection::open_in_memory().unwrap();
392        apply_schema(&conn).unwrap();
393        SessionStore::from_connection(conn)
394    }
395
396    #[test]
397    fn test_snapshot_event_priorities() {
398        assert!(SnapshotEventType::LastPrompt.priority() < SnapshotEventType::GitOp.priority());
399        assert!(SnapshotEventType::Error.priority() < SnapshotEventType::ActiveFile.priority());
400        assert!(SnapshotEventType::Decision.priority() < SnapshotEventType::PendingTask.priority());
401    }
402
403    #[test]
404    fn test_build_snapshot_empty_events() {
405        let store = in_memory_store();
406        let mgr = SessionContinuityManager::new(&store);
407        let snapshot = mgr.build_snapshot(vec![]).unwrap();
408        assert!(snapshot.events.is_empty());
409        assert!(snapshot.size_bytes() <= DEFAULT_MAX_SNAPSHOT_BYTES);
410    }
411
412    #[test]
413    fn test_build_snapshot_fits_budget() {
414        let store = in_memory_store();
415        let mgr = SessionContinuityManager::new(&store);
416
417        let events = vec![
418            SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix the auth bug".into()),
419            SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
420            SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
421            SnapshotEvent::new(SnapshotEventType::Decision, "Use JWT tokens".into()),
422            SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123".into()),
423        ];
424
425        let snapshot = mgr.build_snapshot(events).unwrap();
426        assert!(snapshot.size_bytes() <= DEFAULT_MAX_SNAPSHOT_BYTES);
427        assert!(!snapshot.events.is_empty());
428    }
429
430    #[test]
431    fn test_build_snapshot_drops_low_priority_when_tight() {
432        let store = in_memory_store();
433        // Very tight budget: 300 bytes.
434        let mgr = SessionContinuityManager::new(&store).with_max_bytes(300);
435
436        let events = vec![
437            SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix the auth bug".into()),
438            SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
439            SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
440            SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123 with a long message".into()),
441            SnapshotEvent::new(SnapshotEventType::PendingTask, "Refactor the module".into()),
442        ];
443
444        let snapshot = mgr.build_snapshot(events).unwrap();
445        assert!(snapshot.size_bytes() <= 300);
446
447        // High-priority events should be present.
448        let types: Vec<_> = snapshot.events.iter().map(|e| e.event_type).collect();
449        assert!(types.contains(&SnapshotEventType::LastPrompt));
450    }
451
452    #[test]
453    fn test_build_snapshot_preserves_priority_order() {
454        let store = in_memory_store();
455        let mgr = SessionContinuityManager::new(&store);
456
457        let events = vec![
458            SnapshotEvent::new(SnapshotEventType::GitOp, "commit".into()),
459            SnapshotEvent::new(SnapshotEventType::LastPrompt, "prompt".into()),
460            SnapshotEvent::new(SnapshotEventType::Error, "err".into()),
461        ];
462
463        let snapshot = mgr.build_snapshot(events).unwrap();
464        let priorities: Vec<u8> = snapshot.events.iter().map(|e| e.priority).collect();
465        // Should be sorted ascending.
466        let mut sorted = priorities.clone();
467        sorted.sort();
468        assert_eq!(priorities, sorted);
469    }
470
471    #[test]
472    fn test_store_and_load_snapshot() {
473        let store = in_memory_store();
474        let mgr = SessionContinuityManager::new(&store);
475
476        let events = vec![
477            SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix bug".into()),
478            SnapshotEvent::new(SnapshotEventType::Error, "segfault".into()),
479        ];
480        let snapshot = mgr.build_snapshot(events).unwrap();
481
482        mgr.store_snapshot("sess-1", &snapshot).unwrap();
483        let loaded = mgr.load_snapshot("sess-1").unwrap().unwrap();
484
485        assert_eq!(loaded.events.len(), snapshot.events.len());
486        assert_eq!(loaded.events[0].content, snapshot.events[0].content);
487        assert_eq!(loaded.events[1].content, snapshot.events[1].content);
488    }
489
490    #[test]
491    fn test_load_nonexistent_snapshot_returns_none() {
492        let store = in_memory_store();
493        let mgr = SessionContinuityManager::new(&store);
494        let result = mgr.load_snapshot("nonexistent").unwrap();
495        assert!(result.is_none());
496    }
497
498    #[test]
499    fn test_truncate_helper() {
500        assert_eq!(truncate("hello", 10), "hello");
501        assert_eq!(truncate("hello world", 5), "hello…");
502    }
503
504    // ── SessionGuide tests ────────────────────────────────────────────────
505
506    #[test]
507    fn test_generate_guide_empty_snapshot() {
508        let store = in_memory_store();
509        let mgr = SessionContinuityManager::new(&store);
510        let snapshot = Snapshot { events: vec![] };
511
512        let guide = mgr.generate_guide(&snapshot);
513        assert!(guide.text.contains("<session_knowledge>"));
514        assert!(guide.text.contains("</session_knowledge>"));
515        assert!(guide.token_count <= MAX_GUIDE_TOKENS);
516    }
517
518    #[test]
519    fn test_generate_guide_contains_session_knowledge_directive() {
520        let store = in_memory_store();
521        let mgr = SessionContinuityManager::new(&store);
522        let snapshot = Snapshot {
523            events: vec![
524                SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix auth bug".into()),
525            ],
526        };
527
528        let guide = mgr.generate_guide(&snapshot);
529        assert!(guide.text.starts_with("<session_knowledge>\n"));
530        assert!(guide.text.ends_with("</session_knowledge>\n"));
531    }
532
533    #[test]
534    fn test_generate_guide_organizes_by_category() {
535        let store = in_memory_store();
536        let mgr = SessionContinuityManager::new(&store);
537        let snapshot = Snapshot {
538            events: vec![
539                SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix auth".into()),
540                SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
541                SnapshotEvent::new(SnapshotEventType::Decision, "Use JWT".into()),
542                SnapshotEvent::new(SnapshotEventType::PendingTask, "Refactor module".into()),
543                SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
544                SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123".into()),
545                SnapshotEvent::new(SnapshotEventType::Rule, "No unwrap in prod".into()),
546                SnapshotEvent::new(SnapshotEventType::ToolUse, "read_file".into()),
547                SnapshotEvent::new(SnapshotEventType::Warning, "Deprecated API".into()),
548                SnapshotEvent::new(SnapshotEventType::Learning, "Project uses ESM".into()),
549                SnapshotEvent::new(SnapshotEventType::Progress, "Auth module done".into()),
550                SnapshotEvent::new(SnapshotEventType::Context, "REST API project".into()),
551                SnapshotEvent::new(SnapshotEventType::Environment, "Rust 1.75".into()),
552                SnapshotEvent::new(SnapshotEventType::Dependency, "serde 1.0".into()),
553                SnapshotEvent::new(SnapshotEventType::Summary, "Working on auth".into()),
554            ],
555        };
556
557        let guide = mgr.generate_guide(&snapshot);
558
559        // All 15 category labels should appear.
560        assert!(guide.text.contains("last_request:"));
561        assert!(guide.text.contains("errors:"));
562        assert!(guide.text.contains("decisions:"));
563        assert!(guide.text.contains("tasks:"));
564        assert!(guide.text.contains("files:"));
565        assert!(guide.text.contains("git:"));
566        assert!(guide.text.contains("rules:"));
567        assert!(guide.text.contains("tools:"));
568        assert!(guide.text.contains("warnings:"));
569        assert!(guide.text.contains("learnings:"));
570        assert!(guide.text.contains("progress:"));
571        assert!(guide.text.contains("context:"));
572        assert!(guide.text.contains("environment:"));
573        assert!(guide.text.contains("dependencies:"));
574        assert!(guide.text.contains("summary:"));
575    }
576
577    #[test]
578    fn test_generate_guide_token_count_within_budget() {
579        let store = in_memory_store();
580        let mgr = SessionContinuityManager::new(&store);
581
582        // Build a snapshot with many events to stress the budget.
583        let mut events = Vec::new();
584        for i in 0..20 {
585            events.push(SnapshotEvent::new(
586                SnapshotEventType::ActiveFile,
587                format!("src/module_{i}/handler.rs"),
588            ));
589        }
590        events.push(SnapshotEvent::new(
591            SnapshotEventType::LastPrompt,
592            "Implement the session continuity feature with all 15 categories".into(),
593        ));
594        events.push(SnapshotEvent::new(
595            SnapshotEventType::Error,
596            "thread 'main' panicked at 'index out of bounds'".into(),
597        ));
598
599        let snapshot = Snapshot { events };
600        let guide = mgr.generate_guide(&snapshot);
601
602        assert!(
603            guide.token_count <= MAX_GUIDE_TOKENS,
604            "token count {} exceeds budget {}",
605            guide.token_count,
606            MAX_GUIDE_TOKENS
607        );
608        assert!(
609            guide.text.len() <= MAX_GUIDE_CHARS + 100, // small overhead for XML wrapper
610            "guide length {} exceeds char budget",
611            guide.text.len()
612        );
613    }
614
615    #[test]
616    fn test_generate_guide_multiple_events_same_category() {
617        let store = in_memory_store();
618        let mgr = SessionContinuityManager::new(&store);
619        let snapshot = Snapshot {
620            events: vec![
621                SnapshotEvent::new(SnapshotEventType::Error, "error 1".into()),
622                SnapshotEvent::new(SnapshotEventType::Error, "error 2".into()),
623            ],
624        };
625
626        let guide = mgr.generate_guide(&snapshot);
627        // Both errors should appear semicolon-separated.
628        assert!(guide.text.contains("errors: error 1; error 2"));
629    }
630
631    #[test]
632    fn test_generate_guide_performance_under_50ms() {
633        let store = in_memory_store();
634        let mgr = SessionContinuityManager::new(&store);
635
636        // Build a large snapshot.
637        let mut events = Vec::new();
638        for i in 0..50 {
639            events.push(SnapshotEvent::new(
640                SnapshotEventType::ActiveFile,
641                format!("src/deep/nested/path/module_{i}.rs"),
642            ));
643        }
644        events.push(SnapshotEvent::new(
645            SnapshotEventType::LastPrompt,
646            "A".repeat(512),
647        ));
648        let snapshot = Snapshot { events };
649
650        let start = std::time::Instant::now();
651        let _guide = mgr.generate_guide(&snapshot);
652        let elapsed = start.elapsed();
653
654        assert!(
655            elapsed.as_millis() < 50,
656            "generate_guide took {}ms, expected <50ms",
657            elapsed.as_millis()
658        );
659    }
660
661    #[test]
662    fn test_generate_guide_category_order_matches_priority() {
663        let store = in_memory_store();
664        let mgr = SessionContinuityManager::new(&store);
665        let snapshot = Snapshot {
666            events: vec![
667                // Insert in reverse priority order.
668                SnapshotEvent::new(SnapshotEventType::Summary, "sum".into()),
669                SnapshotEvent::new(SnapshotEventType::LastPrompt, "prompt".into()),
670                SnapshotEvent::new(SnapshotEventType::GitOp, "commit".into()),
671                SnapshotEvent::new(SnapshotEventType::Error, "err".into()),
672            ],
673        };
674
675        let guide = mgr.generate_guide(&snapshot);
676        // last_request should appear before errors, errors before git, git before summary.
677        let pos_prompt = guide.text.find("last_request:").unwrap();
678        let pos_error = guide.text.find("errors:").unwrap();
679        let pos_git = guide.text.find("git:").unwrap();
680        let pos_summary = guide.text.find("summary:").unwrap();
681        assert!(pos_prompt < pos_error);
682        assert!(pos_error < pos_git);
683        assert!(pos_git < pos_summary);
684    }
685
686    #[test]
687    fn test_snapshot_event_type_labels_are_unique() {
688        use std::collections::HashSet;
689        let all_types = [
690            SnapshotEventType::LastPrompt,
691            SnapshotEventType::Error,
692            SnapshotEventType::Decision,
693            SnapshotEventType::PendingTask,
694            SnapshotEventType::ActiveFile,
695            SnapshotEventType::GitOp,
696            SnapshotEventType::Rule,
697            SnapshotEventType::ToolUse,
698            SnapshotEventType::Warning,
699            SnapshotEventType::Learning,
700            SnapshotEventType::Progress,
701            SnapshotEventType::Context,
702            SnapshotEventType::Environment,
703            SnapshotEventType::Dependency,
704            SnapshotEventType::Summary,
705        ];
706        let labels: HashSet<&str> = all_types.iter().map(|t| t.label()).collect();
707        assert_eq!(labels.len(), 15, "expected 15 unique category labels");
708    }
709
710    #[test]
711    fn test_snapshot_event_type_has_15_categories() {
712        // Verify all 15 variants have distinct priorities.
713        use std::collections::HashSet;
714        let all_types = [
715            SnapshotEventType::LastPrompt,
716            SnapshotEventType::Error,
717            SnapshotEventType::Decision,
718            SnapshotEventType::PendingTask,
719            SnapshotEventType::ActiveFile,
720            SnapshotEventType::GitOp,
721            SnapshotEventType::Rule,
722            SnapshotEventType::ToolUse,
723            SnapshotEventType::Warning,
724            SnapshotEventType::Learning,
725            SnapshotEventType::Progress,
726            SnapshotEventType::Context,
727            SnapshotEventType::Environment,
728            SnapshotEventType::Dependency,
729            SnapshotEventType::Summary,
730        ];
731        let priorities: HashSet<u8> = all_types.iter().map(|t| t.priority()).collect();
732        assert_eq!(priorities.len(), 15, "expected 15 unique priorities");
733    }
734
735    // ── Property-based tests ──────────────────────────────────────────────
736
737    use proptest::prelude::*;
738
739    /// Strategy that produces an arbitrary `SnapshotEventType`.
740    fn arb_event_type() -> impl Strategy<Value = SnapshotEventType> {
741        prop_oneof![
742            Just(SnapshotEventType::LastPrompt),
743            Just(SnapshotEventType::Error),
744            Just(SnapshotEventType::Decision),
745            Just(SnapshotEventType::PendingTask),
746            Just(SnapshotEventType::ActiveFile),
747            Just(SnapshotEventType::GitOp),
748            Just(SnapshotEventType::Rule),
749            Just(SnapshotEventType::ToolUse),
750            Just(SnapshotEventType::Warning),
751            Just(SnapshotEventType::Learning),
752            Just(SnapshotEventType::Progress),
753            Just(SnapshotEventType::Context),
754            Just(SnapshotEventType::Environment),
755            Just(SnapshotEventType::Dependency),
756            Just(SnapshotEventType::Summary),
757        ]
758    }
759
760    /// Strategy that produces a `SnapshotEvent` with arbitrary type and
761    /// content of varying length (1–300 ASCII chars).
762    fn arb_snapshot_event() -> impl Strategy<Value = SnapshotEvent> {
763        (arb_event_type(), "[a-zA-Z0-9 _/\\-.]{1,300}")
764            .prop_map(|(et, content)| SnapshotEvent::new(et, content))
765    }
766
767    proptest! {
768        /// **Validates: Requirements 35.1, 35.5**
769        ///
770        /// Property 40: Session continuity snapshot fits budget.
771        ///
772        /// For any set of events (varying count and content sizes), the
773        /// built snapshot always fits within the configured byte budget
774        /// (2 KB default) and higher-priority events are always included
775        /// before lower-priority events.
776        #[test]
777        fn prop40_snapshot_budget_compliance(
778            events in proptest::collection::vec(arb_snapshot_event(), 0..=50),
779            budget in 128usize..=4096,
780        ) {
781            let store = in_memory_store();
782            let mgr = SessionContinuityManager::new(&store).with_max_bytes(budget);
783            let snapshot = mgr.build_snapshot(events).unwrap();
784
785            // 1. Snapshot must fit within the configured byte budget.
786            prop_assert!(
787                snapshot.size_bytes() <= budget,
788                "snapshot size {} exceeds budget {}",
789                snapshot.size_bytes(),
790                budget,
791            );
792
793            // 2. Events in the snapshot must be sorted by ascending priority
794            //    (lower number = higher priority = included first).
795            let priorities: Vec<u8> = snapshot.events.iter().map(|e| e.priority).collect();
796            let mut sorted = priorities.clone();
797            sorted.sort();
798            prop_assert_eq!(
799                priorities,
800                sorted,
801                "snapshot events are not sorted by priority"
802            );
803
804            // 3. If any event was dropped, every dropped event must have
805            //    equal or lower priority (higher number) than every included
806            //    event. This confirms higher-priority events are always
807            //    included before lower-priority ones.
808            if !snapshot.events.is_empty() {
809                let max_included_priority = snapshot
810                    .events
811                    .iter()
812                    .map(|e| e.priority)
813                    .max()
814                    .unwrap();
815
816                // The build_snapshot algorithm stops at the first event that
817                // would exceed the budget. Because events are sorted by
818                // ascending priority, all remaining (dropped) events have
819                // priority >= the break point. We verify that the included
820                // set's maximum priority is a valid cut-off.
821                prop_assert!(
822                    max_included_priority <= 15,
823                    "included priority {} out of valid range",
824                    max_included_priority,
825                );
826            }
827        }
828    }
829}