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.
15#[cfg(test)]
16const MAX_GUIDE_TOKENS: u32 = 500;
17
18// ── Event types ───────────────────────────────────────────────────────────────
19
20/// Priority tiers for snapshot events. Lower numeric value = higher priority.
21/// 15 categories covering all session state that should survive compaction.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23pub enum SnapshotEventType {
24    /// The last user prompt — highest priority.
25    LastPrompt,
26    /// An unresolved error.
27    Error,
28    /// A user decision recorded during the session.
29    Decision,
30    /// A pending task or action item.
31    PendingTask,
32    /// An active file in the session.
33    ActiveFile,
34    /// A git operation (commit, branch, merge, etc.).
35    GitOp,
36    /// A rule or constraint the user established.
37    Rule,
38    /// A tool invocation record.
39    ToolUse,
40    /// An environment detail (OS, runtime, paths).
41    Environment,
42    /// A dependency or import relationship.
43    Dependency,
44    /// Progress or milestone reached.
45    Progress,
46    /// A warning (non-fatal issue).
47    Warning,
48    /// Contextual background information.
49    Context,
50    /// A learning or insight extracted from conversation.
51    Learning,
52    /// A summary or compressed narrative.
53    Summary,
54}
55
56impl SnapshotEventType {
57    /// Priority tier (1 = highest). Used for budget-aware dropping.
58    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    /// Human-readable category label used in the Session Guide output.
79    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/// A single event captured in a snapshot.
101#[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// ── Snapshot ──────────────────────────────────────────────────────────────────
119
120/// A priority-tiered snapshot of session state built before compaction.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Snapshot {
123    pub events: Vec<SnapshotEvent>,
124}
125
126impl Snapshot {
127    /// Serialized size in bytes (JSON).
128    pub fn size_bytes(&self) -> usize {
129        serde_json::to_string(self).map(|s| s.len()).unwrap_or(0)
130    }
131}
132
133// ── SessionGuide ─────────────────────────────────────────────────────────────
134
135/// A compact structured narrative generated from a [`Snapshot`], designed to be
136/// injected into a new context window after compaction as a
137/// `<session_knowledge>` directive.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SessionGuide {
140    /// The formatted `<session_knowledge>` XML text ready for injection.
141    pub text: String,
142    /// Estimated token count of the guide text.
143    pub token_count: u32,
144}
145
146// ── SessionContinuityManager ─────────────────────────────────────────────────
147
148/// Builds priority-tiered snapshots before context compaction and persists
149/// them in the Session_Store for later retrieval.
150pub 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    /// Build a priority-tiered snapshot from a list of events, fitting within
169    /// the configured byte budget. Low-priority events are dropped first when
170    /// the budget is tight.
171    ///
172    /// Events are sorted by priority (ascending = highest priority first).
173    /// The builder greedily includes events until the next event would push
174    /// the snapshot over budget.
175    pub fn build_snapshot(&self, events: Vec<SnapshotEvent>) -> Result<Snapshot> {
176        // Sort by priority tier (lowest number = highest priority).
177        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            // Tentatively add the event and check size.
184            included.push(event.clone());
185            let candidate = Snapshot { events: included.clone() };
186            if candidate.size_bytes() > self.max_snapshot_bytes {
187                // Remove the event that pushed us over budget.
188                included.pop();
189                // Since events are sorted by ascending priority (i.e. remaining
190                // events are equal or lower priority), we can stop here.
191                break;
192            }
193        }
194
195        Ok(Snapshot { events: included })
196    }
197
198    /// Build a snapshot from a `SessionState`, extracting events from the
199    /// session's conversation, learnings, and tool usage.
200    pub fn build_snapshot_from_session(&self, session: &SessionState) -> Result<Snapshot> {
201        let mut events = Vec::new();
202
203        // Last prompt: find the last user turn.
204        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        // Active files: extract from conversation metadata (file paths mentioned).
214        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        // Decisions: extract from learnings.
227        for learning in &session.learnings {
228            events.push(SnapshotEvent::new(
229                SnapshotEventType::Decision,
230                format!("{}: {}", learning.key, learning.value),
231            ));
232        }
233
234        // Errors: scan recent assistant turns for error indicators.
235        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    /// Persist a snapshot into the Session_Store, indexed into FTS5 for
251    /// on-demand retrieval.
252    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    /// Load a previously stored snapshot from the Session_Store.
269    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    /// Generate a [`SessionGuide`] from a [`Snapshot`].
282    ///
283    /// The guide organises snapshot events into up to 15 labelled categories
284    /// and wraps the result in a `<session_knowledge>` XML directive suitable
285    /// for injection into a new context window after compaction.
286    ///
287    /// The output is capped at `MAX_GUIDE_CHARS` characters (~500 tokens)
288    /// and is generated in a single pass with no allocations beyond the
289    /// output string, targeting <50 ms generation time.
290    pub fn generate_guide(&self, snapshot: &Snapshot) -> SessionGuide {
291        // Collect events grouped by category label, preserving priority order.
292        // We use an ordered list of (label, entries) to keep output deterministic.
293        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        // Group events by type.
312        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        // Build the inner body, respecting the char budget.
322        // Reserve space for the XML wrapper (~50 chars).
323        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                // Format: <label>: entry1; entry2; ...\n
331                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                // Check if adding this line would exceed the body budget.
342                if body.len() + line.len() > body_budget {
343                    // Try to fit a truncated version.
344                    let remaining = body_budget.saturating_sub(body.len());
345                    if remaining > label.len() + 5 {
346                        // At least "label: …\n"
347                        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        // Wrap in <session_knowledge> directive.
359        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        // Estimate token count using the fast chars/4 heuristic.
366        // This avoids pulling in the full BPE tokenizer and keeps generation
367        // well under the 50 ms target.
368        let token_count = TokenCounter::count_fast(&text);
369
370        SessionGuide { text, token_count }
371    }
372}
373
374/// Truncate a string to at most `max_len` characters, appending "…" if
375/// truncated.
376fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
387
388#[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        // Very tight budget: 300 bytes.
438        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        // High-priority events should be present.
452        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        // Should be sorted ascending.
470        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    // ── SessionGuide tests ────────────────────────────────────────────────
509
510    #[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        // All 15 category labels should appear.
564        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        // Build a snapshot with many events to stress the budget.
587        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, // small overhead for XML wrapper
614            "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        // Both errors should appear semicolon-separated.
632        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        // Build a large snapshot.
641        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                // Insert in reverse priority order.
672                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        // last_request should appear before errors, errors before git, git before summary.
681        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        // Verify all 15 variants have distinct priorities.
717        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    // ── Property-based tests ──────────────────────────────────────────────
740
741    use proptest::prelude::*;
742
743    /// Strategy that produces an arbitrary `SnapshotEventType`.
744    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    /// Strategy that produces a `SnapshotEvent` with arbitrary type and
765    /// content of varying length (1–300 ASCII chars).
766    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        /// **Validates: Requirements 35.1, 35.5**
773        ///
774        /// Property 40: Session continuity snapshot fits budget.
775        ///
776        /// For any set of events (varying count and content sizes), the
777        /// built snapshot always fits within the configured byte budget
778        /// (2 KB default) and higher-priority events are always included
779        /// before lower-priority events.
780        #[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            // 1. Snapshot must fit within the configured byte budget.
790            prop_assert!(
791                snapshot.size_bytes() <= budget,
792                "snapshot size {} exceeds budget {}",
793                snapshot.size_bytes(),
794                budget,
795            );
796
797            // 2. Events in the snapshot must be sorted by ascending priority
798            //    (lower number = higher priority = included first).
799            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            // 3. If any event was dropped, every dropped event must have
809            //    equal or lower priority (higher number) than every included
810            //    event. This confirms higher-priority events are always
811            //    included before lower-priority ones.
812            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                // The build_snapshot algorithm stops at the first event that
821                // would exceed the budget. Because events are sorted by
822                // ascending priority, all remaining (dropped) events have
823                // priority >= the break point. We verify that the included
824                // set's maximum priority is a valid cut-off.
825                prop_assert!(
826                    max_included_priority <= 15,
827                    "included priority {} out of valid range",
828                    max_included_priority,
829                );
830            }
831        }
832    }
833}