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 trunc = &line[..remaining.min(line.len()).saturating_sub(2)];
348                        body.push_str(trunc);
349                        body.push_str("…\n");
350                    }
351                    break;
352                }
353                body.push_str(&line);
354            }
355        }
356
357        // Wrap in <session_knowledge> directive.
358        let text = if body.is_empty() {
359            "<session_knowledge>\n</session_knowledge>\n".to_string()
360        } else {
361            format!("<session_knowledge>\n{body}</session_knowledge>\n")
362        };
363
364        // Estimate token count using the fast chars/4 heuristic.
365        // This avoids pulling in the full BPE tokenizer and keeps generation
366        // well under the 50 ms target.
367        let token_count = TokenCounter::count_fast(&text);
368
369        SessionGuide { text, token_count }
370    }
371}
372
373/// Truncate a string to at most `max_len` characters, appending "…" if
374/// truncated.
375fn truncate(s: &str, max_len: usize) -> String {
376    if s.len() <= max_len {
377        s.to_string()
378    } else {
379        let mut result = s[..max_len].to_string();
380        result.push('…');
381        result
382    }
383}
384
385// ── Tests ─────────────────────────────────────────────────────────────────────
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::session_store::{apply_schema, SessionStore};
391    use rusqlite::Connection;
392
393    fn in_memory_store() -> SessionStore {
394        let conn = Connection::open_in_memory().unwrap();
395        apply_schema(&conn).unwrap();
396        SessionStore::from_connection(conn)
397    }
398
399    #[test]
400    fn test_snapshot_event_priorities() {
401        assert!(SnapshotEventType::LastPrompt.priority() < SnapshotEventType::GitOp.priority());
402        assert!(SnapshotEventType::Error.priority() < SnapshotEventType::ActiveFile.priority());
403        assert!(SnapshotEventType::Decision.priority() < SnapshotEventType::PendingTask.priority());
404    }
405
406    #[test]
407    fn test_build_snapshot_empty_events() {
408        let store = in_memory_store();
409        let mgr = SessionContinuityManager::new(&store);
410        let snapshot = mgr.build_snapshot(vec![]).unwrap();
411        assert!(snapshot.events.is_empty());
412        assert!(snapshot.size_bytes() <= DEFAULT_MAX_SNAPSHOT_BYTES);
413    }
414
415    #[test]
416    fn test_build_snapshot_fits_budget() {
417        let store = in_memory_store();
418        let mgr = SessionContinuityManager::new(&store);
419
420        let events = vec![
421            SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix the auth bug".into()),
422            SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
423            SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
424            SnapshotEvent::new(SnapshotEventType::Decision, "Use JWT tokens".into()),
425            SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123".into()),
426        ];
427
428        let snapshot = mgr.build_snapshot(events).unwrap();
429        assert!(snapshot.size_bytes() <= DEFAULT_MAX_SNAPSHOT_BYTES);
430        assert!(!snapshot.events.is_empty());
431    }
432
433    #[test]
434    fn test_build_snapshot_drops_low_priority_when_tight() {
435        let store = in_memory_store();
436        // Very tight budget: 300 bytes.
437        let mgr = SessionContinuityManager::new(&store).with_max_bytes(300);
438
439        let events = vec![
440            SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix the auth bug".into()),
441            SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
442            SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
443            SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123 with a long message".into()),
444            SnapshotEvent::new(SnapshotEventType::PendingTask, "Refactor the module".into()),
445        ];
446
447        let snapshot = mgr.build_snapshot(events).unwrap();
448        assert!(snapshot.size_bytes() <= 300);
449
450        // High-priority events should be present.
451        let types: Vec<_> = snapshot.events.iter().map(|e| e.event_type).collect();
452        assert!(types.contains(&SnapshotEventType::LastPrompt));
453    }
454
455    #[test]
456    fn test_build_snapshot_preserves_priority_order() {
457        let store = in_memory_store();
458        let mgr = SessionContinuityManager::new(&store);
459
460        let events = vec![
461            SnapshotEvent::new(SnapshotEventType::GitOp, "commit".into()),
462            SnapshotEvent::new(SnapshotEventType::LastPrompt, "prompt".into()),
463            SnapshotEvent::new(SnapshotEventType::Error, "err".into()),
464        ];
465
466        let snapshot = mgr.build_snapshot(events).unwrap();
467        let priorities: Vec<u8> = snapshot.events.iter().map(|e| e.priority).collect();
468        // Should be sorted ascending.
469        let mut sorted = priorities.clone();
470        sorted.sort();
471        assert_eq!(priorities, sorted);
472    }
473
474    #[test]
475    fn test_store_and_load_snapshot() {
476        let store = in_memory_store();
477        let mgr = SessionContinuityManager::new(&store);
478
479        let events = vec![
480            SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix bug".into()),
481            SnapshotEvent::new(SnapshotEventType::Error, "segfault".into()),
482        ];
483        let snapshot = mgr.build_snapshot(events).unwrap();
484
485        mgr.store_snapshot("sess-1", &snapshot).unwrap();
486        let loaded = mgr.load_snapshot("sess-1").unwrap().unwrap();
487
488        assert_eq!(loaded.events.len(), snapshot.events.len());
489        assert_eq!(loaded.events[0].content, snapshot.events[0].content);
490        assert_eq!(loaded.events[1].content, snapshot.events[1].content);
491    }
492
493    #[test]
494    fn test_load_nonexistent_snapshot_returns_none() {
495        let store = in_memory_store();
496        let mgr = SessionContinuityManager::new(&store);
497        let result = mgr.load_snapshot("nonexistent").unwrap();
498        assert!(result.is_none());
499    }
500
501    #[test]
502    fn test_truncate_helper() {
503        assert_eq!(truncate("hello", 10), "hello");
504        assert_eq!(truncate("hello world", 5), "hello…");
505    }
506
507    // ── SessionGuide tests ────────────────────────────────────────────────
508
509    #[test]
510    fn test_generate_guide_empty_snapshot() {
511        let store = in_memory_store();
512        let mgr = SessionContinuityManager::new(&store);
513        let snapshot = Snapshot { events: vec![] };
514
515        let guide = mgr.generate_guide(&snapshot);
516        assert!(guide.text.contains("<session_knowledge>"));
517        assert!(guide.text.contains("</session_knowledge>"));
518        assert!(guide.token_count <= MAX_GUIDE_TOKENS);
519    }
520
521    #[test]
522    fn test_generate_guide_contains_session_knowledge_directive() {
523        let store = in_memory_store();
524        let mgr = SessionContinuityManager::new(&store);
525        let snapshot = Snapshot {
526            events: vec![
527                SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix auth bug".into()),
528            ],
529        };
530
531        let guide = mgr.generate_guide(&snapshot);
532        assert!(guide.text.starts_with("<session_knowledge>\n"));
533        assert!(guide.text.ends_with("</session_knowledge>\n"));
534    }
535
536    #[test]
537    fn test_generate_guide_organizes_by_category() {
538        let store = in_memory_store();
539        let mgr = SessionContinuityManager::new(&store);
540        let snapshot = Snapshot {
541            events: vec![
542                SnapshotEvent::new(SnapshotEventType::LastPrompt, "Fix auth".into()),
543                SnapshotEvent::new(SnapshotEventType::Error, "panic at line 42".into()),
544                SnapshotEvent::new(SnapshotEventType::Decision, "Use JWT".into()),
545                SnapshotEvent::new(SnapshotEventType::PendingTask, "Refactor module".into()),
546                SnapshotEvent::new(SnapshotEventType::ActiveFile, "src/auth.rs".into()),
547                SnapshotEvent::new(SnapshotEventType::GitOp, "commit abc123".into()),
548                SnapshotEvent::new(SnapshotEventType::Rule, "No unwrap in prod".into()),
549                SnapshotEvent::new(SnapshotEventType::ToolUse, "read_file".into()),
550                SnapshotEvent::new(SnapshotEventType::Warning, "Deprecated API".into()),
551                SnapshotEvent::new(SnapshotEventType::Learning, "Project uses ESM".into()),
552                SnapshotEvent::new(SnapshotEventType::Progress, "Auth module done".into()),
553                SnapshotEvent::new(SnapshotEventType::Context, "REST API project".into()),
554                SnapshotEvent::new(SnapshotEventType::Environment, "Rust 1.75".into()),
555                SnapshotEvent::new(SnapshotEventType::Dependency, "serde 1.0".into()),
556                SnapshotEvent::new(SnapshotEventType::Summary, "Working on auth".into()),
557            ],
558        };
559
560        let guide = mgr.generate_guide(&snapshot);
561
562        // All 15 category labels should appear.
563        assert!(guide.text.contains("last_request:"));
564        assert!(guide.text.contains("errors:"));
565        assert!(guide.text.contains("decisions:"));
566        assert!(guide.text.contains("tasks:"));
567        assert!(guide.text.contains("files:"));
568        assert!(guide.text.contains("git:"));
569        assert!(guide.text.contains("rules:"));
570        assert!(guide.text.contains("tools:"));
571        assert!(guide.text.contains("warnings:"));
572        assert!(guide.text.contains("learnings:"));
573        assert!(guide.text.contains("progress:"));
574        assert!(guide.text.contains("context:"));
575        assert!(guide.text.contains("environment:"));
576        assert!(guide.text.contains("dependencies:"));
577        assert!(guide.text.contains("summary:"));
578    }
579
580    #[test]
581    fn test_generate_guide_token_count_within_budget() {
582        let store = in_memory_store();
583        let mgr = SessionContinuityManager::new(&store);
584
585        // Build a snapshot with many events to stress the budget.
586        let mut events = Vec::new();
587        for i in 0..20 {
588            events.push(SnapshotEvent::new(
589                SnapshotEventType::ActiveFile,
590                format!("src/module_{i}/handler.rs"),
591            ));
592        }
593        events.push(SnapshotEvent::new(
594            SnapshotEventType::LastPrompt,
595            "Implement the session continuity feature with all 15 categories".into(),
596        ));
597        events.push(SnapshotEvent::new(
598            SnapshotEventType::Error,
599            "thread 'main' panicked at 'index out of bounds'".into(),
600        ));
601
602        let snapshot = Snapshot { events };
603        let guide = mgr.generate_guide(&snapshot);
604
605        assert!(
606            guide.token_count <= MAX_GUIDE_TOKENS,
607            "token count {} exceeds budget {}",
608            guide.token_count,
609            MAX_GUIDE_TOKENS
610        );
611        assert!(
612            guide.text.len() <= MAX_GUIDE_CHARS + 100, // small overhead for XML wrapper
613            "guide length {} exceeds char budget",
614            guide.text.len()
615        );
616    }
617
618    #[test]
619    fn test_generate_guide_multiple_events_same_category() {
620        let store = in_memory_store();
621        let mgr = SessionContinuityManager::new(&store);
622        let snapshot = Snapshot {
623            events: vec![
624                SnapshotEvent::new(SnapshotEventType::Error, "error 1".into()),
625                SnapshotEvent::new(SnapshotEventType::Error, "error 2".into()),
626            ],
627        };
628
629        let guide = mgr.generate_guide(&snapshot);
630        // Both errors should appear semicolon-separated.
631        assert!(guide.text.contains("errors: error 1; error 2"));
632    }
633
634    #[test]
635    fn test_generate_guide_performance_under_50ms() {
636        let store = in_memory_store();
637        let mgr = SessionContinuityManager::new(&store);
638
639        // Build a large snapshot.
640        let mut events = Vec::new();
641        for i in 0..50 {
642            events.push(SnapshotEvent::new(
643                SnapshotEventType::ActiveFile,
644                format!("src/deep/nested/path/module_{i}.rs"),
645            ));
646        }
647        events.push(SnapshotEvent::new(
648            SnapshotEventType::LastPrompt,
649            "A".repeat(512),
650        ));
651        let snapshot = Snapshot { events };
652
653        let start = std::time::Instant::now();
654        let _guide = mgr.generate_guide(&snapshot);
655        let elapsed = start.elapsed();
656
657        assert!(
658            elapsed.as_millis() < 50,
659            "generate_guide took {}ms, expected <50ms",
660            elapsed.as_millis()
661        );
662    }
663
664    #[test]
665    fn test_generate_guide_category_order_matches_priority() {
666        let store = in_memory_store();
667        let mgr = SessionContinuityManager::new(&store);
668        let snapshot = Snapshot {
669            events: vec![
670                // Insert in reverse priority order.
671                SnapshotEvent::new(SnapshotEventType::Summary, "sum".into()),
672                SnapshotEvent::new(SnapshotEventType::LastPrompt, "prompt".into()),
673                SnapshotEvent::new(SnapshotEventType::GitOp, "commit".into()),
674                SnapshotEvent::new(SnapshotEventType::Error, "err".into()),
675            ],
676        };
677
678        let guide = mgr.generate_guide(&snapshot);
679        // last_request should appear before errors, errors before git, git before summary.
680        let pos_prompt = guide.text.find("last_request:").unwrap();
681        let pos_error = guide.text.find("errors:").unwrap();
682        let pos_git = guide.text.find("git:").unwrap();
683        let pos_summary = guide.text.find("summary:").unwrap();
684        assert!(pos_prompt < pos_error);
685        assert!(pos_error < pos_git);
686        assert!(pos_git < pos_summary);
687    }
688
689    #[test]
690    fn test_snapshot_event_type_labels_are_unique() {
691        use std::collections::HashSet;
692        let all_types = [
693            SnapshotEventType::LastPrompt,
694            SnapshotEventType::Error,
695            SnapshotEventType::Decision,
696            SnapshotEventType::PendingTask,
697            SnapshotEventType::ActiveFile,
698            SnapshotEventType::GitOp,
699            SnapshotEventType::Rule,
700            SnapshotEventType::ToolUse,
701            SnapshotEventType::Warning,
702            SnapshotEventType::Learning,
703            SnapshotEventType::Progress,
704            SnapshotEventType::Context,
705            SnapshotEventType::Environment,
706            SnapshotEventType::Dependency,
707            SnapshotEventType::Summary,
708        ];
709        let labels: HashSet<&str> = all_types.iter().map(|t| t.label()).collect();
710        assert_eq!(labels.len(), 15, "expected 15 unique category labels");
711    }
712
713    #[test]
714    fn test_snapshot_event_type_has_15_categories() {
715        // Verify all 15 variants have distinct priorities.
716        use std::collections::HashSet;
717        let all_types = [
718            SnapshotEventType::LastPrompt,
719            SnapshotEventType::Error,
720            SnapshotEventType::Decision,
721            SnapshotEventType::PendingTask,
722            SnapshotEventType::ActiveFile,
723            SnapshotEventType::GitOp,
724            SnapshotEventType::Rule,
725            SnapshotEventType::ToolUse,
726            SnapshotEventType::Warning,
727            SnapshotEventType::Learning,
728            SnapshotEventType::Progress,
729            SnapshotEventType::Context,
730            SnapshotEventType::Environment,
731            SnapshotEventType::Dependency,
732            SnapshotEventType::Summary,
733        ];
734        let priorities: HashSet<u8> = all_types.iter().map(|t| t.priority()).collect();
735        assert_eq!(priorities.len(), 15, "expected 15 unique priorities");
736    }
737
738    // ── Property-based tests ──────────────────────────────────────────────
739
740    use proptest::prelude::*;
741
742    /// Strategy that produces an arbitrary `SnapshotEventType`.
743    fn arb_event_type() -> impl Strategy<Value = SnapshotEventType> {
744        prop_oneof![
745            Just(SnapshotEventType::LastPrompt),
746            Just(SnapshotEventType::Error),
747            Just(SnapshotEventType::Decision),
748            Just(SnapshotEventType::PendingTask),
749            Just(SnapshotEventType::ActiveFile),
750            Just(SnapshotEventType::GitOp),
751            Just(SnapshotEventType::Rule),
752            Just(SnapshotEventType::ToolUse),
753            Just(SnapshotEventType::Warning),
754            Just(SnapshotEventType::Learning),
755            Just(SnapshotEventType::Progress),
756            Just(SnapshotEventType::Context),
757            Just(SnapshotEventType::Environment),
758            Just(SnapshotEventType::Dependency),
759            Just(SnapshotEventType::Summary),
760        ]
761    }
762
763    /// Strategy that produces a `SnapshotEvent` with arbitrary type and
764    /// content of varying length (1–300 ASCII chars).
765    fn arb_snapshot_event() -> impl Strategy<Value = SnapshotEvent> {
766        (arb_event_type(), "[a-zA-Z0-9 _/\\-.]{1,300}")
767            .prop_map(|(et, content)| SnapshotEvent::new(et, content))
768    }
769
770    proptest! {
771        /// **Validates: Requirements 35.1, 35.5**
772        ///
773        /// Property 40: Session continuity snapshot fits budget.
774        ///
775        /// For any set of events (varying count and content sizes), the
776        /// built snapshot always fits within the configured byte budget
777        /// (2 KB default) and higher-priority events are always included
778        /// before lower-priority events.
779        #[test]
780        fn prop40_snapshot_budget_compliance(
781            events in proptest::collection::vec(arb_snapshot_event(), 0..=50),
782            budget in 128usize..=4096,
783        ) {
784            let store = in_memory_store();
785            let mgr = SessionContinuityManager::new(&store).with_max_bytes(budget);
786            let snapshot = mgr.build_snapshot(events).unwrap();
787
788            // 1. Snapshot must fit within the configured byte budget.
789            prop_assert!(
790                snapshot.size_bytes() <= budget,
791                "snapshot size {} exceeds budget {}",
792                snapshot.size_bytes(),
793                budget,
794            );
795
796            // 2. Events in the snapshot must be sorted by ascending priority
797            //    (lower number = higher priority = included first).
798            let priorities: Vec<u8> = snapshot.events.iter().map(|e| e.priority).collect();
799            let mut sorted = priorities.clone();
800            sorted.sort();
801            prop_assert_eq!(
802                priorities,
803                sorted,
804                "snapshot events are not sorted by priority"
805            );
806
807            // 3. If any event was dropped, every dropped event must have
808            //    equal or lower priority (higher number) than every included
809            //    event. This confirms higher-priority events are always
810            //    included before lower-priority ones.
811            if !snapshot.events.is_empty() {
812                let max_included_priority = snapshot
813                    .events
814                    .iter()
815                    .map(|e| e.priority)
816                    .max()
817                    .unwrap();
818
819                // The build_snapshot algorithm stops at the first event that
820                // would exceed the budget. Because events are sorted by
821                // ascending priority, all remaining (dropped) events have
822                // priority >= the break point. We verify that the included
823                // set's maximum priority is a valid cut-off.
824                prop_assert!(
825                    max_included_priority <= 15,
826                    "included priority {} out of valid range",
827                    max_included_priority,
828                );
829            }
830        }
831    }
832}