Skip to main content

cove_cli/sidebar/
context.rs

1// ── Background context generation for sessions ──
2//
3// Reads Claude session JSONL files directly, extracts conversation text,
4// then calls `claude -p` with a compact prompt (no session resume).
5// This avoids the ~600KB context reload that `claude -c` would trigger.
6// Results flow back via mpsc channel so the sidebar event loop never blocks.
7
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::sync::Arc;
13use std::sync::mpsc;
14use std::time::{Duration, Instant};
15use std::{io, thread};
16
17use crate::events;
18use crate::sidebar::state::WindowState;
19use crate::tmux::WindowInfo;
20
21// ── Types ──
22
23type GeneratorFn = Arc<dyn Fn(&str, &str) -> Option<String> + Send + Sync>;
24
25pub struct ContextManager {
26    contexts: HashMap<String, String>,
27    in_flight: HashSet<String>,
28    failed: HashMap<String, Instant>,
29    tx: mpsc::Sender<(String, String)>,
30    rx: mpsc::Receiver<(String, String)>,
31    generator: GeneratorFn,
32    prev_selected_name: Option<String>,
33}
34
35// ── Constants ──
36
37const SUMMARY_PROMPT: &str = "\
38Summarize the overall goal and current state of this conversation in 1-2 sentences. \
39What is the user trying to accomplish, and where are things at? \
40Output only the summary, nothing else.";
41
42/// Max chars of conversation text to include in the prompt.
43const CONVERSATION_BUDGET: usize = 6000;
44
45/// Max chars per individual message (truncate long code blocks, etc.).
46const MESSAGE_TRUNCATE: usize = 300;
47
48const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(30);
49const RETRY_COOLDOWN: Duration = Duration::from_secs(30);
50
51// ── Public API ──
52
53impl Default for ContextManager {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl ContextManager {
60    pub fn new() -> Self {
61        let (tx, rx) = mpsc::channel();
62        Self {
63            contexts: HashMap::new(),
64            in_flight: HashSet::new(),
65            failed: HashMap::new(),
66            tx,
67            rx,
68            generator: Arc::new(generate_context),
69            prev_selected_name: None,
70        }
71    }
72
73    pub fn with_generator(
74        generator_fn: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
75    ) -> Self {
76        let (tx, rx) = mpsc::channel();
77        Self {
78            contexts: HashMap::new(),
79            in_flight: HashSet::new(),
80            failed: HashMap::new(),
81            tx,
82            rx,
83            generator: Arc::new(generator_fn),
84            prev_selected_name: None,
85        }
86    }
87
88    /// Drain completed context results from background threads.
89    pub fn drain(&mut self) {
90        while let Ok((name, context)) = self.rx.try_recv() {
91            self.in_flight.remove(&name);
92            if context.is_empty() {
93                self.failed.insert(name, Instant::now());
94            } else {
95                self.contexts.insert(name, context);
96            }
97        }
98    }
99
100    /// Get the context for a window, if available.
101    pub fn get(&self, name: &str) -> Option<&str> {
102        self.contexts.get(name).map(String::as_str)
103    }
104
105    /// Whether a context request is currently running for this window.
106    pub fn is_loading(&self, name: &str) -> bool {
107        self.in_flight.contains(name)
108    }
109
110    /// Run one tick of context orchestration.
111    ///
112    /// - Prefetches context for all non-Fresh sessions
113    /// - Drains completed results from background threads
114    /// - On selection change: refreshes old session (if active), requests new session (if active)
115    pub fn tick(
116        &mut self,
117        windows: &[WindowInfo],
118        states: &HashMap<u32, WindowState>,
119        selected: usize,
120        pane_id_for: &impl Fn(u32) -> Option<String>,
121    ) {
122        // Prefetch context for all non-fresh sessions
123        for win in windows {
124            let state = states
125                .get(&win.index)
126                .copied()
127                .unwrap_or(WindowState::Fresh);
128            if state != WindowState::Fresh {
129                let pane_id = pane_id_for(win.index).unwrap_or_default();
130                self.request(&win.name, &win.pane_path, &pane_id);
131            }
132        }
133
134        // Drain completed context results from background threads
135        self.drain();
136
137        // Track selection changes and manage context generation
138        let current_name = windows.get(selected).map(|w| w.name.clone());
139        if current_name != self.prev_selected_name {
140            // Refresh context for old session — only if it had activity (not Fresh)
141            if let Some(ref prev_name) = self.prev_selected_name {
142                if let Some(prev_win) = windows.iter().find(|w| w.name == *prev_name) {
143                    let state = states
144                        .get(&prev_win.index)
145                        .copied()
146                        .unwrap_or(WindowState::Fresh);
147                    if state != WindowState::Fresh {
148                        let pane_id = pane_id_for(prev_win.index).unwrap_or_default();
149                        self.refresh(&prev_win.name, &prev_win.pane_path, &pane_id);
150                    }
151                }
152            }
153            // Request context for new session — only if it had activity (not Fresh)
154            if let Some(win) = windows.get(selected) {
155                let state = states
156                    .get(&win.index)
157                    .copied()
158                    .unwrap_or(WindowState::Fresh);
159                if state != WindowState::Fresh {
160                    let pane_id = pane_id_for(win.index).unwrap_or_default();
161                    self.request(&win.name, &win.pane_path, &pane_id);
162                }
163            }
164            self.prev_selected_name = current_name;
165        }
166    }
167
168    /// Request context generation for a window (no-op if cached, in flight, or within retry cooldown).
169    pub fn request(&mut self, name: &str, cwd: &str, pane_id: &str) {
170        if self.contexts.contains_key(name) || self.in_flight.contains(name) {
171            return;
172        }
173        if let Some(failed_at) = self.failed.get(name) {
174            if failed_at.elapsed() < RETRY_COOLDOWN {
175                return;
176            }
177        }
178        self.failed.remove(name);
179        self.spawn(name, cwd, pane_id);
180    }
181
182    /// Force-refresh context for a window (clears cache/failed, respects in_flight).
183    pub fn refresh(&mut self, name: &str, cwd: &str, pane_id: &str) {
184        if self.in_flight.contains(name) {
185            return;
186        }
187        self.contexts.remove(name);
188        self.failed.remove(name);
189        self.spawn(name, cwd, pane_id);
190    }
191
192    fn spawn(&mut self, name: &str, cwd: &str, pane_id: &str) {
193        self.in_flight.insert(name.to_string());
194        let tx = self.tx.clone();
195        let name = name.to_string();
196        let cwd = cwd.to_string();
197        let pane_id = pane_id.to_string();
198        let generator = Arc::clone(&self.generator);
199        thread::spawn(move || {
200            let context = generator(&cwd, &pane_id).unwrap_or_default();
201            let _ = tx.send((name, context));
202        });
203    }
204}
205
206// ── Session Lookup ──
207
208/// Derive the Claude project directory from a cwd.
209fn claude_project_dir(cwd: &str) -> PathBuf {
210    let home = std::env::var("HOME").unwrap_or_default();
211    let project_key = cwd.replace('/', "-");
212    PathBuf::from(home)
213        .join(".claude")
214        .join("projects")
215        .join(project_key)
216}
217
218/// Find the Claude session JSONL file for a given pane.
219fn find_session_file(cwd: &str, pane_id: &str) -> Option<PathBuf> {
220    let session_id = events::find_session_id(pane_id)?;
221    let project_dir = claude_project_dir(cwd);
222    let path = project_dir.join(format!("{session_id}.jsonl"));
223    if path.exists() { Some(path) } else { None }
224}
225
226// ── JSONL Parsing ──
227
228/// Extract conversation text from a Claude session JSONL file.
229/// Returns a compact representation of user/assistant messages, truncated
230/// to fit within CONVERSATION_BUDGET.
231fn extract_conversation(path: &Path) -> Option<String> {
232    let content = fs::read_to_string(path).ok()?;
233    let mut messages = Vec::new();
234
235    for line in content.lines() {
236        let entry: serde_json::Value = match serde_json::from_str(line) {
237            Ok(v) => v,
238            Err(_) => continue,
239        };
240
241        let entry_type = match entry.get("type").and_then(|t| t.as_str()) {
242            Some(t) => t,
243            None => continue,
244        };
245
246        if entry_type != "user" && entry_type != "assistant" {
247            continue;
248        }
249
250        let message = match entry.get("message") {
251            Some(m) => m,
252            None => continue,
253        };
254
255        let role = message
256            .get("role")
257            .and_then(|r| r.as_str())
258            .unwrap_or(entry_type);
259
260        let content_arr = match message.get("content").and_then(|c| c.as_array()) {
261            Some(arr) => arr,
262            None => continue,
263        };
264
265        for item in content_arr {
266            // Only extract text content — skip images, tool_use, tool_result
267            if item.get("type").and_then(|t| t.as_str()) != Some("text") {
268                continue;
269            }
270            if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
271                let text = text.trim();
272                if text.is_empty() {
273                    continue;
274                }
275                let truncated = if text.chars().count() > MESSAGE_TRUNCATE {
276                    let t: String = text.chars().take(MESSAGE_TRUNCATE).collect();
277                    format!("{t}\u{2026}")
278                } else {
279                    text.to_string()
280                };
281                let label = if role == "user" { "User" } else { "Assistant" };
282                messages.push(format!("{label}: {truncated}"));
283            }
284        }
285    }
286
287    if messages.is_empty() {
288        return None;
289    }
290
291    // Keep recent messages, truncated to fit within budget
292    let mut kept = Vec::new();
293    let mut total_len = 0;
294    for msg in messages.iter().rev() {
295        if total_len + msg.len() + 2 > CONVERSATION_BUDGET {
296            break;
297        }
298        total_len += msg.len() + 2;
299        kept.push(msg.as_str());
300    }
301    kept.reverse();
302
303    Some(kept.join("\n\n"))
304}
305
306// ── Context Generation ──
307
308fn generate_context(cwd: &str, pane_id: &str) -> Option<String> {
309    let session_path = find_session_file(cwd, pane_id)?;
310    let conversation = extract_conversation(&session_path)?;
311
312    let prompt = format!("{SUMMARY_PROMPT}\n\nConversation:\n{conversation}");
313
314    // Fresh claude -p call — no -c, no session resume, no large context reload.
315    // Must clear CLAUDECODE env var to avoid "nested session" detection,
316    // since the sidebar itself runs inside a Claude Code session.
317    let mut child = Command::new("claude")
318        .args(["-p", &prompt, "--max-turns", "1", "--model", "haiku"])
319        .current_dir(cwd)
320        .env_remove("CLAUDECODE")
321        .stdout(Stdio::piped())
322        .stderr(Stdio::null())
323        .spawn()
324        .ok()?;
325
326    let deadline = Instant::now() + SUBPROCESS_TIMEOUT;
327    let status = loop {
328        match child.try_wait() {
329            Ok(Some(status)) => break status,
330            Ok(None) => {
331                if Instant::now() >= deadline {
332                    let _ = child.kill();
333                    let _ = child.wait();
334                    return None;
335                }
336                thread::sleep(Duration::from_millis(200));
337            }
338            Err(_) => return None,
339        }
340    };
341
342    if !status.success() {
343        return None;
344    }
345
346    let mut stdout = child.stdout.take()?;
347    let mut text = String::new();
348    io::Read::read_to_string(&mut stdout, &mut text).ok()?;
349    let text = text.trim().to_string();
350    if text.is_empty() { None } else { Some(text) }
351}
352
353// ── Tests ──
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::io::Write;
359
360    fn write_session_jsonl(dir: &Path, session_id: &str, messages: &[(&str, &str)]) -> PathBuf {
361        let path = dir.join(format!("{session_id}.jsonl"));
362        let mut f = fs::File::create(&path).unwrap();
363        for (role, text) in messages {
364            let entry = serde_json::json!({
365                "type": role,
366                "message": {
367                    "role": role,
368                    "content": [{"type": "text", "text": text}]
369                }
370            });
371            writeln!(f, "{}", serde_json::to_string(&entry).unwrap()).unwrap();
372        }
373        path
374    }
375
376    #[test]
377    fn test_extract_conversation_basic() {
378        let dir = tempfile::tempdir().unwrap();
379        let path = write_session_jsonl(
380            dir.path(),
381            "test",
382            &[
383                ("user", "Fix the login bug"),
384                ("assistant", "I'll look into the auth module"),
385                ("user", "Also check the session handling"),
386            ],
387        );
388
389        let result = extract_conversation(&path).unwrap();
390        assert!(result.contains("User: Fix the login bug"));
391        assert!(result.contains("Assistant: I'll look into the auth module"));
392        assert!(result.contains("User: Also check the session handling"));
393    }
394
395    #[test]
396    fn test_extract_conversation_skips_non_text() {
397        let dir = tempfile::tempdir().unwrap();
398        let path = dir.path().join("test.jsonl");
399        let mut f = fs::File::create(&path).unwrap();
400
401        // User message with text
402        writeln!(
403            f,
404            r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"hello"}}]}}}}"#
405        )
406        .unwrap();
407        // Progress entry (should be skipped)
408        writeln!(f, r#"{{"type":"progress","data":{{"hook":"test"}}}}"#).unwrap();
409        // Assistant with tool_use (should be skipped)
410        writeln!(
411            f,
412            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"tool_use","name":"Bash"}}]}}}}"#
413        )
414        .unwrap();
415
416        let result = extract_conversation(&path).unwrap();
417        assert!(result.contains("User: hello"));
418        assert!(!result.contains("progress"));
419        assert!(!result.contains("Bash"));
420    }
421
422    #[test]
423    fn test_extract_conversation_truncates_long_messages() {
424        let dir = tempfile::tempdir().unwrap();
425        let long_text = "a".repeat(500);
426        let path = write_session_jsonl(dir.path(), "test", &[("user", &long_text)]);
427
428        let result = extract_conversation(&path).unwrap();
429        // Should be truncated to MESSAGE_TRUNCATE + "…"
430        assert!(result.chars().count() < 500);
431        assert!(result.ends_with('\u{2026}'));
432    }
433
434    #[test]
435    fn test_extract_conversation_empty_file() {
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("empty.jsonl");
438        fs::File::create(&path).unwrap();
439
440        assert!(extract_conversation(&path).is_none());
441    }
442
443    #[test]
444    fn test_extract_conversation_respects_budget() {
445        let dir = tempfile::tempdir().unwrap();
446        let msg = "x".repeat(MESSAGE_TRUNCATE - 10);
447        let messages: Vec<(&str, &str)> = (0..100).map(|_| ("user", msg.as_str())).collect();
448        let path = write_session_jsonl(dir.path(), "test", &messages);
449
450        let result = extract_conversation(&path).unwrap();
451        assert!(result.len() <= CONVERSATION_BUDGET + 200); // some slack for labels
452    }
453
454    #[test]
455    fn test_claude_project_dir() {
456        let dir = claude_project_dir("/Users/test/workspace/myproject");
457        assert!(
458            dir.to_str()
459                .unwrap()
460                .ends_with("/.claude/projects/-Users-test-workspace-myproject")
461        );
462    }
463
464    #[test]
465    fn test_find_session_id_from_events() {
466        let dir = tempfile::tempdir().unwrap();
467        let events_path = dir.path().join("abc-123.jsonl");
468        let mut f = fs::File::create(&events_path).unwrap();
469        writeln!(
470            f,
471            r#"{{"state":"working","cwd":"/tmp","pane_id":"%5","ts":1000}}"#
472        )
473        .unwrap();
474        writeln!(
475            f,
476            r#"{{"state":"idle","cwd":"/tmp","pane_id":"%5","ts":1001}}"#
477        )
478        .unwrap();
479
480        // Test the lookup logic directly (can't use find_session_id since it reads ~/.cove)
481        let content = fs::read_to_string(&events_path).unwrap();
482        let last_line = content
483            .lines()
484            .rev()
485            .find(|l| !l.trim().is_empty())
486            .unwrap();
487        let event: serde_json::Value = serde_json::from_str(last_line).unwrap();
488        assert_eq!(event.get("pane_id").and_then(|v| v.as_str()), Some("%5"));
489    }
490
491    // ── Context lifecycle integration tests ──
492    //
493    // These test the orchestration logic in tick(): when context generation
494    // fires (or doesn't) based on session state and selection changes.
495
496    use std::sync::Mutex;
497
498    /// Build a mock generator that tracks calls and returns controlled results.
499    fn mock_generator() -> (
500        impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
501        Arc<Mutex<Vec<(String, String)>>>,
502    ) {
503        let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
504        let calls_clone = Arc::clone(&calls);
505        let generator = move |cwd: &str, pane_id: &str| -> Option<String> {
506            calls_clone
507                .lock()
508                .unwrap()
509                .push((cwd.to_string(), pane_id.to_string()));
510            Some(format!("context for {pane_id}"))
511        };
512        (generator, calls)
513    }
514
515    fn win(index: u32, name: &str) -> WindowInfo {
516        WindowInfo {
517            index,
518            name: name.to_string(),
519            is_active: false,
520            pane_path: format!("/project/{name}"),
521        }
522    }
523
524    fn pane_ids(map: &HashMap<u32, String>) -> impl Fn(u32) -> Option<String> + '_ {
525        move |idx| map.get(&idx).cloned()
526    }
527
528    /// Drain results by waiting briefly for background threads to complete.
529    fn drain_with_wait(mgr: &mut ContextManager) {
530        // Background threads are fast (mock generator returns immediately),
531        // but we need a brief pause for the thread to send its result.
532        thread::sleep(Duration::from_millis(50));
533        mgr.drain();
534    }
535
536    // ── Scenario 1: New session → no context action ──
537    //
538    // A brand-new session (Fresh state) should not trigger any context
539    // generation. No "loading…", no failed generation.
540    #[test]
541    fn test_new_session_no_context_action() {
542        let (generator, calls) = mock_generator();
543        let mut mgr = ContextManager::with_generator(generator);
544
545        let windows = vec![win(1, "session-1")];
546        let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh)].into_iter().collect();
547        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
548
549        // Run a tick with the Fresh session selected
550        mgr.tick(&windows, &states, 0, &pane_ids(&panes));
551
552        // No generation should have been spawned
553        assert!(calls.lock().unwrap().is_empty());
554        assert!(!mgr.is_loading("session-1"));
555        assert!(mgr.get("session-1").is_none());
556    }
557
558    // ── Scenario 2: Switch from new (no prompt) session to another new session → no context action ──
559    //
560    // Both sessions are Fresh. Switching between them should not trigger
561    // context generation for either session.
562    #[test]
563    fn test_switch_between_fresh_sessions_no_context_action() {
564        let (generator, calls) = mock_generator();
565        let mut mgr = ContextManager::with_generator(generator);
566
567        let windows = vec![win(1, "session-1"), win(2, "session-2")];
568        let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh), (2, WindowState::Fresh)]
569            .into_iter()
570            .collect();
571        let panes: HashMap<u32, String> =
572            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
573
574        // Tick 1: session-1 selected (Fresh)
575        mgr.tick(&windows, &states, 0, &pane_ids(&panes));
576        assert!(calls.lock().unwrap().is_empty());
577
578        // Tick 2: switch to session-2 (also Fresh)
579        mgr.tick(&windows, &states, 1, &pane_ids(&panes));
580
581        // No generation for either session
582        assert!(calls.lock().unwrap().is_empty());
583        assert!(!mgr.is_loading("session-1"));
584        assert!(!mgr.is_loading("session-2"));
585        assert!(mgr.get("session-1").is_none());
586        assert!(mgr.get("session-2").is_none());
587    }
588
589    // ── Scenario 3: Switch from active session to new session → fire context for previous ──
590    //
591    // When switching away from a session that has had at least 1 conversation
592    // turn (not Fresh), context generation should fire for that session.
593    // The new Fresh session should NOT get context generated.
594    #[test]
595    fn test_switch_from_active_to_fresh_fires_context_for_previous() {
596        let (generator, calls) = mock_generator();
597        let mut mgr = ContextManager::with_generator(generator);
598
599        let windows = vec![win(1, "active-session"), win(2, "new-session")];
600        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
601            .into_iter()
602            .collect();
603        let panes: HashMap<u32, String> =
604            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
605
606        // Tick 1: active-session selected (Idle — has had activity)
607        mgr.tick(&windows, &states, 0, &pane_ids(&panes));
608        drain_with_wait(&mut mgr);
609
610        // The prefetch loop should have requested context for the active session
611        let call_count_after_tick1 = calls.lock().unwrap().len();
612        assert!(
613            call_count_after_tick1 > 0,
614            "should request context for Idle session"
615        );
616        assert!(
617            calls.lock().unwrap().iter().any(|(_, pid)| pid == "%0"),
618            "should request for pane %0"
619        );
620
621        // Tick 2: switch to new-session (Fresh)
622        calls.lock().unwrap().clear();
623        mgr.tick(&windows, &states, 1, &pane_ids(&panes));
624        // Wait for background thread to complete so calls are recorded
625        drain_with_wait(&mut mgr);
626
627        // Should fire refresh for old active-session (pane %0)
628        // Should NOT fire for new-session (pane %1) since it's Fresh
629        let tick2_calls = calls.lock().unwrap().clone();
630        assert!(
631            tick2_calls.iter().any(|(_, pid)| pid == "%0"),
632            "should refresh context for previous active session"
633        );
634        assert!(
635            !tick2_calls.iter().any(|(_, pid)| pid == "%1"),
636            "should NOT request context for Fresh new session"
637        );
638
639        // new-session should have no loading state or context
640        assert!(!mgr.is_loading("new-session"));
641        assert!(mgr.get("new-session").is_none());
642    }
643
644    // ── Scenario 4: Switch back to previous session while context is pending → loading state ──
645    //
646    // After switching away from an active session (triggering refresh),
647    // switching back before the generation completes should show loading state.
648    #[test]
649    fn test_switch_back_while_pending_shows_loading() {
650        // Use a slow generator that blocks until signaled
651        let barrier = Arc::new(std::sync::Barrier::new(2));
652        let barrier_clone = Arc::clone(&barrier);
653        let slow_generator = move |_cwd: &str, _pane_id: &str| -> Option<String> {
654            barrier_clone.wait();
655            Some("generated context".to_string())
656        };
657        let mut mgr = ContextManager::with_generator(slow_generator);
658
659        let windows = vec![win(1, "active-session"), win(2, "new-session")];
660        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
661            .into_iter()
662            .collect();
663        let panes: HashMap<u32, String> =
664            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
665
666        // Tick 1: active-session selected — starts context generation (blocked on barrier)
667        mgr.tick(&windows, &states, 0, &pane_ids(&panes));
668        // Context is in-flight but blocked, so is_loading should be true
669        assert!(
670            mgr.is_loading("active-session"),
671            "should be loading while generator is running"
672        );
673
674        // Tick 2: switch to new-session — triggers refresh for active-session
675        // But active-session is still in-flight (blocked), so refresh is a no-op
676        mgr.tick(&windows, &states, 1, &pane_ids(&panes));
677
678        // Tick 3: switch back to active-session — should still show loading
679        mgr.tick(&windows, &states, 0, &pane_ids(&panes));
680        assert!(
681            mgr.is_loading("active-session"),
682            "should still be loading when switching back"
683        );
684        assert!(
685            mgr.get("active-session").is_none(),
686            "context should not be available yet"
687        );
688
689        // Unblock the generator
690        barrier.wait();
691        drain_with_wait(&mut mgr);
692
693        // Now context should be available
694        assert!(
695            !mgr.is_loading("active-session"),
696            "should not be loading after drain"
697        );
698        assert_eq!(
699            mgr.get("active-session"),
700            Some("generated context"),
701            "context should be populated after drain"
702        );
703    }
704}