Skip to main content

agentmux_srv/backend/history/
index.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! In-memory session index built from adapter discovery.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9
10use super::adapter::*;
11
12/// Format version of the saved metadata cache (`SessionIndex::with_cache_file`).
13/// Bump when `SessionMeta` or what `extract_meta` derives changes meaning, so
14/// a stale file is rebuilt rather than trusted.
15pub(crate) const META_CACHE_VERSION: u32 = 1;
16
17/// The saved metadata cache is rewritten at most this often (when changed).
18pub(crate) const META_CACHE_WRITE_INTERVAL_MS: i64 = 10 * 60 * 1000;
19
20#[derive(serde::Deserialize)]
21struct MetaCacheFile {
22    version: u32,
23    entries: HashMap<String, (i64, Option<SessionMeta>)>,
24}
25
26#[derive(serde::Serialize)]
27struct MetaCacheFileRef<'a> {
28    version: u32,
29    entries: &'a HashMap<String, (i64, Option<SessionMeta>)>,
30}
31
32/// Max characters of matched text returned per hit.
33///
34/// A single history message can be hundreds of KB, and this tool must never be
35/// the reason a caller blows its own context window — the problem it exists to
36/// solve is an agent not being able to audit itself, which is made worse, not
37/// better, by an answer too large to read. Not theoretical: the
38/// `GetAgentTranscript` call that motivated
39/// `SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md` returned 112 KB and had to be
40/// spilled to a file.
41const SNIPPET_MAX_CHARS: usize = 400;
42/// Characters of leading context kept before the match inside a snippet.
43const SNIPPET_LEAD_CHARS: usize = 120;
44
45/// Query parameters for [`SessionIndex::search_sessions`].
46#[derive(Debug, Clone, Default)]
47pub struct HistorySearchOptions {
48    /// Case-insensitive substring. Matched against message content, tool
49    /// names, and tool argument summaries.
50    pub query: String,
51    /// Restrict to `"user"` or `"assistant"` messages.
52    pub role: Option<String>,
53    /// Only match tool calls whose name equals this (case-insensitive).
54    ///
55    /// Its own parameter rather than folding into `query` because "find where
56    /// I *called* SendMessage" is a different question from "find where I
57    /// wrote the word SendMessage", and only the structural answer settles an
58    /// audit.
59    pub tool: Option<String>,
60    /// Max hits to return before reporting `truncated`.
61    pub limit: usize,
62    /// Only messages at or after this instant (Unix ms).
63    pub since_ms: Option<i64>,
64    /// Only messages at or before this instant (Unix ms).
65    pub until_ms: Option<i64>,
66    /// Open at most this many of the candidates, in the order given. The rest
67    /// are counted in `sessions_skipped`, never silently dropped.
68    pub max_sessions: Option<usize>,
69    /// Also search sessions found only by working directory or account, not
70    /// in the agent's own record (see `HistoryService::search_for_agent`).
71    pub include_inferred: bool,
72}
73
74/// One match.
75#[derive(Debug, Clone, serde::Serialize)]
76pub struct HistorySearchHit {
77    pub session_id: String,
78    pub file_path: String,
79    pub timestamp: i64,
80    pub role: String,
81    pub snippet: String,
82    /// `Some(name)` when the hit is a tool call rather than message prose.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub tool_name: Option<String>,
85    /// Where the text was read: `"provider_transcript"` or `"agentmux_record"`
86    /// (the transcript is gone; AgentMux's own record of the session).
87    pub source: &'static str,
88    /// Why the session is the caller's: `"agent_record"` (its id is in the
89    /// agent's own record or segment log) or `"inferred"` (found by working
90    /// directory or account only).
91    pub attribution: &'static str,
92}
93
94/// Where a searched session's content comes from.
95#[derive(Debug, Clone)]
96pub(crate) enum Source {
97    Transcript,
98    Record(super::record::RecordSession),
99}
100
101/// Why a session is the caller's.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) enum Attribution {
104    /// Its id is in the agent's own record or segment log.
105    AgentRecord,
106    /// Found by working directory or account only.
107    Inferred,
108}
109
110/// A session a search may open.
111#[derive(Debug, Clone)]
112pub(crate) struct SearchCandidate {
113    pub meta: SessionMeta,
114    pub source: Source,
115    pub attribution: Attribution,
116}
117
118impl SearchCandidate {
119    fn source_label(&self) -> &'static str {
120        match self.source {
121            Source::Transcript => "provider_transcript",
122            Source::Record(_) => "agentmux_record",
123        }
124    }
125    fn attribution_label(&self) -> &'static str {
126        match self.attribution {
127            Attribution::AgentRecord => "agent_record",
128            Attribution::Inferred => "inferred",
129        }
130    }
131}
132
133/// Result of a bounded search.
134#[derive(Debug, Clone, serde::Serialize)]
135pub struct HistorySearchOutcome {
136    pub hits: Vec<HistorySearchHit>,
137    /// Sessions actually opened and read.
138    pub sessions_scanned: u32,
139    /// Candidates in the caller's window, counted before `max_sessions`.
140    pub total_sessions: u32,
141    /// True when the scan stopped on `limit` rather than exhausting every
142    /// candidate — see `search_sessions`' doc comment for why this must never
143    /// be conflated with "no matches".
144    pub truncated: bool,
145    /// True only when every candidate was read to the end: nothing cut by
146    /// `max_sessions`, nothing unreadable, no hit limit reached. An empty
147    /// `hits` is evidence that something did not happen only when this is
148    /// true; otherwise the answer is "unknown", and `incomplete_reasons`
149    /// says why.
150    pub complete: bool,
151    /// `"hit_limit"`, `"max_sessions"`, `"unreadable_sessions"`,
152    /// `"unreadable_records"`, `"discovery_errors"`.
153    pub incomplete_reasons: Vec<&'static str>,
154    /// Candidates `max_sessions` left unopened.
155    pub sessions_skipped: u32,
156    /// Candidates that could not be read, and why.
157    pub sessions_unreadable: Vec<UnreadableSession>,
158    /// Candidates searched, but with records that could not be read.
159    pub sessions_partly_read: Vec<UnreadableSession>,
160    /// What the index refresh behind this search couldn't read. A session
161    /// there may be missing from the candidates.
162    pub discovery_errors: Vec<String>,
163    /// Sessions the agent's own record or segment log names.
164    pub ledger_sessions: u32,
165    /// Of those, sessions whose provider transcript is gone, searched in
166    /// AgentMux's record instead.
167    pub record_only_sessions: u32,
168    /// Sessions found only by working directory or account and left out,
169    /// because the agent's own record doesn't name them. `include_inferred`
170    /// searches them too.
171    pub inferred_sessions_excluded: u32,
172}
173
174/// At most this many discovery errors are listed in a search's answer.
175const DISCOVERY_ERRORS_LISTED: usize = 20;
176
177impl HistorySearchOutcome {
178    /// Record that the snapshot this search read couldn't see everything.
179    pub fn add_discovery_errors(&mut self, problems: Vec<String>) {
180        if problems.is_empty() {
181            return;
182        }
183        let total = problems.len();
184        self.discovery_errors = problems.into_iter().take(DISCOVERY_ERRORS_LISTED).collect();
185        if total > DISCOVERY_ERRORS_LISTED {
186            self.discovery_errors.push(format!("… and {} more", total - DISCOVERY_ERRORS_LISTED));
187        }
188        self.incomplete_reasons.push("discovery_errors");
189        self.complete = false;
190    }
191}
192
193/// A candidate session the search could not read.
194#[derive(Debug, Clone, serde::Serialize)]
195pub struct UnreadableSession {
196    pub session_id: String,
197    pub reason: String,
198}
199
200/// Whether a recorded tool name is the tool the caller named. Claude records
201/// an MCP tool with its server prefix (`mcp__agentmux__SendMessage`) while
202/// callers — and SearchHistory's own schema — name it bare (`SendMessage`), so
203/// both forms match. Case-insensitive.
204fn tool_name_matches(recorded: &str, wanted: &str) -> bool {
205    let recorded = recorded.to_ascii_lowercase();
206    let wanted = wanted.to_ascii_lowercase();
207    recorded == wanted
208        || (recorded.starts_with("mcp__") && recorded.ends_with(&format!("__{wanted}")))
209}
210
211/// Whether a message falls inside the caller's time window. A message with
212/// no timestamp (0) can't be placed outside it, and dropping it could only
213/// hide evidence, so it's kept.
214fn within_window(timestamp_ms: i64, opts: &HistorySearchOptions) -> bool {
215    timestamp_ms == 0
216        || (opts.since_ms.is_none_or(|since| timestamp_ms >= since)
217            && opts.until_ms.is_none_or(|until| timestamp_ms <= until))
218}
219
220/// Case-insensitive substring search returning a byte offset into the
221/// **original** haystack, not into a lowercased copy of it.
222///
223/// The naive version of this (`haystack.to_lowercase().find(needle)`, then
224/// slice `haystack` at the result) is wrong, and wrong in a way that can
225/// panic: lowercasing is not length-preserving. Turkish `İ` (U+0130, 2 bytes)
226/// lowercases to `i̇` (3 bytes), so every such character before the match
227/// shifts the two strings out of alignment — slicing the original at an offset
228/// derived from the lowercased copy can land mid-character and panic, or
229/// silently return a misaligned snippet. Caught by reagentx P1 on PR #3321.
230///
231/// So the lowercase mapping is built explicitly, recording which original byte
232/// each lowercased byte came from.
233fn find_case_insensitive(haystack: &str, needle_lower: &str) -> Option<usize> {
234    if needle_lower.is_empty() {
235        return None;
236    }
237    let mut lowered = String::with_capacity(haystack.len());
238    // lowered-byte-offset -> original-byte-offset
239    let mut origin: Vec<usize> = Vec::with_capacity(haystack.len() + 1);
240    for (orig_idx, ch) in haystack.char_indices() {
241        for lc in ch.to_lowercase() {
242            let mut buf = [0u8; 4];
243            let encoded = lc.encode_utf8(&mut buf);
244            for _ in 0..encoded.len() {
245                origin.push(orig_idx);
246            }
247            lowered.push_str(encoded);
248        }
249    }
250    origin.push(haystack.len());
251    let at = lowered.find(needle_lower)?;
252    // Always a char boundary in the original: every entry is the start index
253    // of the char that produced it.
254    origin.get(at).copied()
255}
256
257/// Bound a matched region to something a caller can actually read, keeping a
258/// little context before the match so the hit is interpretable.
259fn snippet_around(haystack: &str, match_at: usize) -> String {
260    let start = haystack[..match_at]
261        .char_indices()
262        .rev()
263        .take(SNIPPET_LEAD_CHARS)
264        .last()
265        .map(|(i, _)| i)
266        .unwrap_or(match_at);
267    let tail: String = haystack[start..].chars().take(SNIPPET_MAX_CHARS).collect();
268    let mut out = String::new();
269    if start > 0 {
270        out.push('…');
271    }
272    out.push_str(&tail);
273    if start + tail.len() < haystack.len() {
274        out.push('…');
275    }
276    out
277}
278
279/// Every match within one message: its prose, plus each tool call.
280fn match_message(
281    candidate: &SearchCandidate,
282    msg: &HistoryMessage,
283    needle: &str,
284    opts: &HistorySearchOptions,
285) -> Vec<HistorySearchHit> {
286    let meta = &candidate.meta;
287    let mut hits = Vec::new();
288
289    // Tool-call matches. When `tool` is set the search is structural: the
290    // filter is the tool NAME, and an empty query then means "every call to
291    // it" rather than "no matches".
292    //
293    // Every snippet offset below is resolved against the exact string the
294    // snippet is cut from. Deriving an offset from one string and slicing
295    // another — e.g. finding in `"{name} {args}"` and slicing `args` — puts
296    // the window in the wrong place, and for a long argument summary silently
297    // returns evidence that does not contain the match. A hit whose snippet
298    // omits the match is worse than no hit: it looks like verification.
299    // Both were reagentx P1s on PR #3321.
300    for tu in &msg.tool_uses {
301        if let Some(want) = opts.tool.as_deref() {
302            if !tool_name_matches(&tu.name, want) {
303                continue;
304            }
305            // Empty query + tool filter = "every call to this tool", so the
306            // start of the arguments is the right window.
307            let at = if needle.is_empty() {
308                Some(0)
309            } else {
310                find_case_insensitive(&tu.argument_summary, needle)
311            };
312            if let Some(at) = at {
313                hits.push(HistorySearchHit {
314                    session_id: meta.session_id.clone(),
315                    file_path: meta.file_path.clone(),
316                    timestamp: msg.timestamp,
317                    role: msg.role.clone(),
318                    snippet: snippet_around(&tu.argument_summary, at),
319                    tool_name: Some(tu.name.clone()),
320                    source: candidate.source_label(),
321                    attribution: candidate.attribution_label(),
322                });
323            }
324            continue;
325        }
326        if needle.is_empty() {
327            continue;
328        }
329        // No tool filter: the query may match the tool's NAME or its
330        // arguments. Searched separately so the snippet offset always belongs
331        // to `argument_summary`, the string actually being cut.
332        let at = if find_case_insensitive(&tu.name, needle).is_some() {
333            Some(0)
334        } else {
335            find_case_insensitive(&tu.argument_summary, needle)
336        };
337        if let Some(at) = at {
338            hits.push(HistorySearchHit {
339                session_id: meta.session_id.clone(),
340                file_path: meta.file_path.clone(),
341                timestamp: msg.timestamp,
342                role: msg.role.clone(),
343                snippet: snippet_around(&tu.argument_summary, at),
344                tool_name: Some(tu.name.clone()),
345                source: candidate.source_label(),
346                attribution: candidate.attribution_label(),
347            });
348        }
349    }
350
351    // Prose match — skipped entirely when the caller asked a structural
352    // tool-only question.
353    if opts.tool.is_none() && !needle.is_empty() {
354        if let Some(at) = find_case_insensitive(&msg.content, needle) {
355            hits.push(HistorySearchHit {
356                session_id: meta.session_id.clone(),
357                file_path: meta.file_path.clone(),
358                timestamp: msg.timestamp,
359                role: msg.role.clone(),
360                snippet: snippet_around(&msg.content, at),
361                tool_name: None,
362                source: candidate.source_label(),
363                attribution: candidate.attribution_label(),
364            });
365        }
366    }
367
368    hits
369}
370
371/// Search message content and tool calls across a chosen set of sessions,
372/// whose content `load` reads — from a provider transcript or from AgentMux's
373/// record. See [`SessionIndex::search_sessions`] for why stopping early is
374/// always reported.
375pub(crate) fn search_candidates(
376    candidates: &[SearchCandidate],
377    opts: &HistorySearchOptions,
378    load: &dyn Fn(&SearchCandidate) -> Result<Option<HistorySession>, HistoryError>,
379) -> HistorySearchOutcome {
380    let needle = opts.query.to_lowercase();
381    let mut hits: Vec<HistorySearchHit> = Vec::new();
382    let mut sessions_scanned = 0u32;
383    let mut sessions_unreadable: Vec<UnreadableSession> = Vec::new();
384    let mut sessions_partly_read: Vec<UnreadableSession> = Vec::new();
385    let mut truncated = false;
386
387    let opened = opts.max_sessions.map_or(candidates.len(), |max| max.min(candidates.len()));
388    let sessions_skipped = (candidates.len() - opened) as u32;
389
390    for candidate in &candidates[..opened] {
391        let meta = &candidate.meta;
392        if hits.len() >= opts.limit {
393            // More candidates remained that were never opened.
394            truncated = true;
395            break;
396        }
397        // A session that fails to parse is skipped, not fatal: one
398        // malformed file must not make the whole audit unanswerable. But
399        // it's reported, never counted as read.
400        let session = match load(candidate) {
401            Ok(Some(session)) => session,
402            Ok(None) => {
403                sessions_unreadable.push(UnreadableSession {
404                    session_id: meta.session_id.clone(),
405                    reason: "no longer readable (moved, deleted or emptied)".into(),
406                });
407                continue;
408            }
409            Err(e) => {
410                sessions_unreadable.push(UnreadableSession {
411                    session_id: meta.session_id.clone(),
412                    reason: e.to_string(),
413                });
414                continue;
415            }
416        };
417        sessions_scanned += 1;
418        if session.skipped_records > 0 {
419            sessions_partly_read.push(UnreadableSession {
420                session_id: meta.session_id.clone(),
421                reason: format!(
422                    "{} record(s) could not be read and were not searched",
423                    session.skipped_records
424                ),
425            });
426        }
427        for msg in &session.messages {
428            if let Some(role) = opts.role.as_deref() {
429                if msg.role != role {
430                    continue;
431                }
432            }
433            if !within_window(msg.timestamp, opts) {
434                continue;
435            }
436            if hits.len() >= opts.limit {
437                truncated = true;
438                break;
439            }
440            for hit in match_message(candidate, msg, &needle, opts) {
441                if hits.len() >= opts.limit {
442                    truncated = true;
443                    break;
444                }
445                hits.push(hit);
446            }
447        }
448    }
449
450    let mut incomplete_reasons = Vec::new();
451    if truncated {
452        incomplete_reasons.push("hit_limit");
453    }
454    if sessions_skipped > 0 {
455        incomplete_reasons.push("max_sessions");
456    }
457    if !sessions_unreadable.is_empty() {
458        incomplete_reasons.push("unreadable_sessions");
459    }
460    if !sessions_partly_read.is_empty() {
461        incomplete_reasons.push("unreadable_records");
462    }
463    HistorySearchOutcome {
464        hits,
465        sessions_scanned,
466        total_sessions: candidates.len() as u32,
467        truncated,
468        complete: incomplete_reasons.is_empty(),
469        incomplete_reasons,
470        sessions_skipped,
471        sessions_unreadable,
472        sessions_partly_read,
473        discovery_errors: Vec::new(),
474        ledger_sessions: 0,
475        record_only_sessions: 0,
476        inferred_sessions_excluded: 0,
477    }
478}
479
480/// AgentMux-ISOLATED provider-home roots under which delete/clear is permitted:
481/// `<shared>/providers/` and `<shared>/identities/`. Anything outside these
482/// (the user's personal `~/.claude` / `~/.config/claude-*`) is OFF-LIMITS so a
483/// "clear all" can never nuke transcripts AgentMux didn't create.
484fn default_isolated_roots() -> Vec<PathBuf> {
485    let shared = std::env::var_os("AGENTMUX_SHARED_DIR")
486        .map(PathBuf::from)
487        .or_else(|| agentmux_common::data_paths::agentmux_root().ok().map(|root| root.join("shared")));
488    match shared {
489        Some(s) => vec![s.join("providers"), s.join("identities")],
490        None => Vec::new(),
491    }
492}
493
494/// Normalize a working directory for use as an index key.
495///
496/// Separators are unified and trailing ones dropped so the same directory
497/// written `C:\x\y`, `C:/x/y` and `C:/x/y/` all collide on one key. Case is
498/// folded on Windows only: its filesystem is case-insensitive, so `C:\Users`
499/// and `c:\users` are genuinely the same directory — but on Linux they are
500/// two different directories, and folding case there would merge the history
501/// of two unrelated agents.
502pub(crate) fn normalize_working_dir(dir: &str) -> String {
503    let unified = dir.replace('\\', "/");
504    let trimmed = unified.trim_end_matches('/');
505    if cfg!(windows) {
506        trimmed.to_lowercase()
507    } else {
508        trimmed.to_string()
509    }
510}
511
512/// Outcome of [`SessionIndex::try_first_build`].
513#[derive(Debug, PartialEq, Eq)]
514pub enum FirstBuild {
515    /// This call ran the first build.
516    Built { discovered: u32 },
517    /// Another caller already finished it.
518    AlreadyBuilt,
519    /// A refresh (possibly the first build) is running right now.
520    Busy,
521}
522
523/// In-memory index of discovered sessions.
524pub struct SessionIndex {
525    /// session_id -> SessionMeta
526    sessions: Mutex<HashMap<String, SessionMeta>>,
527    /// normalized working directory -> session_ids.
528    ///
529    /// The identity index below can only find sessions for an agent that has
530    /// a `db_agent_identity_links` row binding it to the account its
531    /// transcripts were written under. An agent running on ambient
532    /// credentials has no such row (and its registry record's `identity_id`
533    /// reads `"default"`), so for that — the common — case the identity index
534    /// resolves to nothing at all while the transcripts sit on disk perfectly
535    /// intact. The working directory is recorded both in the transcript
536    /// itself (`cwd`) and on the agent's own row, which makes it the join key
537    /// that actually holds for those agents. See
538    /// `docs/specs/SPEC_CROSS_CHANNEL_AGENT_HISTORY_RESOLUTION_2026_09_21.md`.
539    /// **LOCK ORDER — `sessions` is always acquired BEFORE this map, and
540    /// before `by_identity`.** Never the reverse, anywhere.
541    ///
542    /// `refresh()` holds the `sessions` guard while it swaps both index maps
543    /// in, so it takes `sessions -> by_working_dir`. A reader that took
544    /// `by_working_dir -> sessions` would be a textbook AB-BA deadlock the
545    /// moment a `refresh()` (triggered by any `list`/`sessions_for_agent`
546    /// call) overlapped it on the shared `Arc<SessionIndex>` — thread A
547    /// holding `sessions` and blocking on the map, thread B holding the map
548    /// and blocking on `sessions`. Both readers below therefore take
549    /// `sessions` first, even though reading the map first would read more
550    /// naturally. reagentx P1 on PR #3480.
551    by_working_dir: Mutex<HashMap<String, Vec<String>>>,
552    /// identity_id -> session_ids (only non-empty `SessionMeta::identity_id`
553    /// values are indexed here). Maintained alongside `sessions` so "this
554    /// identity's sessions" is an O(k) HashMap lookup + small per-identity
555    /// sort, not an O(total sessions) scan of everything on disk — see
556    /// `list_for_identity` and
557    /// `docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md`
558    /// §4.4.
559    by_identity: Mutex<HashMap<String, Vec<String>>>,
560    /// file path -> (mtime it was parsed at, what it parsed to). A cold
561    /// refresh JSON-parses every line of every transcript on the machine
562    /// (42k files / 3.8 GB on one real host), so later refreshes reuse this
563    /// for files whose mtime hasn't moved. Only touched under `refresh_lock`.
564    meta_cache: Mutex<HashMap<String, (i64, Option<SessionMeta>)>>,
565    /// Where `meta_cache` is kept between launches (`with_cache_file`), so the
566    /// first refresh after a start parses only transcripts that changed since,
567    /// instead of every transcript on the machine (63k sessions took 92–154 s
568    /// per launch; SPEC_AGENT_OPEN_LATENCY_2026_09_27.md §4.3). `None`: kept in
569    /// memory only.
570    cache_file: Option<PathBuf>,
571    /// The cache file has been read (once, at the first refresh).
572    cache_loaded: std::sync::atomic::AtomicBool,
573    /// Unix ms when the cache file was last written; 0 = never.
574    cache_written_at_ms: std::sync::atomic::AtomicI64,
575    /// Serializes refreshes. Held for a whole refresh, never while taking any
576    /// lock above, so it adds no ordering constraint.
577    pub(super) refresh_lock: Mutex<()>,
578    /// Unix ms when the last refresh finished; 0 = never.
579    refreshed_at_ms: std::sync::atomic::AtomicI64,
580    /// Refreshes started so far; each takes the next number, under
581    /// `refresh_lock`, before it lists anything.
582    refreshes_started: std::sync::atomic::AtomicU64,
583    /// The number of the last refresh that finished. A request that finds it
584    /// higher than `refreshes_started` was when the request began knows a
585    /// refresh listed the disk after it began — see [`Self::refresh_covering_now`].
586    last_finished_refresh: std::sync::atomic::AtomicU64,
587    /// What the last refresh couldn't read (see [`Discovery::problems`]),
588    /// kept with the snapshot it produced.
589    discovery_problems: Mutex<Vec<String>>,
590    /// Adapters for all registered providers
591    adapters: Vec<Box<dyn HistoryAdapter>>,
592    /// Roots under which destructive ops (delete/clear) are allowed.
593    isolated_roots: Vec<PathBuf>,
594}
595
596impl SessionIndex {
597    pub fn new(adapters: Vec<Box<dyn HistoryAdapter>>) -> Self {
598        Self::with_isolated_roots(adapters, default_isolated_roots())
599    }
600
601    /// Construct with explicit isolated roots (used by new() and tests).
602    pub fn with_isolated_roots(
603        adapters: Vec<Box<dyn HistoryAdapter>>,
604        isolated_roots: Vec<PathBuf>,
605    ) -> Self {
606        SessionIndex {
607            sessions: Mutex::new(HashMap::new()),
608            by_working_dir: Mutex::new(HashMap::new()),
609            by_identity: Mutex::new(HashMap::new()),
610            meta_cache: Mutex::new(HashMap::new()),
611            cache_file: None,
612            cache_loaded: std::sync::atomic::AtomicBool::new(false),
613            cache_written_at_ms: std::sync::atomic::AtomicI64::new(0),
614            refresh_lock: Mutex::new(()),
615            refreshed_at_ms: std::sync::atomic::AtomicI64::new(0),
616            refreshes_started: std::sync::atomic::AtomicU64::new(0),
617            last_finished_refresh: std::sync::atomic::AtomicU64::new(0),
618            discovery_problems: Mutex::new(Vec::new()),
619            adapters,
620            isolated_roots,
621        }
622    }
623
624    /// Keep the parsed-metadata cache in `path` between launches. Read at the
625    /// first refresh (not here: the file can be tens of MB, and construction
626    /// runs at srv startup), written after the first build and then at most
627    /// every [`META_CACHE_WRITE_INTERVAL_MS`], only when something changed.
628    pub fn with_cache_file(mut self, path: PathBuf) -> Self {
629        self.cache_file = Some(path);
630        self
631    }
632
633    /// Unix ms when the last refresh finished; 0 if none has.
634    pub fn refreshed_at_ms(&self) -> i64 {
635        self.refreshed_at_ms.load(std::sync::atomic::Ordering::Acquire)
636    }
637
638    /// [`Self::refresh`], unless another refresh is already running — then
639    /// `None` at once instead of waiting it out.
640    pub fn try_refresh(&self) -> Option<(u32, u32, u32)> {
641        let guard = match self.refresh_lock.try_lock() {
642            Ok(g) => g,
643            Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
644            Err(std::sync::TryLockError::WouldBlock) => return None,
645        };
646        Some(self.refresh_holding(guard))
647    }
648
649    /// The first build, unless one isn't needed or can't start now: never
650    /// waits on the refresh lock, and checks "already built" while holding it
651    /// (`refreshed_at_ms` is stored under the lock), so it can't follow
652    /// another caller's first build with a second full pass.
653    pub fn try_first_build(&self) -> FirstBuild {
654        let guard = match self.refresh_lock.try_lock() {
655            Ok(g) => g,
656            Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
657            Err(std::sync::TryLockError::WouldBlock) => return FirstBuild::Busy,
658        };
659        if self.refreshed_at_ms() > 0 {
660            return FirstBuild::AlreadyBuilt;
661        }
662        let (discovered, _, _) = self.refresh_holding(guard);
663        FirstBuild::Built { discovered }
664    }
665
666    /// Fill `meta_cache` from the cache file, once per index, if it is still
667    /// empty. A missing, unreadable or other-version file is ignored: the
668    /// refresh then parses everything, as it always did.
669    fn load_cache_file_once(&self) {
670        use std::sync::atomic::Ordering;
671        let Some(path) = &self.cache_file else { return };
672        if self.cache_loaded.swap(true, Ordering::AcqRel) {
673            return;
674        }
675        let Ok(bytes) = std::fs::read(path) else { return };
676        match serde_json::from_slice::<MetaCacheFile>(&bytes) {
677            Ok(file) if file.version == META_CACHE_VERSION => {
678                let mut cache = self.meta_cache.lock().unwrap();
679                if cache.is_empty() {
680                    tracing::info!(entries = file.entries.len(), "history: loaded the saved metadata cache");
681                    *cache = file.entries;
682                }
683            }
684            Ok(file) => tracing::info!(version = file.version, "history: saved metadata cache is another version; rebuilding"),
685            Err(e) => tracing::warn!(error = %e, "history: saved metadata cache unreadable; rebuilding"),
686        }
687    }
688
689    /// Save `cache` to the cache file when it changed: always after the first
690    /// build, then at most every `META_CACHE_WRITE_INTERVAL_MS` (searches also
691    /// refresh, and the file is tens of MB). Written to a temporary file and
692    /// renamed over the old one, so a reader (another srv instance) never sees
693    /// half a file. Failures are logged, never fatal: the cache only saves time.
694    fn write_cache_file(&self, cache: &HashMap<String, (i64, Option<SessionMeta>)>, changed: bool) {
695        use std::sync::atomic::Ordering;
696        let Some(path) = &self.cache_file else { return };
697        let now = chrono::Utc::now().timestamp_millis();
698        let last = self.cache_written_at_ms.load(Ordering::Acquire);
699        if !changed && last > 0 {
700            return;
701        }
702        if last > 0 && now - last < META_CACHE_WRITE_INTERVAL_MS {
703            return;
704        }
705        let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
706        let write = || -> std::io::Result<()> {
707            if let Some(dir) = path.parent() {
708                std::fs::create_dir_all(dir)?;
709            }
710            let body = serde_json::to_vec(&MetaCacheFileRef { version: META_CACHE_VERSION, entries: cache })
711                .map_err(std::io::Error::other)?;
712            std::fs::write(&tmp, body)?;
713            std::fs::rename(&tmp, path)
714        };
715        // Recorded whether or not the write works: an unwritable cache (a
716        // read-only shared dir, a full disk) then waits out the interval too,
717        // instead of being serialized and retried on every refresh — and
718        // searches refresh.
719        self.cache_written_at_ms.store(now.max(1), Ordering::Release);
720        match write() {
721            Ok(()) => tracing::debug!(entries = cache.len(), "history: saved the metadata cache"),
722            Err(e) => {
723                // Never leave a partial or orphaned temporary file (tens of MB,
724                // one per srv process) in the shared dir.
725                let _ = std::fs::remove_file(&tmp);
726                tracing::warn!(error = %e, path = %path.display(), "history: couldn't save the metadata cache");
727            }
728        }
729    }
730
731    /// True if `path` lives under an AgentMux-isolated provider home and is
732    /// therefore safe to delete. Personal global homes are never isolated.
733    fn is_isolated(&self, path: &Path) -> bool {
734        self.isolated_roots.iter().any(|r| path.starts_with(r))
735    }
736
737    /// Full scan: discover all files and extract metadata.
738    /// Returns (discovered, updated, new) counts.
739    pub fn refresh(&self) -> (u32, u32, u32) {
740        let guard = self.refresh_lock.lock().unwrap_or_else(|p| p.into_inner());
741        self.refresh_holding(guard)
742    }
743
744    /// Make the snapshot include every session on disk when this call began,
745    /// sharing the work with concurrent callers. A refresh that started after
746    /// this call began already listed everything this call needs, so once it
747    /// has finished there's nothing left to do; otherwise this runs its own.
748    /// However many searches arrive together, that's at most two walks —
749    /// every search running its own, one after another, put the fourth past
750    /// the MCP's 10 s timeout on a heavy host (Codex P1 on #3693).
751    pub fn refresh_covering_now(&self) {
752        use std::sync::atomic::Ordering;
753        let started_before_this_call = self.refreshes_started.load(Ordering::Acquire);
754        let guard = self.refresh_lock.lock().unwrap_or_else(|p| p.into_inner());
755        if self.last_finished_refresh.load(Ordering::Acquire) > started_before_this_call {
756            return;
757        }
758        self.refresh_holding(guard);
759    }
760
761    /// What the last refresh couldn't read — a session there may be missing
762    /// from the snapshot.
763    pub fn discovery_problems(&self) -> Vec<String> {
764        self.discovery_problems.lock().unwrap().clone()
765    }
766
767    fn refresh_holding(&self, _refreshing: std::sync::MutexGuard<'_, ()>) -> (u32, u32, u32) {
768        use std::sync::atomic::Ordering;
769        let this_refresh = self.refreshes_started.fetch_add(1, Ordering::AcqRel) + 1;
770        let mut problems: Vec<String> = Vec::new();
771        let mut discovered: u32 = 0;
772        let mut updated: u32 = 0;
773        let mut new_count: u32 = 0;
774        self.load_cache_file_once();
775        let old_cache = std::mem::take(&mut *self.meta_cache.lock().unwrap());
776        let mut parsed: u32 = 0;
777        let mut reused: usize = 0;
778        let mut new_cache: HashMap<String, (i64, Option<SessionMeta>)> = HashMap::with_capacity(old_cache.len());
779
780        let mut new_sessions: HashMap<String, SessionMeta> = HashMap::new();
781        let mut new_by_identity: HashMap<String, Vec<String>> = HashMap::new();
782        let mut new_by_working_dir: HashMap<String, Vec<String>> = HashMap::new();
783
784        for adapter in &self.adapters {
785            let files = match adapter.discover() {
786                Ok(found) => {
787                    problems.extend(found.problems);
788                    found.files
789                }
790                Err(e) => {
791                    tracing::warn!(
792                        "history: failed to discover {} files: {}",
793                        adapter.provider(),
794                        e
795                    );
796                    problems.push(format!("{} discovery failed: {e}", adapter.provider()));
797                    continue;
798                }
799            };
800
801            discovered += files.len() as u32;
802
803            for file in &files {
804                let extracted = match old_cache.get(&file.file_path) {
805                    Some((mtime, cached)) if *mtime == file.mtime_ms => {
806                        reused += 1;
807                        Ok(cached.clone())
808                    }
809                    _ => {
810                        parsed += 1;
811                        adapter.extract_meta(&file.file_path)
812                    }
813                };
814                if let Ok(ref meta) = extracted {
815                    new_cache.insert(file.file_path.clone(), (file.mtime_ms, meta.clone()));
816                }
817                match extracted {
818                    Ok(Some(meta)) => {
819                        if !meta.identity_id.is_empty() {
820                            new_by_identity
821                                .entry(meta.identity_id.clone())
822                                .or_default()
823                                .push(meta.session_id.clone());
824                        }
825                        if !meta.working_directory.is_empty() {
826                            new_by_working_dir
827                                .entry(normalize_working_dir(&meta.working_directory))
828                                .or_default()
829                                .push(meta.session_id.clone());
830                        }
831                        new_sessions.insert(meta.session_id.clone(), meta);
832                    }
833                    Ok(None) => {} // empty/invalid session
834                    Err(e) => {
835                        tracing::debug!(
836                            "history: failed to extract meta from {}: {}",
837                            file.file_path,
838                            e
839                        );
840                        // A transcript that couldn't be indexed is missing
841                        // from the snapshot, whoever it belongs to.
842                        problems.push(format!("{}: {e}", file.file_path));
843                    }
844                }
845            }
846        }
847
848        // Compare with existing index
849        let mut sessions = self.sessions.lock().unwrap();
850        for (id, _meta) in &new_sessions {
851            if sessions.contains_key(id) {
852                updated += 1;
853            } else {
854                new_count += 1;
855            }
856        }
857
858        *sessions = new_sessions;
859        *self.by_identity.lock().unwrap() = new_by_identity;
860        *self.by_working_dir.lock().unwrap() = new_by_working_dir;
861        drop(sessions);
862        // Something to save: a transcript parsed afresh, or one gone since.
863        let changed = parsed > 0 || reused < old_cache.len();
864        self.write_cache_file(&new_cache, changed);
865        tracing::debug!(parsed, reused, "history: refresh reused cached metadata");
866        *self.meta_cache.lock().unwrap() = new_cache;
867        if !problems.is_empty() {
868            tracing::warn!(count = problems.len(), first = %problems[0], "history: discovery couldn't read everything");
869        }
870        *self.discovery_problems.lock().unwrap() = problems;
871        self.refreshed_at_ms.store(
872            chrono::Utc::now().timestamp_millis().max(1),
873            Ordering::Release,
874        );
875        self.last_finished_refresh.store(this_refresh, Ordering::Release);
876
877        (discovered, updated, new_count)
878    }
879
880    /// Every session recorded as having run in `working_directory`.
881    ///
882    /// Matching is on the normalized full path ([`normalize_working_dir`]),
883    /// never a substring: `list`'s `project` filter uses `contains`, which
884    /// would make an agent in `/agents/foo` also claim every session from
885    /// `/agents/foo-2`.
886    pub fn list_for_working_directory(&self, working_directory: &str) -> Vec<SessionMeta> {
887        if working_directory.is_empty() {
888            return Vec::new();
889        }
890        let key = normalize_working_dir(working_directory);
891        // `sessions` FIRST — see the lock-order invariant on `by_working_dir`.
892        let sessions = self.sessions.lock().unwrap();
893        let by_working_dir = self.by_working_dir.lock().unwrap();
894        let Some(session_ids) = by_working_dir.get(&key) else {
895            return Vec::new();
896        };
897        session_ids.iter().filter_map(|id| sessions.get(id)).cloned().collect()
898    }
899
900    /// This identity's sessions, sorted/paginated the same way `list` is —
901    /// but via the `by_identity` index instead of scanning every session on
902    /// disk. `identity_id` is a bundle/account id (`SessionMeta::identity_id`
903    /// — see its own doc comment); resolving an `agent_id` to one is the
904    /// caller's job (`Store::agent_identity_list_for_agent`), not this
905    /// index's — a bundle is a filesystem fact, which agent(s) claim it is
906    /// a database fact, and this module has no Store access by design (it's
907    /// pure discovery/indexing over the filesystem).
908    pub fn list_for_identity(
909        &self,
910        identity_id: &str,
911        offset: usize,
912        limit: usize,
913        sort_by: &str,
914        sort_dir: &str,
915    ) -> (Vec<SessionMeta>, u32, bool) {
916        // `sessions` FIRST — see the lock-order invariant on `by_working_dir`.
917        // This ordering was inverted here BEFORE PR #3480 (`refresh()` has
918        // always held `sessions` while swapping `by_identity` in), so the same
919        // AB-BA deadlock was already latent on `main` for the identity index.
920        // Fixed alongside the `by_working_dir` one rather than leaving a known
921        // instance of the bug in place next to its fix.
922        let sessions = self.sessions.lock().unwrap();
923        let by_identity = self.by_identity.lock().unwrap();
924        let Some(session_ids) = by_identity.get(identity_id) else {
925            return (Vec::new(), 0, false);
926        };
927        let mut filtered: Vec<&SessionMeta> =
928            session_ids.iter().filter_map(|id| sessions.get(id)).collect();
929        Self::sort_sessions(&mut filtered, sort_by, sort_dir);
930
931        let total = filtered.len() as u32;
932        let has_more = offset + limit < filtered.len();
933        let page: Vec<SessionMeta> = filtered.into_iter().skip(offset).take(limit).cloned().collect();
934        (page, total, has_more)
935    }
936
937    /// Shared sort logic between `list` (scans everything) and
938    /// `list_for_identity` (scans one identity's sessions) — extracted so
939    /// the two can't silently diverge on sort semantics.
940    pub(crate) fn sort_sessions(filtered: &mut [&SessionMeta], sort_by: &str, sort_dir: &str) {
941        let desc = sort_dir != "asc";
942        match sort_by {
943            "created_at" | "created" => {
944                filtered.sort_by(|a, b| {
945                    if desc {
946                        b.created_at.cmp(&a.created_at)
947                    } else {
948                        a.created_at.cmp(&b.created_at)
949                    }
950                });
951            }
952            "messages" => {
953                filtered.sort_by(|a, b| {
954                    if desc {
955                        b.message_count.cmp(&a.message_count)
956                    } else {
957                        a.message_count.cmp(&b.message_count)
958                    }
959                });
960            }
961            "tokens" => {
962                filtered.sort_by(|a, b| {
963                    if desc {
964                        b.total_tokens.cmp(&a.total_tokens)
965                    } else {
966                        a.total_tokens.cmp(&b.total_tokens)
967                    }
968                });
969            }
970            _ => {
971                // Default: modified_at desc
972                filtered.sort_by(|a, b| {
973                    if desc {
974                        b.modified_at.cmp(&a.modified_at)
975                    } else {
976                        a.modified_at.cmp(&b.modified_at)
977                    }
978                });
979            }
980        }
981    }
982
983    /// List sessions with pagination and optional filters.
984    pub fn list(
985        &self,
986        provider: Option<&str>,
987        project: Option<&str>,
988        offset: usize,
989        limit: usize,
990        sort_by: &str,
991        sort_dir: &str,
992    ) -> (Vec<SessionMeta>, u32, bool) {
993        let sessions = self.sessions.lock().unwrap();
994
995        let mut filtered: Vec<&SessionMeta> = sessions
996            .values()
997            .filter(|s| {
998                if let Some(p) = provider {
999                    if s.provider != p {
1000                        return false;
1001                    }
1002                }
1003                if let Some(proj) = project {
1004                    if !s.working_directory.contains(proj) {
1005                        return false;
1006                    }
1007                }
1008                true
1009            })
1010            .collect();
1011
1012        Self::sort_sessions(&mut filtered, sort_by, sort_dir);
1013
1014        let total = filtered.len() as u32;
1015        let has_more = offset + limit < filtered.len();
1016        let page: Vec<SessionMeta> = filtered
1017            .into_iter()
1018            .skip(offset)
1019            .take(limit)
1020            .cloned()
1021            .collect();
1022
1023        (page, total, has_more)
1024    }
1025
1026    /// Get a session by ID — returns just the meta from index.
1027    pub fn get_meta(&self, session_id: &str) -> Option<SessionMeta> {
1028        let sessions = self.sessions.lock().unwrap();
1029        sessions.get(session_id).cloned()
1030    }
1031
1032    /// Full parse of a session by ID.
1033    pub fn get_full(&self, session_id: &str) -> Result<Option<HistorySession>, HistoryError> {
1034        let meta = match self.get_meta(session_id) {
1035            Some(m) => m,
1036            None => return Ok(None),
1037        };
1038
1039        // Find the adapter for this provider
1040        for adapter in &self.adapters {
1041            if adapter.provider() == meta.provider {
1042                return adapter.parse_file(&meta.file_path);
1043            }
1044        }
1045
1046        Err(HistoryError::Other(format!(
1047            "no adapter for provider: {}",
1048            meta.provider
1049        )))
1050    }
1051
1052    /// Search message content and tool calls across a specific, already-chosen
1053    /// set of sessions.
1054    ///
1055    /// `SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md` §3.1/§3.2. The caller picks
1056    /// the candidate sessions (normally `HistoryService::search_for_agent`,
1057    /// which resolves them from the agent's linked identities and applies the
1058    /// `since`/`until` window) — this function only scans what it is handed,
1059    /// so the expensive `get_full` parse never touches a session the caller
1060    /// already ruled out on cheap indexed metadata.
1061    ///
1062    /// Bounded deliberately: it stops at `limit` hits and reports whether it
1063    /// stopped early. **That signal is load-bearing.** A search that silently
1064    /// truncates and returns nothing reproduces the exact failure this whole
1065    /// feature exists to prevent — an agent concluding "I never did that" from
1066    /// an incomplete audit — so "no matches" and "ran out of budget" must never
1067    /// look the same to a caller. The same goes for every other way a
1068    /// candidate goes unread (a `max_sessions` cut, a file that won't parse):
1069    /// each is counted, and any of them makes the answer not `complete`.
1070    #[cfg(test)]
1071    pub fn search_sessions(
1072        &self,
1073        candidates: &[SessionMeta],
1074        opts: &HistorySearchOptions,
1075    ) -> HistorySearchOutcome {
1076        let candidates: Vec<SearchCandidate> = candidates
1077            .iter()
1078            .map(|meta| SearchCandidate {
1079                meta: meta.clone(),
1080                source: Source::Transcript,
1081                attribution: Attribution::AgentRecord,
1082            })
1083            .collect();
1084        search_candidates(&candidates, opts, &|c| self.get_full(&c.meta.session_id))
1085    }
1086
1087    /// Check if the index has been populated.
1088    #[cfg(test)]
1089    pub fn is_empty(&self) -> bool {
1090        self.sessions.lock().unwrap().is_empty()
1091    }
1092
1093    /// Delete a session: remove its on-disk transcript (and the sibling
1094    /// `<session_id>/` subagents dir Claude keeps next to it) and drop it from
1095    /// the index. Returns Ok(true) if a file was removed, Ok(false) if the
1096    /// session id is unknown.
1097    pub fn delete(&self, session_id: &str) -> Result<bool, HistoryError> {
1098        let meta = match self.get_meta(session_id) {
1099            Some(m) => m,
1100            None => return Ok(false),
1101        };
1102        let path = Path::new(&meta.file_path);
1103        // Safety: only delete inside AgentMux-isolated homes — never the user's
1104        // personal Claude/Codex/... transcripts that the browse also surfaces.
1105        if !self.is_isolated(path) {
1106            return Err(HistoryError::Other(format!(
1107                "refusing to delete '{}' — it lives in your personal {} home, not AgentMux's",
1108                meta.file_path, meta.provider
1109            )));
1110        }
1111        // Treat an already-removed file (GC'd out-of-band) as success so the
1112        // stale index entry still gets dropped (otherwise clear-all can't
1113        // converge and the index stays non-empty).
1114        match std::fs::remove_file(path) {
1115            Ok(()) => {}
1116            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1117            Err(e) => return Err(e.into()),
1118        }
1119        // Claude stores subagent transcripts in a sibling `<session_id>/` dir;
1120        // remove it too so the clear actually frees the space. Best-effort.
1121        if let Some(parent) = path.parent() {
1122            let sidecar = parent.join(session_id);
1123            if sidecar.is_dir() {
1124                let _ = std::fs::remove_dir_all(&sidecar);
1125            }
1126        }
1127        self.sessions.lock().unwrap().remove(session_id);
1128        Ok(true)
1129    }
1130
1131    /// Bulk-delete all indexed sessions matching the optional provider/project
1132    /// filter (no filter = clear everything). Returns the number removed.
1133    pub fn clear(&self, provider: Option<&str>, project: Option<&str>) -> u32 {
1134        // Snapshot matching ids while holding the lock, then do fs ops without
1135        // it (delete() re-locks per id).
1136        let ids: Vec<String> = {
1137            let sessions = self.sessions.lock().unwrap();
1138            sessions
1139                .values()
1140                .filter(|s| provider.map_or(true, |p| s.provider == p))
1141                .filter(|s| project.map_or(true, |proj| s.working_directory.contains(proj)))
1142                // Never bulk-delete the user's personal global transcripts.
1143                .filter(|s| self.is_isolated(Path::new(&s.file_path)))
1144                .map(|s| s.session_id.clone())
1145                .collect()
1146        };
1147        let mut deleted = 0;
1148        for id in ids {
1149            if matches!(self.delete(&id), Ok(true)) {
1150                deleted += 1;
1151            }
1152        }
1153        deleted
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160    use std::fs;
1161    use std::path::PathBuf;
1162
1163    /// Adapter that "discovers" caller-supplied files and derives the session id
1164    /// from each file stem. Just enough to populate the index for delete tests.
1165    struct MockAdapter {
1166        files: Vec<DiscoveredFile>,
1167        provider: String,
1168        working_directory: String,
1169        identity_id: String,
1170    }
1171    impl HistoryAdapter for MockAdapter {
1172        fn provider(&self) -> &str {
1173            &self.provider
1174        }
1175        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1176            Ok(self
1177                .files
1178                .iter()
1179                .map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms })
1180                .collect())
1181        }
1182        fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1183            let id = PathBuf::from(file_path)
1184                .file_stem()
1185                .map(|s| s.to_string_lossy().into_owned())
1186                .unwrap_or_default();
1187            Ok(Some(SessionMeta {
1188                session_id: id,
1189                file_path: file_path.to_string(),
1190                provider: self.provider.clone(),
1191                model: String::new(),
1192                slug: String::new(),
1193                working_directory: self.working_directory.clone(),
1194                created_at: 0,
1195                modified_at: 0,
1196                message_count: 0,
1197                first_user_message: String::new(),
1198                file_size_bytes: 0,
1199                git_branch: String::new(),
1200                total_tokens: 0,
1201                subagent_count: 0,
1202                identity_id: self.identity_id.clone(),
1203                starts_undated: false,
1204            }))
1205        }
1206        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
1207            Ok(None)
1208        }
1209    }
1210
1211    fn write_session(dir: &std::path::Path, id: &str) -> String {
1212        let f = dir.join(format!("{id}.jsonl"));
1213        fs::write(&f, b"{}").unwrap();
1214        f.to_string_lossy().into_owned()
1215    }
1216
1217    #[test]
1218    fn delete_removes_file_and_index_entry() {
1219        let dir = std::env::temp_dir().join(format!("amux-hist-del-{}", std::process::id()));
1220        fs::create_dir_all(&dir).unwrap();
1221        let fp = write_session(&dir, "sess-abc");
1222        // A sibling subagents dir should be removed too.
1223        let sidecar = dir.join("sess-abc");
1224        fs::create_dir_all(sidecar.join("subagents")).unwrap();
1225
1226        let idx = SessionIndex::with_isolated_roots(
1227            vec![Box::new(MockAdapter {
1228                files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
1229                provider: "mock".into(),
1230                working_directory: "/proj".into(),
1231                identity_id: String::new(),
1232            })],
1233            vec![dir.clone()],
1234        );
1235        idx.refresh();
1236        assert!(idx.get_meta("sess-abc").is_some());
1237
1238        assert!(matches!(idx.delete("sess-abc"), Ok(true)));
1239        assert!(!std::path::Path::new(&fp).exists(), "transcript removed");
1240        assert!(!sidecar.exists(), "subagents sidecar removed");
1241        assert!(idx.get_meta("sess-abc").is_none(), "dropped from index");
1242        assert!(matches!(idx.delete("sess-abc"), Ok(false)), "second delete is a no-op");
1243
1244        let _ = fs::remove_dir_all(&dir);
1245    }
1246
1247    #[test]
1248    fn clear_respects_provider_filter() {
1249        let dir = std::env::temp_dir().join(format!("amux-hist-clr-{}", std::process::id()));
1250        fs::create_dir_all(&dir).unwrap();
1251        let a = write_session(&dir, "a");
1252        let b = write_session(&dir, "b");
1253
1254        let idx = SessionIndex::with_isolated_roots(
1255            vec![Box::new(MockAdapter {
1256                files: vec![
1257                    DiscoveredFile { file_path: a.clone(), mtime_ms: 0 },
1258                    DiscoveredFile { file_path: b.clone(), mtime_ms: 0 },
1259                ],
1260                provider: "mock".into(),
1261                working_directory: "/proj".into(),
1262                identity_id: String::new(),
1263            })],
1264            vec![dir.clone()],
1265        );
1266        idx.refresh();
1267
1268        // Non-matching provider clears nothing; matching clears both.
1269        assert_eq!(idx.clear(Some("other"), None), 0);
1270        assert!(std::path::Path::new(&a).exists());
1271        assert_eq!(idx.clear(Some("mock"), None), 2);
1272        assert!(!std::path::Path::new(&a).exists() && !std::path::Path::new(&b).exists());
1273        assert!(idx.is_empty());
1274
1275        let _ = fs::remove_dir_all(&dir);
1276    }
1277
1278    #[test]
1279    fn refuses_to_delete_outside_isolated_roots() {
1280        // A session that lives OUTSIDE the isolated roots (i.e. the user's
1281        // personal global home) must never be deletable.
1282        let dir = std::env::temp_dir().join(format!("amux-hist-guard-{}", std::process::id()));
1283        fs::create_dir_all(&dir).unwrap();
1284        let fp = write_session(&dir, "personal-sess");
1285
1286        let idx = SessionIndex::with_isolated_roots(
1287            vec![Box::new(MockAdapter {
1288                files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
1289                provider: "claude".into(),
1290                working_directory: "/home/me/.claude".into(),
1291                identity_id: String::new(),
1292            })],
1293            // isolated roots deliberately do NOT include `dir`.
1294            vec![std::env::temp_dir().join("amux-some-other-isolated-root")],
1295        );
1296        idx.refresh();
1297
1298        assert!(idx.delete("personal-sess").is_err(), "single delete must refuse");
1299        assert!(std::path::Path::new(&fp).exists(), "file must be intact");
1300        assert_eq!(idx.clear(None, None), 0, "clear-all must skip it");
1301        assert!(std::path::Path::new(&fp).exists(), "still intact after clear");
1302
1303        let _ = fs::remove_dir_all(&dir);
1304    }
1305
1306    // docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md
1307    // §4.4: list_for_identity is the actual "fast lookup" this whole step
1308    // exists for — an O(sessions for this identity) HashMap-backed lookup
1309    // instead of list()'s O(total sessions on disk) scan.
1310
1311    #[test]
1312    fn list_for_identity_returns_only_that_identitys_sessions() {
1313        let dir = std::env::temp_dir().join(format!("amux-hist-idx-{}", std::process::id()));
1314        fs::create_dir_all(&dir).unwrap();
1315        let a1 = write_session(&dir, "a1");
1316        let a2 = write_session(&dir, "a2");
1317        let b1 = write_session(&dir, "b1");
1318        let unattributed = write_session(&dir, "unattributed");
1319
1320        let idx = SessionIndex::with_isolated_roots(
1321            vec![
1322                Box::new(MockAdapter {
1323                    files: vec![
1324                        DiscoveredFile { file_path: a1, mtime_ms: 1 },
1325                        DiscoveredFile { file_path: a2, mtime_ms: 2 },
1326                    ],
1327                    provider: "mock".into(),
1328                    working_directory: "/proj".into(),
1329                    identity_id: "identity-a".into(),
1330                }),
1331                Box::new(MockAdapter {
1332                    files: vec![DiscoveredFile { file_path: b1, mtime_ms: 1 }],
1333                    provider: "mock".into(),
1334                    working_directory: "/proj".into(),
1335                    identity_id: "identity-b".into(),
1336                }),
1337                Box::new(MockAdapter {
1338                    files: vec![DiscoveredFile { file_path: unattributed, mtime_ms: 1 }],
1339                    provider: "mock".into(),
1340                    working_directory: "/proj".into(),
1341                    identity_id: String::new(),
1342                }),
1343            ],
1344            vec![dir.clone()],
1345        );
1346        idx.refresh();
1347
1348        let (sessions_a, total_a, _) = idx.list_for_identity("identity-a", 0, 10, "created_at", "desc");
1349        assert_eq!(total_a, 2);
1350        let ids_a: std::collections::HashSet<_> = sessions_a.iter().map(|s| s.session_id.clone()).collect();
1351        assert_eq!(ids_a, std::collections::HashSet::from(["a1".to_string(), "a2".to_string()]));
1352
1353        let (sessions_b, total_b, _) = idx.list_for_identity("identity-b", 0, 10, "created_at", "desc");
1354        assert_eq!(total_b, 1);
1355        assert_eq!(sessions_b[0].session_id, "b1");
1356
1357        let _ = fs::remove_dir_all(&dir);
1358    }
1359
1360    #[test]
1361    fn list_for_identity_returns_empty_for_an_unknown_identity() {
1362        let idx = SessionIndex::with_isolated_roots(vec![], vec![]);
1363        idx.refresh();
1364        let (sessions, total, has_more) = idx.list_for_identity("no-such-identity", 0, 10, "created_at", "desc");
1365        assert!(sessions.is_empty());
1366        assert_eq!(total, 0);
1367        assert!(!has_more);
1368    }
1369
1370    #[test]
1371    fn list_for_identity_paginates_and_respects_sort_direction() {
1372        let dir = std::env::temp_dir().join(format!("amux-hist-idx-page-{}", std::process::id()));
1373        fs::create_dir_all(&dir).unwrap();
1374        let files: Vec<DiscoveredFile> = (0..3)
1375            .map(|i| DiscoveredFile { file_path: write_session(&dir, &format!("s{i}")), mtime_ms: i })
1376            .collect();
1377
1378        let idx = SessionIndex::with_isolated_roots(
1379            vec![Box::new(MockAdapter {
1380                files,
1381                provider: "mock".into(),
1382                working_directory: "/proj".into(),
1383                identity_id: "identity-a".into(),
1384            })],
1385            vec![dir.clone()],
1386        );
1387        idx.refresh();
1388
1389        let (page1, total, has_more) = idx.list_for_identity("identity-a", 0, 2, "created_at", "desc");
1390        assert_eq!(total, 3);
1391        assert_eq!(page1.len(), 2);
1392        assert!(has_more);
1393        let (page2, _, has_more2) = idx.list_for_identity("identity-a", 2, 2, "created_at", "desc");
1394        assert_eq!(page2.len(), 1);
1395        assert!(!has_more2);
1396
1397        let _ = fs::remove_dir_all(&dir);
1398    }
1399
1400    /// Hammers the two conflicting lock orders against each other on the same
1401    /// `Arc<SessionIndex>` a real deployment shares between requests.
1402    ///
1403    /// A deadlock does not *fail* a test, it hangs it — and a hung test hangs
1404    /// CI rather than reporting anything useful. So the workers signal a
1405    /// channel and the assertion is a bounded `recv_timeout`: a deadlock
1406    /// becomes a normal test failure with a readable message, and the threads
1407    /// are deliberately not joined (joining a deadlocked thread would hang
1408    /// exactly as badly as the bug).
1409    ///
1410    /// Against the pre-fix ordering — `refresh()` taking `sessions ->
1411    /// by_working_dir` while `list_for_working_directory()` took
1412    /// `by_working_dir -> sessions` — this wedges. reagentx P1 on PR #3480.
1413    #[test]
1414    fn concurrent_refresh_and_lookups_do_not_deadlock() {
1415        use std::sync::mpsc;
1416        use std::sync::Arc;
1417        use std::time::Duration;
1418
1419        let dir = std::env::temp_dir().join(format!("amux-hist-lockorder-{}", std::process::id()));
1420        std::fs::create_dir_all(&dir).unwrap();
1421        let f = write_session(&dir, "lock-order-session");
1422
1423        let index = Arc::new(SessionIndex::with_isolated_roots(
1424            vec![Box::new(MockAdapter {
1425                files: vec![DiscoveredFile { file_path: f, mtime_ms: 1 }],
1426                provider: "mock".into(),
1427                working_directory: "/proj".into(),
1428                identity_id: "acct-lock".into(),
1429            })],
1430            vec![dir.clone()],
1431        ));
1432
1433        const ITERATIONS: usize = 300;
1434        let (tx, rx) = mpsc::channel();
1435
1436        for worker in 0..3 {
1437            let idx = Arc::clone(&index);
1438            let tx = tx.clone();
1439            std::thread::spawn(move || {
1440                for _ in 0..ITERATIONS {
1441                    match worker {
1442                        0 => {
1443                            idx.refresh();
1444                        }
1445                        1 => {
1446                            idx.list_for_working_directory("/proj");
1447                        }
1448                        _ => {
1449                            idx.list_for_identity("acct-lock", 0, 10, "created_at", "desc");
1450                        }
1451                    }
1452                }
1453                let _ = tx.send(worker);
1454            });
1455        }
1456        drop(tx);
1457
1458        for _ in 0..3 {
1459            rx.recv_timeout(Duration::from_secs(30)).expect(
1460                "deadlock: a worker never finished. `refresh()` holds `sessions` while \
1461                 swapping the index maps, so every reader must take `sessions` FIRST — \
1462                 see the lock-order invariant on `SessionIndex::by_working_dir`",
1463            );
1464        }
1465
1466        std::fs::remove_dir_all(&dir).ok();
1467    }
1468
1469    #[test]
1470    fn refresh_rebuilds_the_identity_index_from_scratch_each_time() {
1471        // A session that's re-discovered under a DIFFERENT identity on a
1472        // second refresh (e.g. its bundle got re-linked) must not leave a
1473        // stale entry under the old identity_id.
1474        let dir = std::env::temp_dir().join(format!("amux-hist-idx-rebuild-{}", std::process::id()));
1475        fs::create_dir_all(&dir).unwrap();
1476        let fp = write_session(&dir, "moved");
1477
1478        let idx = SessionIndex::with_isolated_roots(
1479            vec![Box::new(MockAdapter {
1480                files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 1 }],
1481                provider: "mock".into(),
1482                working_directory: "/proj".into(),
1483                identity_id: "identity-old".into(),
1484            })],
1485            vec![dir.clone()],
1486        );
1487        idx.refresh();
1488        assert_eq!(idx.list_for_identity("identity-old", 0, 10, "created_at", "desc").1, 1);
1489
1490        let idx2 = SessionIndex::with_isolated_roots(
1491            vec![Box::new(MockAdapter {
1492                files: vec![DiscoveredFile { file_path: fp, mtime_ms: 1 }],
1493                provider: "mock".into(),
1494                working_directory: "/proj".into(),
1495                identity_id: "identity-new".into(),
1496            })],
1497            vec![dir.clone()],
1498        );
1499        idx2.refresh();
1500        assert_eq!(idx2.list_for_identity("identity-old", 0, 10, "created_at", "desc").1, 0, "stale identity must not linger");
1501        assert_eq!(idx2.list_for_identity("identity-new", 0, 10, "created_at", "desc").1, 1);
1502
1503        let _ = fs::remove_dir_all(&dir);
1504    }
1505
1506    // ── History search (SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md) ──
1507
1508    /// Adapter serving canned messages, recording which files were actually
1509    /// opened — the recording is what lets a test prove the search never
1510    /// parses a session the caller already excluded.
1511    struct SearchMockAdapter {
1512        sessions: HashMap<String, Vec<HistoryMessage>>,
1513        parsed: Mutex<Vec<String>>,
1514    }
1515
1516    impl HistoryAdapter for SearchMockAdapter {
1517        fn provider(&self) -> &str {
1518            "mock"
1519        }
1520        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1521            Ok(self
1522                .sessions
1523                .keys()
1524                .map(|id| DiscoveredFile { file_path: format!("/tmp/{id}.jsonl"), mtime_ms: 0 })
1525                .collect())
1526        }
1527        fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1528            let id = PathBuf::from(file_path)
1529                .file_stem()
1530                .map(|s| s.to_string_lossy().into_owned())
1531                .unwrap_or_default();
1532            Ok(Some(SessionMeta {
1533                session_id: id,
1534                file_path: file_path.to_string(),
1535                provider: "mock".into(),
1536                model: String::new(),
1537                slug: String::new(),
1538                working_directory: "/proj".into(),
1539                created_at: 0,
1540                modified_at: 0,
1541                message_count: 0,
1542                first_user_message: String::new(),
1543                file_size_bytes: 0,
1544                git_branch: String::new(),
1545                total_tokens: 0,
1546                subagent_count: 0,
1547                identity_id: String::new(),
1548                starts_undated: false,
1549            }))
1550        }
1551        fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
1552            self.parsed.lock().unwrap().push(file_path.to_string());
1553            let id = PathBuf::from(file_path)
1554                .file_stem()
1555                .map(|s| s.to_string_lossy().into_owned())
1556                .unwrap_or_default();
1557            if id.starts_with("corrupt") {
1558                return Err(HistoryError::Other("unexpected end of JSON".into()));
1559            }
1560            let Some(messages) = self.sessions.get(&id) else { return Ok(None) };
1561            let meta = self.extract_meta(file_path)?.unwrap();
1562            let skipped_records = if id.starts_with("partial") { 2 } else { 0 };
1563            Ok(Some(HistorySession { meta, messages: messages.clone(), skipped_records }))
1564        }
1565    }
1566
1567    fn msg(role: &str, content: &str, tools: Vec<(&str, &str)>) -> HistoryMessage {
1568        HistoryMessage {
1569            role: role.to_string(),
1570            content: content.to_string(),
1571            timestamp: 100,
1572            tool_uses: tools
1573                .into_iter()
1574                .map(|(n, a)| ToolUseSummary { name: n.to_string(), argument_summary: a.to_string() })
1575                .collect(),
1576        }
1577    }
1578
1579    fn search_index(sessions: Vec<(&str, Vec<HistoryMessage>)>) -> SessionIndex {
1580        let map: HashMap<String, Vec<HistoryMessage>> =
1581            sessions.into_iter().map(|(id, m)| (id.to_string(), m)).collect();
1582        let idx = SessionIndex::new(vec![Box::new(SearchMockAdapter {
1583            sessions: map,
1584            parsed: Mutex::new(Vec::new()),
1585        })]);
1586        idx.refresh();
1587        idx
1588    }
1589
1590    fn opts(query: &str) -> HistorySearchOptions {
1591        HistorySearchOptions { query: query.into(), limit: 50, ..Default::default() }
1592    }
1593
1594    fn at(timestamp: i64, content: &str) -> HistoryMessage {
1595        HistoryMessage { timestamp, ..msg("assistant", content, vec![]) }
1596    }
1597
1598    #[test]
1599    fn a_bare_tool_name_matches_the_mcp_prefixed_call() {
1600        // Claude records an MCP tool with its server prefix, while callers —
1601        // and SearchHistory's own schema — name it bare. "Did I call
1602        // SendMessage" must find the AgentMux tool, not only the built-in one.
1603        let idx = search_index(vec![(
1604            "s1",
1605            vec![msg("assistant", "sent", vec![("mcp__agentmux__SendMessage", r#"{"to":"Agent4"}"#)])],
1606        )]);
1607        let cands = vec![idx.get_meta("s1").unwrap()];
1608        for wanted in ["SendMessage", "sendmessage", "mcp__agentmux__SendMessage"] {
1609            let mut o = opts("");
1610            o.tool = Some(wanted.into());
1611            let out = idx.search_sessions(&cands, &o);
1612            assert_eq!(out.hits.len(), 1, "tool: {wanted}");
1613            assert_eq!(out.hits[0].tool_name.as_deref(), Some("mcp__agentmux__SendMessage"));
1614        }
1615        let mut other = opts("");
1616        other.tool = Some("Message".into());
1617        assert!(idx.search_sessions(&cands, &other).hits.is_empty(), "a suffix alone is not a name");
1618    }
1619
1620    #[test]
1621    fn messages_outside_the_time_window_are_not_hits() {
1622        let idx = search_index(vec![(
1623            "s1",
1624            vec![at(1_000, "deploy early"), at(5_000, "deploy late"), at(0, "deploy undated")],
1625        )]);
1626        let cands = vec![idx.get_meta("s1").unwrap()];
1627        let mut o = opts("deploy");
1628        o.since_ms = Some(2_000);
1629        let snippets: Vec<String> = idx.search_sessions(&cands, &o).hits.into_iter().map(|h| h.snippet).collect();
1630        // An undated message can't be placed outside the window, and dropping
1631        // it could only hide evidence.
1632        assert_eq!(snippets, vec!["deploy late", "deploy undated"]);
1633
1634        let mut o = opts("deploy");
1635        o.until_ms = Some(2_000);
1636        let snippets: Vec<String> = idx.search_sessions(&cands, &o).hits.into_iter().map(|h| h.snippet).collect();
1637        assert_eq!(snippets, vec!["deploy early", "deploy undated"]);
1638    }
1639
1640    #[test]
1641    fn a_full_search_says_it_is_complete() {
1642        let idx = search_index(vec![("s1", vec![msg("assistant", "nothing relevant", vec![])])]);
1643        let cands = vec![idx.get_meta("s1").unwrap()];
1644        let out = idx.search_sessions(&cands, &opts("never said"));
1645        assert!(out.hits.is_empty());
1646        assert!(out.complete, "every candidate was read to the end");
1647        assert!(out.incomplete_reasons.is_empty());
1648    }
1649
1650    #[test]
1651    fn a_max_sessions_cut_is_counted_and_makes_the_answer_incomplete() {
1652        let idx = search_index(vec![
1653            ("a", vec![msg("assistant", "x", vec![])]),
1654            ("b", vec![msg("assistant", "x", vec![])]),
1655            ("c", vec![msg("assistant", "x", vec![])]),
1656        ]);
1657        let cands: Vec<SessionMeta> = ["a", "b", "c"].iter().map(|id| idx.get_meta(id).unwrap()).collect();
1658        let mut o = opts("absent");
1659        o.max_sessions = Some(1);
1660        let out = idx.search_sessions(&cands, &o);
1661        assert_eq!(out.total_sessions, 3, "counted before the cut");
1662        assert_eq!(out.sessions_scanned, 1);
1663        assert_eq!(out.sessions_skipped, 2);
1664        assert!(!out.complete);
1665        assert_eq!(out.incomplete_reasons, vec!["max_sessions"]);
1666    }
1667
1668    #[test]
1669    fn a_session_with_unreadable_records_makes_the_answer_incomplete() {
1670        // What the session's readable records hold is still found; that it
1671        // had records the search couldn't read is reported, so "no hit" for
1672        // anything else isn't taken as proof (Codex P1 on #3693).
1673        let idx = search_index(vec![("partial-1", vec![msg("assistant", "deploy done", vec![])])]);
1674        let cands = vec![idx.get_meta("partial-1").unwrap()];
1675        let out = idx.search_sessions(&cands, &opts("deploy"));
1676        assert_eq!(out.hits.len(), 1);
1677        assert_eq!(out.sessions_scanned, 1, "it was read, partly");
1678        assert_eq!(out.sessions_partly_read.len(), 1);
1679        assert_eq!(out.sessions_partly_read[0].session_id, "partial-1");
1680        assert!(out.sessions_partly_read[0].reason.contains("2 record"), "{}", out.sessions_partly_read[0].reason);
1681        assert!(!out.complete);
1682        assert_eq!(out.incomplete_reasons, vec!["unreadable_records"]);
1683    }
1684
1685    #[test]
1686    fn an_unreadable_session_is_reported_not_counted_as_scanned() {
1687        let idx = search_index(vec![
1688            ("good", vec![msg("assistant", "x", vec![])]),
1689            ("corrupt-1", vec![]),
1690        ]);
1691        let cands: Vec<SessionMeta> =
1692            ["good", "corrupt-1"].iter().map(|id| idx.get_meta(id).unwrap()).collect();
1693        let out = idx.search_sessions(&cands, &opts("absent"));
1694        assert_eq!(out.sessions_scanned, 1, "only the session actually read");
1695        assert_eq!(out.sessions_unreadable.len(), 1);
1696        assert_eq!(out.sessions_unreadable[0].session_id, "corrupt-1");
1697        assert!(out.sessions_unreadable[0].reason.contains("unexpected end of JSON"));
1698        assert!(!out.complete);
1699        assert_eq!(out.incomplete_reasons, vec!["unreadable_sessions"]);
1700    }
1701
1702    #[test]
1703    fn search_matches_message_content() {
1704        let idx = search_index(vec![("s1", vec![msg("assistant", "I merged the WAN signing PR", vec![])])]);
1705        let cands = vec![idx.get_meta("s1").unwrap()];
1706        let out = idx.search_sessions(&cands, &opts("wan signing"));
1707        assert_eq!(out.hits.len(), 1);
1708        assert!(out.hits[0].snippet.contains("WAN signing"), "snippet carries the match");
1709        assert!(!out.truncated);
1710    }
1711
1712    #[test]
1713    fn search_matches_a_tool_call_structurally_not_just_prose() {
1714        // The question that motivated this whole feature: "did I call
1715        // SendMessage to Agent4" must be answerable even when no prose in the
1716        // transcript mentions it.
1717        let args = r#"{"to":"Agent4","message":"following up"}"#;
1718        let idx = search_index(vec![(
1719            "s1",
1720            vec![msg("assistant", "sure, sending now", vec![("SendMessage", args)])],
1721        )]);
1722        let cands = vec![idx.get_meta("s1").unwrap()];
1723
1724        let mut o = opts("");
1725        o.tool = Some("sendmessage".into()); // case-insensitive
1726        let out = idx.search_sessions(&cands, &o);
1727        assert_eq!(out.hits.len(), 1);
1728        assert_eq!(out.hits[0].tool_name.as_deref(), Some("SendMessage"));
1729        assert!(out.hits[0].snippet.contains("Agent4"));
1730
1731        // And filtering by argument content within that tool.
1732        let mut o2 = opts("agent4");
1733        o2.tool = Some("SendMessage".into());
1734        assert_eq!(idx.search_sessions(&cands, &o2).hits.len(), 1);
1735        let mut o3 = opts("agent9");
1736        o3.tool = Some("SendMessage".into());
1737        assert_eq!(idx.search_sessions(&cands, &o3).hits.len(), 0);
1738    }
1739
1740    #[test]
1741    fn an_exhausted_budget_is_distinguishable_from_no_matches() {
1742        // The single most important property here. A search that silently
1743        // stops early and returns nothing recreates the exact failure this
1744        // feature exists to prevent: an agent concluding "I never did that"
1745        // from an incomplete audit.
1746        let many: Vec<HistoryMessage> =
1747            (0..10).map(|i| msg("assistant", &format!("hit number {i}"), vec![])).collect();
1748        let idx = search_index(vec![("s1", many)]);
1749        let cands = vec![idx.get_meta("s1").unwrap()];
1750
1751        let mut limited = opts("hit number");
1752        limited.limit = 3;
1753        let out = idx.search_sessions(&cands, &limited);
1754        assert_eq!(out.hits.len(), 3);
1755        assert!(out.truncated, "stopping on budget must be reported");
1756        assert!(!out.complete);
1757        assert_eq!(out.incomplete_reasons, vec!["hit_limit"]);
1758
1759        let absent = idx.search_sessions(&cands, &opts("never appears anywhere"));
1760        assert!(absent.hits.is_empty());
1761        assert!(!absent.truncated, "genuinely-no-matches must NOT look truncated");
1762        assert!(absent.complete);
1763    }
1764
1765    #[test]
1766    fn search_only_opens_the_sessions_it_is_handed() {
1767        // What makes the since/until window cheap: excluded candidates are
1768        // never parsed, because the caller filters on indexed metadata first.
1769        let idx = search_index(vec![
1770            ("keep", vec![msg("assistant", "findme", vec![])]),
1771            ("skip", vec![msg("assistant", "findme", vec![])]),
1772        ]);
1773        let cands = vec![idx.get_meta("keep").unwrap()];
1774        let out = idx.search_sessions(&cands, &opts("findme"));
1775        assert_eq!(out.hits.len(), 1);
1776        assert_eq!(out.sessions_scanned, 1);
1777        assert_eq!(out.total_sessions, 1);
1778        assert_eq!(out.hits[0].session_id, "keep");
1779    }
1780
1781    #[test]
1782    fn role_filter_narrows_to_one_side_of_the_conversation() {
1783        let idx = search_index(vec![(
1784            "s1",
1785            vec![msg("user", "deploy it", vec![]), msg("assistant", "deploy it? confirming first", vec![])],
1786        )]);
1787        let cands = vec![idx.get_meta("s1").unwrap()];
1788        let mut o = opts("deploy it");
1789        o.role = Some("user".into());
1790        let out = idx.search_sessions(&cands, &o);
1791        assert_eq!(out.hits.len(), 1);
1792        assert_eq!(out.hits[0].role, "user");
1793    }
1794
1795    #[test]
1796    fn a_snippet_is_bounded_even_for_a_pathologically_large_message() {
1797        // This tool must never be the reason a caller blows its own context.
1798        let huge = format!("{}NEEDLE{}", "x".repeat(50_000), "y".repeat(50_000));
1799        let idx = search_index(vec![("s1", vec![msg("assistant", &huge, vec![])])]);
1800        let cands = vec![idx.get_meta("s1").unwrap()];
1801        let out = idx.search_sessions(&cands, &opts("needle"));
1802        assert_eq!(out.hits.len(), 1);
1803        assert!(out.hits[0].snippet.chars().count() <= SNIPPET_MAX_CHARS + 2, "snippet stays bounded");
1804        assert!(out.hits[0].snippet.contains("NEEDLE"), "and still shows the match");
1805    }
1806
1807    // ── Snippet/offset correctness (reagentx P1s on PR #3321) ──
1808    //
1809    // All three of these fail against the original implementation. They are
1810    // about the same underlying mistake in three places: an offset computed
1811    // against one string and then used to slice a different one.
1812
1813    #[test]
1814    fn a_tool_hit_snippet_contains_the_match_even_far_into_a_long_argument() {
1815        // The snippet used to be cut from offset 0 whenever a `tool` filter
1816        // was set, so a match past SNIPPET_MAX_CHARS was reported as a hit
1817        // whose evidence did not contain it. A hit whose snippet omits the
1818        // match is worse than no hit — it looks like verification.
1819        let args = format!("{}NEEDLE_TARGET{}", "p".repeat(3_000), "q".repeat(100));
1820        let idx = search_index(vec![("s1", vec![msg("assistant", "sent", vec![("SendMessage", &args)])])]);
1821        let cands = vec![idx.get_meta("s1").unwrap()];
1822
1823        let mut o = opts("needle_target");
1824        o.tool = Some("SendMessage".into());
1825        let out = idx.search_sessions(&cands, &o);
1826        assert_eq!(out.hits.len(), 1);
1827        assert!(
1828            out.hits[0].snippet.contains("NEEDLE_TARGET"),
1829            "snippet must show the match, not the start of the arguments: {}",
1830            out.hits[0].snippet
1831        );
1832    }
1833
1834    #[test]
1835    fn an_untooled_tool_hit_offsets_against_the_arguments_not_name_plus_arguments() {
1836        // Previously the offset came from `format!("{name} {args}")` but was
1837        // used to slice `args` alone, so the window was shifted by
1838        // name.len()+1 — and for a long name effectively clamped to the end.
1839        let args = format!("{}FIND_ME{}", "z".repeat(1_000), "w".repeat(1_000));
1840        let idx = search_index(vec![(
1841            "s1",
1842            vec![msg("assistant", "no prose match here", vec![("SomeVeryLongToolNameIndeed", &args)])],
1843        )]);
1844        let cands = vec![idx.get_meta("s1").unwrap()];
1845
1846        let out = idx.search_sessions(&cands, &opts("find_me"));
1847        assert_eq!(out.hits.len(), 1);
1848        assert_eq!(out.hits[0].tool_name.as_deref(), Some("SomeVeryLongToolNameIndeed"));
1849        assert!(
1850            out.hits[0].snippet.contains("FIND_ME"),
1851            "snippet window must be centred on the real match: {}",
1852            out.hits[0].snippet
1853        );
1854    }
1855
1856    #[test]
1857    fn matching_is_safe_when_lowercasing_changes_byte_length() {
1858        // Lowercasing is not length-preserving: 'İ' (U+0130, 2 bytes) becomes
1859        // 'i̇' (3 bytes). Finding in a lowercased copy and slicing the
1860        // original therefore drifts by one byte per such character, which can
1861        // slice mid-character and panic. This must neither panic nor
1862        // mis-window.
1863        let content = format!("{}NEEDLE tail", "İ".repeat(200));
1864        let idx = search_index(vec![("s1", vec![msg("assistant", &content, vec![])])]);
1865        let cands = vec![idx.get_meta("s1").unwrap()];
1866
1867        let out = idx.search_sessions(&cands, &opts("needle"));
1868        assert_eq!(out.hits.len(), 1);
1869        assert!(
1870            out.hits[0].snippet.contains("NEEDLE"),
1871            "snippet must still contain the match: {}",
1872            out.hits[0].snippet
1873        );
1874    }
1875
1876    #[test]
1877    fn case_insensitive_find_reports_offsets_into_the_original_string() {
1878        // Direct unit coverage of the helper the three fixes above rely on.
1879        assert_eq!(find_case_insensitive("Hello World", "world"), Some(6));
1880        assert_eq!(find_case_insensitive("abc", "zzz"), None);
1881        assert_eq!(find_case_insensitive("abc", ""), None);
1882
1883        // 'İ' is 2 bytes and lowercases to 3, so a naive lowercased-offset
1884        // would report 3 here instead of the correct 2.
1885        let s = "İX";
1886        assert_eq!(find_case_insensitive(s, "x"), Some(2));
1887        assert!(s.is_char_boundary(find_case_insensitive(s, "x").unwrap()));
1888    }
1889}
1890
1891#[cfg(test)]
1892mod refresh_tests {
1893    //! Incremental refresh (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md
1894    //! §4.5): a cold refresh parses every transcript on the machine, so a
1895    //! refresh must only re-parse what changed.
1896    use super::*;
1897    use std::sync::Arc;
1898
1899    /// Files whose mtimes a test can change between refreshes, counting every
1900    /// `extract_meta` call per path.
1901    struct ChangingAdapter {
1902        files: Mutex<Vec<DiscoveredFile>>,
1903        extracted: Mutex<Vec<String>>,
1904    }
1905
1906    impl ChangingAdapter {
1907        fn new(files: &[(&str, i64)]) -> Arc<Self> {
1908            Arc::new(Self {
1909                files: Mutex::new(
1910                    files
1911                        .iter()
1912                        .map(|(p, m)| DiscoveredFile { file_path: p.to_string(), mtime_ms: *m })
1913                        .collect(),
1914                ),
1915                extracted: Mutex::new(Vec::new()),
1916            })
1917        }
1918        fn set(&self, files: &[(&str, i64)]) {
1919            *self.files.lock().unwrap() = files
1920                .iter()
1921                .map(|(p, m)| DiscoveredFile { file_path: p.to_string(), mtime_ms: *m })
1922                .collect();
1923        }
1924        fn take_extracted(&self) -> Vec<String> {
1925            let mut v = std::mem::take(&mut *self.extracted.lock().unwrap());
1926            v.sort();
1927            v
1928        }
1929    }
1930
1931    impl HistoryAdapter for Arc<ChangingAdapter> {
1932        fn provider(&self) -> &str {
1933            "mock"
1934        }
1935        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1936            Ok(self
1937                .files
1938                .lock()
1939                .unwrap()
1940                .iter()
1941                .map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms })
1942                .collect())
1943        }
1944        fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1945            self.extracted.lock().unwrap().push(file_path.to_string());
1946            let id = PathBuf::from(file_path)
1947                .file_stem()
1948                .map(|s| s.to_string_lossy().into_owned())
1949                .unwrap_or_default();
1950            Ok(Some(SessionMeta {
1951                session_id: id,
1952                file_path: file_path.to_string(),
1953                provider: "mock".into(),
1954                model: String::new(),
1955                slug: String::new(),
1956                working_directory: "/proj".into(),
1957                created_at: 0,
1958                modified_at: 0,
1959                message_count: 0,
1960                first_user_message: String::new(),
1961                file_size_bytes: 0,
1962                git_branch: String::new(),
1963                total_tokens: 0,
1964                subagent_count: 0,
1965                identity_id: String::new(),
1966                starts_undated: false,
1967            }))
1968        }
1969        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
1970            Ok(None)
1971        }
1972    }
1973
1974    fn index_over(adapter: &Arc<ChangingAdapter>) -> SessionIndex {
1975        SessionIndex::with_isolated_roots(vec![Box::new(adapter.clone())], vec![])
1976    }
1977
1978    /// Across a restart: a new index over the same saved cache file parses
1979    /// only what changed since the last launch saved it.
1980    #[test]
1981    fn a_new_index_with_the_saved_cache_reparses_only_what_changed() {
1982        let dir = tempfile::tempdir().unwrap();
1983        let cache = dir.path().join("history").join("session-meta-cache.json");
1984        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 1)]);
1985        let first = index_over(&a).with_cache_file(cache.clone());
1986        first.refresh();
1987        assert_eq!(a.take_extracted(), vec!["/h/a.jsonl", "/h/b.jsonl"]);
1988        assert!(cache.exists(), "the first build saves the cache");
1989
1990        // "Restart": a fresh index, b changed while AgentMux was closed.
1991        a.set(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 2), ("/h/c.jsonl", 1)]);
1992        let second = index_over(&a).with_cache_file(cache.clone());
1993        second.refresh();
1994        assert_eq!(a.take_extracted(), vec!["/h/b.jsonl", "/h/c.jsonl"], "a came from the saved cache");
1995        assert!(second.get_meta("a").is_some() && second.get_meta("c").is_some());
1996    }
1997
1998    /// An unwritable cache location is tried once per interval, not on every
1999    /// refresh, and leaves no temporary file behind.
2000    #[test]
2001    fn an_unwritable_cache_waits_out_the_interval_and_leaves_no_temp_file() {
2002        let dir = tempfile::tempdir().unwrap();
2003        let blocker = dir.path().join("not-a-dir");
2004        std::fs::write(&blocker, b"a file where the cache dir would be").unwrap();
2005        let cache = blocker.join("session-meta-cache.json");
2006        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2007        let idx = index_over(&a).with_cache_file(cache.clone());
2008        idx.refresh();
2009        let first_try = idx.cache_written_at_ms.load(std::sync::atomic::Ordering::Acquire);
2010        assert!(first_try > 0, "the failed attempt is recorded");
2011        a.set(&[("/h/a.jsonl", 2)]);
2012        idx.refresh();
2013        assert_eq!(idx.cache_written_at_ms.load(std::sync::atomic::Ordering::Acquire), first_try, "not retried within the interval");
2014        let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect();
2015        assert_eq!(leftovers.len(), 1, "only the blocker file: {leftovers:?}");
2016    }
2017
2018    #[test]
2019    fn a_saved_cache_of_another_version_is_rebuilt_not_trusted() {
2020        let dir = tempfile::tempdir().unwrap();
2021        let cache = dir.path().join("session-meta-cache.json");
2022        std::fs::write(&cache, br#"{"version":999,"entries":{"/h/a.jsonl":[1,null]}}"#).unwrap();
2023        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2024        let idx = index_over(&a).with_cache_file(cache);
2025        idx.refresh();
2026        assert_eq!(a.take_extracted(), vec!["/h/a.jsonl"]);
2027        assert!(idx.get_meta("a").is_some(), "the stale entry (no meta) wasn't used");
2028    }
2029
2030    #[test]
2031    fn an_unreadable_saved_cache_is_ignored() {
2032        let dir = tempfile::tempdir().unwrap();
2033        let cache = dir.path().join("session-meta-cache.json");
2034        std::fs::write(&cache, b"not json").unwrap();
2035        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2036        let idx = index_over(&a).with_cache_file(cache.clone());
2037        idx.refresh();
2038        assert_eq!(a.take_extracted(), vec!["/h/a.jsonl"]);
2039        let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache).unwrap()).unwrap();
2040        assert_eq!(saved["version"], serde_json::json!(META_CACHE_VERSION), "rewritten after the build");
2041    }
2042
2043    #[test]
2044    fn a_second_refresh_reparses_only_what_changed() {
2045        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 1)]);
2046        let idx = index_over(&a);
2047        idx.refresh();
2048        assert_eq!(a.take_extracted(), vec!["/h/a.jsonl", "/h/b.jsonl"]);
2049
2050        idx.refresh();
2051        assert!(a.take_extracted().is_empty(), "unchanged files must not be re-parsed");
2052
2053        a.set(&[("/h/a.jsonl", 2), ("/h/b.jsonl", 1), ("/h/c.jsonl", 1)]);
2054        idx.refresh();
2055        assert_eq!(a.take_extracted(), vec!["/h/a.jsonl", "/h/c.jsonl"]);
2056        assert!(idx.get_meta("c").is_some());
2057    }
2058
2059    #[test]
2060    fn a_file_gone_from_disk_leaves_the_index() {
2061        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 1)]);
2062        let idx = index_over(&a);
2063        idx.refresh();
2064        a.set(&[("/h/a.jsonl", 1)]);
2065        idx.refresh();
2066        assert!(idx.get_meta("b").is_none());
2067        assert!(idx.get_meta("a").is_some());
2068        assert!(a.take_extracted().len() == 2, "only the first refresh parsed");
2069    }
2070
2071    #[test]
2072    fn refreshed_at_is_zero_until_the_first_refresh_completes() {
2073        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2074        let idx = index_over(&a);
2075        assert_eq!(idx.refreshed_at_ms(), 0);
2076        idx.refresh();
2077        assert!(idx.refreshed_at_ms() > 0);
2078    }
2079
2080    /// A search must never wait out a cold build behind the MCP's 10 s
2081    /// timeout: while another refresh runs, `try_refresh` returns at once.
2082    #[test]
2083    fn try_refresh_does_not_wait_for_a_refresh_in_progress() {
2084        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2085        let idx = index_over(&a);
2086        let _running = idx.refresh_lock.lock().unwrap();
2087        assert!(idx.try_refresh().is_none());
2088        assert!(a.take_extracted().is_empty());
2089    }
2090
2091    #[test]
2092    fn try_refresh_refreshes_when_nothing_else_is() {
2093        let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2094        let idx = index_over(&a);
2095        assert!(idx.try_refresh().is_some());
2096        assert!(idx.get_meta("a").is_some());
2097    }
2098
2099    /// Discovery that walks slowly and counts its walks.
2100    struct SlowAdapter {
2101        walks: std::sync::atomic::AtomicUsize,
2102    }
2103
2104    impl HistoryAdapter for Arc<SlowAdapter> {
2105        fn provider(&self) -> &str {
2106            "mock"
2107        }
2108        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
2109            self.walks.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2110            std::thread::sleep(std::time::Duration::from_millis(150));
2111            Ok(Vec::new())
2112        }
2113        fn extract_meta(&self, _: &str) -> Result<Option<SessionMeta>, HistoryError> {
2114            Ok(None)
2115        }
2116        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
2117            Ok(None)
2118        }
2119    }
2120
2121    /// Searches arriving together share refreshes: a refresh that started
2122    /// after a request began already lists everything that existed when it
2123    /// began. Every search running its own full walk, one after another,
2124    /// put the fourth past the MCP's 10 s timeout on a heavy host (Codex P1
2125    /// on #3693).
2126    #[test]
2127    fn concurrent_searches_share_refreshes() {
2128        let a = Arc::new(SlowAdapter { walks: Default::default() });
2129        let idx = Arc::new(SessionIndex::with_isolated_roots(vec![Box::new(a.clone())], vec![]));
2130        let barrier = Arc::new(std::sync::Barrier::new(8));
2131        let threads: Vec<_> = (0..8)
2132            .map(|_| {
2133                let (idx, barrier) = (idx.clone(), barrier.clone());
2134                std::thread::spawn(move || {
2135                    barrier.wait();
2136                    idx.refresh_covering_now();
2137                })
2138            })
2139            .collect();
2140        for t in threads {
2141            t.join().unwrap();
2142        }
2143        let walks = a.walks.load(std::sync::atomic::Ordering::SeqCst);
2144        assert!((1..=2).contains(&walks), "8 simultaneous searches walked {walks} times");
2145    }
2146
2147    /// …but a refresh that finished before the request began may have
2148    /// missed a session written since, so it's never reused.
2149    #[test]
2150    fn a_refresh_finished_before_the_request_is_not_reused() {
2151        let a = Arc::new(SlowAdapter { walks: Default::default() });
2152        let idx = SessionIndex::with_isolated_roots(vec![Box::new(a.clone())], vec![]);
2153        idx.refresh();
2154        idx.refresh_covering_now();
2155        assert_eq!(a.walks.load(std::sync::atomic::Ordering::SeqCst), 2);
2156    }
2157
2158    /// Discovery that reports what it couldn't look at.
2159    struct ProblemAdapter {
2160        problems: Mutex<Vec<String>>,
2161    }
2162
2163    impl HistoryAdapter for Arc<ProblemAdapter> {
2164        fn provider(&self) -> &str {
2165            "mock"
2166        }
2167        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
2168            Ok(vec![DiscoveredFile { file_path: "/h/locked.jsonl".into(), mtime_ms: 1 }])
2169        }
2170        fn discover(&self) -> Result<Discovery, HistoryError> {
2171            Ok(Discovery { files: self.discover_files()?, problems: self.problems.lock().unwrap().clone() })
2172        }
2173        fn extract_meta(&self, _: &str) -> Result<Option<SessionMeta>, HistoryError> {
2174            Err(HistoryError::Other("sharing violation".into()))
2175        }
2176        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
2177            Ok(None)
2178        }
2179    }
2180
2181    /// A directory the walk couldn't read, or a transcript it couldn't index,
2182    /// may hold the very session being searched for. Both are kept with the
2183    /// snapshot so a search over it can't claim to be complete (Codex P1 on
2184    /// #3693).
2185    #[test]
2186    fn what_discovery_could_not_read_is_kept_with_the_snapshot() {
2187        let a = Arc::new(ProblemAdapter { problems: Mutex::new(vec!["/h/private: Access is denied".into()]) });
2188        let idx = SessionIndex::with_isolated_roots(vec![Box::new(a.clone())], vec![]);
2189        idx.refresh();
2190        let problems = idx.discovery_problems();
2191        assert!(problems.iter().any(|p| p.contains("/h/private")), "{problems:?}");
2192        assert!(
2193            problems.iter().any(|p| p.contains("locked.jsonl") && p.contains("sharing violation")),
2194            "a transcript that couldn't be indexed is a problem too: {problems:?}"
2195        );
2196
2197        a.problems.lock().unwrap().clear();
2198        idx.refresh();
2199        assert_eq!(idx.discovery_problems().len(), 1, "each refresh replaces the last one's problems");
2200    }
2201
2202    /// The first build never waits on, or repeats, another caller's
2203    /// (Codex P2 on #3930).
2204    #[test]
2205    fn try_first_build_skips_when_busy_or_already_built() {
2206        let idx = SessionIndex::with_isolated_roots(vec![], vec![]);
2207        {
2208            let _running = idx.refresh_lock.lock().unwrap();
2209            assert_eq!(idx.try_first_build(), FirstBuild::Busy);
2210        }
2211        assert_eq!(idx.try_first_build(), FirstBuild::Built { discovered: 0 });
2212        let built_at = idx.refreshed_at_ms();
2213        assert!(built_at > 0);
2214        assert_eq!(idx.try_first_build(), FirstBuild::AlreadyBuilt);
2215        assert_eq!(idx.refreshed_at_ms(), built_at);
2216    }
2217}