Skip to main content

coding_agent_search/ui/
data.rs

1use crate::model::types::{Conversation, Message, MessageRole, Workspace};
2use crate::search::query::SearchHit;
3use crate::storage::sqlite::FrankenStorage;
4use crate::ui::components::theme::ThemePalette;
5use anyhow::Result;
6use frankensqlite::compat::{ConnectionExt, RowExt};
7use frankensqlite::{FrankenError, Row};
8use lru::LruCache;
9use once_cell::sync::Lazy;
10use parking_lot::RwLock;
11use std::num::NonZeroUsize;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15// -------------------------------------------------------------------------
16// Input Mode
17// -------------------------------------------------------------------------
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum InputMode {
21    Query,
22    Agent,
23    Workspace,
24    CreatedFrom,
25    CreatedTo,
26    PaneFilter,
27    /// Inline find within the detail pane (local, non-indexed)
28    DetailFind,
29}
30
31// -------------------------------------------------------------------------
32// Conversation View
33// -------------------------------------------------------------------------
34
35#[derive(Clone, Debug)]
36pub struct ConversationView {
37    pub convo: Conversation,
38    pub messages: Vec<Message>,
39    pub workspace: Option<Workspace>,
40}
41
42fn normalized_ui_source_identity_sql_expr(
43    source_id_column: &str,
44    origin_host_column: &str,
45) -> String {
46    format!(
47        "CASE WHEN TRIM(COALESCE({source_id_column}, '')) = '' THEN CASE WHEN TRIM(COALESCE({origin_host_column}, '')) = '' THEN '{local}' ELSE TRIM(COALESCE({origin_host_column}, '')) END \
48         WHEN LOWER(TRIM(COALESCE({source_id_column}, ''))) = '{local}' THEN '{local}' \
49         ELSE TRIM(COALESCE({source_id_column}, '')) END",
50        local = crate::sources::provenance::LOCAL_SOURCE_ID,
51    )
52}
53
54fn normalize_ui_source_id_value(source_id: Option<&str>) -> String {
55    let trimmed = source_id.unwrap_or_default().trim();
56    if trimmed.is_empty()
57        || trimmed.eq_ignore_ascii_case(crate::sources::provenance::LOCAL_SOURCE_ID)
58    {
59        crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
60    } else {
61        trimmed.to_string()
62    }
63}
64
65fn normalize_ui_source_id_parts(source_id: Option<&str>, origin_host: Option<&str>) -> String {
66    let trimmed_source_id = source_id.unwrap_or_default().trim();
67    if !trimmed_source_id.is_empty() {
68        return normalize_ui_source_id_value(Some(trimmed_source_id));
69    }
70
71    origin_host
72        .map(str::trim)
73        .filter(|value| !value.is_empty())
74        .map(str::to_string)
75        .unwrap_or_else(|| crate::sources::provenance::LOCAL_SOURCE_ID.to_string())
76}
77
78fn normalize_ui_hit_source_id(hit: &SearchHit) -> String {
79    let trimmed_source_id = hit.source_id.trim();
80    if !trimmed_source_id.is_empty() {
81        return normalize_ui_source_id_value(Some(trimmed_source_id));
82    }
83
84    if let Some(host) = hit
85        .origin_host
86        .as_deref()
87        .map(str::trim)
88        .filter(|value| !value.is_empty())
89    {
90        return host.to_string();
91    }
92
93    if hit.origin_kind.trim().eq_ignore_ascii_case("ssh")
94        || hit.origin_kind.trim().eq_ignore_ascii_case("remote")
95    {
96        return "remote".to_string();
97    }
98
99    crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
100}
101
102// -------------------------------------------------------------------------
103// Conversation Cache (P1 Opt 1.3)
104// -------------------------------------------------------------------------
105
106/// Cache statistics for monitoring performance.
107#[derive(Debug, Default)]
108pub struct CacheStats {
109    pub hits: AtomicU64,
110    pub misses: AtomicU64,
111    pub evictions: AtomicU64,
112}
113
114impl CacheStats {
115    /// Get current stats as a tuple: (hits, misses, evictions).
116    pub fn get(&self) -> (u64, u64, u64) {
117        (
118            self.hits.load(Ordering::Relaxed),
119            self.misses.load(Ordering::Relaxed),
120            self.evictions.load(Ordering::Relaxed),
121        )
122    }
123
124    /// Calculate hit rate as a percentage (0.0 - 1.0).
125    pub fn hit_rate(&self) -> f64 {
126        let hits = self.hits.load(Ordering::Relaxed);
127        let misses = self.misses.load(Ordering::Relaxed);
128        let total = hits + misses;
129        if total == 0 {
130            0.0
131        } else {
132            hits as f64 / total as f64
133        }
134    }
135}
136
137/// Number of cache shards (must be power of 2 for efficient modulo).
138const NUM_SHARDS: usize = 16;
139
140/// Default capacity per shard.
141const DEFAULT_CAPACITY_PER_SHARD: usize = 256;
142
143/// Sharded LRU cache for ConversationView to reduce lock contention.
144///
145/// Caching conversation views avoids:
146/// - Database queries (conversation + messages)
147/// - JSON parsing (metadata_json, extra_json)
148///
149/// This is particularly beneficial for:
150/// - TUI scrolling (repeated access to same results)
151/// - Detail view expansion (view -> expand -> view pattern)
152pub struct ConversationCache {
153    shards: [RwLock<LruCache<u64, Arc<ConversationView>>>; NUM_SHARDS],
154    stats: CacheStats,
155}
156
157impl ConversationCache {
158    /// Create a new cache with the specified capacity per shard.
159    pub fn new(capacity_per_shard: usize) -> Self {
160        Self {
161            shards: std::array::from_fn(|_| {
162                RwLock::new(LruCache::new(
163                    NonZeroUsize::new(capacity_per_shard).unwrap_or(NonZeroUsize::MIN),
164                ))
165            }),
166            stats: CacheStats::default(),
167        }
168    }
169
170    /// Hash a cache scope + source identity to a u64 key using rustc-hash's FxHasher.
171    #[inline]
172    fn hash_key(cache_scope: Option<&str>, source_id: Option<&str>, source_path: &str) -> u64 {
173        use std::hash::{Hash, Hasher};
174        let mut hasher = rustc_hash::FxHasher::default();
175        cache_scope.unwrap_or("").hash(&mut hasher);
176        if let Some(source_id) = source_id {
177            normalize_ui_source_id_value(Some(source_id)).hash(&mut hasher);
178        } else {
179            "".hash(&mut hasher);
180        }
181        source_path.hash(&mut hasher);
182        hasher.finish()
183    }
184
185    /// Get the shard index for a given hash.
186    #[inline]
187    fn shard_index(hash: u64) -> usize {
188        (hash as usize) % NUM_SHARDS
189    }
190
191    /// Get a cached conversation view by source identity.
192    pub fn get(&self, source_id: Option<&str>, source_path: &str) -> Option<Arc<ConversationView>> {
193        self.get_scoped("", source_id, source_path)
194    }
195
196    /// Get a cached conversation view scoped to a specific database identity.
197    pub fn get_scoped(
198        &self,
199        cache_scope: &str,
200        source_id: Option<&str>,
201        source_path: &str,
202    ) -> Option<Arc<ConversationView>> {
203        let hash = Self::hash_key(Some(cache_scope), source_id, source_path);
204        let shard_idx = Self::shard_index(hash);
205        let mut shard = self.shards[shard_idx].write();
206
207        if let Some(cached) = shard.get(&hash) {
208            self.stats.hits.fetch_add(1, Ordering::Relaxed);
209            Some(Arc::clone(cached))
210        } else {
211            self.stats.misses.fetch_add(1, Ordering::Relaxed);
212            None
213        }
214    }
215
216    /// Insert a conversation view into the cache.
217    pub fn insert(
218        &self,
219        source_id: Option<&str>,
220        source_path: &str,
221        view: ConversationView,
222    ) -> Arc<ConversationView> {
223        self.insert_scoped("", source_id, source_path, view)
224    }
225
226    /// Insert a conversation view into the cache scoped to a specific database identity.
227    pub fn insert_scoped(
228        &self,
229        cache_scope: &str,
230        source_id: Option<&str>,
231        source_path: &str,
232        view: ConversationView,
233    ) -> Arc<ConversationView> {
234        let hash = Self::hash_key(Some(cache_scope), source_id, source_path);
235        let shard_idx = Self::shard_index(hash);
236        let arc = Arc::new(view);
237
238        let mut shard = self.shards[shard_idx].write();
239        // Only count eviction if shard is full AND key doesn't already exist
240        if shard.len() == shard.cap().get() && !shard.contains(&hash) {
241            self.stats.evictions.fetch_add(1, Ordering::Relaxed);
242        }
243        shard.put(hash, Arc::clone(&arc));
244
245        arc
246    }
247
248    /// Invalidate a specific cache entry by source identity.
249    pub fn invalidate(&self, source_id: Option<&str>, source_path: &str) {
250        self.invalidate_scoped("", source_id, source_path)
251    }
252
253    /// Invalidate a specific cache entry scoped to a specific database identity.
254    pub fn invalidate_scoped(&self, cache_scope: &str, source_id: Option<&str>, source_path: &str) {
255        let hash = Self::hash_key(Some(cache_scope), source_id, source_path);
256        let shard_idx = Self::shard_index(hash);
257        let mut shard = self.shards[shard_idx].write();
258        shard.pop(&hash);
259    }
260
261    /// Invalidate all cache entries.
262    pub fn invalidate_all(&self) {
263        for shard in &self.shards {
264            shard.write().clear();
265        }
266    }
267
268    /// Get cache statistics.
269    pub fn stats(&self) -> &CacheStats {
270        &self.stats
271    }
272
273    /// Get total number of cached entries across all shards.
274    pub fn len(&self) -> usize {
275        self.shards.iter().map(|s| s.read().len()).sum()
276    }
277
278    /// Check if cache is empty.
279    pub fn is_empty(&self) -> bool {
280        self.len() == 0
281    }
282}
283
284/// Global conversation cache instance.
285pub static CONVERSATION_CACHE: Lazy<ConversationCache> = Lazy::new(|| {
286    let capacity = dotenvy::var("CASS_CONV_CACHE_SIZE")
287        .ok()
288        .and_then(|s| s.parse().ok())
289        .unwrap_or(DEFAULT_CAPACITY_PER_SHARD);
290    ConversationCache::new(capacity)
291});
292
293fn storage_cache_scope(storage: &FrankenStorage) -> Option<String> {
294    storage
295        .database_path()
296        .ok()
297        .map(|path| path.to_string_lossy().into_owned())
298}
299
300fn ui_conversation_row_parts(
301    row: &Row,
302) -> std::result::Result<(i64, Conversation, Option<Workspace>), FrankenError> {
303    let convo_id: i64 = row.get_typed(0)?;
304    let workspace_path = row
305        .get_typed::<Option<String>>(3)?
306        .map(std::path::PathBuf::from);
307    let metadata_json = row
308        .get_typed::<Option<String>>(11)?
309        .and_then(|s| serde_json::from_str(&s).ok())
310        .or_else(|| {
311            row.get_typed::<Option<Vec<u8>>>(14)
312                .ok()
313                .flatten()
314                .and_then(|b| rmp_serde::from_slice(&b).ok())
315        })
316        .unwrap_or_default();
317    let convo = Conversation {
318        id: Some(convo_id),
319        agent_slug: row.get_typed(1)?,
320        workspace: workspace_path.clone(),
321        external_id: row.get_typed(5)?,
322        title: row.get_typed(6)?,
323        source_path: std::path::PathBuf::from(row.get_typed::<String>(7)?),
324        started_at: row.get_typed(8)?,
325        ended_at: row.get_typed(9)?,
326        approx_tokens: row.get_typed(10)?,
327        metadata_json,
328        messages: Vec::new(),
329        source_id: normalize_ui_source_id_parts(
330            row.get_typed::<Option<String>>(12)?.as_deref(),
331            row.get_typed::<Option<String>>(13)?.as_deref(),
332        ),
333        origin_host: row.get_typed(13)?,
334    };
335    let workspace = row.get_typed::<Option<i64>>(2)?.map(|id| Workspace {
336        id: Some(id),
337        path: workspace_path.unwrap_or_default(),
338        display_name: row.get_typed(4).ok().flatten(),
339    });
340    Ok((convo_id, convo, workspace))
341}
342
343fn load_conversation_by_id_uncached(
344    storage: &FrankenStorage,
345    conversation_id: i64,
346) -> Result<Option<ConversationView>> {
347    // LEFT JOIN + COALESCE on agents so conversations with NULL agent_id
348    // (legacy V1 schema) still load instead of returning "conversation not
349    // found" in the UI.  Consistent with 8a0c547c / e1c08e7c.
350    let rows = storage.raw().query_map_collect(
351        "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
352                c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
353         FROM conversations c
354         LEFT JOIN agents a ON c.agent_id = a.id
355         LEFT JOIN workspaces w ON c.workspace_id = w.id
356         WHERE c.id = ?1
357         LIMIT 1",
358        frankensqlite::params![conversation_id],
359        ui_conversation_row_parts,
360    )?;
361    if let Some((convo_id, convo, workspace)) = rows.into_iter().next() {
362        let messages = storage.fetch_messages(convo_id)?;
363        return Ok(Some(ConversationView {
364            convo,
365            messages,
366            workspace,
367        }));
368    }
369    Ok(None)
370}
371
372// -------------------------------------------------------------------------
373// Load Conversation (with caching)
374// -------------------------------------------------------------------------
375
376/// Load a conversation from the database (bypassing cache).
377/// Use `load_conversation` or `load_conversation_for_source` for cached access.
378pub(crate) fn load_conversation_uncached(
379    storage: &FrankenStorage,
380    source_id: Option<&str>,
381    source_path: &str,
382) -> Result<Option<ConversationView>> {
383    let normalized_source_sql =
384        normalized_ui_source_identity_sql_expr("c.source_id", "c.origin_host");
385    // LEFT JOIN + COALESCE on agents for the same NULL-agent_id safety as
386    // load_conversation_by_id_uncached.
387    let (sql, params) = if let Some(source_id) = source_id {
388        (
389            format!(
390                "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
391                        c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
392                 FROM conversations c
393                 LEFT JOIN agents a ON c.agent_id = a.id
394                 LEFT JOIN workspaces w ON c.workspace_id = w.id
395                 WHERE c.source_path = ?1 AND {normalized_source_sql} = ?2
396                 ORDER BY c.started_at DESC LIMIT 1"
397            ),
398            frankensqlite::params![source_path, normalize_ui_source_id_value(Some(source_id))],
399        )
400    } else {
401        (
402            format!(
403                "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
404                        c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
405                 FROM conversations c
406                 LEFT JOIN agents a ON c.agent_id = a.id
407                 LEFT JOIN workspaces w ON c.workspace_id = w.id
408                 WHERE c.source_path = ?1
409                 ORDER BY CASE WHEN {normalized_source_sql} = '{local}' THEN 0 ELSE 1 END,
410                          c.started_at DESC
411                 LIMIT 1",
412                local = crate::sources::provenance::LOCAL_SOURCE_ID,
413            ),
414            frankensqlite::params![source_path],
415        )
416    };
417    let rows = storage
418        .raw()
419        .query_map_collect(&sql, params, ui_conversation_row_parts)?;
420    if let Some((convo_id, convo, workspace)) = rows.into_iter().next() {
421        let messages = storage.fetch_messages(convo_id)?;
422        return Ok(Some(ConversationView {
423            convo,
424            messages,
425            workspace,
426        }));
427    }
428    Ok(None)
429}
430
431/// Load a conversation with LRU caching.
432///
433/// This is the primary function for loading conversations in the TUI.
434/// It uses a sharded LRU cache to avoid repeated database queries and
435/// JSON parsing for the same conversation.
436///
437/// Cache behavior:
438/// - Hit: Returns cached Arc<ConversationView> (fast path)
439/// - Miss: Queries database, parses JSON, caches result
440///
441/// The cache is keyed by source identity and has a configurable capacity
442/// via the CASS_CONV_CACHE_SIZE environment variable (default: 256 per shard,
443/// 4096 total entries across 16 shards).
444fn cached_conversation_matches_lookup_head(
445    storage: &FrankenStorage,
446    source_id: Option<&str>,
447    source_path: &str,
448    cached: &ConversationView,
449) -> Result<bool> {
450    let Some(cached_id) = cached.convo.id else {
451        return Ok(false);
452    };
453
454    let normalized_source_sql = normalized_ui_source_identity_sql_expr("source_id", "origin_host");
455    let (sql, params) = if let Some(source_id) = source_id {
456        (
457            format!(
458                "SELECT id, {normalized_source_sql} FROM conversations WHERE source_path = ?1 AND {normalized_source_sql} = ?2 ORDER BY started_at DESC LIMIT 1"
459            ),
460            frankensqlite::params![source_path, normalize_ui_source_id_value(Some(source_id))],
461        )
462    } else {
463        (
464            format!(
465                "SELECT id, {normalized_source_sql} FROM conversations WHERE source_path = ?1 ORDER BY CASE WHEN {normalized_source_sql} = '{local}' THEN 0 ELSE 1 END, started_at DESC LIMIT 1",
466                local = crate::sources::provenance::LOCAL_SOURCE_ID,
467            ),
468            frankensqlite::params![source_path],
469        )
470    };
471
472    let rows = storage.raw().query_map_collect(&sql, params, |row: &Row| {
473        Ok((row.get_typed::<i64>(0)?, row.get_typed::<String>(1)?))
474    })?;
475
476    Ok(
477        matches!(rows.into_iter().next(), Some((latest_id, latest_source_id)) if latest_id == cached_id && latest_source_id == cached.convo.source_id),
478    )
479}
480
481pub fn load_conversation(
482    storage: &FrankenStorage,
483    source_path: &str,
484) -> Result<Option<ConversationView>> {
485    let cache_scope = storage_cache_scope(storage);
486
487    // Fast path: check cache first
488    if let Some(scope) = cache_scope.as_deref()
489        && let Some(cached) = CONVERSATION_CACHE.get_scoped(scope, None, source_path)
490    {
491        match cached_conversation_matches_lookup_head(storage, None, source_path, &cached) {
492            Ok(true) => {
493                // Clone out of Arc for API compatibility
494                return Ok(Some((*cached).clone()));
495            }
496            Ok(false) => {
497                CONVERSATION_CACHE.invalidate_scoped(scope, None, source_path);
498            }
499            Err(_) => {
500                return Ok(Some((*cached).clone()));
501            }
502        }
503    }
504
505    // Cache miss: load from database
506    let view = load_conversation_uncached(storage, None, source_path)?;
507
508    // Cache the result if found
509    if let Some(v) = view {
510        if let Some(scope) = cache_scope.as_deref() {
511            CONVERSATION_CACHE.insert_scoped(scope, None, source_path, v.clone());
512        }
513        return Ok(Some(v));
514    }
515
516    Ok(None)
517}
518
519/// Load a conversation for a specific source with caching.
520pub fn load_conversation_for_source(
521    storage: &FrankenStorage,
522    source_id: &str,
523    source_path: &str,
524) -> Result<Option<ConversationView>> {
525    let cache_scope = storage_cache_scope(storage);
526
527    if let Some(scope) = cache_scope.as_deref()
528        && let Some(cached) = CONVERSATION_CACHE.get_scoped(scope, Some(source_id), source_path)
529    {
530        match cached_conversation_matches_lookup_head(
531            storage,
532            Some(source_id),
533            source_path,
534            &cached,
535        ) {
536            Ok(true) => {
537                return Ok(Some((*cached).clone()));
538            }
539            Ok(false) => {
540                CONVERSATION_CACHE.invalidate_scoped(scope, Some(source_id), source_path);
541            }
542            Err(_) => {
543                return Ok(Some((*cached).clone()));
544            }
545        }
546    }
547
548    let view = load_conversation_uncached(storage, Some(source_id), source_path)?;
549
550    if let Some(v) = view {
551        if let Some(scope) = cache_scope.as_deref() {
552            CONVERSATION_CACHE.insert_scoped(scope, Some(source_id), source_path, v.clone());
553        }
554        return Ok(Some(v));
555    }
556
557    Ok(None)
558}
559
560pub(crate) fn search_hit_has_identity_hint(hit: &SearchHit) -> bool {
561    let snippet = hit.snippet.trim();
562    let snippet_prefix = snippet.strip_suffix("...").unwrap_or(snippet).trim();
563    let title = hit.title.trim();
564    hit.conversation_id.is_some()
565        || hit.line_number.is_some()
566        || hit.created_at.is_some()
567        || !hit.content.is_empty()
568        || !snippet_prefix.is_empty()
569        || !title.is_empty()
570}
571
572pub(crate) fn search_hit_has_secondary_identity_hint(hit: &SearchHit) -> bool {
573    let snippet = hit.snippet.trim();
574    let snippet_prefix = snippet.strip_suffix("...").unwrap_or(snippet).trim();
575    let title = hit.title.trim();
576    hit.line_number.is_some_and(|line| line > 0)
577        || hit.created_at.is_some()
578        || !hit.content.is_empty()
579        || !snippet_prefix.is_empty()
580        || !title.is_empty()
581}
582
583pub(crate) fn conversation_view_matches_hit(view: &ConversationView, hit: &SearchHit) -> bool {
584    let conversation_id_mismatch = match hit.conversation_id {
585        Some(expected_conversation_id) if view.convo.id == Some(expected_conversation_id) => {
586            return true;
587        }
588        Some(_) => true,
589        None => false,
590    };
591    let normalized_hit_source_id = normalize_ui_hit_source_id(hit);
592    if view.convo.source_id != normalized_hit_source_id
593        || view.convo.source_path != std::path::Path::new(&hit.source_path)
594    {
595        return false;
596    }
597
598    let snippet = hit.snippet.trim();
599    let snippet_prefix = snippet.strip_suffix("...").unwrap_or(snippet).trim();
600    let hit_title = hit.title.trim();
601    let convo_title = view
602        .convo
603        .title
604        .as_deref()
605        .map(str::trim)
606        .filter(|title| !title.is_empty());
607    let has_identity_hint = search_hit_has_identity_hint(hit);
608    let has_strong_message_identity_hint = hit.created_at.is_some() || !hit.content.is_empty();
609    if conversation_id_mismatch && !search_hit_has_secondary_identity_hint(hit) {
610        return false;
611    }
612    if !has_identity_hint {
613        return true;
614    }
615
616    if !hit_title.is_empty() {
617        match convo_title {
618            Some(title) if title != hit_title && !has_strong_message_identity_hint => return false,
619            None if hit.line_number.is_none()
620                && hit.created_at.is_none()
621                && hit.content.is_empty()
622                && snippet_prefix.is_empty() =>
623            {
624                return false;
625            }
626            _ => {}
627        }
628    }
629
630    view.messages.iter().enumerate().any(|(pos, msg)| {
631        let line_from_idx = (msg.idx >= 0).then_some((msg.idx as usize) + 1);
632        let line_from_pos = pos + 1;
633
634        if let Some(expected_line) = hit.line_number
635            && line_from_idx != Some(expected_line)
636            && line_from_pos != expected_line
637        {
638            return false;
639        }
640
641        if let Some(expected_created_at) = hit.created_at {
642            let created_matches = msg.created_at == Some(expected_created_at)
643                || (msg.created_at.is_none()
644                    && view.convo.started_at == Some(expected_created_at)
645                    && hit
646                        .line_number
647                        .is_some_and(|line| line == line_from_idx.unwrap_or(line_from_pos)));
648            if !created_matches {
649                return false;
650            }
651
652            // A timestamp match is a stronger identity signal than the search-hit payload,
653            // which may be truncated or normalized for display.
654            return true;
655        }
656
657        if !hit.content.is_empty() {
658            return msg.content == hit.content;
659        }
660
661        if !snippet_prefix.is_empty() {
662            return msg.content.contains(snippet_prefix);
663        }
664
665        true
666    })
667}
668
669pub fn load_conversation_for_hit(
670    storage: &FrankenStorage,
671    hit: &SearchHit,
672) -> Result<Option<ConversationView>> {
673    let cache_scope = storage_cache_scope(storage);
674    if let Some(scope) = cache_scope.as_deref()
675        && let Some(cached) = CONVERSATION_CACHE.get_scoped(
676            scope,
677            Some(normalize_ui_hit_source_id(hit).as_str()),
678            &hit.source_path,
679        )
680    {
681        if conversation_view_matches_hit(&cached, hit) {
682            return Ok(Some((*cached).clone()));
683        }
684        let normalized_hit_source_id = normalize_ui_hit_source_id(hit);
685        CONVERSATION_CACHE.invalidate_scoped(
686            scope,
687            Some(normalized_hit_source_id.as_str()),
688            &hit.source_path,
689        );
690    }
691
692    let fallback_hit = if let Some(conversation_id) = hit.conversation_id {
693        if let Some(view) = load_conversation_by_id_uncached(storage, conversation_id)?
694            && conversation_view_matches_hit(&view, hit)
695        {
696            return Ok(Some(view));
697        }
698        let mut fallback_hit = hit.clone();
699        fallback_hit.conversation_id = None;
700        fallback_hit
701    } else {
702        hit.clone()
703    };
704
705    let normalized_source_sql =
706        normalized_ui_source_identity_sql_expr("c.source_id", "c.origin_host");
707    // LEFT JOIN + COALESCE on agents for consistency with the other UI
708    // conversation loaders (NULL agent_id rows must still load).
709    let sql = format!(
710        "SELECT c.id, COALESCE(a.slug, 'unknown'), w.id, w.path, w.display_name, c.external_id, c.title, c.source_path,
711                c.started_at, c.ended_at, c.approx_tokens, c.metadata_json, c.source_id, c.origin_host, c.metadata_bin
712         FROM conversations c
713         LEFT JOIN agents a ON c.agent_id = a.id
714         LEFT JOIN workspaces w ON c.workspace_id = w.id
715         WHERE c.source_path = ?1 AND {normalized_source_sql} = ?2
716         ORDER BY c.started_at DESC"
717    );
718    let rows = storage.raw().query_map_collect(
719        &sql,
720        frankensqlite::params![
721            fallback_hit.source_path.as_str(),
722            normalize_ui_hit_source_id(&fallback_hit)
723        ],
724        ui_conversation_row_parts,
725    )?;
726
727    for (convo_id, convo, workspace) in rows {
728        let messages = storage.fetch_messages(convo_id)?;
729        let view = ConversationView {
730            convo,
731            messages,
732            workspace,
733        };
734        if conversation_view_matches_hit(&view, &fallback_hit) {
735            return Ok(Some(view));
736        }
737    }
738
739    if search_hit_has_identity_hint(&fallback_hit) {
740        Ok(None)
741    } else {
742        load_conversation_uncached(
743            storage,
744            Some(normalize_ui_hit_source_id(&fallback_hit).as_str()),
745            &fallback_hit.source_path,
746        )
747    }
748}
749
750/// Load a conversation with caching, returning Arc for efficiency.
751///
752/// Use this variant when you need to hold the conversation view for
753/// an extended period without cloning.
754pub fn load_conversation_arc(
755    storage: &FrankenStorage,
756    source_path: &str,
757) -> Result<Option<Arc<ConversationView>>> {
758    let cache_scope = storage_cache_scope(storage);
759
760    // Fast path: check cache first
761    if let Some(scope) = cache_scope.as_deref()
762        && let Some(cached) = CONVERSATION_CACHE.get_scoped(scope, None, source_path)
763    {
764        match cached_conversation_matches_lookup_head(storage, None, source_path, &cached) {
765            Ok(true) => {
766                return Ok(Some(cached));
767            }
768            Ok(false) => {
769                CONVERSATION_CACHE.invalidate_scoped(scope, None, source_path);
770            }
771            Err(_) => {
772                return Ok(Some(cached));
773            }
774        }
775    }
776
777    // Cache miss: load from database
778    let view = load_conversation_uncached(storage, None, source_path)?;
779
780    // Cache and return the Arc
781    if let Some(v) = view {
782        if let Some(scope) = cache_scope.as_deref() {
783            let arc = CONVERSATION_CACHE.insert_scoped(scope, None, source_path, v);
784            return Ok(Some(arc));
785        }
786        return Ok(Some(Arc::new(v)));
787    }
788
789    Ok(None)
790}
791
792/// Log conversation cache statistics.
793///
794/// Outputs cache stats at debug level via tracing.
795pub fn log_conversation_cache_stats() {
796    let (hits, misses, evictions) = CONVERSATION_CACHE.stats().get();
797    let hit_rate = CONVERSATION_CACHE.stats().hit_rate();
798    let count = CONVERSATION_CACHE.len();
799
800    tracing::debug!(
801        target: "cass::perf::conversation_cache",
802        hits = hits,
803        misses = misses,
804        evictions = evictions,
805        hit_rate = format!("{:.1}%", hit_rate * 100.0),
806        cached_count = count,
807        "Conversation cache statistics"
808    );
809}
810
811pub fn role_style(role: &MessageRole, palette: ThemePalette) -> ftui::Style {
812    match role {
813        MessageRole::User => ftui::Style::new().fg(palette.user),
814        MessageRole::Agent => ftui::Style::new().fg(palette.agent),
815        MessageRole::Tool => ftui::Style::new().fg(palette.tool),
816        MessageRole::System => ftui::Style::new().fg(palette.system),
817        MessageRole::Other(_) => ftui::Style::new().fg(palette.hint),
818    }
819}
820
821// -------------------------------------------------------------------------
822// Shared TUI types (moved from tui.rs to remove ratatui dependency)
823// -------------------------------------------------------------------------
824
825/// How search results are ranked and ordered.
826#[derive(Clone, Copy, Debug, PartialEq, Eq)]
827pub enum RankingMode {
828    RecentHeavy,
829    Balanced,
830    RelevanceHeavy,
831    MatchQualityHeavy,
832    DateNewest,
833    DateOldest,
834}
835
836/// Format a timestamp as a short human-readable date for filter chips.
837/// Shows "Nov 25" for same year, "Nov 25, 2023" for other years.
838pub fn format_time_short(ms: i64) -> String {
839    use chrono::{DateTime, Datelike, Utc};
840    let now = Utc::now();
841    DateTime::<Utc>::from_timestamp_millis(ms)
842        .map(|dt| {
843            if dt.year() == now.year() {
844                dt.format("%b %d").to_string()
845            } else {
846                dt.format("%b %d, %Y").to_string()
847            }
848        })
849        .unwrap_or_else(|| "?".to_string())
850}
851
852// =========================================================================
853// Explainability Cockpit — Information Architecture (1mfw3.3.1)
854// =========================================================================
855//
856// The cockpit is an inspector-mode overlay that surfaces causal explanations
857// for adaptive runtime decisions: diff strategy, resize coalescing, frame
858// budget/degradation, and a correlating timeline of decision events.
859//
860// Panel taxonomy:
861//   1. DiffStrategy   — Why the last frame used full vs partial redraw.
862//   2. ResizeRegime   — BOCPD regime classification and coalescer decisions.
863//   3. BudgetHealth   — Frame budget vs actual, degradation level, PID state.
864//   4. Timeline       — Chronological feed of major decision events.
865//
866// Each panel has a data contract struct defining required fields, source of
867// truth, and empty/error-state policies.
868
869/// Panel taxonomy for the explainability cockpit.
870///
871/// Each variant represents one cockpit surface. Panels are rendered as
872/// stacked sections inside the inspector overlay when the cockpit mode is
873/// active. The inspector can be in either classic (Timing/Layout/HitRegions)
874/// or cockpit mode, toggled independently.
875#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
876pub enum CockpitPanel {
877    #[default]
878    /// Frame diff strategy decisions: full vs partial redraw, dirty-row counts.
879    DiffStrategy,
880    /// Resize coalescer regime: Steady vs Burst, BOCPD probability, recent history.
881    ResizeRegime,
882    /// Frame budget health: target vs actual, degradation level, PID controller state.
883    BudgetHealth,
884    /// Chronological timeline of major decision events across all subsystems.
885    Timeline,
886}
887
888impl CockpitPanel {
889    pub fn label(self) -> &'static str {
890        match self {
891            Self::DiffStrategy => "Diff",
892            Self::ResizeRegime => "Resize",
893            Self::BudgetHealth => "Budget",
894            Self::Timeline => "Timeline",
895        }
896    }
897
898    pub fn next(self) -> Self {
899        match self {
900            Self::DiffStrategy => Self::ResizeRegime,
901            Self::ResizeRegime => Self::BudgetHealth,
902            Self::BudgetHealth => Self::Timeline,
903            Self::Timeline => Self::DiffStrategy,
904        }
905    }
906
907    pub fn prev(self) -> Self {
908        match self {
909            Self::DiffStrategy => Self::Timeline,
910            Self::ResizeRegime => Self::DiffStrategy,
911            Self::BudgetHealth => Self::ResizeRegime,
912            Self::Timeline => Self::BudgetHealth,
913        }
914    }
915
916    /// All panels in display order.
917    pub const ALL: [CockpitPanel; 4] = [
918        Self::DiffStrategy,
919        Self::ResizeRegime,
920        Self::BudgetHealth,
921        Self::Timeline,
922    ];
923}
924
925/// Empty/error-state display policy for cockpit panels.
926///
927/// When telemetry data is missing (cold start, no resize events, etc.),
928/// panels should never crash or show garbled output. Each field specifies
929/// the placeholder text to render when the corresponding data is absent.
930#[derive(Clone, Debug)]
931pub struct CockpitEmptyPolicy {
932    /// Placeholder when no evidence is available at all.
933    pub no_data: &'static str,
934    /// Placeholder when the subsystem hasn't fired yet (e.g., no resize events).
935    pub awaiting: &'static str,
936    /// Placeholder when the feature is disabled in config.
937    pub disabled: &'static str,
938}
939
940impl Default for CockpitEmptyPolicy {
941    fn default() -> Self {
942        Self {
943            no_data: "\u{2014}", // em dash
944            awaiting: "awaiting first event\u{2026}",
945            disabled: "(disabled)",
946        }
947    }
948}
949
950/// Data contract for the Diff Strategy cockpit panel.
951///
952/// Source of truth: `ftui::runtime::evidence_telemetry::diff_snapshot()`
953///
954/// Answers: "Why did the last frame use full vs partial redraw?"
955#[derive(Clone, Debug, Default)]
956pub struct DiffStrategyContract {
957    /// Whether the last frame was a full redraw.
958    pub last_was_full_redraw: bool,
959    /// Number of dirty rows detected in the last partial redraw.
960    pub dirty_row_count: u32,
961    /// Total row count for the frame (dirty_row_count / total = dirty ratio).
962    pub total_row_count: u32,
963    /// Reason for the diff decision (human-readable).
964    pub reason: &'static str,
965    /// Number of consecutive full redraws.
966    pub consecutive_full_redraws: u32,
967    /// Cumulative full-redraw ratio (full / total frames observed).
968    pub full_redraw_ratio: f64,
969}
970
971impl DiffStrategyContract {
972    /// Dirty row ratio (0.0..1.0). Returns 0.0 if total_row_count is zero.
973    pub fn dirty_ratio(&self) -> f64 {
974        if self.total_row_count == 0 {
975            0.0
976        } else {
977            self.dirty_row_count as f64 / self.total_row_count as f64
978        }
979    }
980
981    /// Whether meaningful data has been captured.
982    pub fn has_data(&self) -> bool {
983        self.total_row_count > 0
984    }
985}
986
987/// Data contract for the Resize Regime cockpit panel.
988///
989/// Source of truth: `ftui::runtime::evidence_telemetry::resize_snapshot()`
990/// and `ResizeEvidenceSummary::recent_resizes` ring buffer.
991///
992/// Answers: "What resize regime are we in and why?"
993#[derive(Clone, Debug)]
994pub struct ResizeRegimeContract {
995    /// Current regime label ("Steady", "Burst", or em-dash).
996    pub regime: &'static str,
997    /// Current terminal size (cols, rows).
998    pub terminal_size: Option<(u16, u16)>,
999    /// BOCPD burst probability (0.0..1.0), None if BOCPD disabled.
1000    pub bocpd_p_burst: Option<f64>,
1001    /// BOCPD recommended coalescer delay (ms), None if not applicable.
1002    pub bocpd_delay_ms: Option<u32>,
1003    /// Number of resize events in history buffer.
1004    pub history_len: usize,
1005    /// Most recent resize action ("apply", "defer", "coalesce").
1006    pub last_action: &'static str,
1007    /// Inter-arrival time of the most recent resize event (ms).
1008    pub last_dt_ms: f64,
1009    /// Events per second at the last decision.
1010    pub last_event_rate: f64,
1011}
1012
1013impl Default for ResizeRegimeContract {
1014    fn default() -> Self {
1015        Self {
1016            regime: "\u{2014}",
1017            terminal_size: None,
1018            bocpd_p_burst: None,
1019            bocpd_delay_ms: None,
1020            history_len: 0,
1021            last_action: "\u{2014}",
1022            last_dt_ms: 0.0,
1023            last_event_rate: 0.0,
1024        }
1025    }
1026}
1027
1028impl ResizeRegimeContract {
1029    /// Whether meaningful data has been captured.
1030    pub fn has_data(&self) -> bool {
1031        self.regime != "\u{2014}"
1032    }
1033}
1034
1035/// Data contract for the Budget Health cockpit panel.
1036///
1037/// Source of truth: `ftui::runtime::evidence_telemetry::budget_snapshot()`
1038/// and `ResizeEvidenceSummary` budget-related fields.
1039///
1040/// Answers: "Is the frame budget healthy? What degradation is active?"
1041#[derive(Clone, Debug)]
1042pub struct BudgetHealthContract {
1043    /// Current degradation level label.
1044    pub degradation: &'static str,
1045    /// Target frame budget (microseconds).
1046    pub budget_us: f64,
1047    /// Actual frame time (microseconds).
1048    pub frame_time_us: f64,
1049    /// PID controller output (positive = headroom, negative = over-budget).
1050    pub pid_output: f64,
1051    /// Whether the budget controller is still in warmup.
1052    pub in_warmup: bool,
1053    /// Total frames observed by the budget controller.
1054    pub frames_observed: u32,
1055    /// Budget pressure ratio: frame_time / budget (>1.0 means over-budget).
1056    pub pressure: f64,
1057}
1058
1059impl Default for BudgetHealthContract {
1060    fn default() -> Self {
1061        Self {
1062            degradation: "\u{2014}",
1063            budget_us: 0.0,
1064            frame_time_us: 0.0,
1065            pid_output: 0.0,
1066            in_warmup: true,
1067            frames_observed: 0,
1068            pressure: 0.0,
1069        }
1070    }
1071}
1072
1073impl BudgetHealthContract {
1074    /// Whether meaningful data has been captured.
1075    pub fn has_data(&self) -> bool {
1076        self.frames_observed > 0
1077    }
1078
1079    /// Whether the frame budget is currently exceeded.
1080    pub fn is_over_budget(&self) -> bool {
1081        self.pressure > 1.0
1082    }
1083}
1084
1085/// A single event in the cockpit timeline feed.
1086///
1087/// Timeline events correlate decision points across subsystems,
1088/// enabling causal diagnosis ("the resize burst caused degradation
1089/// to drop to SimpleBorders").
1090#[derive(Clone, Debug)]
1091pub struct CockpitTimelineEvent {
1092    /// Subsystem that generated the event.
1093    pub source: CockpitPanel,
1094    /// Human-readable one-line summary of what happened.
1095    pub summary: String,
1096    /// Monotonic event index for ordering.
1097    pub event_idx: u64,
1098    /// Elapsed time since app start (seconds).
1099    pub elapsed_secs: f64,
1100    /// Severity/importance of the event.
1101    pub severity: TimelineEventSeverity,
1102}
1103
1104/// Severity levels for cockpit timeline events.
1105#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1106pub enum TimelineEventSeverity {
1107    /// Routine decision (e.g., normal resize apply).
1108    #[default]
1109    Info,
1110    /// Notable state change (e.g., regime transition Steady -> Burst).
1111    StateChange,
1112    /// Degradation or pressure event (e.g., over-budget, degradation level change).
1113    Warning,
1114}
1115
1116/// Data contract for the Timeline cockpit panel.
1117///
1118/// Source of truth: aggregated from all other cockpit contracts.
1119/// Events are pushed by `EvidenceSnapshots::refresh()` when it detects
1120/// state transitions.
1121///
1122/// Answers: "What changed, when, and across which subsystem?"
1123#[derive(Clone, Debug)]
1124pub struct TimelineContract {
1125    /// Ring buffer of recent timeline events (newest last).
1126    pub events: std::collections::VecDeque<CockpitTimelineEvent>,
1127    /// Maximum events to retain.
1128    pub capacity: usize,
1129}
1130
1131/// Default timeline capacity.
1132const TIMELINE_CAPACITY: usize = 64;
1133
1134impl Default for TimelineContract {
1135    fn default() -> Self {
1136        Self::new()
1137    }
1138}
1139
1140impl TimelineContract {
1141    pub fn new() -> Self {
1142        Self {
1143            events: std::collections::VecDeque::with_capacity(TIMELINE_CAPACITY),
1144            capacity: TIMELINE_CAPACITY,
1145        }
1146    }
1147
1148    /// Push a new event, evicting the oldest if at capacity.
1149    pub fn push(&mut self, event: CockpitTimelineEvent) {
1150        if self.events.len() >= self.capacity {
1151            self.events.pop_front();
1152        }
1153        self.events.push_back(event);
1154    }
1155
1156    /// Whether any events have been recorded.
1157    pub fn has_data(&self) -> bool {
1158        !self.events.is_empty()
1159    }
1160
1161    /// Number of events in the buffer.
1162    pub fn len(&self) -> usize {
1163        self.events.len()
1164    }
1165
1166    /// Whether the buffer is empty.
1167    pub fn is_empty(&self) -> bool {
1168        self.events.is_empty()
1169    }
1170}
1171
1172/// Cockpit display mode controlling overlay sizing behaviour.
1173///
1174/// **Overlay** is a compact bottom-right panel (default).
1175/// **Expanded** is a taller panel that occupies more vertical space,
1176/// allowing multi-panel stacking and more timeline events.
1177#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1178pub enum CockpitMode {
1179    /// Compact overlay anchored to bottom-right corner.
1180    #[default]
1181    Overlay,
1182    /// Expanded surface that takes more vertical space for deeper inspection.
1183    Expanded,
1184}
1185
1186impl CockpitMode {
1187    /// Cycle to the next mode.
1188    pub fn cycle(self) -> Self {
1189        match self {
1190            Self::Overlay => Self::Expanded,
1191            Self::Expanded => Self::Overlay,
1192        }
1193    }
1194
1195    /// Short label for status display.
1196    pub fn label(self) -> &'static str {
1197        match self {
1198            Self::Overlay => "overlay",
1199            Self::Expanded => "expanded",
1200        }
1201    }
1202}
1203
1204/// Aggregated cockpit state holding all panel contracts.
1205///
1206/// This struct is the single rendering-ready data source for the
1207/// cockpit overlay. It is updated each tick by polling evidence
1208/// telemetry and detecting state transitions for timeline events.
1209#[derive(Clone, Debug, Default)]
1210pub struct CockpitState {
1211    /// Active cockpit panel (for single-panel focus mode).
1212    pub active_panel: CockpitPanel,
1213    /// Whether cockpit mode is active (vs classic inspector tabs).
1214    pub enabled: bool,
1215    /// Display mode (overlay vs expanded).
1216    pub mode: CockpitMode,
1217    /// Diff strategy contract.
1218    pub diff: DiffStrategyContract,
1219    /// Resize regime contract.
1220    pub resize: ResizeRegimeContract,
1221    /// Budget health contract.
1222    pub budget: BudgetHealthContract,
1223    /// Timeline event feed.
1224    pub timeline: TimelineContract,
1225    /// Empty-state display policy.
1226    pub empty_policy: CockpitEmptyPolicy,
1227}
1228
1229impl CockpitState {
1230    pub fn new() -> Self {
1231        Self {
1232            timeline: TimelineContract::new(),
1233            ..Default::default()
1234        }
1235    }
1236
1237    /// Whether any panel has meaningful data to display.
1238    pub fn has_any_data(&self) -> bool {
1239        self.diff.has_data()
1240            || self.resize.has_data()
1241            || self.budget.has_data()
1242            || self.timeline.has_data()
1243    }
1244
1245    /// Get the empty-state message for a panel.
1246    pub fn empty_message(&self, panel: CockpitPanel) -> &'static str {
1247        match panel {
1248            CockpitPanel::DiffStrategy => {
1249                if self.diff.has_data() {
1250                    ""
1251                } else {
1252                    self.empty_policy.awaiting
1253                }
1254            }
1255            CockpitPanel::ResizeRegime => {
1256                if self.resize.has_data() {
1257                    ""
1258                } else {
1259                    self.empty_policy.awaiting
1260                }
1261            }
1262            CockpitPanel::BudgetHealth => {
1263                if self.budget.has_data() {
1264                    ""
1265                } else {
1266                    self.empty_policy.awaiting
1267                }
1268            }
1269            CockpitPanel::Timeline => {
1270                if self.timeline.has_data() {
1271                    ""
1272                } else {
1273                    self.empty_policy.no_data
1274                }
1275            }
1276        }
1277    }
1278}
1279
1280// -------------------------------------------------------------------------
1281// Unit Tests
1282// -------------------------------------------------------------------------
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287    use crate::search::query::MatchType;
1288    use std::path::PathBuf;
1289    use tempfile::tempdir;
1290
1291    fn make_test_view(id: i64) -> ConversationView {
1292        ConversationView {
1293            convo: Conversation {
1294                id: Some(id),
1295                agent_slug: "claude".to_string(),
1296                workspace: Some(PathBuf::from("/test/workspace")),
1297                external_id: Some(format!("ext-{}", id)),
1298                title: Some(format!("Test Conversation {}", id)),
1299                source_path: PathBuf::from(format!("/test/path/{}.jsonl", id)),
1300                started_at: Some(1704067200 + id),
1301                ended_at: None,
1302                approx_tokens: Some(1000),
1303                metadata_json: serde_json::json!({"test": true}),
1304                messages: Vec::new(),
1305                source_id: "local".to_string(),
1306                origin_host: None,
1307            },
1308            messages: vec![Message {
1309                id: Some(1),
1310                idx: 0,
1311                role: MessageRole::User,
1312                author: None,
1313                created_at: Some(1704067200),
1314                content: "Test message".to_string(),
1315                extra_json: serde_json::json!({}),
1316                snippets: Vec::new(),
1317            }],
1318            workspace: Some(Workspace {
1319                id: Some(1),
1320                path: PathBuf::from("/test/workspace"),
1321                display_name: None,
1322            }),
1323        }
1324    }
1325
1326    #[test]
1327    fn test_cache_insert_and_get() {
1328        let cache = ConversationCache::new(10);
1329        let view = make_test_view(1);
1330        let source_path = "/test/path/1.jsonl";
1331
1332        // Insert into cache
1333        let arc = cache.insert(None, source_path, view.clone());
1334        assert_eq!(arc.convo.id, Some(1));
1335
1336        // Get from cache
1337        let cached = cache.get(None, source_path);
1338        assert!(cached.is_some());
1339        assert_eq!(cached.unwrap().convo.id, Some(1));
1340
1341        // Check stats
1342        let (hits, misses, _) = cache.stats().get();
1343        assert_eq!(hits, 1);
1344        assert_eq!(misses, 0);
1345    }
1346
1347    #[test]
1348    fn test_cache_distinguishes_same_path_across_sources() {
1349        let cache = ConversationCache::new(10);
1350        let source_path = "/test/shared/session.jsonl";
1351
1352        let mut local = make_test_view(1);
1353        local.convo.source_path = PathBuf::from(source_path);
1354        local.convo.source_id = "local".to_string();
1355        let mut remote = make_test_view(2);
1356        remote.convo.source_path = PathBuf::from(source_path);
1357        remote.convo.source_id = "work-laptop".to_string();
1358
1359        cache.insert(Some("local"), source_path, local);
1360        cache.insert(Some("work-laptop"), source_path, remote);
1361
1362        let local_cached = cache.get(Some("local"), source_path).expect("local cached");
1363        let remote_cached = cache
1364            .get(Some("work-laptop"), source_path)
1365            .expect("remote cached");
1366
1367        assert_eq!(local_cached.convo.source_id, "local");
1368        assert_eq!(remote_cached.convo.source_id, "work-laptop");
1369        assert_ne!(local_cached.convo.id, remote_cached.convo.id);
1370    }
1371
1372    #[test]
1373    fn load_conversation_cache_is_scoped_by_database_path() {
1374        use crate::storage::sqlite::FrankenStorage;
1375
1376        let shared_path = "/shared/cross-db-session.jsonl";
1377        let tmp_a = tempfile::TempDir::new().expect("tempdir a");
1378        let db_path_a = tmp_a.path().join("cass-a.db");
1379        let storage_a = FrankenStorage::open(&db_path_a).expect("open storage a");
1380        let conn_a = storage_a.raw();
1381        let scope_a =
1382            storage_cache_scope(&storage_a).unwrap_or_else(|| db_path_a.display().to_string());
1383
1384        let tmp_b = tempfile::TempDir::new().expect("tempdir b");
1385        let db_path_b = tmp_b.path().join("cass-b.db");
1386        let storage_b = FrankenStorage::open(&db_path_b).expect("open storage b");
1387        let conn_b = storage_b.raw();
1388        let scope_b =
1389            storage_cache_scope(&storage_b).unwrap_or_else(|| db_path_b.display().to_string());
1390
1391        CONVERSATION_CACHE.invalidate_scoped(&scope_a, None, shared_path);
1392        CONVERSATION_CACHE.invalidate_scoped(&scope_b, None, shared_path);
1393
1394        for conn in [&conn_a, &conn_b] {
1395            conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1396                .expect("insert agent");
1397        }
1398
1399        {
1400            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1401            let p = [ParamValue::from(shared_path.to_string())];
1402            conn_a.execute_with_params(
1403                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'db-a', 'DB A Session', ?1, 'local', 100)",
1404                &param_slice_to_values(&p),
1405            )
1406            .expect("insert db a conversation");
1407            conn_b.execute_with_params(
1408                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'db-b', 'DB B Session', ?1, 'local', 100)",
1409                &param_slice_to_values(&p),
1410            )
1411            .expect("insert db b conversation");
1412        }
1413        conn_a.execute(
1414            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'db a body')",
1415        )
1416        .expect("insert db a message");
1417        conn_b.execute(
1418            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'db b body')",
1419        )
1420        .expect("insert db b message");
1421
1422        let from_a = load_conversation(&storage_a, shared_path)
1423            .expect("load from db a")
1424            .expect("db a conversation present");
1425        assert_eq!(from_a.convo.title.as_deref(), Some("DB A Session"));
1426        assert_eq!(from_a.messages[0].content, "db a body");
1427
1428        let from_b = load_conversation(&storage_b, shared_path)
1429            .expect("load from db b")
1430            .expect("db b conversation present");
1431        assert_eq!(from_b.convo.title.as_deref(), Some("DB B Session"));
1432        assert_eq!(from_b.messages[0].content, "db b body");
1433
1434        CONVERSATION_CACHE.invalidate_scoped(&scope_a, None, shared_path);
1435        CONVERSATION_CACHE.invalidate_scoped(&scope_b, None, shared_path);
1436    }
1437
1438    #[test]
1439    fn load_conversation_for_source_selects_blank_remote_source_id_via_origin_host() {
1440        use crate::storage::sqlite::FrankenStorage;
1441
1442        let tmp = tempfile::TempDir::new().expect("tempdir");
1443        let db_path = tmp.path().join("cass.db");
1444        let storage = FrankenStorage::open(&db_path).expect("open storage");
1445        let conn = storage.raw();
1446        let shared_path = "/shared/session.jsonl";
1447
1448        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1449            .expect("insert agent");
1450        conn.execute(
1451            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('   ', 'ssh', 'user@laptop', 0, 0)",
1452        )
1453        .expect("insert blank-id remote source");
1454        {
1455            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1456            let p = [ParamValue::from(shared_path.to_string())];
1457            conn.execute_with_params(
1458                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, origin_host, started_at) VALUES (1, 1, 'remote-ext', 'Remote Session', ?1, '   ', 'user@laptop', 200)",
1459                &param_slice_to_values(&p),
1460            )
1461            .expect("insert remote conversation");
1462        }
1463        conn.execute(
1464            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'remote body')",
1465        )
1466        .expect("insert remote message");
1467
1468        let loaded = load_conversation_for_source(&storage, "user@laptop", shared_path)
1469            .expect("load conversation")
1470            .expect("conversation present");
1471
1472        assert_eq!(loaded.convo.source_id, "user@laptop");
1473        assert_eq!(loaded.convo.origin_host.as_deref(), Some("user@laptop"));
1474        assert_eq!(loaded.convo.title.as_deref(), Some("Remote Session"));
1475        assert_eq!(loaded.messages[0].content, "remote body");
1476    }
1477
1478    #[test]
1479    fn load_conversation_for_source_selects_exact_source_id() {
1480        use crate::storage::sqlite::FrankenStorage;
1481
1482        let tmp = tempfile::TempDir::new().expect("tempdir");
1483        let db_path = tmp.path().join("cass.db");
1484        let storage = FrankenStorage::open(&db_path).expect("open storage");
1485        let conn = storage.raw();
1486        let shared_path = "/shared/session.jsonl";
1487
1488        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1489            .expect("insert agent");
1490        conn.execute(
1491            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('  local  ', 'local', 'local', 0, 0)",
1492        )
1493        .expect("insert local source");
1494        conn.execute(
1495            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('work-laptop', 'ssh', 'work-laptop', 0, 0)",
1496        )
1497        .expect("insert source");
1498        {
1499            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1500            let p = [ParamValue::from(shared_path.to_string())];
1501            conn.execute_with_params(
1502                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Local Session', ?1, '  local  ', 200)",
1503                &param_slice_to_values(&p),
1504            )
1505            .expect("insert local conversation");
1506            conn.execute_with_params(
1507                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'remote-ext', 'Remote Session', ?1, 'work-laptop', 100)",
1508                &param_slice_to_values(&p),
1509            )
1510            .expect("insert remote conversation");
1511        }
1512        conn.execute(
1513            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'local body')",
1514        )
1515        .expect("insert local message");
1516        conn.execute(
1517            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'remote body')",
1518        )
1519        .expect("insert remote message");
1520
1521        let local = load_conversation_for_source(&storage, "local", shared_path)
1522            .expect("load local")
1523            .expect("local conversation");
1524        let remote = load_conversation_for_source(&storage, "work-laptop", shared_path)
1525            .expect("load remote")
1526            .expect("remote conversation");
1527
1528        assert_eq!(local.convo.source_id, "local");
1529        assert_eq!(local.convo.title.as_deref(), Some("Local Session"));
1530        assert_eq!(local.messages[0].content, "local body");
1531
1532        assert_eq!(remote.convo.source_id, "work-laptop");
1533        assert_eq!(remote.convo.title.as_deref(), Some("Remote Session"));
1534        assert_eq!(remote.messages[0].content, "remote body");
1535    }
1536
1537    #[test]
1538    fn load_conversation_for_source_invalidates_cache_when_newer_conversation_arrives() {
1539        use crate::storage::sqlite::FrankenStorage;
1540
1541        let tmp = tempfile::TempDir::new().expect("tempdir");
1542        let db_path = tmp.path().join("cass.db");
1543        let storage = FrankenStorage::open(&db_path).expect("open storage");
1544        let conn = storage.raw();
1545        let shared_path = "/shared/source-specific-session.jsonl";
1546
1547        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1548
1549        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1550            .expect("insert agent");
1551        {
1552            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1553            let p = [ParamValue::from(shared_path.to_string())];
1554            conn.execute_with_params(
1555                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', ?1, 'local', 100)",
1556                &param_slice_to_values(&p),
1557            )
1558            .expect("insert old conversation");
1559        }
1560        conn.execute(
1561            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'old body')",
1562        )
1563        .expect("insert old message");
1564
1565        let first = load_conversation_for_source(&storage, "local", shared_path)
1566            .expect("load old conversation")
1567            .expect("old conversation present");
1568        assert_eq!(first.convo.title.as_deref(), Some("Old Session"));
1569        assert_eq!(first.messages[0].content, "old body");
1570
1571        {
1572            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1573            let p = [ParamValue::from(shared_path.to_string())];
1574            conn.execute_with_params(
1575                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', ?1, 'local', 200)",
1576                &param_slice_to_values(&p),
1577            )
1578            .expect("insert new conversation");
1579        }
1580        conn.execute(
1581            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'new body')",
1582        )
1583        .expect("insert new message");
1584
1585        let second = load_conversation_for_source(&storage, "local", shared_path)
1586            .expect("load new conversation")
1587            .expect("new conversation present");
1588
1589        assert_eq!(second.convo.title.as_deref(), Some("New Session"));
1590        assert_eq!(second.messages[0].content, "new body");
1591
1592        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1593    }
1594
1595    #[test]
1596    fn load_conversation_prefers_local_source_for_shared_path() {
1597        use crate::storage::sqlite::FrankenStorage;
1598
1599        let tmp = tempfile::TempDir::new().expect("tempdir");
1600        let db_path = tmp.path().join("cass.db");
1601        let storage = FrankenStorage::open(&db_path).expect("open storage");
1602        let conn = storage.raw();
1603        let shared_path = "/shared/session.jsonl";
1604
1605        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1606            .expect("insert agent");
1607        conn.execute(
1608            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('  local  ', 'local', 'local', 0, 0)",
1609        )
1610        .expect("insert local source");
1611        conn.execute(
1612            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('work-laptop', 'ssh', 'work-laptop', 0, 0)",
1613        )
1614        .expect("insert source");
1615        {
1616            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1617            let p = [ParamValue::from(shared_path.to_string())];
1618            conn.execute_with_params(
1619                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Local Session', ?1, '  local  ', 100)",
1620                &param_slice_to_values(&p),
1621            )
1622            .expect("insert local conversation");
1623            conn.execute_with_params(
1624                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'remote-ext', 'Remote Session', ?1, 'work-laptop', 200)",
1625                &param_slice_to_values(&p),
1626            )
1627            .expect("insert remote conversation");
1628        }
1629        conn.execute(
1630            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'local body')",
1631        )
1632        .expect("insert local message");
1633        conn.execute(
1634            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'remote body')",
1635        )
1636        .expect("insert remote message");
1637
1638        let loaded = load_conversation(&storage, shared_path)
1639            .expect("load conversation")
1640            .expect("conversation present");
1641
1642        assert_eq!(loaded.convo.source_id, "local");
1643        assert_eq!(loaded.convo.title.as_deref(), Some("Local Session"));
1644        assert_eq!(loaded.messages[0].content, "local body");
1645    }
1646
1647    #[test]
1648    fn load_conversation_uses_cached_value_when_validation_query_fails() {
1649        use crate::storage::sqlite::FrankenStorage;
1650
1651        let tmp = tempfile::TempDir::new().expect("tempdir");
1652        let db_path = tmp.path().join("cass.db");
1653        let storage = FrankenStorage::open(&db_path).expect("open storage");
1654        let conn = storage.raw();
1655        let shared_path = "/shared/cached-when-db-breaks.jsonl";
1656
1657        CONVERSATION_CACHE.invalidate(None, shared_path);
1658        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1659
1660        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1661            .expect("insert agent");
1662        {
1663            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1664            let p = [ParamValue::from(shared_path.to_string())];
1665            conn.execute_with_params(
1666                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Cached Session', ?1, 'local', 100)",
1667                &param_slice_to_values(&p),
1668            )
1669            .expect("insert local conversation");
1670        }
1671        conn.execute(
1672            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'cached body')",
1673        )
1674        .expect("insert local message");
1675
1676        let cached = load_conversation(&storage, shared_path)
1677            .expect("load initial conversation")
1678            .expect("conversation present");
1679        assert_eq!(cached.convo.title.as_deref(), Some("Cached Session"));
1680        assert_eq!(cached.messages[0].content, "cached body");
1681
1682        conn.execute("DROP TABLE conversations")
1683            .expect("drop conversations to force validation failure");
1684
1685        let still_cached = load_conversation(&storage, shared_path)
1686            .expect("use cached conversation after validation failure")
1687            .expect("cached conversation still present");
1688
1689        assert_eq!(still_cached.convo.title.as_deref(), Some("Cached Session"));
1690        assert_eq!(still_cached.messages[0].content, "cached body");
1691
1692        CONVERSATION_CACHE.invalidate(None, shared_path);
1693        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1694    }
1695
1696    #[test]
1697    fn conversation_view_matches_hit_normalizes_blank_remote_source_id_via_origin_host() {
1698        let view = ConversationView {
1699            convo: Conversation {
1700                id: Some(1),
1701                agent_slug: "claude_code".to_string(),
1702                workspace: None,
1703                external_id: Some("ext-1".to_string()),
1704                title: Some("Session".to_string()),
1705                source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1706                started_at: Some(100),
1707                ended_at: None,
1708                approx_tokens: None,
1709                metadata_json: serde_json::Value::Null,
1710                messages: Vec::new(),
1711                source_id: "user@laptop".to_string(),
1712                origin_host: Some("user@laptop".to_string()),
1713            },
1714            messages: vec![Message {
1715                id: Some(1),
1716                idx: 0,
1717                role: MessageRole::User,
1718                author: None,
1719                created_at: Some(101),
1720                content: "body".to_string(),
1721                extra_json: serde_json::Value::Null,
1722                snippets: Vec::new(),
1723            }],
1724            workspace: None,
1725        };
1726
1727        let hit = SearchHit {
1728            title: "Session".to_string(),
1729            snippet: String::new(),
1730            content: String::new(),
1731            content_hash: 0,
1732            score: 0.0,
1733            conversation_id: None,
1734            source_path: "/shared/session.jsonl".to_string(),
1735            agent: "claude_code".to_string(),
1736            workspace: String::new(),
1737            workspace_original: None,
1738            created_at: None,
1739            line_number: None,
1740            match_type: Default::default(),
1741            source_id: "   ".to_string(),
1742            origin_kind: "remote".to_string(),
1743            origin_host: Some("user@laptop".to_string()),
1744        };
1745
1746        assert!(conversation_view_matches_hit(&view, &hit));
1747    }
1748
1749    #[test]
1750    fn conversation_view_matches_hit_normalizes_local_source_id_variants() {
1751        let view = ConversationView {
1752            convo: Conversation {
1753                id: Some(1),
1754                agent_slug: "claude_code".to_string(),
1755                workspace: None,
1756                external_id: Some("ext-1".to_string()),
1757                title: Some("Session".to_string()),
1758                source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1759                started_at: Some(100),
1760                ended_at: None,
1761                approx_tokens: None,
1762                metadata_json: serde_json::Value::Null,
1763                messages: Vec::new(),
1764                source_id: "local".to_string(),
1765                origin_host: None,
1766            },
1767            messages: vec![Message {
1768                id: Some(1),
1769                idx: 0,
1770                role: MessageRole::User,
1771                author: None,
1772                created_at: Some(101),
1773                content: "body".to_string(),
1774                extra_json: serde_json::Value::Null,
1775                snippets: Vec::new(),
1776            }],
1777            workspace: None,
1778        };
1779
1780        let hit = SearchHit {
1781            title: "Session".to_string(),
1782            snippet: String::new(),
1783            content: String::new(),
1784            content_hash: 0,
1785            score: 0.0,
1786            conversation_id: None,
1787            source_path: "/shared/session.jsonl".to_string(),
1788            agent: "claude_code".to_string(),
1789            workspace: String::new(),
1790            workspace_original: None,
1791            created_at: None,
1792            line_number: None,
1793            match_type: Default::default(),
1794            source_id: "  LOCAL  ".to_string(),
1795            origin_kind: "local".to_string(),
1796            origin_host: None,
1797        };
1798
1799        assert!(conversation_view_matches_hit(&view, &hit));
1800    }
1801
1802    #[test]
1803    fn conversation_view_matches_hit_falls_back_when_stale_conversation_id_has_other_hints() {
1804        let view = ConversationView {
1805            convo: Conversation {
1806                id: Some(1),
1807                agent_slug: "claude_code".to_string(),
1808                workspace: None,
1809                external_id: Some("ext-1".to_string()),
1810                title: Some("Session".to_string()),
1811                source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1812                started_at: Some(100),
1813                ended_at: None,
1814                approx_tokens: None,
1815                metadata_json: serde_json::Value::Null,
1816                messages: Vec::new(),
1817                source_id: "local".to_string(),
1818                origin_host: None,
1819            },
1820            messages: vec![Message {
1821                id: Some(1),
1822                idx: 0,
1823                role: MessageRole::User,
1824                author: None,
1825                created_at: Some(101),
1826                content: "body".to_string(),
1827                extra_json: serde_json::Value::Null,
1828                snippets: Vec::new(),
1829            }],
1830            workspace: None,
1831        };
1832
1833        let hit = SearchHit {
1834            title: "Session".to_string(),
1835            snippet: String::new(),
1836            content: "body".to_string(),
1837            content_hash: 0,
1838            score: 0.0,
1839            conversation_id: Some(999),
1840            source_path: "/shared/session.jsonl".to_string(),
1841            agent: "claude_code".to_string(),
1842            workspace: String::new(),
1843            workspace_original: None,
1844            created_at: Some(101),
1845            line_number: Some(1),
1846            match_type: Default::default(),
1847            source_id: "local".to_string(),
1848            origin_kind: "local".to_string(),
1849            origin_host: None,
1850        };
1851
1852        assert!(conversation_view_matches_hit(&view, &hit));
1853    }
1854
1855    #[test]
1856    fn conversation_view_matches_hit_rejects_stale_conversation_id_without_other_hints() {
1857        let view = ConversationView {
1858            convo: Conversation {
1859                id: Some(1),
1860                agent_slug: "claude_code".to_string(),
1861                workspace: None,
1862                external_id: Some("ext-1".to_string()),
1863                title: Some("Session".to_string()),
1864                source_path: std::path::PathBuf::from("/shared/session.jsonl"),
1865                started_at: Some(100),
1866                ended_at: None,
1867                approx_tokens: None,
1868                metadata_json: serde_json::Value::Null,
1869                messages: vec![],
1870                source_id: "local".to_string(),
1871                origin_host: None,
1872            },
1873            messages: vec![Message {
1874                id: Some(1),
1875                idx: 0,
1876                role: MessageRole::User,
1877                author: None,
1878                created_at: Some(101),
1879                content: "body".to_string(),
1880                extra_json: serde_json::Value::Null,
1881                snippets: Vec::new(),
1882            }],
1883            workspace: None,
1884        };
1885
1886        let hit = SearchHit {
1887            title: String::new(),
1888            snippet: String::new(),
1889            content: String::new(),
1890            content_hash: 0,
1891            score: 0.0,
1892            conversation_id: Some(999),
1893            source_path: "/shared/session.jsonl".to_string(),
1894            agent: "claude_code".to_string(),
1895            workspace: String::new(),
1896            workspace_original: None,
1897            created_at: None,
1898            line_number: None,
1899            match_type: Default::default(),
1900            source_id: "local".to_string(),
1901            origin_kind: "local".to_string(),
1902            origin_host: None,
1903        };
1904
1905        assert!(!conversation_view_matches_hit(&view, &hit));
1906    }
1907
1908    #[test]
1909    fn load_conversation_for_source_uses_cached_value_when_validation_query_fails() {
1910        use crate::storage::sqlite::FrankenStorage;
1911
1912        let tmp = tempfile::TempDir::new().expect("tempdir");
1913        let db_path = tmp.path().join("cass.db");
1914        let storage = FrankenStorage::open(&db_path).expect("open storage");
1915        let conn = storage.raw();
1916        let shared_path = "/shared/source-cache-when-db-breaks.jsonl";
1917
1918        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1919
1920        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1921            .expect("insert agent");
1922        {
1923            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1924            let p = [ParamValue::from(shared_path.to_string())];
1925            conn.execute_with_params(
1926                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'local-ext', 'Cached Session', ?1, 'local', 100)",
1927                &param_slice_to_values(&p),
1928            )
1929            .expect("insert local conversation");
1930        }
1931        conn.execute(
1932            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'cached body')",
1933        )
1934        .expect("insert local message");
1935
1936        let cached = load_conversation_for_source(&storage, "local", shared_path)
1937            .expect("load initial conversation")
1938            .expect("conversation present");
1939        assert_eq!(cached.convo.title.as_deref(), Some("Cached Session"));
1940        assert_eq!(cached.messages[0].content, "cached body");
1941
1942        conn.execute("DROP TABLE conversations")
1943            .expect("drop conversations to force validation failure");
1944
1945        let still_cached = load_conversation_for_source(&storage, "  LOCAL  ", shared_path)
1946            .expect("use cached conversation after validation failure")
1947            .expect("cached conversation still present");
1948
1949        assert_eq!(still_cached.convo.title.as_deref(), Some("Cached Session"));
1950        assert_eq!(still_cached.messages[0].content, "cached body");
1951
1952        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1953    }
1954
1955    #[test]
1956    fn load_conversation_invalidates_path_only_cache_when_local_source_appears() {
1957        use crate::storage::sqlite::FrankenStorage;
1958
1959        let tmp = tempfile::TempDir::new().expect("tempdir");
1960        let db_path = tmp.path().join("cass.db");
1961        let storage = FrankenStorage::open(&db_path).expect("open storage");
1962        let conn = storage.raw();
1963        let shared_path = "/shared/late-local-session.jsonl";
1964
1965        CONVERSATION_CACHE.invalidate(None, shared_path);
1966        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
1967        CONVERSATION_CACHE.invalidate(Some("work-laptop"), shared_path);
1968
1969        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
1970            .expect("insert agent");
1971        conn.execute(
1972            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('work-laptop', 'ssh', 'work-laptop', 0, 0)",
1973        )
1974        .expect("insert source");
1975        {
1976            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1977            let p = [ParamValue::from(shared_path.to_string())];
1978            conn.execute_with_params(
1979                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'remote-ext', 'Remote Session', ?1, 'work-laptop', 200)",
1980                &param_slice_to_values(&p),
1981            )
1982            .expect("insert remote conversation");
1983        }
1984        conn.execute(
1985            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'remote body')",
1986        )
1987        .expect("insert remote message");
1988
1989        let first = load_conversation(&storage, shared_path)
1990            .expect("load remote conversation")
1991            .expect("remote conversation present");
1992        assert_eq!(first.convo.source_id, "work-laptop");
1993        assert_eq!(first.messages[0].content, "remote body");
1994
1995        {
1996            use frankensqlite::compat::{ParamValue, param_slice_to_values};
1997            let p = [ParamValue::from(shared_path.to_string())];
1998            conn.execute_with_params(
1999                "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'local-ext', 'Local Session', ?1, 'local', 100)",
2000                &param_slice_to_values(&p),
2001            )
2002            .expect("insert local conversation");
2003        }
2004        conn.execute(
2005            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'local body')",
2006        )
2007        .expect("insert local message");
2008
2009        let second = load_conversation(&storage, shared_path)
2010            .expect("load local conversation")
2011            .expect("local conversation present");
2012
2013        assert_eq!(second.convo.source_id, "local");
2014        assert_eq!(second.convo.title.as_deref(), Some("Local Session"));
2015        assert_eq!(second.messages[0].content, "local body");
2016
2017        CONVERSATION_CACHE.invalidate(None, shared_path);
2018        CONVERSATION_CACHE.invalidate(Some("local"), shared_path);
2019        CONVERSATION_CACHE.invalidate(Some("work-laptop"), shared_path);
2020    }
2021
2022    #[test]
2023    fn load_conversation_for_hit_selects_exact_conversation_within_same_source_and_path() {
2024        use crate::storage::sqlite::FrankenStorage;
2025
2026        let tmp = tempfile::TempDir::new().expect("tempdir");
2027        let db_path = tmp.path().join("cass.db");
2028        let storage = FrankenStorage::open(&db_path).expect("open storage");
2029        let conn = storage.raw();
2030        let shared_path = "/shared/cursor.sqlite";
2031
2032        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2033            .expect("insert agent");
2034        conn.execute(
2035            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2036        )
2037        .expect("insert old conversation");
2038        conn.execute(
2039            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2040        )
2041        .expect("insert new conversation");
2042        conn.execute(
2043            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 101, 'old conversation body')",
2044        )
2045        .expect("insert old message");
2046        conn.execute(
2047            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (2, 2, 0, 'user', 201, 'new conversation body')",
2048        )
2049        .expect("insert new message");
2050
2051        let hit = SearchHit {
2052            title: "New Session".to_string(),
2053            snippet: "new conversation body".to_string(),
2054            content: "new conversation body".to_string(),
2055            content_hash: 0,
2056            conversation_id: None,
2057            score: 0.0,
2058            source_path: shared_path.to_string(),
2059            agent: "cursor".to_string(),
2060            workspace: String::new(),
2061            workspace_original: None,
2062            created_at: Some(201),
2063            line_number: Some(1),
2064            match_type: Default::default(),
2065            source_id: "local".to_string(),
2066            origin_kind: "local".to_string(),
2067            origin_host: None,
2068        };
2069
2070        let loaded = load_conversation_for_hit(&storage, &hit)
2071            .expect("load exact conversation")
2072            .expect("matching conversation");
2073
2074        assert_eq!(loaded.convo.external_id.as_deref(), Some("new-ext"));
2075        assert_eq!(loaded.convo.title.as_deref(), Some("New Session"));
2076        assert_eq!(loaded.messages[0].content, "new conversation body");
2077    }
2078
2079    #[test]
2080    fn load_conversation_for_hit_accepts_matching_timestamp_even_when_hit_content_is_stale() {
2081        use crate::storage::sqlite::FrankenStorage;
2082
2083        let tmp = tempfile::TempDir::new().expect("tempdir");
2084        let db_path = tmp.path().join("cass.db");
2085        let storage = FrankenStorage::open(&db_path).expect("open storage");
2086        let conn = storage.raw();
2087        let shared_path = "/shared/cursor.sqlite";
2088
2089        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2090            .expect("insert agent");
2091        conn.execute(
2092            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2093        )
2094        .expect("insert conversation");
2095        conn.execute(
2096            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'new conversation body')",
2097        )
2098        .expect("insert message");
2099
2100        let hit = SearchHit {
2101            title: "New Session".to_string(),
2102            snippet: "rendered fragment".to_string(),
2103            content: "stale search fragment".to_string(),
2104            content_hash: 0,
2105            conversation_id: None,
2106            score: 0.0,
2107            source_path: shared_path.to_string(),
2108            agent: "cursor".to_string(),
2109            workspace: String::new(),
2110            workspace_original: None,
2111            created_at: Some(201),
2112            line_number: None,
2113            match_type: Default::default(),
2114            source_id: "local".to_string(),
2115            origin_kind: "local".to_string(),
2116            origin_host: None,
2117        };
2118
2119        let loaded = load_conversation_for_hit(&storage, &hit)
2120            .expect("load exact conversation")
2121            .expect("matching conversation");
2122
2123        assert_eq!(loaded.convo.external_id.as_deref(), Some("new-ext"));
2124        assert_eq!(loaded.messages[0].content, "new conversation body");
2125    }
2126
2127    #[test]
2128    fn load_conversation_for_hit_falls_back_when_conversation_id_is_stale() {
2129        let tmp = tempdir().expect("tempdir");
2130        let db_path = tmp.path().join("cass.db");
2131        let storage = FrankenStorage::open(&db_path).expect("open db");
2132        let conn = storage.raw();
2133        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'claude_code', 'Claude Code', 'local', 0, 0)")
2134            .expect("insert agent");
2135        conn.execute(
2136            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'exact-ext', 'Database Title', '/shared/cursor.sqlite', 'local', 200)",
2137        )
2138        .expect("insert conversation");
2139        conn.execute(
2140            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2141        )
2142        .expect("insert message");
2143
2144        let hit = SearchHit {
2145            title: "Database Title".to_string(),
2146            snippet: "db body".to_string(),
2147            content: "db body".to_string(),
2148            content_hash: 0,
2149            conversation_id: Some(999),
2150            score: 1.0,
2151            source_path: "/shared/cursor.sqlite".to_string(),
2152            agent: "claude_code".to_string(),
2153            workspace: String::new(),
2154            workspace_original: None,
2155            created_at: Some(201),
2156            line_number: Some(1),
2157            match_type: MatchType::Exact,
2158            source_id: "local".to_string(),
2159            origin_kind: "local".to_string(),
2160            origin_host: None,
2161        };
2162        let loaded = load_conversation_for_hit(&storage, &hit)
2163            .expect("load attempt succeeds")
2164            .expect("should fall back to provenance match after stale conversation id misses");
2165
2166        assert_eq!(loaded.convo.id, Some(1));
2167        assert_eq!(
2168            loaded.convo.source_path,
2169            std::path::Path::new("/shared/cursor.sqlite")
2170        );
2171    }
2172
2173    #[test]
2174    fn load_conversation_for_hit_uses_origin_host_when_db_source_id_is_blank_remote() {
2175        use crate::storage::sqlite::FrankenStorage;
2176
2177        let tmp = tempfile::TempDir::new().expect("tempdir");
2178        let db_path = tmp.path().join("cass.db");
2179        let storage = FrankenStorage::open(&db_path).expect("open storage");
2180        let conn = storage.raw();
2181        let shared_path = "/shared/remote.sqlite";
2182
2183        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2184            .expect("insert agent");
2185        conn.execute(
2186            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('   ', 'ssh', 'user@laptop', 0, 0)",
2187        )
2188        .expect("insert blank-id remote source");
2189        conn.execute(
2190            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, origin_host, started_at) VALUES (1, 1, 'remote-ext', 'Remote Session', '/shared/remote.sqlite', '   ', 'user@laptop', 200)",
2191        )
2192        .expect("insert conversation");
2193        conn.execute(
2194            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2195        )
2196        .expect("insert message");
2197
2198        let hit = SearchHit {
2199            title: "Remote Session".to_string(),
2200            snippet: String::new(),
2201            content: String::new(),
2202            content_hash: 0,
2203            conversation_id: Some(1),
2204            score: 0.0,
2205            source_path: shared_path.to_string(),
2206            agent: "cursor".to_string(),
2207            workspace: String::new(),
2208            workspace_original: None,
2209            created_at: None,
2210            line_number: None,
2211            match_type: Default::default(),
2212            source_id: "   ".to_string(),
2213            origin_kind: "remote".to_string(),
2214            origin_host: Some("user@laptop".to_string()),
2215        };
2216
2217        let loaded = load_conversation_for_hit(&storage, &hit)
2218            .expect("load exact conversation")
2219            .expect("matching conversation");
2220
2221        assert_eq!(loaded.convo.id, Some(1));
2222        assert_eq!(loaded.convo.source_id, "user@laptop");
2223        assert_eq!(loaded.convo.origin_host.as_deref(), Some("user@laptop"));
2224    }
2225
2226    #[test]
2227    fn load_conversation_for_hit_prefers_exact_conversation_id_over_stale_path() {
2228        use crate::storage::sqlite::FrankenStorage;
2229
2230        let tmp = tempfile::TempDir::new().expect("tempdir");
2231        let db_path = tmp.path().join("cass.db");
2232        let storage = FrankenStorage::open(&db_path).expect("open storage");
2233        let conn = storage.raw();
2234
2235        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2236            .expect("insert agent");
2237        conn.execute(
2238            "INSERT INTO sources (id, kind, host_label, created_at, updated_at) VALUES ('  local  ', 'local', 'local', 0, 0)",
2239        )
2240        .expect("insert local source");
2241        conn.execute(
2242            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'exact-ext', 'Database Title', '/db/real/path.sqlite', '  local  ', 200)",
2243        )
2244        .expect("insert conversation");
2245        conn.execute(
2246            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2247        )
2248        .expect("insert message");
2249
2250        let hit = SearchHit {
2251            title: "Stale Indexed Title".to_string(),
2252            snippet: String::new(),
2253            content: String::new(),
2254            content_hash: 0,
2255            conversation_id: Some(1),
2256            score: 0.0,
2257            source_path: "/stale/index/path.sqlite".to_string(),
2258            agent: "cursor".to_string(),
2259            workspace: String::new(),
2260            workspace_original: None,
2261            created_at: None,
2262            line_number: None,
2263            match_type: Default::default(),
2264            source_id: "remote-laptop".to_string(),
2265            origin_kind: "remote".to_string(),
2266            origin_host: Some("dev@laptop".to_string()),
2267        };
2268
2269        let loaded = load_conversation_for_hit(&storage, &hit)
2270            .expect("load exact conversation")
2271            .expect("matching conversation");
2272
2273        assert_eq!(loaded.convo.id, Some(1));
2274        assert_eq!(
2275            loaded.convo.source_path.to_string_lossy(),
2276            "/db/real/path.sqlite"
2277        );
2278        assert_eq!(loaded.convo.source_id, "local");
2279    }
2280
2281    #[test]
2282    fn load_conversation_for_hit_prefers_exact_conversation_id_over_stale_title() {
2283        use crate::storage::sqlite::FrankenStorage;
2284
2285        let tmp = tempfile::TempDir::new().expect("tempdir");
2286        let db_path = tmp.path().join("cass.db");
2287        let storage = FrankenStorage::open(&db_path).expect("open storage");
2288        let conn = storage.raw();
2289        let shared_path = "/shared/cursor.sqlite";
2290
2291        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2292            .expect("insert agent");
2293        conn.execute(
2294            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'exact-ext', 'Database Title', '/shared/cursor.sqlite', 'local', 200)",
2295        )
2296        .expect("insert conversation");
2297        conn.execute(
2298            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 201, 'db body')",
2299        )
2300        .expect("insert message");
2301
2302        let hit = SearchHit {
2303            title: "Stale Indexed Title".to_string(),
2304            snippet: String::new(),
2305            content: String::new(),
2306            content_hash: 0,
2307            conversation_id: Some(1),
2308            score: 0.0,
2309            source_path: shared_path.to_string(),
2310            agent: "cursor".to_string(),
2311            workspace: String::new(),
2312            workspace_original: None,
2313            created_at: None,
2314            line_number: None,
2315            match_type: Default::default(),
2316            source_id: "local".to_string(),
2317            origin_kind: "local".to_string(),
2318            origin_host: None,
2319        };
2320
2321        let loaded = load_conversation_for_hit(&storage, &hit)
2322            .expect("load exact conversation")
2323            .expect("matching conversation");
2324
2325        assert_eq!(loaded.convo.id, Some(1));
2326        assert_eq!(loaded.convo.title.as_deref(), Some("Database Title"));
2327    }
2328
2329    #[test]
2330    fn load_conversation_for_hit_ignores_stale_title_when_exact_content_identifies_match() {
2331        use crate::storage::sqlite::FrankenStorage;
2332
2333        let tmp = tempfile::TempDir::new().expect("tempdir");
2334        let db_path = tmp.path().join("cass.db");
2335        let storage = FrankenStorage::open(&db_path).expect("open storage");
2336        let conn = storage.raw();
2337        let shared_path = "/shared/cursor.sqlite";
2338
2339        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2340            .expect("insert agent");
2341        conn.execute(
2342            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2343        )
2344        .expect("insert old conversation");
2345        conn.execute(
2346            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2347        )
2348        .expect("insert new conversation");
2349        conn.execute(
2350            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'old conversation body')",
2351        )
2352        .expect("insert old message");
2353        conn.execute(
2354            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'new conversation body')",
2355        )
2356        .expect("insert new message");
2357
2358        let hit = SearchHit {
2359            title: "Stale Indexed Title".to_string(),
2360            snippet: "new conversation body".to_string(),
2361            content: "new conversation body".to_string(),
2362            content_hash: 0,
2363            conversation_id: None,
2364            score: 0.0,
2365            source_path: shared_path.to_string(),
2366            agent: "cursor".to_string(),
2367            workspace: String::new(),
2368            workspace_original: None,
2369            created_at: None,
2370            line_number: Some(1),
2371            match_type: Default::default(),
2372            source_id: "local".to_string(),
2373            origin_kind: "local".to_string(),
2374            origin_host: None,
2375        };
2376
2377        let loaded = load_conversation_for_hit(&storage, &hit)
2378            .expect("load exact conversation")
2379            .expect("matching conversation");
2380
2381        assert_eq!(loaded.convo.external_id.as_deref(), Some("new-ext"));
2382        assert_eq!(loaded.convo.title.as_deref(), Some("New Session"));
2383        assert_eq!(loaded.messages[0].content, "new conversation body");
2384    }
2385
2386    #[test]
2387    fn load_conversation_for_hit_uses_title_only_identity_hint() {
2388        use crate::storage::sqlite::FrankenStorage;
2389
2390        let tmp = tempfile::TempDir::new().expect("tempdir");
2391        let db_path = tmp.path().join("cass.db");
2392        let storage = FrankenStorage::open(&db_path).expect("open storage");
2393        let conn = storage.raw();
2394        let shared_path = "/shared/cursor.sqlite";
2395
2396        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2397            .expect("insert agent");
2398        conn.execute(
2399            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2400        )
2401        .expect("insert old conversation");
2402        conn.execute(
2403            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2404        )
2405        .expect("insert new conversation");
2406        conn.execute(
2407            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (1, 1, 0, 'user', 'old conversation body')",
2408        )
2409        .expect("insert old message");
2410        conn.execute(
2411            "INSERT INTO messages (id, conversation_id, idx, role, content) VALUES (2, 2, 0, 'user', 'new conversation body')",
2412        )
2413        .expect("insert new message");
2414
2415        let hit = SearchHit {
2416            title: "Old Session".to_string(),
2417            snippet: String::new(),
2418            content: String::new(),
2419            content_hash: 0,
2420            conversation_id: None,
2421            score: 0.0,
2422            source_path: shared_path.to_string(),
2423            agent: "cursor".to_string(),
2424            workspace: String::new(),
2425            workspace_original: None,
2426            created_at: None,
2427            line_number: None,
2428            match_type: Default::default(),
2429            source_id: "local".to_string(),
2430            origin_kind: "local".to_string(),
2431            origin_host: None,
2432        };
2433
2434        let loaded = load_conversation_for_hit(&storage, &hit)
2435            .expect("load attempt succeeds")
2436            .expect("matching conversation");
2437
2438        assert_eq!(loaded.convo.external_id.as_deref(), Some("old-ext"));
2439        assert_eq!(loaded.convo.title.as_deref(), Some("Old Session"));
2440    }
2441
2442    #[test]
2443    fn load_conversation_for_hit_does_not_fall_back_to_wrong_conversation_when_identity_misses() {
2444        use crate::storage::sqlite::FrankenStorage;
2445
2446        let tmp = tempfile::TempDir::new().expect("tempdir");
2447        let db_path = tmp.path().join("cass.db");
2448        let storage = FrankenStorage::open(&db_path).expect("open storage");
2449        let conn = storage.raw();
2450        let shared_path = "/shared/cursor.sqlite";
2451
2452        conn.execute("INSERT INTO agents (id, slug, name, kind, created_at, updated_at) VALUES (1, 'cursor', 'Cursor', 'local', 0, 0)")
2453            .expect("insert agent");
2454        conn.execute(
2455            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (1, 1, 'old-ext', 'Old Session', '/shared/cursor.sqlite', 'local', 100)",
2456        )
2457        .expect("insert old conversation");
2458        conn.execute(
2459            "INSERT INTO conversations (id, agent_id, external_id, title, source_path, source_id, started_at) VALUES (2, 1, 'new-ext', 'New Session', '/shared/cursor.sqlite', 'local', 200)",
2460        )
2461        .expect("insert new conversation");
2462        conn.execute(
2463            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (1, 1, 0, 'user', 101, 'old conversation body')",
2464        )
2465        .expect("insert old message");
2466        conn.execute(
2467            "INSERT INTO messages (id, conversation_id, idx, role, created_at, content) VALUES (2, 2, 0, 'user', 201, 'new conversation body')",
2468        )
2469        .expect("insert new message");
2470
2471        let hit = SearchHit {
2472            title: "Missing Session".to_string(),
2473            snippet: "missing conversation body".to_string(),
2474            content: "missing conversation body".to_string(),
2475            content_hash: 0,
2476            conversation_id: None,
2477            score: 0.0,
2478            source_path: shared_path.to_string(),
2479            agent: "cursor".to_string(),
2480            workspace: String::new(),
2481            workspace_original: None,
2482            created_at: Some(999),
2483            line_number: Some(1),
2484            match_type: Default::default(),
2485            source_id: "local".to_string(),
2486            origin_kind: "local".to_string(),
2487            origin_host: None,
2488        };
2489
2490        let loaded = load_conversation_for_hit(&storage, &hit).expect("load attempt succeeds");
2491        assert!(
2492            loaded.is_none(),
2493            "identity-mismatched hits must not fall back to an arbitrary conversation"
2494        );
2495    }
2496
2497    #[test]
2498    fn test_cache_miss() {
2499        let cache = ConversationCache::new(10);
2500
2501        // Get from empty cache
2502        let cached = cache.get(None, "/nonexistent/path.jsonl");
2503        assert!(cached.is_none());
2504
2505        // Check stats
2506        let (hits, misses, _) = cache.stats().get();
2507        assert_eq!(hits, 0);
2508        assert_eq!(misses, 1);
2509    }
2510
2511    #[test]
2512    fn test_cache_invalidation() {
2513        let cache = ConversationCache::new(10);
2514        let view = make_test_view(1);
2515        let source_path = "/test/path/1.jsonl";
2516
2517        // Insert and verify
2518        cache.insert(None, source_path, view);
2519        assert!(cache.get(None, source_path).is_some());
2520
2521        // Invalidate
2522        cache.invalidate(None, source_path);
2523        assert!(cache.get(None, source_path).is_none());
2524    }
2525
2526    #[test]
2527    fn test_cache_invalidate_all() {
2528        let cache = ConversationCache::new(10);
2529
2530        // Insert multiple entries
2531        for i in 0..5 {
2532            let view = make_test_view(i);
2533            let source_path = format!("/test/path/{}.jsonl", i);
2534            cache.insert(None, &source_path, view);
2535        }
2536
2537        assert_eq!(cache.len(), 5);
2538
2539        // Invalidate all
2540        cache.invalidate_all();
2541        assert_eq!(cache.len(), 0);
2542        assert!(cache.is_empty());
2543    }
2544
2545    #[test]
2546    fn test_cache_lru_eviction() {
2547        let cache = ConversationCache::new(2); // 2 per shard, 32 total
2548
2549        // Insert more entries than a single shard can hold
2550        // All entries go to same shard by using paths that hash to same shard
2551        // (in practice, FxHasher distributes well, so we insert many entries)
2552        for i in 0..100 {
2553            let view = make_test_view(i);
2554            let source_path = format!("/test/path/{}.jsonl", i);
2555            cache.insert(None, &source_path, view);
2556        }
2557
2558        // Some early entries should have been evicted
2559        let (_, _, evictions) = cache.stats().get();
2560        assert!(evictions > 0, "Expected some evictions with small capacity");
2561    }
2562
2563    #[test]
2564    fn test_cache_hit_rate() {
2565        let cache = ConversationCache::new(10);
2566        let view = make_test_view(1);
2567        let source_path = "/test/path/1.jsonl";
2568
2569        // Initial hit rate is 0
2570        assert_eq!(cache.stats().hit_rate(), 0.0);
2571
2572        // Insert and access twice (1 miss on insert lookup, then 2 hits)
2573        cache.insert(None, source_path, view);
2574        let _ = cache.get(None, source_path);
2575        let _ = cache.get(None, source_path);
2576
2577        // Hit rate should be positive (2 hits / 2 total)
2578        let hit_rate = cache.stats().hit_rate();
2579        assert!(
2580            hit_rate > 0.5,
2581            "Expected >50% hit rate, got {:.1}%",
2582            hit_rate * 100.0
2583        );
2584    }
2585
2586    #[test]
2587    fn test_cache_shard_distribution() {
2588        let cache = ConversationCache::new(100);
2589
2590        // Insert 1000 entries
2591        for i in 0..1000 {
2592            let view = make_test_view(i);
2593            let source_path = format!("/various/paths/{}/session.jsonl", i);
2594            cache.insert(None, &source_path, view);
2595        }
2596
2597        // All entries should be cached
2598        assert_eq!(cache.len(), 1000);
2599    }
2600
2601    #[test]
2602    fn test_cache_concurrent_access() {
2603        use std::thread;
2604
2605        let cache = Arc::new(ConversationCache::new(100));
2606        let mut handles = vec![];
2607
2608        // Spawn writers
2609        for t in 0..4 {
2610            let cache = Arc::clone(&cache);
2611            handles.push(thread::spawn(move || {
2612                for i in 0..250 {
2613                    let id = t * 250 + i;
2614                    let view = make_test_view(id);
2615                    let source_path = format!("/test/path/{}.jsonl", id);
2616                    cache.insert(None, &source_path, view);
2617                }
2618            }));
2619        }
2620
2621        // Spawn readers
2622        for _ in 0..4 {
2623            let cache = Arc::clone(&cache);
2624            handles.push(thread::spawn(move || {
2625                for i in 0..1000 {
2626                    let source_path = format!("/test/path/{}.jsonl", i);
2627                    let _ = cache.get(None, &source_path);
2628                }
2629            }));
2630        }
2631
2632        for handle in handles {
2633            handle.join().unwrap();
2634        }
2635
2636        // Verify cache is consistent
2637        let (hits, misses, _) = cache.stats().get();
2638        assert!(hits + misses > 0, "Expected some cache operations");
2639    }
2640
2641    // =====================================================================
2642    // Cockpit IA contract tests (1mfw3.3.1)
2643    // =====================================================================
2644
2645    #[test]
2646    fn cockpit_panel_label_and_navigation() {
2647        assert_eq!(CockpitPanel::DiffStrategy.label(), "Diff");
2648        assert_eq!(CockpitPanel::ResizeRegime.label(), "Resize");
2649        assert_eq!(CockpitPanel::BudgetHealth.label(), "Budget");
2650        assert_eq!(CockpitPanel::Timeline.label(), "Timeline");
2651
2652        // Full forward cycle
2653        let mut p = CockpitPanel::DiffStrategy;
2654        p = p.next();
2655        assert_eq!(p, CockpitPanel::ResizeRegime);
2656        p = p.next();
2657        assert_eq!(p, CockpitPanel::BudgetHealth);
2658        p = p.next();
2659        assert_eq!(p, CockpitPanel::Timeline);
2660        p = p.next();
2661        assert_eq!(p, CockpitPanel::DiffStrategy);
2662
2663        // Full backward cycle
2664        p = CockpitPanel::DiffStrategy;
2665        p = p.prev();
2666        assert_eq!(p, CockpitPanel::Timeline);
2667        p = p.prev();
2668        assert_eq!(p, CockpitPanel::BudgetHealth);
2669        p = p.prev();
2670        assert_eq!(p, CockpitPanel::ResizeRegime);
2671        p = p.prev();
2672        assert_eq!(p, CockpitPanel::DiffStrategy);
2673    }
2674
2675    #[test]
2676    fn cockpit_panel_all_constant() {
2677        assert_eq!(CockpitPanel::ALL.len(), 4);
2678        assert_eq!(CockpitPanel::ALL[0], CockpitPanel::DiffStrategy);
2679        assert_eq!(CockpitPanel::ALL[3], CockpitPanel::Timeline);
2680    }
2681
2682    #[test]
2683    fn diff_strategy_contract_defaults_no_data() {
2684        let diff = DiffStrategyContract::default();
2685        assert!(!diff.has_data());
2686        assert_eq!(diff.dirty_ratio(), 0.0);
2687        assert!(!diff.last_was_full_redraw);
2688    }
2689
2690    #[test]
2691    fn diff_strategy_contract_dirty_ratio() {
2692        let diff = DiffStrategyContract {
2693            dirty_row_count: 10,
2694            total_row_count: 40,
2695            ..Default::default()
2696        };
2697        assert!(diff.has_data());
2698        assert!((diff.dirty_ratio() - 0.25).abs() < f64::EPSILON);
2699    }
2700
2701    #[test]
2702    fn resize_regime_contract_defaults_no_data() {
2703        let resize = ResizeRegimeContract::default();
2704        assert!(!resize.has_data());
2705        assert_eq!(resize.regime, "\u{2014}");
2706    }
2707
2708    #[test]
2709    fn resize_regime_contract_with_data() {
2710        let resize = ResizeRegimeContract {
2711            regime: "Burst",
2712            terminal_size: Some((120, 40)),
2713            bocpd_p_burst: Some(0.87),
2714            history_len: 5,
2715            last_action: "defer",
2716            ..Default::default()
2717        };
2718        assert!(resize.has_data());
2719        assert_eq!(resize.terminal_size, Some((120, 40)));
2720    }
2721
2722    #[test]
2723    fn budget_health_contract_defaults_no_data() {
2724        let budget = BudgetHealthContract::default();
2725        assert!(!budget.has_data());
2726        assert!(!budget.is_over_budget());
2727    }
2728
2729    #[test]
2730    fn budget_health_contract_over_budget() {
2731        let budget = BudgetHealthContract {
2732            budget_us: 16_666.0,
2733            frame_time_us: 25_000.0,
2734            pressure: 1.5,
2735            frames_observed: 100,
2736            ..Default::default()
2737        };
2738        assert!(budget.has_data());
2739        assert!(budget.is_over_budget());
2740    }
2741
2742    #[test]
2743    fn timeline_contract_push_and_eviction() {
2744        let mut timeline = TimelineContract {
2745            events: std::collections::VecDeque::new(),
2746            capacity: 3,
2747        };
2748        assert!(timeline.is_empty());
2749        assert!(!timeline.has_data());
2750
2751        for i in 0..5 {
2752            timeline.push(CockpitTimelineEvent {
2753                source: CockpitPanel::BudgetHealth,
2754                summary: format!("event {i}"),
2755                event_idx: i,
2756                elapsed_secs: i as f64,
2757                severity: TimelineEventSeverity::Info,
2758            });
2759        }
2760
2761        assert_eq!(timeline.len(), 3);
2762        assert!(timeline.has_data());
2763        // Oldest events should be evicted
2764        assert_eq!(timeline.events[0].event_idx, 2);
2765        assert_eq!(timeline.events[2].event_idx, 4);
2766    }
2767
2768    #[test]
2769    fn cockpit_state_empty_messages() {
2770        let state = CockpitState::new();
2771        assert!(!state.has_any_data());
2772
2773        // All panels should return awaiting/no_data messages
2774        assert!(!state.empty_message(CockpitPanel::DiffStrategy).is_empty());
2775        assert!(!state.empty_message(CockpitPanel::ResizeRegime).is_empty());
2776        assert!(!state.empty_message(CockpitPanel::BudgetHealth).is_empty());
2777        assert!(!state.empty_message(CockpitPanel::Timeline).is_empty());
2778    }
2779
2780    #[test]
2781    fn cockpit_state_partial_data() {
2782        let mut state = CockpitState::new();
2783        state.resize = ResizeRegimeContract {
2784            regime: "Steady",
2785            ..Default::default()
2786        };
2787        assert!(state.has_any_data());
2788        // Resize has data, so empty_message returns ""
2789        assert_eq!(state.empty_message(CockpitPanel::ResizeRegime), "");
2790        // Others still show placeholder
2791        assert!(!state.empty_message(CockpitPanel::DiffStrategy).is_empty());
2792    }
2793
2794    #[test]
2795    fn timeline_event_severity_default_is_info() {
2796        assert_eq!(
2797            TimelineEventSeverity::default(),
2798            TimelineEventSeverity::Info
2799        );
2800    }
2801
2802    #[test]
2803    fn cockpit_empty_policy_defaults() {
2804        let policy = CockpitEmptyPolicy::default();
2805        assert_eq!(policy.no_data, "\u{2014}");
2806        assert!(policy.awaiting.contains("awaiting"));
2807        assert!(policy.disabled.contains("disabled"));
2808    }
2809
2810    // -- CockpitMode tests (1mfw3.3.3) ------------------------------------
2811
2812    #[test]
2813    fn cockpit_mode_default_is_overlay() {
2814        assert_eq!(CockpitMode::default(), CockpitMode::Overlay);
2815    }
2816
2817    #[test]
2818    fn cockpit_mode_cycle() {
2819        assert_eq!(CockpitMode::Overlay.cycle(), CockpitMode::Expanded);
2820        assert_eq!(CockpitMode::Expanded.cycle(), CockpitMode::Overlay);
2821    }
2822
2823    #[test]
2824    fn cockpit_mode_labels() {
2825        assert_eq!(CockpitMode::Overlay.label(), "overlay");
2826        assert_eq!(CockpitMode::Expanded.label(), "expanded");
2827    }
2828
2829    #[test]
2830    fn cockpit_state_includes_mode() {
2831        let state = CockpitState::new();
2832        assert_eq!(state.mode, CockpitMode::Overlay);
2833        assert!(!state.enabled);
2834    }
2835}