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 a local Ollama instance to generate a summary.
5// Results flow back via mpsc channel so the sidebar event loop never blocks.
6
7use std::collections::{HashMap, HashSet};
8use std::fs::{self, OpenOptions};
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::sync::mpsc;
13use std::thread;
14use std::time::{Duration, Instant};
15
16use crate::events;
17use crate::paths;
18use crate::sidebar::state::WindowState;
19use crate::tmux::WindowInfo;
20
21// ── Types ──
22
23type GeneratorFn = Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;
24
25pub struct ContextManager {
26    contexts: HashMap<String, String>,
27    in_flight: HashSet<String>,
28    failed: HashMap<String, Instant>,
29    /// Last error message per window — displayed in the UI when context generation fails.
30    errors: HashMap<String, String>,
31    tx: mpsc::Sender<(String, Result<String, String>)>,
32    rx: mpsc::Receiver<(String, Result<String, String>)>,
33    generator: GeneratorFn,
34    prev_selected_name: Option<String>,
35    /// Previous state per window — used to detect transitions and invalidate cache.
36    prev_states: HashMap<String, WindowState>,
37}
38
39// ── Constants ──
40
41const SUMMARY_PROMPT: &str = "\
42Summarize the overall goal and current state of this conversation in 1-2 sentences. \
43What is the user trying to accomplish, and where are things at? \
44Output only the summary, nothing else.";
45
46/// Max chars of conversation text to include in the prompt.
47const CONVERSATION_BUDGET: usize = 6000;
48
49/// Max chars per individual message (truncate long code blocks, etc.).
50const MESSAGE_TRUNCATE: usize = 300;
51
52const API_TIMEOUT: Duration = Duration::from_secs(30);
53const RETRY_COOLDOWN: Duration = Duration::from_secs(30);
54
55// ── Diagnostic Logging ──
56
57fn log_context(msg: &str) {
58    let path = paths::state_dir().join("context.log");
59    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
60        let ts = std::time::SystemTime::now()
61            .duration_since(std::time::UNIX_EPOCH)
62            .unwrap_or_default()
63            .as_secs();
64        let _ = writeln!(f, "[{ts}] {msg}");
65    }
66}
67
68// ── Public API ──
69
70impl Default for ContextManager {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl ContextManager {
77    pub fn new() -> Self {
78        let (tx, rx) = mpsc::channel();
79        Self {
80            contexts: HashMap::new(),
81            in_flight: HashSet::new(),
82            failed: HashMap::new(),
83            errors: HashMap::new(),
84            tx,
85            rx,
86            generator: Arc::new(generate_context),
87            prev_selected_name: None,
88            prev_states: HashMap::new(),
89        }
90    }
91
92    pub fn with_generator(
93        generator_fn: impl Fn(&str, &str) -> Result<String, String> + Send + Sync + 'static,
94    ) -> Self {
95        let (tx, rx) = mpsc::channel();
96        Self {
97            contexts: HashMap::new(),
98            in_flight: HashSet::new(),
99            failed: HashMap::new(),
100            errors: HashMap::new(),
101            tx,
102            rx,
103            generator: Arc::new(generator_fn),
104            prev_selected_name: None,
105            prev_states: HashMap::new(),
106        }
107    }
108
109    /// Drain completed context results from background threads.
110    pub fn drain(&mut self) {
111        while let Ok((name, result)) = self.rx.try_recv() {
112            self.in_flight.remove(&name);
113            match result {
114                Ok(context) => {
115                    self.errors.remove(&name);
116                    self.contexts.insert(name, context);
117                }
118                Err(reason) => {
119                    log_context(&format!("failed for {name}: {reason}"));
120                    self.errors.insert(name.clone(), reason);
121                    self.failed.insert(name, Instant::now());
122                }
123            }
124        }
125    }
126
127    /// Get the context for a window, if available.
128    pub fn get(&self, name: &str) -> Option<&str> {
129        self.contexts.get(name).map(String::as_str)
130    }
131
132    /// Get the last error for a window, if generation failed.
133    pub fn get_error(&self, name: &str) -> Option<&str> {
134        self.errors.get(name).map(String::as_str)
135    }
136
137    /// Whether a context request is currently running for this window.
138    pub fn is_loading(&self, name: &str) -> bool {
139        self.in_flight.contains(name)
140    }
141
142    /// Run one tick of context orchestration.
143    ///
144    /// - Drains completed results from background threads
145    /// - Invalidates cache on state transitions (Working → settled)
146    /// - Requests context for the selected window (lazy — only when settled)
147    /// - On selection change: refreshes old session (if settled), requests new session
148    pub fn tick(
149        &mut self,
150        windows: &[WindowInfo],
151        states: &HashMap<u32, WindowState>,
152        selected: usize,
153        pane_id_for: &impl Fn(u32) -> Option<String>,
154        cwd_for: &impl Fn(u32) -> Option<String>,
155    ) {
156        // Drain completed context results first so they're available this tick
157        self.drain();
158
159        // Detect state transitions and invalidate stale context.
160        // When a session transitions from Working to a settled state (Idle/Asking/Waiting),
161        // the conversation has new content — clear cache so fresh context is generated.
162        if let Some(win) = windows.get(selected) {
163            let state = states
164                .get(&win.index)
165                .copied()
166                .unwrap_or(WindowState::Fresh);
167            let prev = self
168                .prev_states
169                .get(&win.name)
170                .copied()
171                .unwrap_or(WindowState::Fresh);
172
173            if prev == WindowState::Working && is_settled(state) {
174                self.contexts.remove(&win.name);
175                self.failed.remove(&win.name);
176            }
177
178            self.prev_states.insert(win.name.clone(), state);
179        }
180
181        // Request context for the selected window only (lazy generation).
182        // Only fire for settled states — Working is too early (conversation incomplete,
183        // subprocess may fail), and failures trigger a 30s retry cooldown that blocks
184        // subsequent attempts when the session actually reaches Idle.
185        if let Some(win) = windows.get(selected) {
186            let state = states
187                .get(&win.index)
188                .copied()
189                .unwrap_or(WindowState::Fresh);
190            if is_settled(state) {
191                let pane_id = pane_id_for(win.index).unwrap_or_default();
192                let cwd = cwd_for(win.index).unwrap_or_else(|| win.pane_path.clone());
193                self.request(&win.name, &cwd, &pane_id);
194            }
195        }
196
197        // Track selection changes and manage context generation
198        let current_name = windows.get(selected).map(|w| w.name.clone());
199        if current_name != self.prev_selected_name {
200            // Refresh context for old session — only if it's in a settled state
201            if let Some(ref prev_name) = self.prev_selected_name {
202                if let Some(prev_win) = windows.iter().find(|w| w.name == *prev_name) {
203                    let state = states
204                        .get(&prev_win.index)
205                        .copied()
206                        .unwrap_or(WindowState::Fresh);
207                    if is_settled(state) {
208                        let pane_id = pane_id_for(prev_win.index).unwrap_or_default();
209                        let cwd =
210                            cwd_for(prev_win.index).unwrap_or_else(|| prev_win.pane_path.clone());
211                        self.refresh(&prev_win.name, &cwd, &pane_id);
212                    }
213                }
214            }
215            self.prev_selected_name = current_name;
216        }
217    }
218
219    /// Request context generation for a window (no-op if cached, in flight, or within retry cooldown).
220    pub fn request(&mut self, name: &str, cwd: &str, pane_id: &str) {
221        if self.contexts.contains_key(name) || self.in_flight.contains(name) {
222            return;
223        }
224        if let Some(failed_at) = self.failed.get(name) {
225            if failed_at.elapsed() < RETRY_COOLDOWN {
226                return;
227            }
228        }
229        self.failed.remove(name);
230        self.spawn(name, cwd, pane_id);
231    }
232
233    /// Force-refresh context for a window (clears cache/failed/errors, respects in_flight).
234    pub fn refresh(&mut self, name: &str, cwd: &str, pane_id: &str) {
235        if self.in_flight.contains(name) {
236            return;
237        }
238        self.contexts.remove(name);
239        self.failed.remove(name);
240        self.errors.remove(name);
241        self.spawn(name, cwd, pane_id);
242    }
243
244    fn spawn(&mut self, name: &str, cwd: &str, pane_id: &str) {
245        self.in_flight.insert(name.to_string());
246        let tx = self.tx.clone();
247        let name = name.to_string();
248        let cwd = cwd.to_string();
249        let pane_id = pane_id.to_string();
250        let generator = Arc::clone(&self.generator);
251        thread::spawn(move || {
252            let result = generator(&cwd, &pane_id);
253            let _ = tx.send((name, result));
254        });
255    }
256}
257
258/// A settled state means Claude has finished a turn and the conversation has content
259/// worth summarizing. Only generate context in these states — not during Working
260/// (conversation incomplete) or Fresh/Done (no activity / session ended).
261fn is_settled(state: WindowState) -> bool {
262    matches!(
263        state,
264        WindowState::Idle | WindowState::Asking | WindowState::Waiting
265    )
266}
267
268// ── Session Lookup ──
269
270/// Derive the Claude project directory from a cwd.
271fn claude_project_dir(cwd: &str) -> PathBuf {
272    let home = std::env::var("HOME").unwrap_or_default();
273    let project_key = cwd.replace('/', "-");
274    PathBuf::from(home)
275        .join(".claude")
276        .join("projects")
277        .join(project_key)
278}
279
280/// Find the Claude session JSONL file for a given pane.
281fn find_session_file(cwd: &str, pane_id: &str) -> Option<PathBuf> {
282    let session_id = match events::find_session_id(pane_id) {
283        Some(id) => id,
284        None => {
285            log_context(&format!("no session_id for pane_id={pane_id}"));
286            return None;
287        }
288    };
289    let project_dir = claude_project_dir(cwd);
290    let path = project_dir.join(format!("{session_id}.jsonl"));
291    if path.exists() {
292        Some(path)
293    } else {
294        log_context(&format!("JSONL not found: {}", path.display()));
295        None
296    }
297}
298
299// ── JSONL Parsing ──
300
301/// Extract conversation text from a Claude session JSONL file.
302/// Returns a compact representation of user/assistant messages, truncated
303/// to fit within CONVERSATION_BUDGET.
304fn extract_conversation(path: &Path) -> Option<String> {
305    let content = match fs::read_to_string(path) {
306        Ok(c) => c,
307        Err(e) => {
308            log_context(&format!(
309                "read session file failed: {}: {e}",
310                path.display()
311            ));
312            return None;
313        }
314    };
315    let mut messages = Vec::new();
316
317    for line in content.lines() {
318        let entry: serde_json::Value = match serde_json::from_str(line) {
319            Ok(v) => v,
320            Err(_) => continue,
321        };
322
323        let entry_type = match entry.get("type").and_then(|t| t.as_str()) {
324            Some(t) => t,
325            None => continue,
326        };
327
328        if entry_type != "user" && entry_type != "assistant" {
329            continue;
330        }
331
332        let message = match entry.get("message") {
333            Some(m) => m,
334            None => continue,
335        };
336
337        let role = message
338            .get("role")
339            .and_then(|r| r.as_str())
340            .unwrap_or(entry_type);
341
342        let content_arr = match message.get("content").and_then(|c| c.as_array()) {
343            Some(arr) => arr,
344            None => continue,
345        };
346
347        for item in content_arr {
348            // Only extract text content — skip images, tool_use, tool_result
349            if item.get("type").and_then(|t| t.as_str()) != Some("text") {
350                continue;
351            }
352            if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
353                let text = text.trim();
354                if text.is_empty() {
355                    continue;
356                }
357                let truncated = if text.chars().count() > MESSAGE_TRUNCATE {
358                    let t: String = text.chars().take(MESSAGE_TRUNCATE).collect();
359                    format!("{t}\u{2026}")
360                } else {
361                    text.to_string()
362                };
363                let label = if role == "user" { "User" } else { "Assistant" };
364                messages.push(format!("{label}: {truncated}"));
365            }
366        }
367    }
368
369    if messages.is_empty() {
370        return None;
371    }
372
373    // Keep recent messages, truncated to fit within budget
374    let mut kept = Vec::new();
375    let mut total_len = 0;
376    for msg in messages.iter().rev() {
377        if total_len + msg.len() + 2 > CONVERSATION_BUDGET {
378            break;
379        }
380        total_len += msg.len() + 2;
381        kept.push(msg.as_str());
382    }
383    kept.reverse();
384
385    Some(kept.join("\n\n"))
386}
387
388// ── Context Generation ──
389
390fn generate_context(cwd: &str, pane_id: &str) -> Result<String, String> {
391    let session_path = find_session_file(cwd, pane_id)
392        .ok_or_else(|| format!("no session file: cwd={cwd} pane_id={pane_id}"))?;
393    let conversation = extract_conversation(&session_path)
394        .ok_or_else(|| format!("no conversation: path={}", session_path.display()))?;
395
396    let prompt = format!("{SUMMARY_PROMPT}\n\nConversation:\n{conversation}");
397    call_ollama(&prompt)
398}
399
400const OLLAMA_DEFAULT_MODEL: &str = "llama3.2";
401
402fn call_ollama(prompt: &str) -> Result<String, String> {
403    let model =
404        std::env::var("COVE_OLLAMA_MODEL").unwrap_or_else(|_| OLLAMA_DEFAULT_MODEL.to_string());
405
406    let config = ureq::Agent::config_builder()
407        .timeout_global(Some(API_TIMEOUT))
408        .build();
409    let agent: ureq::Agent = config.into();
410
411    let body = serde_json::json!({
412        "model": model,
413        "messages": [{"role": "user", "content": prompt}],
414        "stream": false
415    });
416
417    let mut response = agent
418        .post("http://localhost:11434/api/chat")
419        .send_json(&body)
420        .map_err(|e| {
421            if e.to_string().contains("Connection refused") {
422                "Ollama not connected".to_string()
423            } else {
424                format!("Ollama request failed: {e}")
425            }
426        })?;
427
428    let response_text = response
429        .body_mut()
430        .read_to_string()
431        .map_err(|e| format!("read response failed: {e}"))?;
432
433    let parsed: serde_json::Value =
434        serde_json::from_str(&response_text).map_err(|e| format!("parse response failed: {e}"))?;
435
436    let text = parsed
437        .get("message")
438        .and_then(|m| m.get("content"))
439        .and_then(|t| t.as_str())
440        .ok_or_else(|| "no text in Ollama response".to_string())?;
441
442    let text = text.trim().to_string();
443    if text.is_empty() {
444        Err("Ollama returned empty text".to_string())
445    } else {
446        Ok(text)
447    }
448}
449
450// ── Tests ──
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use std::io::Write;
456
457    fn write_session_jsonl(dir: &Path, session_id: &str, messages: &[(&str, &str)]) -> PathBuf {
458        let path = dir.join(format!("{session_id}.jsonl"));
459        let mut f = fs::File::create(&path).unwrap();
460        for (role, text) in messages {
461            let entry = serde_json::json!({
462                "type": role,
463                "message": {
464                    "role": role,
465                    "content": [{"type": "text", "text": text}]
466                }
467            });
468            writeln!(f, "{}", serde_json::to_string(&entry).unwrap()).unwrap();
469        }
470        path
471    }
472
473    #[test]
474    fn test_extract_conversation_basic() {
475        let dir = tempfile::tempdir().unwrap();
476        let path = write_session_jsonl(
477            dir.path(),
478            "test",
479            &[
480                ("user", "Fix the login bug"),
481                ("assistant", "I'll look into the auth module"),
482                ("user", "Also check the session handling"),
483            ],
484        );
485
486        let result = extract_conversation(&path).unwrap();
487        assert!(result.contains("User: Fix the login bug"));
488        assert!(result.contains("Assistant: I'll look into the auth module"));
489        assert!(result.contains("User: Also check the session handling"));
490    }
491
492    #[test]
493    fn test_extract_conversation_skips_non_text() {
494        let dir = tempfile::tempdir().unwrap();
495        let path = dir.path().join("test.jsonl");
496        let mut f = fs::File::create(&path).unwrap();
497
498        // User message with text
499        writeln!(
500            f,
501            r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"hello"}}]}}}}"#
502        )
503        .unwrap();
504        // Progress entry (should be skipped)
505        writeln!(f, r#"{{"type":"progress","data":{{"hook":"test"}}}}"#).unwrap();
506        // Assistant with tool_use (should be skipped)
507        writeln!(
508            f,
509            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"tool_use","name":"Bash"}}]}}}}"#
510        )
511        .unwrap();
512
513        let result = extract_conversation(&path).unwrap();
514        assert!(result.contains("User: hello"));
515        assert!(!result.contains("progress"));
516        assert!(!result.contains("Bash"));
517    }
518
519    #[test]
520    fn test_extract_conversation_truncates_long_messages() {
521        let dir = tempfile::tempdir().unwrap();
522        let long_text = "a".repeat(500);
523        let path = write_session_jsonl(dir.path(), "test", &[("user", &long_text)]);
524
525        let result = extract_conversation(&path).unwrap();
526        // Should be truncated to MESSAGE_TRUNCATE + "…"
527        assert!(result.chars().count() < 500);
528        assert!(result.ends_with('\u{2026}'));
529    }
530
531    #[test]
532    fn test_extract_conversation_empty_file() {
533        let dir = tempfile::tempdir().unwrap();
534        let path = dir.path().join("empty.jsonl");
535        fs::File::create(&path).unwrap();
536
537        assert!(extract_conversation(&path).is_none());
538    }
539
540    #[test]
541    fn test_extract_conversation_respects_budget() {
542        let dir = tempfile::tempdir().unwrap();
543        let msg = "x".repeat(MESSAGE_TRUNCATE - 10);
544        let messages: Vec<(&str, &str)> = (0..100).map(|_| ("user", msg.as_str())).collect();
545        let path = write_session_jsonl(dir.path(), "test", &messages);
546
547        let result = extract_conversation(&path).unwrap();
548        assert!(result.len() <= CONVERSATION_BUDGET + 200); // some slack for labels
549    }
550
551    #[test]
552    fn test_claude_project_dir() {
553        let dir = claude_project_dir("/Users/test/workspace/myproject");
554        assert!(
555            dir.to_str()
556                .unwrap()
557                .ends_with("/.claude/projects/-Users-test-workspace-myproject")
558        );
559    }
560
561    #[test]
562    fn test_find_session_id_from_events() {
563        let dir = tempfile::tempdir().unwrap();
564        let events_path = dir.path().join("abc-123.jsonl");
565        let mut f = fs::File::create(&events_path).unwrap();
566        writeln!(
567            f,
568            r#"{{"state":"working","cwd":"/tmp","pane_id":"%5","ts":1000}}"#
569        )
570        .unwrap();
571        writeln!(
572            f,
573            r#"{{"state":"idle","cwd":"/tmp","pane_id":"%5","ts":1001}}"#
574        )
575        .unwrap();
576
577        // Test the lookup logic directly (can't use find_session_id since it reads ~/.cove)
578        let content = fs::read_to_string(&events_path).unwrap();
579        let last_line = content
580            .lines()
581            .rev()
582            .find(|l| !l.trim().is_empty())
583            .unwrap();
584        let event: serde_json::Value = serde_json::from_str(last_line).unwrap();
585        assert_eq!(event.get("pane_id").and_then(|v| v.as_str()), Some("%5"));
586    }
587
588    // ── Context lifecycle integration tests ──
589    //
590    // These test the orchestration logic in tick(): when context generation
591    // fires (or doesn't) based on session state and selection changes.
592
593    use std::sync::Mutex;
594
595    /// Build a mock generator that tracks calls and returns controlled results.
596    fn mock_generator() -> (
597        impl Fn(&str, &str) -> Result<String, String> + Send + Sync + 'static,
598        Arc<Mutex<Vec<(String, String)>>>,
599    ) {
600        let calls: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
601        let calls_clone = Arc::clone(&calls);
602        let generator = move |cwd: &str, pane_id: &str| -> Result<String, String> {
603            calls_clone
604                .lock()
605                .unwrap()
606                .push((cwd.to_string(), pane_id.to_string()));
607            Ok(format!("context for {pane_id}"))
608        };
609        (generator, calls)
610    }
611
612    fn win(index: u32, name: &str) -> WindowInfo {
613        WindowInfo {
614            index,
615            name: name.to_string(),
616            is_active: false,
617            pane_path: format!("/project/{name}"),
618        }
619    }
620
621    fn pane_ids(map: &HashMap<u32, String>) -> impl Fn(u32) -> Option<String> + '_ {
622        move |idx| map.get(&idx).cloned()
623    }
624
625    fn no_cwd(_idx: u32) -> Option<String> {
626        None
627    }
628
629    /// Drain results by waiting briefly for background threads to complete.
630    fn drain_with_wait(mgr: &mut ContextManager) {
631        // Background threads are fast (mock generator returns immediately),
632        // but we need a brief pause for the thread to send its result.
633        thread::sleep(Duration::from_millis(50));
634        mgr.drain();
635    }
636
637    // ── Scenario 1: New session → no context action ──
638    //
639    // A brand-new session (Fresh state) should not trigger any context
640    // generation. No "loading…", no failed generation.
641    #[test]
642    fn test_new_session_no_context_action() {
643        let (generator, calls) = mock_generator();
644        let mut mgr = ContextManager::with_generator(generator);
645
646        let windows = vec![win(1, "session-1")];
647        let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh)].into_iter().collect();
648        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
649
650        // Run a tick with the Fresh session selected
651        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
652
653        // No generation should have been spawned
654        assert!(calls.lock().unwrap().is_empty());
655        assert!(!mgr.is_loading("session-1"));
656        assert!(mgr.get("session-1").is_none());
657    }
658
659    // ── Scenario 2: Switch from new (no prompt) session to another new session → no context action ──
660    //
661    // Both sessions are Fresh. Switching between them should not trigger
662    // context generation for either session.
663    #[test]
664    fn test_switch_between_fresh_sessions_no_context_action() {
665        let (generator, calls) = mock_generator();
666        let mut mgr = ContextManager::with_generator(generator);
667
668        let windows = vec![win(1, "session-1"), win(2, "session-2")];
669        let states: HashMap<u32, WindowState> = [(1, WindowState::Fresh), (2, WindowState::Fresh)]
670            .into_iter()
671            .collect();
672        let panes: HashMap<u32, String> =
673            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
674
675        // Tick 1: session-1 selected (Fresh)
676        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
677        assert!(calls.lock().unwrap().is_empty());
678
679        // Tick 2: switch to session-2 (also Fresh)
680        mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
681
682        // No generation for either session
683        assert!(calls.lock().unwrap().is_empty());
684        assert!(!mgr.is_loading("session-1"));
685        assert!(!mgr.is_loading("session-2"));
686        assert!(mgr.get("session-1").is_none());
687        assert!(mgr.get("session-2").is_none());
688    }
689
690    // ── Scenario 3: Switch from active session to new session → fire context for previous ──
691    //
692    // When switching away from a session that has had at least 1 conversation
693    // turn (not Fresh), context generation should fire for that session.
694    // The new Fresh session should NOT get context generated.
695    #[test]
696    fn test_switch_from_active_to_fresh_fires_context_for_previous() {
697        let (generator, calls) = mock_generator();
698        let mut mgr = ContextManager::with_generator(generator);
699
700        let windows = vec![win(1, "active-session"), win(2, "new-session")];
701        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
702            .into_iter()
703            .collect();
704        let panes: HashMap<u32, String> =
705            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
706
707        // Tick 1: active-session selected (Idle — has had activity)
708        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
709        drain_with_wait(&mut mgr);
710
711        // The prefetch loop should have requested context for the active session
712        let call_count_after_tick1 = calls.lock().unwrap().len();
713        assert!(
714            call_count_after_tick1 > 0,
715            "should request context for Idle session"
716        );
717        assert!(
718            calls.lock().unwrap().iter().any(|(_, pid)| pid == "%0"),
719            "should request for pane %0"
720        );
721
722        // Tick 2: switch to new-session (Fresh)
723        calls.lock().unwrap().clear();
724        mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
725        // Wait for background thread to complete so calls are recorded
726        drain_with_wait(&mut mgr);
727
728        // Should fire refresh for old active-session (pane %0)
729        // Should NOT fire for new-session (pane %1) since it's Fresh
730        let tick2_calls = calls.lock().unwrap().clone();
731        assert!(
732            tick2_calls.iter().any(|(_, pid)| pid == "%0"),
733            "should refresh context for previous active session"
734        );
735        assert!(
736            !tick2_calls.iter().any(|(_, pid)| pid == "%1"),
737            "should NOT request context for Fresh new session"
738        );
739
740        // new-session should have no loading state or context
741        assert!(!mgr.is_loading("new-session"));
742        assert!(mgr.get("new-session").is_none());
743    }
744
745    // ── Scenario 4: Switch back to previous session while context is pending → loading state ──
746    //
747    // After switching away from an active session (triggering refresh),
748    // switching back before the generation completes should show loading state.
749    #[test]
750    fn test_switch_back_while_pending_shows_loading() {
751        // Use a slow generator that blocks until signaled
752        let barrier = Arc::new(std::sync::Barrier::new(2));
753        let barrier_clone = Arc::clone(&barrier);
754        let slow_generator = move |_cwd: &str, _pane_id: &str| -> Result<String, String> {
755            barrier_clone.wait();
756            Ok("generated context".to_string())
757        };
758        let mut mgr = ContextManager::with_generator(slow_generator);
759
760        let windows = vec![win(1, "active-session"), win(2, "new-session")];
761        let states: HashMap<u32, WindowState> = [(1, WindowState::Idle), (2, WindowState::Fresh)]
762            .into_iter()
763            .collect();
764        let panes: HashMap<u32, String> =
765            [(1, "%0".into()), (2, "%1".into())].into_iter().collect();
766
767        // Tick 1: active-session selected — starts context generation (blocked on barrier)
768        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
769        // Context is in-flight but blocked, so is_loading should be true
770        assert!(
771            mgr.is_loading("active-session"),
772            "should be loading while generator is running"
773        );
774
775        // Tick 2: switch to new-session — triggers refresh for active-session
776        // But active-session is still in-flight (blocked), so refresh is a no-op
777        mgr.tick(&windows, &states, 1, &pane_ids(&panes), &no_cwd);
778
779        // Tick 3: switch back to active-session — should still show loading
780        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
781        assert!(
782            mgr.is_loading("active-session"),
783            "should still be loading when switching back"
784        );
785        assert!(
786            mgr.get("active-session").is_none(),
787            "context should not be available yet"
788        );
789
790        // Unblock the generator
791        barrier.wait();
792        drain_with_wait(&mut mgr);
793
794        // Now context should be available
795        assert!(
796            !mgr.is_loading("active-session"),
797            "should not be loading after drain"
798        );
799        assert_eq!(
800            mgr.get("active-session"),
801            Some("generated context"),
802            "context should be populated after drain"
803        );
804    }
805
806    // ── Scenario 5: Working state should NOT fire context generation ──
807    //
808    // During Working state, the conversation is incomplete — Claude hasn't
809    // responded yet. Firing the generator here risks failure (session file not
810    // ready) which triggers a 30s retry cooldown, blocking context generation
811    // when the session eventually reaches Idle.
812    #[test]
813    fn test_working_state_does_not_fire_context() {
814        let (generator, calls) = mock_generator();
815        let mut mgr = ContextManager::with_generator(generator);
816
817        let windows = vec![win(1, "session-1")];
818        let states: HashMap<u32, WindowState> = [(1, WindowState::Working)].into_iter().collect();
819        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
820
821        // Tick with Working state selected
822        mgr.tick(&windows, &states, 0, &pane_ids(&panes), &no_cwd);
823
824        // Generator should NOT fire during Working
825        assert!(
826            calls.lock().unwrap().is_empty(),
827            "should not fire context during Working"
828        );
829        assert!(!mgr.is_loading("session-1"));
830        assert!(mgr.get("session-1").is_none());
831    }
832
833    // ── Scenario 6: Full user flow — Fresh → Working → Idle ──
834    //
835    // Simulates the exact user experience:
836    // 1. Session starts (Fresh) → no context
837    // 2. User asks a question (Working) → no context fires
838    // 3. Claude responds (Idle) → context fires and resolves
839    #[test]
840    fn test_full_user_flow_fresh_working_idle() {
841        let (generator, calls) = mock_generator();
842        let mut mgr = ContextManager::with_generator(generator);
843
844        let windows = vec![win(1, "my-session")];
845        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
846
847        // Phase 1: Fresh — no context activity
848        let states_fresh: HashMap<u32, WindowState> =
849            [(1, WindowState::Fresh)].into_iter().collect();
850        mgr.tick(&windows, &states_fresh, 0, &pane_ids(&panes), &no_cwd);
851        assert!(calls.lock().unwrap().is_empty(), "Fresh: no generator call");
852        assert!(mgr.get("my-session").is_none(), "Fresh: no context");
853        assert!(!mgr.is_loading("my-session"), "Fresh: not loading");
854
855        // Phase 2: Working — still no context activity
856        let states_working: HashMap<u32, WindowState> =
857            [(1, WindowState::Working)].into_iter().collect();
858        mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
859        assert!(
860            calls.lock().unwrap().is_empty(),
861            "Working: no generator call"
862        );
863        assert!(mgr.get("my-session").is_none(), "Working: no context");
864        assert!(!mgr.is_loading("my-session"), "Working: not loading");
865
866        // Phase 3: Idle — context fires
867        let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
868        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
869
870        // spawn() sets in_flight synchronously before the thread runs
871        assert!(
872            mgr.is_loading("my-session"),
873            "Idle: loading while in-flight"
874        );
875
876        // Let background thread complete and drain results
877        drain_with_wait(&mut mgr);
878
879        // Generator should have been called (check after drain — calls is populated in thread)
880        assert_eq!(
881            calls.lock().unwrap().len(),
882            1,
883            "Idle: generator called once"
884        );
885
886        // Context should now be available
887        assert!(
888            !mgr.is_loading("my-session"),
889            "Idle: not loading after drain"
890        );
891        assert_eq!(
892            mgr.get("my-session"),
893            Some("context for %0"),
894            "Idle: context populated"
895        );
896    }
897
898    // ── Scenario 7: State transition invalidates stale cache ──
899    //
900    // After context is cached for a session, a new Working → Idle transition
901    // should invalidate the cache so fresh context is generated.
902    #[test]
903    fn test_working_to_idle_invalidates_cache() {
904        let (generator, calls) = mock_generator();
905        let mut mgr = ContextManager::with_generator(generator);
906
907        let windows = vec![win(1, "my-session")];
908        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
909
910        // First round: Idle → generates and caches context
911        let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
912        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
913        drain_with_wait(&mut mgr);
914        assert_eq!(mgr.get("my-session"), Some("context for %0"));
915        assert_eq!(calls.lock().unwrap().len(), 1);
916
917        // Second round: user asks another question → Working
918        let states_working: HashMap<u32, WindowState> =
919            [(1, WindowState::Working)].into_iter().collect();
920        mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
921        // Context still cached from first round (not cleared during Working)
922        assert_eq!(mgr.get("my-session"), Some("context for %0"));
923
924        // Third round: Claude responds → Idle again
925        // The Working → Idle transition should invalidate the cache
926        calls.lock().unwrap().clear();
927        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
928
929        // spawn() is synchronous — should be in-flight (cache was cleared by transition)
930        assert!(
931            mgr.is_loading("my-session"),
932            "should re-request after Working → Idle transition"
933        );
934
935        drain_with_wait(&mut mgr);
936
937        // Generator should have been called again
938        assert_eq!(
939            calls.lock().unwrap().len(),
940            1,
941            "should have called generator after Working → Idle transition"
942        );
943        assert_eq!(
944            mgr.get("my-session"),
945            Some("context for %0"),
946            "fresh context should be available"
947        );
948    }
949
950    // ── Scenario 8: Failed generator during Idle retries correctly ──
951    //
952    // Verifies the 30s cooldown behavior: if the generator fails during Idle,
953    // the retry cooldown prevents immediate re-requests (as expected).
954    // But a state transition (Working → Idle) should clear the cooldown.
955    #[test]
956    fn test_failed_generator_cooldown_cleared_on_transition() {
957        let call_count = Arc::new(Mutex::new(0u32));
958        let call_count_clone = Arc::clone(&call_count);
959        // Generator fails on first call, succeeds on second
960        let generator = move |_cwd: &str, _pane_id: &str| -> Result<String, String> {
961            let mut count = call_count_clone.lock().unwrap();
962            *count += 1;
963            if *count == 1 {
964                Err("simulated failure".to_string())
965            } else {
966                Ok("generated context".to_string())
967            }
968        };
969        let mut mgr = ContextManager::with_generator(generator);
970
971        let windows = vec![win(1, "my-session")];
972        let panes: HashMap<u32, String> = [(1, "%0".into())].into_iter().collect();
973
974        // Tick 1: Idle → generator fires and fails → enters 30s cooldown
975        let states_idle: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
976        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
977        drain_with_wait(&mut mgr);
978        assert!(mgr.get("my-session").is_none(), "first attempt should fail");
979        assert_eq!(*call_count.lock().unwrap(), 1);
980
981        // Tick 2: Still Idle → cooldown blocks retry
982        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
983        drain_with_wait(&mut mgr);
984        assert_eq!(
985            *call_count.lock().unwrap(),
986            1,
987            "cooldown should block retry"
988        );
989
990        // Simulate new activity: Working → Idle transition clears cooldown
991        let states_working: HashMap<u32, WindowState> =
992            [(1, WindowState::Working)].into_iter().collect();
993        mgr.tick(&windows, &states_working, 0, &pane_ids(&panes), &no_cwd);
994        mgr.tick(&windows, &states_idle, 0, &pane_ids(&panes), &no_cwd);
995        drain_with_wait(&mut mgr);
996
997        // Should have retried and succeeded
998        assert_eq!(
999            *call_count.lock().unwrap(),
1000            2,
1001            "should retry after state transition"
1002        );
1003        assert_eq!(
1004            mgr.get("my-session"),
1005            Some("generated context"),
1006            "second attempt should succeed"
1007        );
1008    }
1009}