Skip to main content

agentmux_srv/backend/history/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! History module — discovers and indexes past CLI agent conversations from disk.
5
6pub mod adapter;
7pub mod claude_adapter;
8pub mod index;
9pub(crate) mod record;
10
11use std::sync::Arc;
12
13use adapter::*;
14use claude_adapter::ClaudeHistoryAdapter;
15use index::SessionIndex;
16
17/// The global named-agent registry record for `agent_id`, for agents that have
18/// no `db_agents` row — launching an agent does not create one, so a live agent
19/// commonly exists only here.
20///
21/// Fetched at most once per `sessions_for_agent` call and used for BOTH
22/// fallbacks the record can answer (definition id and working directory).
23/// It used to return just the working directory, which meant the record's
24/// `definition_id` — sitting right there — went unread, and identity-link
25/// resolution fell back to the raw slug instead (reagentx P1 on PR #3480,
26/// re-review).
27///
28/// The lookup itself comes from `backend::agent_registry_lookup`, which owns
29/// the only copy of the slug-matching and path-reconstruction rules; this
30/// function previously re-implemented both inline, which reagentx P2 on the
31/// same PR flagged against `native_memory_handlers`' copy, since a later fix
32/// to either one would silently miss the other.
33fn registry_record(agent_id: &str) -> Option<crate::registry::NamedAgentRecord> {
34    crate::backend::agent_registry_lookup::find_active_record_by_slug(agent_id)
35}
36
37/// Whose history a lookup is about: the definition id its identity links
38/// are keyed by, and the working directory its transcripts record.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct HistoryOwner {
41    pub definition_id: String,
42    pub working_directory: Option<String>,
43}
44
45impl HistoryOwner {
46    /// Resolve a MuxAPI slug (the pre-M4c-2 path, and the one an
47    /// Unattributed caller keeps).
48    pub fn from_slug(store: &crate::backend::storage::store::Store, agent_id: &str) -> Self {
49        // `agent_id` arrives as the SLUG (`AGENTMUX_AGENT_ID`) from every App
50        // API caller, but `db_agent_identity_links.agent_id` stores the
51        // DEFINITION id — so querying that table with the slug matched zero
52        // rows every time, and the early return on "no links" then reported a
53        // confident, empty history for agents whose transcripts were sitting
54        // on disk. A caller that already holds a definition id passes through
55        // unchanged. Same resolution `app_api::resolve_agent_definition_id`
56        // already documents ("listing links by slug always returns empty").
57        let instance = store.instance_get_by_slug(agent_id).ok().flatten();
58        let definition_from_db = instance.as_ref().map(|i| {
59            if i.definition_id.is_empty() { i.id.clone() } else { i.definition_id.clone() }
60        });
61        let working_dir_from_db = instance
62            .as_ref()
63            .map(|i| i.working_directory.clone())
64            .filter(|w| !w.is_empty());
65
66        // One registry read, shared by both fallbacks below, and skipped
67        // entirely when the `db_agents` row already answered both questions.
68        let record = if definition_from_db.is_none() || working_dir_from_db.is_none() {
69            registry_record(agent_id)
70        } else {
71            None
72        };
73
74        // Without the registry step, a registry-only agent (no `db_agents`
75        // row — the common case) fell through to the raw slug here, and the
76        // link table is keyed by definition id, so the link query matched
77        // nothing and the agent's identity-bound sessions silently vanished.
78        // Same third fallback `app_api::resolve_agent_definition_id` already
79        // has, for the same reason. The raw slug remains the last resort: a
80        // caller that already holds a definition id passes through unchanged.
81        let definition_id = definition_from_db
82            .or_else(|| {
83                record
84                    .as_ref()
85                    .map(|r| r.data.definition_id.clone())
86                    .filter(|d| !d.is_empty())
87            })
88            .unwrap_or_else(|| agent_id.to_string());
89
90        // The ambient-credentials fallback below (`sessions_for_owner`) needs
91        // the working directory: the row's, else the registry record's.
92        let working_directory = working_dir_from_db.or_else(|| {
93            record
94                .as_ref()
95                .and_then(crate::backend::agent_registry_lookup::working_dir_from_record)
96        });
97        Self { definition_id, working_directory }
98    }
99
100    /// An agent's own row — identity M4c-2c, an attributed caller. Its
101    /// working directory is the row's, else its registry record's found by
102    /// the row's slug **and** id, never by slug alone.
103    pub fn of_row(row: &crate::backend::storage::AgentDefinition) -> Self {
104        let working_directory = Some(row.working_directory.clone())
105            .filter(|w| !w.is_empty())
106            .or_else(|| {
107                crate::backend::agent_registry_lookup::find_active_record_by_slug_and_definition(
108                    &row.slug, &row.id,
109                )
110                .as_ref()
111                .and_then(crate::backend::agent_registry_lookup::working_dir_from_record)
112            });
113        Self { definition_id: row.id.clone(), working_directory }
114    }
115}
116
117/// Account ids linked to more than one agent (`db_agent_identity_links`).
118fn accounts_linked_to_several_agents(
119    store: &crate::backend::storage::store::Store,
120) -> Result<std::collections::HashSet<String>, String> {
121    let mut agents_by_account: std::collections::HashMap<String, std::collections::HashSet<String>> =
122        std::collections::HashMap::new();
123    for link in store
124        .agent_identity_list_all()
125        .map_err(|e| format!("failed to read identity links: {e}"))?
126    {
127        agents_by_account.entry(link.account_id).or_default().insert(link.agent_id);
128    }
129    Ok(agents_by_account
130        .into_iter()
131        .filter(|(_, agents)| agents.len() > 1)
132        .map(|(account, _)| account)
133        .collect())
134}
135
136/// A search's answer while the first index build after srv start is running.
137pub const HISTORY_INDEX_BUILDING: &str = "history index is still being built after AgentMux started \
138     (it reads every transcript once) — this is not an empty history; retry in a minute";
139
140/// Why a history search could not answer.
141#[derive(Debug)]
142pub enum HistorySearchError {
143    /// The first index build after srv start is still running. Retryable,
144    /// and never to be read as "no history".
145    IndexBuilding,
146    /// Anything else, e.g. the store failing to list the owner's accounts.
147    Failed(String),
148}
149
150impl std::fmt::Display for HistorySearchError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            HistorySearchError::IndexBuilding => f.write_str(HISTORY_INDEX_BUILDING),
154            HistorySearchError::Failed(e) => f.write_str(e),
155        }
156    }
157}
158
159/// What a search knows about a session only AgentMux's record holds.
160fn record_meta(session: &record::RecordSession) -> SessionMeta {
161    SessionMeta {
162        session_id: session.session_id.clone(),
163        file_path: session
164            .ranges
165            .first()
166            .map(|(zone, _, _)| format!("agentmux-record:{zone}"))
167            .unwrap_or_default(),
168        provider: "claude".to_string(),
169        model: String::new(),
170        slug: String::new(),
171        working_directory: session.cwd.clone().unwrap_or_default(),
172        created_at: session.first_ms,
173        modified_at: session.last_ms,
174        message_count: 0,
175        first_user_message: String::new(),
176        file_size_bytes: session.ranges.iter().map(|(_, start, end)| (end - start).max(0) as u64).sum(),
177        git_branch: String::new(),
178        total_tokens: 0,
179        subagent_count: 0,
180        identity_id: String::new(),
181        starts_undated: session.first_ms == 0,
182    }
183}
184
185/// SearchHistory's `since`/`until` in milliseconds. They're documented as
186/// Unix seconds, but a hit's own `timestamp` is in milliseconds and is the
187/// natural value to pass back; a value this large is already milliseconds
188/// (as seconds it would be the year 5138).
189pub fn unix_time_to_ms(value: i64) -> i64 {
190    const ALREADY_MS: i64 = 100_000_000_000;
191    if value.abs() >= ALREADY_MS {
192        value
193    } else {
194        value.saturating_mul(1000)
195    }
196}
197
198/// How long after srv start the first index build waits
199/// ([`HistoryService::warm_in_background`]): past the window in which the user
200/// opens their first agents. The spec protects the first 30 s
201/// (SPEC_AGENT_OPEN_LATENCY_2026_09_27.md §5), and a cold build can take 25 s
202/// on its own, so a start inside that window would still overlap it (Codex P2
203/// on #3930). A search before then starts the build early
204/// ([`HistoryService::refresh_for_search`]).
205pub const WARM_DELAY: std::time::Duration = std::time::Duration::from_secs(60);
206
207/// Lower the calling thread's scheduling priority for background work. On
208/// Windows, `THREAD_MODE_BACKGROUND_BEGIN` also lowers its I/O and memory
209/// priority — the index build is mostly file reads. On Linux, a nice of 10
210/// for this thread only. Best-effort: a failure leaves normal priority.
211fn lower_current_thread_priority() {
212    #[cfg(windows)]
213    unsafe {
214        use windows_sys::Win32::System::Threading::{
215            GetCurrentThread, SetThreadPriority, THREAD_MODE_BACKGROUND_BEGIN,
216        };
217        let _ = SetThreadPriority(GetCurrentThread(), THREAD_MODE_BACKGROUND_BEGIN);
218    }
219    #[cfg(target_os = "linux")]
220    unsafe {
221        let tid = libc::syscall(libc::SYS_gettid) as libc::id_t;
222        let _ = libc::setpriority(libc::PRIO_PROCESS, tid, 10);
223    }
224}
225
226/// Run the index's first build unless another caller has finished it or is
227/// running it now — never a second full pass right after theirs (Codex P2s on
228/// #3930) — then clear `pending`.
229fn build_first_index(
230    index: &SessionIndex,
231    pending: &std::sync::atomic::AtomicBool,
232    trigger: &'static str,
233) {
234    let started = std::time::Instant::now();
235    match index.try_first_build() {
236        index::FirstBuild::Built { discovered } => tracing::info!(
237            discovered,
238            duration_ms = started.elapsed().as_millis() as u64,
239            trigger,
240            "history: index built"
241        ),
242        index::FirstBuild::AlreadyBuilt => {
243            tracing::info!(trigger, "history: index already built; first build skipped")
244        }
245        index::FirstBuild::Busy => {
246            tracing::info!(
247                trigger,
248                "history: index build already running; first build skipped"
249            )
250        }
251    }
252    pending.store(false, std::sync::atomic::Ordering::SeqCst);
253}
254
255/// The history service exposed to the RPC layer.
256pub struct HistoryService {
257    index: Arc<SessionIndex>,
258    /// AgentMux's own record of each agent's conversations — which sessions
259    /// are whose, and the content of those whose transcript is gone.
260    records: record::AgentRecords,
261    /// A deferred first build is scheduled and hasn't finished yet.
262    warm_pending: Arc<std::sync::atomic::AtomicBool>,
263    /// A search already started the pending first build early.
264    warm_started_early: Arc<std::sync::atomic::AtomicBool>,
265}
266
267impl HistoryService {
268    pub fn new() -> Self {
269        let adapters: Vec<Box<dyn HistoryAdapter>> =
270            vec![Box::new(ClaudeHistoryAdapter::new())];
271
272        // Saved beside the shared agent registry, so every channel and build
273        // on the machine reuses one cache; entries are only trusted while the
274        // transcript's mtime is unchanged.
275        let mut index = SessionIndex::new(adapters);
276        if let Some(shared) = crate::registry::resolve_global_shared_root() {
277            index = index.with_cache_file(shared.join("history").join("session-meta-cache.json"));
278        }
279        HistoryService {
280            index: Arc::new(index),
281            records: record::AgentRecords::global(),
282            warm_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)),
283            warm_started_early: Arc::new(std::sync::atomic::AtomicBool::new(false)),
284        }
285    }
286
287    /// Construct directly from a `SessionIndex` — used by tests that need
288    /// to inject a mock adapter instead of `ClaudeHistoryAdapter::new()`'s
289    /// real filesystem scan. `pub(crate)` (not private) so tests in other
290    /// modules (e.g. `app_api::bundle`'s `bundle.export_for_agent_with_history`
291    /// tests) can build an isolated `HistoryService` too, instead of
292    /// depending on `AppState::history_service`'s real filesystem scan.
293    #[cfg(test)]
294    pub(crate) fn from_index(index: SessionIndex) -> Self {
295        Self::from_index_and_records(index, record::AgentRecords::new(None))
296    }
297
298    #[cfg(test)]
299    pub(crate) fn from_index_and_records(index: SessionIndex, records: record::AgentRecords) -> Self {
300        HistoryService {
301            index: Arc::new(index),
302            records,
303            warm_pending: Arc::new(std::sync::atomic::AtomicBool::new(false)),
304            warm_started_early: Arc::new(std::sync::atomic::AtomicBool::new(false)),
305        }
306    }
307
308    /// Build the index off-thread at srv start, so the first search doesn't
309    /// pay for parsing every transcript on the machine inside the MCP's 10 s
310    /// timeout (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.5).
311    ///
312    /// Deferred by [`WARM_DELAY`] and run at background priority: the first
313    /// build reads every transcript on the machine (61,066 sessions in 23.5 s
314    /// on one host) and used to start the moment srv did — exactly when the
315    /// user opens their first agents, competing with them for CPU and disk
316    /// (SPEC_AGENT_OPEN_LATENCY_2026_09_27.md F2). A search in the meantime
317    /// answers "index building", as it does during the build itself, and
318    /// starts the build now; the History tab's `list`/`get` still build on
319    /// demand.
320    pub fn warm_in_background(&self) -> std::thread::JoinHandle<()> {
321        self.warm_in_background_after(WARM_DELAY)
322    }
323
324    /// [`Self::warm_in_background`] with an explicit delay (tests use zero).
325    pub fn warm_in_background_after(
326        &self,
327        delay: std::time::Duration,
328    ) -> std::thread::JoinHandle<()> {
329        let index = self.index.clone();
330        let pending = self.warm_pending.clone();
331        pending.store(true, std::sync::atomic::Ordering::SeqCst);
332        std::thread::Builder::new()
333            .name("history-index-warm".into())
334            .spawn(move || {
335                lower_current_thread_priority();
336                if !delay.is_zero() {
337                    std::thread::sleep(delay);
338                }
339                build_first_index(&index, &pending, "startup");
340            })
341            .expect("spawn history-index-warm thread")
342    }
343
344    /// A search arrived while the deferred first build is still waiting out
345    /// [`WARM_DELAY`]: someone wants the index now, so build it now, off the
346    /// search's thread. Once.
347    fn start_pending_build_early(&self) {
348        if self
349            .warm_started_early
350            .swap(true, std::sync::atomic::Ordering::SeqCst)
351        {
352            return;
353        }
354        let index = self.index.clone();
355        let pending = self.warm_pending.clone();
356        let _ = std::thread::Builder::new()
357            .name("history-index-first".into())
358            .spawn(move || build_first_index(&index, &pending, "search"));
359    }
360
361    /// Build the index if it never has been. Blocking; for callers without a
362    /// tight deadline.
363    fn ensure_built(&self) {
364        if self.index.refreshed_at_ms() == 0 {
365            self.index.refresh();
366        }
367    }
368
369    /// Bring the index up to date before a search, so that "no hits, complete"
370    /// can mean nothing was there: every session on disk when the search began
371    /// is in the snapshot it reads. Reusing a recent snapshot (it used to be
372    /// reused for 30 s, or whenever another refresh was running) let a session
373    /// written in that window go unseen while the answer claimed to be
374    /// complete (Codex P1 on #3693).
375    ///
376    /// An incremental refresh re-lists the transcript directories and
377    /// re-parses only files whose mtime moved: 51,671 files stat'd in 2.7 s on
378    /// one heavy host (2026-09-24), far less on most. A refresh already
379    /// running is waited for and then followed by this search's own (unless
380    /// another search's refresh began after this one did), since it may have
381    /// started before this search's sessions were written. Only the
382    /// very first build answers "still building" instead of waiting: it reads
383    /// every transcript once and would outlast the MCP's 10 s timeout.
384    /// Concurrent searches share refreshes ([`SessionIndex::refresh_covering_now`]).
385    fn refresh_for_search(&self) -> Result<(), HistorySearchError> {
386        if self.index.refreshed_at_ms() == 0 {
387            // The deferred first build is scheduled but hasn't finished (or
388            // started): answer "building" rather than running that whole
389            // first scan inside this search — same as while it runs.
390            if self.warm_pending.load(std::sync::atomic::Ordering::SeqCst) {
391                self.start_pending_build_early();
392                return Err(HistorySearchError::IndexBuilding);
393            }
394            return match self.index.try_refresh() {
395                Some(_) => Ok(()),
396                None => Err(HistorySearchError::IndexBuilding),
397            };
398        }
399        self.index.refresh_covering_now();
400        Ok(())
401    }
402
403    /// List sessions with pagination and filters.
404    /// Lazy-initializes the index on first call.
405    pub fn list(
406        &self,
407        provider: Option<&str>,
408        project: Option<&str>,
409        offset: usize,
410        limit: usize,
411        sort_by: &str,
412        sort_dir: &str,
413    ) -> serde_json::Value {
414        // Lazy init: scan on first request
415        self.ensure_built();
416
417        let (sessions, total, has_more) =
418            self.index.list(provider, project, offset, limit, sort_by, sort_dir);
419
420        serde_json::json!({
421            "sessions": sessions,
422            "total": total,
423            "has_more": has_more,
424        })
425    }
426
427    /// Typed core of `list_for_agent` — the JSON-returning method wraps
428    /// this. Exists as its own function so other backend code (e.g.
429    /// `bundle.rs`'s `bundle.export_for_agent_with_history`) can get this
430    /// agent's `SessionMeta` list directly, without a JSON
431    /// serialize/deserialize round-trip through the RPC-facing shape.
432    ///
433    /// `force_refresh`: reagentx P1 on PR #2613 — the lazy
434    /// refresh-only-if-empty behavior (shared with `list`/`get`, fine for
435    /// an interactive browse where slight staleness is an acceptable
436    /// trade for speed) is wrong for a caller claiming completeness, like
437    /// `bundle.export_for_agent_with_history`: once the index has been
438    /// populated once (by ANY prior call, interactive or otherwise), a
439    /// session created since then would silently be missing from an
440    /// export that reports itself as the full record. `list_for_agent`
441    /// (the interactive RPC) passes `false`, preserving its existing
442    /// speed/freshness trade-off; the export path passes `true`.
443    pub fn sessions_for_agent(
444        &self,
445        store: &crate::backend::storage::store::Store,
446        agent_id: &str,
447        offset: usize,
448        limit: usize,
449        sort_by: &str,
450        sort_dir: &str,
451        force_refresh: bool,
452    ) -> Result<(Vec<SessionMeta>, u32, bool), String> {
453        let owner = HistoryOwner::from_slug(store, agent_id);
454        self.sessions_for_owner(store, &owner, offset, limit, sort_by, sort_dir, force_refresh)
455    }
456
457    /// [`Self::sessions_for_agent`] for an owner already resolved — identity
458    /// M4c-2c: an attributed caller's own row (`HistoryOwner::of_row`),
459    /// never a slug another agent may share.
460    pub fn sessions_for_owner(
461        &self,
462        store: &crate::backend::storage::store::Store,
463        owner: &HistoryOwner,
464        offset: usize,
465        limit: usize,
466        sort_by: &str,
467        sort_dir: &str,
468        force_refresh: bool,
469    ) -> Result<(Vec<SessionMeta>, u32, bool), String> {
470        if force_refresh {
471            self.index.refresh();
472        } else {
473            self.ensure_built();
474        }
475
476        let links = store
477            .agent_identity_list_for_agent(&owner.definition_id)
478            .map_err(|e| format!("failed to resolve agent's linked identities: {e}"))?;
479
480        // An account linked to several agents holds all of their transcripts
481        // under one account's home, so its sessions are not all this
482        // owner's: `SearchHistory` would return another agent's conversation,
483        // the ungoverned disclosure `search_for_agent`'s doc rules out. From
484        // such an account keep only sessions in the owner's own working
485        // directory; with no known directory, keep none (counted) rather than
486        // guess. An account only this owner uses keeps every session, so an
487        // agent whose working directory moved keeps its older history.
488        let shared_accounts = accounts_linked_to_several_agents(store)?;
489        let own_dir = owner
490            .working_directory
491            .as_deref()
492            .map(index::normalize_working_dir);
493        let mut merged: Vec<SessionMeta> = Vec::new();
494        for link in &links {
495            let (sessions, _total, _has_more) =
496                self.index.list_for_identity(&link.account_id, 0, usize::MAX, sort_by, sort_dir);
497            if !shared_accounts.contains(&link.account_id) {
498                merged.extend(sessions);
499                continue;
500            }
501            match &own_dir {
502                Some(dir) => merged.extend(
503                    sessions
504                        .into_iter()
505                        .filter(|s| &index::normalize_working_dir(&s.working_directory) == dir),
506                ),
507                None => crate::backend::agent_resolve::record_uid_fallback(
508                    "history.shared_account_without_working_dir",
509                ),
510            }
511        }
512
513        // An agent with no bound account has no link row at all — its
514        // registry record's `identity_id` reads "default" while its
515        // transcripts are written under a channel's default home nothing
516        // points at — so the identity lookup above finds nothing for the
517        // common case. The working directory is recorded inside the
518        // transcript (`cwd`) and on the agent's own row, so it resolves the
519        // sessions the account lookup cannot.
520        if let Some(dir) = &owner.working_directory {
521            merged.extend(self.index.list_for_working_directory(dir));
522        }
523
524        // The two sources legitimately overlap for an agent that is both
525        // identity-bound and running in its own directory.
526        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
527        merged.retain(|s| seen.insert(s.session_id.clone()));
528
529        let mut refs: Vec<&SessionMeta> = merged.iter().collect();
530        index::SessionIndex::sort_sessions(&mut refs, sort_by, sort_dir);
531
532        let total = refs.len() as u32;
533        let has_more = offset + limit < refs.len();
534        let page: Vec<SessionMeta> = refs.into_iter().skip(offset).take(limit).cloned().collect();
535        Ok((page, total, has_more))
536    }
537
538    /// Search this agent's OWN conversation history.
539    ///
540    /// `SPEC_AGENT_HISTORY_SEARCH_2026_09_17.md`. Closes the gap where an
541    /// agent's memory of what it did is bounded by its current context window
542    /// while its actual actions are not — so a compaction or session reset
543    /// leaves it confidently misreporting its own past, with nothing marking
544    /// the boundary. Questions like "did I already send that message" are
545    /// answerable from disk; before this they were not answerable by the agent
546    /// being asked.
547    ///
548    /// **Own history only.** Searching another agent's transcript is already a
549    /// governed act — `conversation_visibility` plus the `transcript_request`
550    /// tier rules decide whether one agent may read another's content. A
551    /// search verb that read other agents' sessions directly would be a
552    /// second, ungoverned disclosure path around that machinery, and a more
553    /// dangerous one for looking like an ordinary read tool. Cross-agent
554    /// search must route *through* `transcript_request`, as its own phase.
555    ///
556    /// `opts.since_ms`/`until_ms` drop whole sessions on indexed metadata
557    /// **before** any file is opened, so a narrow time window costs nothing on
558    /// irrelevant sessions, and then filter the messages of the sessions that
559    /// remain. `opts.max_sessions` bounds how many of those are parsed, newest
560    /// first; the rest are counted, not dropped.
561    pub fn search_for_agent(
562        &self,
563        store: &crate::backend::storage::store::Store,
564        owner: &HistoryOwner,
565        opts: &index::HistorySearchOptions,
566    ) -> Result<index::HistorySearchOutcome, HistorySearchError> {
567        self.refresh_for_search()?;
568
569        // Whose sessions these are comes from AgentMux's own record, keyed by
570        // the agent's UID (`owner.definition_id` is the caller's row id): the
571        // same in every channel and version and across account switches. A
572        // session there is searched in its provider transcript when that
573        // still exists, and in the record when it doesn't.
574        let (ledger, record_problems) = self.records.sessions_of(&owner.definition_id);
575        let in_ledger: std::collections::HashSet<String> =
576            ledger.iter().map(|s| s.session_id.clone()).collect();
577        let mut candidates: Vec<index::SearchCandidate> = Vec::with_capacity(ledger.len());
578        let mut record_only = 0u32;
579        for session in ledger {
580            let candidate = match self.index.get_meta(&session.session_id) {
581                Some(meta) => index::SearchCandidate {
582                    meta,
583                    source: index::Source::Transcript,
584                    attribution: index::Attribution::AgentRecord,
585                },
586                None => {
587                    record_only += 1;
588                    index::SearchCandidate {
589                        meta: record_meta(&session),
590                        source: index::Source::Record(session),
591                        attribution: index::Attribution::AgentRecord,
592                    }
593                }
594            };
595            candidates.push(candidate);
596        }
597
598        // Sessions found only by working directory or account. When the agent
599        // has a record they're left out (counted): none of them is in it, so
600        // they're another agent's in a shared folder or account, or older
601        // than the record (none on the host measured, 2026-09-24). An agent
602        // with no record at all has nothing better, so it keeps them.
603        let (by_folder, _total, _has_more) = self
604            .sessions_for_owner(store, owner, 0, usize::MAX, "modified_at", "desc", false)
605            .map_err(HistorySearchError::Failed)?;
606        let inferred: Vec<SessionMeta> =
607            by_folder.into_iter().filter(|s| !in_ledger.contains(&s.session_id)).collect();
608        let mut inferred_excluded = 0u32;
609        if in_ledger.is_empty() || opts.include_inferred {
610            candidates.extend(inferred.into_iter().map(|meta| index::SearchCandidate {
611                meta,
612                source: index::Source::Transcript,
613                attribution: index::Attribution::Inferred,
614            }));
615        } else {
616            inferred_excluded = inferred.len() as u32;
617        }
618
619        // A session last written before `since` holds nothing after it, and
620        // one started after `until` holds nothing before it. An unknown time
621        // — no timestamp at all, or an undated record before the first dated
622        // one — can't rule a session out.
623        let mut in_window: Vec<index::SearchCandidate> = candidates
624            .into_iter()
625            .filter(|c| opts.since_ms.is_none_or(|since| c.meta.modified_at == 0 || c.meta.modified_at >= since))
626            .filter(|c| {
627                opts.until_ms.is_none_or(|until| {
628                    c.meta.created_at == 0 || c.meta.starts_undated || c.meta.created_at <= until
629                })
630            })
631            .collect();
632        in_window.sort_by(|a, b| b.meta.modified_at.cmp(&a.meta.modified_at));
633
634        let mut outcome = index::search_candidates(&in_window, opts, &|c| self.load(c));
635        outcome.ledger_sessions = in_ledger.len() as u32;
636        outcome.record_only_sessions = record_only;
637        outcome.inferred_sessions_excluded = inferred_excluded;
638        let mut problems = self.index.discovery_problems();
639        problems.extend(record_problems);
640        outcome.add_discovery_errors(problems);
641        Ok(outcome)
642    }
643
644    /// A candidate's conversation, from wherever it lives.
645    fn load(&self, candidate: &index::SearchCandidate) -> Result<Option<HistorySession>, HistoryError> {
646        match &candidate.source {
647            index::Source::Transcript => self.index.get_full(&candidate.meta.session_id),
648            index::Source::Record(session) if session.ranges.is_empty() => Err(HistoryError::Other(
649                "in the agent's segment log, but neither a provider transcript nor AgentMux's record holds it"
650                    .into(),
651            )),
652            index::Source::Record(session) => {
653                let (messages, skipped_records) = self.records.read(session)?;
654                Ok(Some(HistorySession { meta: candidate.meta.clone(), messages, skipped_records }))
655            }
656        }
657    }
658
659    /// This agent's own sessions — the actual "fast Conversation History
660    /// lookup" protocol §4.4 asks for, resolving `agent_id` to its bound
661    /// account(s) (`Store::agent_identity_list_for_agent`, the
662    /// same `db_agent_identity_links` table `identity_auth_dirs.rs`
663    /// already keys off) and querying `SessionIndex::list_for_identity`'s
664    /// O(sessions for this identity) HashMap-backed path instead of
665    /// `list()`'s O(total sessions on disk) scan. An agent normally has
666    /// at most one linked account per provider, but this merges across
667    /// however many exist rather than assuming exactly one.
668    pub fn list_for_agent(
669        &self,
670        store: &crate::backend::storage::store::Store,
671        agent_id: &str,
672        offset: usize,
673        limit: usize,
674        sort_by: &str,
675        sort_dir: &str,
676    ) -> serde_json::Value {
677        let (page, total, has_more) = match self.sessions_for_agent(store, agent_id, offset, limit, sort_by, sort_dir, false) {
678            Ok(r) => r,
679            Err(e) => return serde_json::json!({ "error": e }),
680        };
681
682        serde_json::json!({
683            "sessions": page,
684            "total": total,
685            "has_more": has_more,
686        })
687    }
688
689    /// Get full conversation for a session.
690    pub fn get(&self, session_id: &str) -> serde_json::Value {
691        // Lazy init
692        self.ensure_built();
693
694        match self.index.get_full(session_id) {
695            Ok(Some(session)) => serde_json::json!({ "session": session }),
696            Ok(None) => serde_json::json!({ "error": "session not found" }),
697            Err(e) => serde_json::json!({ "error": format!("{}", e) }),
698        }
699    }
700
701    /// Re-scan disk and update the index.
702    pub fn refresh(&self) -> serde_json::Value {
703        let (discovered, updated, new_count) = self.index.refresh();
704        serde_json::json!({
705            "discovered": discovered,
706            "updated": updated,
707            "new": new_count,
708        })
709    }
710
711    /// Delete a single session's on-disk transcript and drop it from the index.
712    pub fn delete(&self, session_id: &str) -> serde_json::Value {
713        self.ensure_built();
714        match self.index.delete(session_id) {
715            Ok(true) => serde_json::json!({ "deleted": true }),
716            Ok(false) => serde_json::json!({ "deleted": false, "error": "session not found" }),
717            Err(e) => serde_json::json!({ "deleted": false, "error": format!("{}", e) }),
718        }
719    }
720
721    /// Bulk-clear sessions matching the optional provider/project filter.
722    pub fn clear(&self, provider: Option<&str>, project: Option<&str>) -> serde_json::Value {
723        self.ensure_built();
724        let deleted = self.index.clear(provider, project);
725        serde_json::json!({ "deleted": deleted })
726    }
727}
728
729/// Whose sessions a search reads, from AgentMux's own record (PR 2 of the
730/// history-robustness plan).
731#[cfg(test)]
732mod agent_record_search_tests {
733    use super::record::fixtures::{append, assistant, init, store, zone, UID};
734    use super::*;
735
736    const CWD: &str = "/work/agent-a";
737
738    /// A service over a real Claude adapter rooted at `shared`, and the
739    /// agent's record in `records`.
740    fn service(shared: &std::path::Path, records: &Arc<crate::backend::storage::filestore::FileStore>) -> HistoryService {
741        HistoryService::from_index_and_records(
742            SessionIndex::with_isolated_roots(
743                vec![Box::new(ClaudeHistoryAdapter::with_roots(None, Some(shared.to_path_buf()), None))],
744                vec![],
745            ),
746            record::AgentRecords::new(Some(records.clone())),
747        )
748    }
749
750    fn transcript(shared: &std::path::Path, sid: &str, text: &str) {
751        let project = shared.join("providers").join("claude").join("projects").join("-work-agent-a");
752        std::fs::create_dir_all(&project).unwrap();
753        let line = format!(
754            r#"{{"type":"assistant","message":{{"role":"assistant","model":"m","content":[{{"type":"text","text":"{text}"}}]}},"timestamp":"2026-09-24T10:00:00Z","cwd":"{CWD}"}}"#
755        );
756        std::fs::write(project.join(format!("{sid}.jsonl")), format!("{line}\n")).unwrap();
757    }
758
759    fn search(svc: &HistoryService, query: &str, include_inferred: bool) -> index::HistorySearchOutcome {
760        let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
761        let owner = HistoryOwner { definition_id: UID.into(), working_directory: Some(CWD.into()) };
762        let opts = index::HistorySearchOptions { query: query.into(), limit: 50, include_inferred, ..Default::default() };
763        svc.search_for_agent(&store, &owner, &opts).unwrap()
764    }
765
766    /// 39% of the sessions on the host measured have no provider transcript
767    /// left (Claude's cleanup, a deleted account folder). They're still in
768    /// AgentMux's record, and found there.
769    #[test]
770    fn a_session_whose_transcript_is_gone_is_found_in_the_agents_record() {
771        let tmp = tempfile::TempDir::new().unwrap();
772        let records = store();
773        append(&records, &zone(), &[init("s-gone", CWD), assistant("s-gone", "only the record remembers this")]);
774        let out = search(&service(tmp.path(), &records), "record remembers", false);
775        assert_eq!(out.hits.len(), 1, "{out:?}");
776        assert_eq!(out.hits[0].source, "agentmux_record");
777        assert_eq!(out.hits[0].attribution, "agent_record");
778        assert_eq!(out.hits[0].session_id, "s-gone");
779        assert_eq!((out.ledger_sessions, out.record_only_sessions), (1, 1));
780        assert!(out.complete, "{out:?}");
781    }
782
783    /// A session whose transcript still exists is read from the transcript,
784    /// once — not again from the record.
785    #[test]
786    fn a_session_with_a_transcript_is_searched_there_once() {
787        let tmp = tempfile::TempDir::new().unwrap();
788        let records = store();
789        append(&records, &zone(), &[init("s1", CWD), assistant("s1", "words in both places")]);
790        transcript(tmp.path(), "s1", "words in both places");
791        let out = search(&service(tmp.path(), &records), "both places", false);
792        assert_eq!(out.hits.len(), 1, "{out:?}");
793        assert_eq!(out.hits[0].source, "provider_transcript");
794        assert_eq!((out.ledger_sessions, out.record_only_sessions), (1, 0));
795    }
796
797    /// Found by folder but not in the agent's record: another agent's session
798    /// in a shared folder, or one older than the record. Left out and counted
799    /// — `include_inferred` searches it, labelled.
800    #[test]
801    fn a_session_found_only_by_folder_is_left_out_when_the_agent_has_a_record() {
802        let tmp = tempfile::TempDir::new().unwrap();
803        let records = store();
804        append(&records, &zone(), &[init("s1", CWD), assistant("s1", "mine")]);
805        transcript(tmp.path(), "s-other", "someone else wrote this");
806        let svc = service(tmp.path(), &records);
807
808        let out = search(&svc, "someone else", false);
809        assert!(out.hits.is_empty(), "{out:?}");
810        assert_eq!(out.inferred_sessions_excluded, 1);
811
812        let out = search(&svc, "someone else", true);
813        assert_eq!(out.hits.len(), 1, "{out:?}");
814        assert_eq!(out.hits[0].attribution, "inferred");
815    }
816
817    /// An agent with no record at all (never ran on a build that keeps one)
818    /// has nothing better than its folder, so it keeps that.
819    #[test]
820    fn an_agent_without_a_record_keeps_the_folder_match() {
821        let tmp = tempfile::TempDir::new().unwrap();
822        transcript(tmp.path(), "s-folder", "found by folder");
823        let out = search(&service(tmp.path(), &store()), "by folder", false);
824        assert_eq!(out.hits.len(), 1, "{out:?}");
825        assert_eq!(out.hits[0].attribution, "inferred");
826        assert_eq!(out.ledger_sessions, 0);
827    }
828
829    /// A session the segment log names but nothing holds any more can't be
830    /// searched, and the answer says so.
831    #[test]
832    fn a_logged_session_with_no_content_anywhere_makes_the_answer_incomplete() {
833        let tmp = tempfile::TempDir::new().unwrap();
834        let records = store();
835        crate::backend::continuity_segments::record_start(
836            &records,
837            crate::backend::continuity_segments::Start {
838                segment_id: String::new(),
839                agent_uid: UID.into(),
840                definition_id: Some(UID.into()),
841                provider: "claude".into(),
842                config_dir: None,
843                provider_session_id: Some("s-lost".into()),
844                cwd: CWD.into(),
845                channel: "test".into(),
846                agentmux_version: "0.0.0".into(),
847                block_id: "b".into(),
848                zone: None,
849                byte_start: None,
850                started_at_ms: 1_000,
851                continuity_rung: crate::backend::continuity_segments::Rung::Fresh,
852                predecessor_segment_id: None,
853                lease_epoch: None,
854                identity_key: None,
855                forked_from: None,
856            },
857        )
858        .unwrap();
859        let out = search(&service(tmp.path(), &records), "anything", false);
860        assert!(!out.complete, "{out:?}");
861        assert_eq!(out.incomplete_reasons, vec!["unreadable_sessions"]);
862        assert_eq!(out.sessions_unreadable[0].session_id, "s-lost");
863    }
864}
865
866#[cfg(test)]
867mod search_freshness_tests {
868    use super::*;
869
870    fn empty_service() -> HistoryService {
871        HistoryService::from_index(SessionIndex::with_isolated_roots(vec![], vec![]))
872    }
873
874    /// The MCP gives a search 10 s; a cold build over every transcript on
875    /// the machine takes far longer. While it runs, a search must answer at
876    /// once — and say the index is building, never "no history".
877    #[test]
878    fn a_search_during_the_first_build_says_so_instead_of_waiting() {
879        let svc = empty_service();
880        let _building = svc.index.refresh_lock.lock().unwrap();
881        let err = svc.refresh_for_search().unwrap_err();
882        assert!(matches!(err, HistorySearchError::IndexBuilding), "{err}");
883        assert!(err.to_string().contains("still being built"), "{err}");
884    }
885
886    #[test]
887    fn since_and_until_accept_seconds_or_milliseconds() {
888        // Documented as seconds …
889        assert_eq!(unix_time_to_ms(1_790_280_000), 1_790_280_000_000);
890        // … but a hit's own timestamp is milliseconds, and passing it back
891        // must not be multiplied into the year 58,000.
892        assert_eq!(unix_time_to_ms(1_790_280_000_123), 1_790_280_000_123);
893        assert_eq!(unix_time_to_ms(0), 0);
894    }
895
896    #[test]
897    fn a_search_on_a_never_built_idle_index_builds_it() {
898        let svc = empty_service();
899        assert!(svc.refresh_for_search().is_ok());
900        assert!(svc.index.refreshed_at_ms() > 0);
901    }
902
903    fn real_service(shared: &std::path::Path) -> HistoryService {
904        HistoryService::from_index(SessionIndex::with_isolated_roots(
905            vec![Box::new(ClaudeHistoryAdapter::with_roots(None, Some(shared.to_path_buf()), None))],
906            vec![],
907        ))
908    }
909
910    fn owner_in(dir: &str) -> HistoryOwner {
911        HistoryOwner { definition_id: "agent-a".into(), working_directory: Some(dir.into()) }
912    }
913
914    /// A transcript whose first message carries no timestamp: its session
915    /// start (the first dated record) is after `until`, but the undated
916    /// message may not be, and the message-level window keeps undated
917    /// messages. Dropping the whole session on its start made the two
918    /// disagree and could answer "complete, no hits" (Codex P2 on #3693).
919    #[test]
920    fn a_session_starting_with_an_undated_message_stays_in_the_until_window() {
921        let tmp = tempfile::TempDir::new().unwrap();
922        let shared = tmp.path().join("shared");
923        let project = shared.join("providers").join("claude").join("projects").join("-work-a");
924        std::fs::create_dir_all(&project).unwrap();
925        let undated = r#"{"type":"user","message":{"role":"user","content":"undated question"},"cwd":"/work/a"}"#;
926        let dated = r#"{"type":"assistant","message":{"role":"assistant","model":"m","content":[{"type":"text","text":"answered"}]},"timestamp":"2026-09-24T12:00:00Z","cwd":"/work/a"}"#;
927        std::fs::write(project.join("s.jsonl"), format!("{undated}\n{dated}\n")).unwrap();
928        let svc = real_service(&shared);
929        let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
930        let until = chrono::DateTime::parse_from_rfc3339("2026-09-24T11:00:00Z").unwrap().timestamp_millis();
931        let opts = index::HistorySearchOptions {
932            query: "undated question".into(),
933            limit: 50,
934            until_ms: Some(until),
935            ..Default::default()
936        };
937        let out = svc.search_for_agent(&store, &owner_in("/work/a"), &opts).unwrap();
938        assert_eq!(out.hits.len(), 1, "{out:?}");
939    }
940
941    /// Discovery that couldn't read somewhere may have missed the session
942    /// being searched for, so the answer isn't complete (Codex P1 on #3693).
943    #[test]
944    fn a_search_over_a_snapshot_with_discovery_problems_is_not_complete() {
945        struct Unreadable;
946        impl HistoryAdapter for Unreadable {
947            fn provider(&self) -> &str {
948                "mock"
949            }
950            fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
951                Ok(Vec::new())
952            }
953            fn discover(&self) -> Result<Discovery, HistoryError> {
954                Ok(Discovery { files: Vec::new(), problems: vec!["/x/projects: Access is denied".into()] })
955            }
956            fn extract_meta(&self, _: &str) -> Result<Option<SessionMeta>, HistoryError> {
957                Ok(None)
958            }
959            fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
960                Ok(None)
961            }
962        }
963        let svc = HistoryService::from_index(SessionIndex::with_isolated_roots(vec![Box::new(Unreadable)], vec![]));
964        let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
965        let opts = index::HistorySearchOptions { query: "anything".into(), limit: 50, ..Default::default() };
966        let out = svc.search_for_agent(&store, &owner_in("/work/a"), &opts).unwrap();
967        assert!(out.hits.is_empty());
968        assert!(!out.complete, "{out:?}");
969        assert_eq!(out.incomplete_reasons, vec!["discovery_errors"]);
970        assert!(out.discovery_errors.iter().any(|p| p.contains("Access is denied")), "{out:?}");
971    }
972
973    /// A session written moments after one search is found by the next. A
974    /// snapshot reused because it was "fresh enough" (under 30 s old, or
975    /// another refresh running) answered `complete: true` without it — the
976    /// false negative this whole tool exists to prevent (Codex P1 on #3693).
977    #[test]
978    fn a_session_written_right_after_a_search_is_found_by_the_next_search() {
979        let tmp = tempfile::TempDir::new().unwrap();
980        let shared = tmp.path().join("shared");
981        let project = shared.join("providers").join("claude").join("projects").join("-work-a");
982        std::fs::create_dir_all(&project).unwrap();
983        let svc = HistoryService::from_index(SessionIndex::with_isolated_roots(
984            vec![Box::new(ClaudeHistoryAdapter::with_roots(None, Some(shared.clone()), None))],
985            vec![],
986        ));
987        let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
988        let owner = HistoryOwner { definition_id: "agent-a".into(), working_directory: Some("/work/a".into()) };
989        let opts = index::HistorySearchOptions { query: "deploy".into(), limit: 50, ..Default::default() };
990
991        let first = svc.search_for_agent(&store, &owner, &opts).unwrap();
992        assert!(first.hits.is_empty() && first.complete, "{first:?}");
993
994        let line = r#"{"type":"user","message":{"role":"user","content":"deploy now"},"timestamp":"2026-09-24T10:00:00Z","cwd":"/work/a"}"#;
995        std::fs::write(project.join("written-after.jsonl"), format!("{line}\n")).unwrap();
996        let second = svc.search_for_agent(&store, &owner, &opts).unwrap();
997        assert_eq!(second.hits.len(), 1, "{second:?}");
998        assert!(second.complete, "{second:?}");
999    }
1000
1001    #[test]
1002    fn warm_in_background_builds_the_index() {
1003        let svc = empty_service();
1004        svc.warm_in_background_after(std::time::Duration::ZERO)
1005            .join()
1006            .unwrap();
1007        assert!(svc.index.refreshed_at_ms() > 0);
1008        assert!(!svc.warm_pending.load(std::sync::atomic::Ordering::SeqCst));
1009    }
1010
1011    /// SPEC_AGENT_OPEN_LATENCY_2026_09_27.md F2: the first build is deferred.
1012    /// A search in that window must not run the whole first scan itself (it
1013    /// would outlast the MCP's 10 s) — it answers "building", like during the
1014    /// build — but it starts the build in the background instead of leaving
1015    /// the index unbuilt until the delay ends.
1016    #[test]
1017    fn a_search_while_the_deferred_build_is_pending_starts_it_in_the_background() {
1018        let svc = empty_service();
1019        let _warm = svc.warm_in_background_after(std::time::Duration::from_secs(3600));
1020        let err = svc.refresh_for_search().unwrap_err();
1021        assert!(matches!(err, HistorySearchError::IndexBuilding), "{err}");
1022        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1023        while svc.index.refreshed_at_ms() == 0 {
1024            assert!(
1025                std::time::Instant::now() < deadline,
1026                "the early build never ran"
1027            );
1028            std::thread::sleep(std::time::Duration::from_millis(10));
1029        }
1030        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1031        while svc.warm_pending.load(std::sync::atomic::Ordering::SeqCst) {
1032            assert!(
1033                std::time::Instant::now() < deadline,
1034                "pending never cleared"
1035            );
1036            std::thread::sleep(std::time::Duration::from_millis(10));
1037        }
1038        assert!(svc.refresh_for_search().is_ok());
1039    }
1040
1041    /// Codex P2 on #3930: an on-demand build during the delay makes the
1042    /// deferred warm-up a no-op instead of a second full discovery pass.
1043    #[test]
1044    fn an_on_demand_build_during_the_delay_skips_the_deferred_build() {
1045        let svc = empty_service();
1046        let warm = svc.warm_in_background_after(std::time::Duration::from_millis(300));
1047        svc.index.refresh(); // e.g. the History tab's list() during startup
1048        let built_at = svc.index.refreshed_at_ms();
1049        assert!(built_at > 0);
1050        warm.join().unwrap();
1051        assert_eq!(
1052            svc.index.refreshed_at_ms(),
1053            built_at,
1054            "the deferred build must not refresh again"
1055        );
1056        assert!(!svc.warm_pending.load(std::sync::atomic::Ordering::SeqCst));
1057    }
1058
1059    /// The spec's protected window is the first 30 s; a cold build can take
1060    /// ~25 s, so it must not start inside it.
1061    #[test]
1062    fn the_first_build_waits_out_the_startup_window() {
1063        assert!(
1064            WARM_DELAY >= std::time::Duration::from_secs(30),
1065            "{WARM_DELAY:?}"
1066        );
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use crate::backend::storage::store::Store;
1074
1075    /// Adapter that "discovers" caller-supplied files, tagging each with a
1076    /// fixed identity_id -- just enough to exercise the agent_id ->
1077    /// identity_id -> sessions chain end to end.
1078    struct MockAdapter {
1079        files: Vec<DiscoveredFile>,
1080        identity_id: String,
1081        /// Defaults to "/proj" via [`MockAdapter::new`]; set explicitly by the
1082        /// working-directory resolution tests.
1083        working_directory: String,
1084    }
1085
1086    impl MockAdapter {
1087        fn new(files: Vec<DiscoveredFile>, identity_id: &str) -> Self {
1088            MockAdapter {
1089                files,
1090                identity_id: identity_id.to_string(),
1091                working_directory: "/proj".to_string(),
1092            }
1093        }
1094        fn in_dir(files: Vec<DiscoveredFile>, identity_id: &str, working_directory: &str) -> Self {
1095            MockAdapter {
1096                files,
1097                identity_id: identity_id.to_string(),
1098                working_directory: working_directory.to_string(),
1099            }
1100        }
1101    }
1102    impl HistoryAdapter for MockAdapter {
1103        fn provider(&self) -> &str {
1104            "mock"
1105        }
1106        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1107            Ok(self.files.iter().map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms }).collect())
1108        }
1109        fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1110            let id = std::path::Path::new(file_path)
1111                .file_stem()
1112                .map(|s| s.to_string_lossy().into_owned())
1113                .unwrap_or_default();
1114            Ok(Some(SessionMeta {
1115                session_id: id,
1116                file_path: file_path.to_string(),
1117                provider: "mock".to_string(),
1118                model: String::new(),
1119                slug: String::new(),
1120                working_directory: self.working_directory.clone(),
1121                created_at: 0,
1122                modified_at: 0,
1123                message_count: 0,
1124                first_user_message: String::new(),
1125                file_size_bytes: 0,
1126                git_branch: String::new(),
1127                total_tokens: 0,
1128                subagent_count: 0,
1129                identity_id: self.identity_id.clone(),
1130                starts_undated: false,
1131            }))
1132        }
1133        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
1134            Ok(None)
1135        }
1136    }
1137
1138    fn write_session(dir: &std::path::Path, id: &str) -> String {
1139        let f = dir.join(format!("{id}.jsonl"));
1140        std::fs::write(&f, b"{}").unwrap();
1141        f.to_string_lossy().into_owned()
1142    }
1143
1144    // docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md
1145    // §4.4: the actual end-to-end feature -- agent_id resolves through a
1146    // real db_agent_identity_links row (not a mocked lookup) to the
1147    // sessions found under that link's account_id.
1148    #[test]
1149    fn list_for_agent_resolves_through_a_real_identity_link_to_the_right_sessions() {
1150        let dir = std::env::temp_dir().join(format!("amux-hist-svc-{}", std::process::id()));
1151        std::fs::create_dir_all(&dir).unwrap();
1152        let mine = write_session(&dir, "mine");
1153        let someone_elses = write_session(&dir, "someone-elses");
1154
1155        let index = SessionIndex::with_isolated_roots(
1156            vec![
1157                Box::new(MockAdapter::new(
1158                    vec![DiscoveredFile { file_path: mine, mtime_ms: 1 }],
1159                    "acct-mine",
1160                )),
1161                Box::new(MockAdapter::new(
1162                    vec![DiscoveredFile { file_path: someone_elses, mtime_ms: 1 }],
1163                    "acct-someone-else",
1164                )),
1165            ],
1166            vec![dir.clone()],
1167        );
1168        let service = HistoryService::from_index(index);
1169
1170        let store = Store::open_in_memory().unwrap();
1171        let mut def = crate::backend::storage::store::AgentDefinition {
1172            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1173            id: "agent-1".to_string(),
1174            slug: String::new(),
1175            name: "T".to_string(),
1176            icon: String::new(),
1177            provider: "claude".to_string(),
1178            description: String::new(),
1179            working_directory: String::new(),
1180            shell: String::new(),
1181            provider_flags: String::new(),
1182            auto_start: 0,
1183            restart_on_crash: 0,
1184            idle_timeout_minutes: 0,
1185            created_at: 0,
1186            agent_type: String::new(),
1187            environment: String::new(),
1188            agent_bus_id: String::new(),
1189            is_seeded: 0,
1190            accounts: String::new(),
1191            parent_id: String::new(),
1192            branch_label: String::new(),
1193            updated_at: 0,
1194            user_hidden: 0,
1195            container_image: String::new(),
1196            container_volumes: "[]".to_string(),
1197            container_name: String::new(),
1198            use_ambient_login: 0,
1199            auto_continue_enabled: 0,
1200            model_vendor_base_url: String::new(),
1201            memory_id: String::new(),
1202        };
1203        store.agent_def_insert(&mut def).unwrap();
1204        store
1205            .identity_upsert(&crate::backend::storage::store::IdentityAccount {
1206                id: "acct-mine".to_string(),
1207                name: "claude-acct-mine".to_string(),
1208                provider: "claude".to_string(),
1209                kind: "pat".to_string(),
1210                display_name: String::new(),
1211                secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: String::new() },
1212                context: serde_json::json!({}),
1213                status: "unknown".to_string(),
1214                created_at: 0,
1215                updated_at: 0,
1216            })
1217            .unwrap();
1218        store.agent_identity_link("agent-1", "acct-mine", "claude").unwrap();
1219
1220        let result = service.list_for_agent(&store, "agent-1", 0, 10, "created_at", "desc");
1221        assert_eq!(result["total"], 1);
1222        assert_eq!(result["sessions"][0]["session_id"], "mine");
1223
1224        std::fs::remove_dir_all(&dir).ok();
1225    }
1226
1227    #[test]
1228    fn list_for_agent_returns_empty_when_nothing_on_disk_matches_the_agent() {
1229        let service = HistoryService::from_index(SessionIndex::with_isolated_roots(vec![], vec![]));
1230        let store = Store::open_in_memory().unwrap();
1231        let result = service.list_for_agent(&store, "agent-with-no-links", 0, 10, "created_at", "desc");
1232        assert_eq!(result["total"], 0);
1233        assert_eq!(result["sessions"].as_array().unwrap().len(), 0);
1234    }
1235
1236    // ── Agent → session resolution
1237    // (docs/specs/SPEC_CROSS_CHANNEL_AGENT_HISTORY_RESOLUTION_2026_09_21.md) ──
1238
1239    /// Insert an agent whose slug differs from its id — i.e. every real
1240    /// agent, since the id is a UUID and the slug is human-readable.
1241    fn insert_agent(store: &Store, id: &str, name: &str, slug: &str, working_directory: &str) {
1242        let mut def = crate::backend::storage::store::AgentDefinition {
1243            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1244            id: id.to_string(),
1245            slug: slug.to_string(),
1246            name: name.to_string(),
1247            icon: String::new(),
1248            provider: "claude".to_string(),
1249            description: String::new(),
1250            working_directory: working_directory.to_string(),
1251            shell: String::new(),
1252            provider_flags: String::new(),
1253            auto_start: 0,
1254            restart_on_crash: 0,
1255            idle_timeout_minutes: 0,
1256            created_at: 0,
1257            agent_type: String::new(),
1258            environment: String::new(),
1259            agent_bus_id: String::new(),
1260            is_seeded: 0,
1261            accounts: String::new(),
1262            parent_id: String::new(),
1263            branch_label: String::new(),
1264            updated_at: 0,
1265            user_hidden: 0,
1266            container_image: String::new(),
1267            container_volumes: "[]".to_string(),
1268            container_name: String::new(),
1269            use_ambient_login: 0,
1270            auto_continue_enabled: 0,
1271            model_vendor_base_url: String::new(),
1272            memory_id: String::new(),
1273        };
1274        store.agent_def_insert(&mut def).unwrap();
1275    }
1276
1277    /// The dominant real-world failure: an agent with no bound account has
1278    /// NO `db_agent_identity_links` row (its registry record's `identity_id`
1279    /// reads "default"), so account resolution finds nothing — while
1280    /// its transcripts sit on disk, recording the directory it ran in. Before
1281    /// the working-directory index this returned a confident, empty history.
1282    #[test]
1283    fn sessions_resolve_by_working_directory_when_the_agent_has_no_identity_link() {
1284        let dir = std::env::temp_dir().join(format!("amux-hist-wd-{}", std::process::id()));
1285        std::fs::create_dir_all(&dir).unwrap();
1286        let mine = write_session(&dir, "mine-wd");
1287        let theirs = write_session(&dir, "theirs-wd");
1288
1289        let index = SessionIndex::with_isolated_roots(
1290            vec![
1291                Box::new(MockAdapter::in_dir(
1292                    vec![DiscoveredFile { file_path: mine, mtime_ms: 1 }],
1293                    "",
1294                    "/agents/agenty-0629j",
1295                )),
1296                Box::new(MockAdapter::in_dir(
1297                    vec![DiscoveredFile { file_path: theirs, mtime_ms: 1 }],
1298                    "",
1299                    "/agents/somebody-else",
1300                )),
1301            ],
1302            vec![dir.clone()],
1303        );
1304        let service = HistoryService::from_index(index);
1305
1306        let store = Store::open_in_memory().unwrap();
1307        insert_agent(&store, "def-uuid-agenty", "AgentY", "agenty", "/agents/agenty-0629j");
1308        // Deliberately NO agent_identity_link: that is the whole point.
1309
1310        let (sessions, total, _) = service
1311            .sessions_for_agent(&store, "agenty", 0, 10, "created_at", "desc", true)
1312            .unwrap();
1313        assert_eq!(total, 1, "the agent's own session must be found without any identity link");
1314        assert_eq!(sessions[0].session_id, "mine-wd");
1315
1316        std::fs::remove_dir_all(&dir).ok();
1317    }
1318
1319    /// One account linked to two agents holds both agents' transcripts. Each
1320    /// agent's history is its own working directory's sessions from it —
1321    /// never the other agent's — while an account only one agent uses keeps
1322    /// every session, including one from an earlier working directory.
1323    #[test]
1324    fn a_shared_account_contributes_only_the_owners_own_directory() {
1325        let dir = std::env::temp_dir().join(format!("amux-hist-shared-{}", std::process::id()));
1326        std::fs::create_dir_all(&dir).unwrap();
1327        let y = write_session(&dir, "shared-y");
1328        let z = write_session(&dir, "shared-z");
1329        let y_old = write_session(&dir, "solo-y-old-dir");
1330
1331        let index = SessionIndex::with_isolated_roots(
1332            vec![
1333                Box::new(MockAdapter::in_dir(vec![DiscoveredFile { file_path: y, mtime_ms: 1 }], "acct-shared", "/agents/y")),
1334                Box::new(MockAdapter::in_dir(vec![DiscoveredFile { file_path: z, mtime_ms: 1 }], "acct-shared", "/agents/z")),
1335                Box::new(MockAdapter::in_dir(vec![DiscoveredFile { file_path: y_old, mtime_ms: 1 }], "acct-solo", "/agents/y-before-move")),
1336            ],
1337            vec![dir.clone()],
1338        );
1339        let service = HistoryService::from_index(index);
1340        let store = Store::open_in_memory().unwrap();
1341        insert_agent(&store, "def-y", "AgentY", "agenty", "/agents/y");
1342        insert_agent(&store, "def-z", "AgentZ", "agentz", "/agents/z");
1343        for acct in ["acct-shared", "acct-solo"] {
1344            store
1345                .identity_upsert(&crate::backend::storage::store::IdentityAccount {
1346                    id: acct.to_string(),
1347                    name: format!("claude-{acct}"),
1348                    provider: "claude".to_string(),
1349                    kind: "oauth".to_string(),
1350                    display_name: String::new(),
1351                    secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: String::new() },
1352                    context: serde_json::json!({}),
1353                    status: "unknown".to_string(),
1354                    created_at: 0,
1355                    updated_at: 0,
1356                })
1357                .unwrap();
1358        }
1359        store.agent_identity_link("def-y", "acct-shared", "claude").unwrap();
1360        store.agent_identity_link("def-z", "acct-shared", "claude").unwrap();
1361        store.agent_identity_link("def-y", "acct-solo", "codex").unwrap();
1362
1363        let ids = |slug: &str| {
1364            let (sessions, _, _) = service
1365                .sessions_for_agent(&store, slug, 0, 10, "created_at", "desc", true)
1366                .unwrap();
1367            let mut ids: Vec<String> = sessions.into_iter().map(|s| s.session_id).collect();
1368            ids.sort();
1369            ids
1370        };
1371        assert_eq!(ids("agenty"), ["shared-y", "solo-y-old-dir"], "never AgentZ's session");
1372        assert_eq!(ids("agentz"), ["shared-z"], "never AgentY's sessions");
1373
1374        std::fs::remove_dir_all(&dir).ok();
1375    }
1376
1377    /// Callers pass the SLUG (`AGENTMUX_AGENT_ID`), but
1378    /// `db_agent_identity_links.agent_id` stores the DEFINITION id — so the
1379    /// link lookup has to resolve the slug first or it matches zero rows.
1380    #[test]
1381    fn identity_links_resolve_from_the_slug_not_only_the_definition_id() {
1382        let dir = std::env::temp_dir().join(format!("amux-hist-slug-{}", std::process::id()));
1383        std::fs::create_dir_all(&dir).unwrap();
1384        let linked = write_session(&dir, "linked-session");
1385
1386        let index = SessionIndex::with_isolated_roots(
1387            vec![Box::new(MockAdapter::in_dir(
1388                vec![DiscoveredFile { file_path: linked, mtime_ms: 1 }],
1389                "acct-mine",
1390                // A directory the agent is NOT in, so only the identity link
1391                // can account for this hit.
1392                "/somewhere/else",
1393            ))],
1394            vec![dir.clone()],
1395        );
1396        let service = HistoryService::from_index(index);
1397
1398        let store = Store::open_in_memory().unwrap();
1399        insert_agent(&store, "def-uuid-agenty", "AgentY", "agenty", "/agents/agenty-0629j");
1400        store
1401            .identity_upsert(&crate::backend::storage::store::IdentityAccount {
1402                id: "acct-mine".to_string(),
1403                name: "claude-acct-mine".to_string(),
1404                provider: "claude".to_string(),
1405                kind: "pat".to_string(),
1406                display_name: String::new(),
1407                secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: String::new() },
1408                context: serde_json::json!({}),
1409                status: "unknown".to_string(),
1410                created_at: 0,
1411                updated_at: 0,
1412            })
1413            .unwrap();
1414        // The link is keyed by DEFINITION id, as production writes it.
1415        store.agent_identity_link("def-uuid-agenty", "acct-mine", "claude").unwrap();
1416
1417        // The caller passes the slug, which is all an agent knows about itself.
1418        let (sessions, total, _) = service
1419            .sessions_for_agent(&store, "agenty", 0, 10, "created_at", "desc", true)
1420            .unwrap();
1421        assert_eq!(total, 1, "the slug must resolve to the definition id the link is stored under");
1422        assert_eq!(sessions[0].session_id, "linked-session");
1423
1424        std::fs::remove_dir_all(&dir).ok();
1425    }
1426
1427    /// `list`'s `project` filter matches on `contains`; working-directory
1428    /// resolution must not, or an agent in `/agents/foo` silently claims
1429    /// every session belonging to `/agents/foo-2`.
1430    #[test]
1431    fn working_directory_matching_is_exact_not_a_prefix() {
1432        let dir = std::env::temp_dir().join(format!("amux-hist-exact-{}", std::process::id()));
1433        std::fs::create_dir_all(&dir).unwrap();
1434        let neighbour = write_session(&dir, "neighbour-session");
1435
1436        let index = SessionIndex::with_isolated_roots(
1437            vec![Box::new(MockAdapter::in_dir(
1438                vec![DiscoveredFile { file_path: neighbour, mtime_ms: 1 }],
1439                "",
1440                "/agents/foo-2",
1441            ))],
1442            vec![dir.clone()],
1443        );
1444        let service = HistoryService::from_index(index);
1445
1446        let store = Store::open_in_memory().unwrap();
1447        insert_agent(&store, "def-uuid-foo", "Foo", "foo", "/agents/foo");
1448
1449        let (_, total, _) = service
1450            .sessions_for_agent(&store, "foo", 0, 10, "created_at", "desc", true)
1451            .unwrap();
1452        assert_eq!(total, 0, "/agents/foo must not claim /agents/foo-2's sessions");
1453
1454        std::fs::remove_dir_all(&dir).ok();
1455    }
1456
1457    /// A trailing separator or different slash style describes the same
1458    /// directory and must resolve to the same sessions.
1459    #[test]
1460    fn working_directory_matching_normalizes_separators_and_trailing_slash() {
1461        let dir = std::env::temp_dir().join(format!("amux-hist-norm-{}", std::process::id()));
1462        std::fs::create_dir_all(&dir).unwrap();
1463        let mine = write_session(&dir, "norm-session");
1464
1465        let index = SessionIndex::with_isolated_roots(
1466            vec![Box::new(MockAdapter::in_dir(
1467                vec![DiscoveredFile { file_path: mine, mtime_ms: 1 }],
1468                "",
1469                r"C:\work\agents\bar",
1470            ))],
1471            vec![dir.clone()],
1472        );
1473        let service = HistoryService::from_index(index);
1474
1475        let store = Store::open_in_memory().unwrap();
1476        insert_agent(&store, "def-uuid-bar", "Bar", "bar", "C:/work/agents/bar/");
1477
1478        let (_, total, _) = service
1479            .sessions_for_agent(&store, "bar", 0, 10, "created_at", "desc", true)
1480            .unwrap();
1481        assert_eq!(total, 1, "the same directory written differently must still match");
1482
1483        std::fs::remove_dir_all(&dir).ok();
1484    }
1485
1486    /// The registry fallback — `registry_record` plus
1487    /// `agent_registry_lookup::working_dir_from_record` — is the path for a
1488    /// live agent with NO `db_agents` row, which is the common case (launching
1489    /// an agent does not create one). Every other test here inserts a real row,
1490    /// so `instance_get_by_slug` always succeeds and this path never runs;
1491    /// a regression in slug derivation or `source_agents_base` handling would
1492    /// ship undetected. reagentx P2 on PR #3480.
1493    #[test]
1494    fn sessions_resolve_via_the_registry_when_the_agent_has_no_db_row() {
1495        let _guard = crate::test_support::ISOLATED_AUTH_ENV_LOCK
1496            .lock()
1497            .unwrap_or_else(|e| e.into_inner());
1498        let prev = std::env::var_os("AGENTMUX_SHARED_DIR");
1499        let tmp = tempfile::tempdir().unwrap();
1500        std::env::set_var("AGENTMUX_SHARED_DIR", tmp.path());
1501
1502        // The registry stores working_dir RELATIVE to source_agents_base.
1503        let base = tmp.path().join("agentsbase");
1504        let resolved_dir = base.join("agenty-0629j").to_string_lossy().to_string();
1505
1506        let registry = crate::registry::Registry::open(tmp.path().join("agents").join("registry")).unwrap();
1507        registry
1508            .upsert(&crate::registry::NamedAgentRecord {
1509                schema_version: 3,
1510                data: crate::registry::NamedAgentRecordV1 {
1511                    instance_id: "inst-agenty".to_string(),
1512                    instance_name: "AgentY".to_string(),
1513                    definition_id: "def-agenty".to_string(),
1514                    identity_id: None,
1515                    memory_id: None,
1516                    session_id: None,
1517                    working_dir: "agenty-0629j".to_string(),
1518                    source_agents_base: Some(base.to_string_lossy().to_string()),
1519                    created_at_ms: 1,
1520                    last_launched_at_ms: 1,
1521                    created_by_version: "test".to_string(),
1522                    last_launched_by_version: "test".to_string(),
1523                },
1524            })
1525            .unwrap();
1526
1527        let dir = std::env::temp_dir().join(format!("amux-hist-reg-{}", std::process::id()));
1528        std::fs::create_dir_all(&dir).unwrap();
1529        let mine = write_session(&dir, "registry-session");
1530        let index = SessionIndex::with_isolated_roots(
1531            vec![Box::new(MockAdapter::in_dir(
1532                vec![DiscoveredFile { file_path: mine, mtime_ms: 1 }],
1533                "",
1534                &resolved_dir,
1535            ))],
1536            vec![dir.clone()],
1537        );
1538        let service = HistoryService::from_index(index);
1539
1540        // Deliberately EMPTY store: no db_agents row, so resolution has to come
1541        // from the registry record above or not at all.
1542        let store = Store::open_in_memory().unwrap();
1543
1544        // The display name is "AgentY"; callers pass the derived slug.
1545        let (sessions, total, _) = service
1546            .sessions_for_agent(&store, "agenty", 0, 10, "created_at", "desc", true)
1547            .unwrap();
1548        assert_eq!(total, 1, "registry fallback must resolve the working directory");
1549        assert_eq!(sessions[0].session_id, "registry-session");
1550
1551        // An unrelated agent must not inherit it by coincidence.
1552        let (_, other_total, _) = service
1553            .sessions_for_agent(&store, "someone-else", 0, 10, "created_at", "desc", true)
1554            .unwrap();
1555        assert_eq!(other_total, 0, "an unrelated slug must not match");
1556
1557        match prev {
1558            Some(v) => std::env::set_var("AGENTMUX_SHARED_DIR", v),
1559            None => std::env::remove_var("AGENTMUX_SHARED_DIR"),
1560        }
1561        std::fs::remove_dir_all(&dir).ok();
1562    }
1563
1564    /// A registry-only agent that ALSO has a real identity link — the gap
1565    /// reagentx P1 flagged on PR #3480 (re-review). The registry test above
1566    /// proves the working-directory fallback, but its record has no identity,
1567    /// so the identity-link half was never exercised without a `db_agents`
1568    /// row.
1569    ///
1570    /// The failure it guards is quiet: with no instance row, `definition_id`
1571    /// fell back to the raw SLUG, and `db_agent_identity_links.agent_id`
1572    /// stores a DEFINITION id — so the link lookup matched zero rows and the
1573    /// agent's identity-bound sessions vanished. That is Bug A, reproduced for
1574    /// exactly the case the PR's own docs call the common one.
1575    ///
1576    /// The session here is deliberately in a directory the registry record
1577    /// does NOT point at, so the working-directory fallback cannot mask a
1578    /// regression by finding it a second way.
1579    #[test]
1580    fn registry_only_agent_still_resolves_its_identity_linked_sessions() {
1581        let _guard = crate::test_support::ISOLATED_AUTH_ENV_LOCK
1582            .lock()
1583            .unwrap_or_else(|e| e.into_inner());
1584        let prev = std::env::var_os("AGENTMUX_SHARED_DIR");
1585        let tmp = tempfile::tempdir().unwrap();
1586        std::env::set_var("AGENTMUX_SHARED_DIR", tmp.path());
1587
1588        let base = tmp.path().join("agentsbase");
1589        let registry =
1590            crate::registry::Registry::open(tmp.path().join("agents").join("registry")).unwrap();
1591        registry
1592            .upsert(&crate::registry::NamedAgentRecord {
1593                schema_version: 3,
1594                data: crate::registry::NamedAgentRecordV1 {
1595                    instance_id: "inst-linked".to_string(),
1596                    instance_name: "AgentY".to_string(),
1597                    definition_id: "def-agenty".to_string(),
1598                    identity_id: None,
1599                    memory_id: None,
1600                    session_id: None,
1601                    working_dir: "agenty-0629j".to_string(),
1602                    source_agents_base: Some(base.to_string_lossy().to_string()),
1603                    created_at_ms: 1,
1604                    last_launched_at_ms: 1,
1605                    created_by_version: "test".to_string(),
1606                    last_launched_by_version: "test".to_string(),
1607                },
1608            })
1609            .unwrap();
1610
1611        let dir = std::env::temp_dir().join(format!("amux-hist-reglink-{}", std::process::id()));
1612        std::fs::create_dir_all(&dir).unwrap();
1613        let mine = write_session(&dir, "identity-linked-session");
1614        let index = SessionIndex::with_isolated_roots(
1615            vec![Box::new(MockAdapter::in_dir(
1616                vec![DiscoveredFile { file_path: mine, mtime_ms: 1 }],
1617                "acct-mine",
1618                "/somewhere/the/registry/does/not/point",
1619            ))],
1620            vec![dir.clone()],
1621        );
1622        let service = HistoryService::from_index(index);
1623
1624        // A definition + identity link, but NO `db_agents` instance row — so
1625        // `instance_get_by_slug("agenty")` finds nothing and the definition id
1626        // can only come from the registry record above.
1627        let store = Store::open_in_memory().unwrap();
1628        insert_test_agent_and_link(&store, "def-agenty", "acct-mine");
1629
1630        let (sessions, total, _) = service
1631            .sessions_for_agent(&store, "agenty", 0, 10, "created_at", "desc", true)
1632            .unwrap();
1633        assert_eq!(
1634            total, 1,
1635            "a registry-only agent's identity-linked sessions must resolve; \
1636             the slug is not a definition id"
1637        );
1638        assert_eq!(sessions[0].session_id, "identity-linked-session");
1639
1640        // The registry record must not hand its definition id to a different
1641        // agent — resolution stays scoped to the matching slug.
1642        let (_, other_total, _) = service
1643            .sessions_for_agent(&store, "someone-else", 0, 10, "created_at", "desc", true)
1644            .unwrap();
1645        assert_eq!(other_total, 0, "an unrelated slug must not inherit the link");
1646
1647        match prev {
1648            Some(v) => std::env::set_var("AGENTMUX_SHARED_DIR", v),
1649            None => std::env::remove_var("AGENTMUX_SHARED_DIR"),
1650        }
1651        std::fs::remove_dir_all(&dir).ok();
1652    }
1653
1654    /// Two live agents whose display names normalize to the SAME slug must not
1655    /// be able to read each other's history. reagentx P1 on PR #3480
1656    /// (third review).
1657    ///
1658    /// Registry records are keyed by `instance_id`, and nothing enforces a
1659    /// unique `instance_name` across them — so `derive_slug("AgentY")` and
1660    /// `derive_slug("AGENTY")` both yield `agenty`. The lookup used to take
1661    /// the *first* match in whatever order the registry happened to list, then
1662    /// hand that record's `definition_id` and `working_dir` to the caller. For
1663    /// this path that means one agent's conversation history silently
1664    /// resolving into another agent's `sessions_for_agent` call.
1665    ///
1666    /// `native_memory_handlers` already guards the same lookup with
1667    /// `.filter(|r| r.data.definition_id == definition_id)`, but that guard is
1668    /// unavailable here: `definition_id` is precisely what this path is trying
1669    /// to resolve. So the lookup itself must refuse to guess.
1670    ///
1671    /// The assertion is deliberately "not the other agent's session" rather
1672    /// than an exact count — failing closed (0 sessions) is the required
1673    /// behaviour, but the bug being prevented is *leakage*, so that is what is
1674    /// asserted.
1675    #[test]
1676    fn colliding_slugs_never_resolve_into_another_agents_history() {
1677        let _guard = crate::test_support::ISOLATED_AUTH_ENV_LOCK
1678            .lock()
1679            .unwrap_or_else(|e| e.into_inner());
1680        let prev = std::env::var_os("AGENTMUX_SHARED_DIR");
1681        let tmp = tempfile::tempdir().unwrap();
1682        std::env::set_var("AGENTMUX_SHARED_DIR", tmp.path());
1683
1684        let base = tmp.path().join("agentsbase");
1685        let registry =
1686            crate::registry::Registry::open(tmp.path().join("agents").join("registry")).unwrap();
1687
1688        let rec = |instance_id: &str, name: &str, def: &str, dir: &str| {
1689            registry
1690                .upsert(&crate::registry::NamedAgentRecord {
1691                    schema_version: 3,
1692                    data: crate::registry::NamedAgentRecordV1 {
1693                        instance_id: instance_id.to_string(),
1694                        instance_name: name.to_string(),
1695                        definition_id: def.to_string(),
1696                        identity_id: None,
1697                        memory_id: None,
1698                        session_id: None,
1699                        working_dir: dir.to_string(),
1700                        source_agents_base: Some(base.to_string_lossy().to_string()),
1701                        created_at_ms: 1,
1702                        last_launched_at_ms: 1,
1703                        created_by_version: "test".to_string(),
1704                        last_launched_by_version: "test".to_string(),
1705                    },
1706                })
1707                .unwrap();
1708        };
1709        // Both normalize to the slug `agenty`.
1710        rec("inst-a", "AgentY", "def-a", "dir-a");
1711        rec("inst-b", "AGENTY", "def-b", "dir-b");
1712
1713        // A session under EACH colliding record, so the assertion does not
1714        // depend on which one `list_active()` happens to return first. Whoever
1715        // the caller really is, at most one of these is theirs — and the slug
1716        // alone cannot say which — so resolving either one is a leak.
1717        let dir = std::env::temp_dir().join(format!("amux-hist-collide-{}", std::process::id()));
1718        std::fs::create_dir_all(&dir).unwrap();
1719        let sess_a = write_session(&dir, "agent-a-private-session");
1720        let sess_b = write_session(&dir, "agent-b-private-session");
1721        let index = SessionIndex::with_isolated_roots(
1722            vec![
1723                Box::new(MockAdapter::in_dir(
1724                    vec![DiscoveredFile { file_path: sess_a, mtime_ms: 1 }],
1725                    "acct-a",
1726                    &base.join("dir-a").to_string_lossy().to_string(),
1727                )),
1728                Box::new(MockAdapter::in_dir(
1729                    vec![DiscoveredFile { file_path: sess_b, mtime_ms: 1 }],
1730                    "acct-b",
1731                    &base.join("dir-b").to_string_lossy().to_string(),
1732                )),
1733            ],
1734            vec![dir.clone()],
1735        );
1736        let service = HistoryService::from_index(index);
1737
1738        let store = Store::open_in_memory().unwrap();
1739        insert_test_agent_and_link(&store, "def-a", "acct-a");
1740        insert_test_agent_and_link(&store, "def-b", "acct-b");
1741
1742        let (sessions, total, _) = service
1743            .sessions_for_agent(&store, "agenty", 0, 10, "created_at", "desc", true)
1744            .unwrap();
1745
1746        assert_eq!(
1747            total,
1748            0,
1749            "an ambiguous slug must resolve to NO history rather than guessing \
1750             an agent; got {:?}",
1751            sessions.iter().map(|s| &s.session_id).collect::<Vec<_>>()
1752        );
1753
1754        match prev {
1755            Some(v) => std::env::set_var("AGENTMUX_SHARED_DIR", v),
1756            None => std::env::remove_var("AGENTMUX_SHARED_DIR"),
1757        }
1758        std::fs::remove_dir_all(&dir).ok();
1759    }
1760
1761    fn insert_test_agent_and_link(store: &Store, agent_id: &str, account_id: &str) {
1762        let mut def = crate::backend::storage::store::AgentDefinition {
1763            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1764            id: agent_id.to_string(),
1765            slug: String::new(),
1766            name: "T".to_string(),
1767            icon: String::new(),
1768            provider: "claude".to_string(),
1769            description: String::new(),
1770            working_directory: String::new(),
1771            shell: String::new(),
1772            provider_flags: String::new(),
1773            auto_start: 0,
1774            restart_on_crash: 0,
1775            idle_timeout_minutes: 0,
1776            created_at: 0,
1777            agent_type: String::new(),
1778            environment: String::new(),
1779            agent_bus_id: String::new(),
1780            is_seeded: 0,
1781            accounts: String::new(),
1782            parent_id: String::new(),
1783            branch_label: String::new(),
1784            updated_at: 0,
1785            user_hidden: 0,
1786            container_image: String::new(),
1787            container_volumes: "[]".to_string(),
1788            container_name: String::new(),
1789            use_ambient_login: 0,
1790            auto_continue_enabled: 0,
1791            model_vendor_base_url: String::new(),
1792            memory_id: String::new(),
1793        };
1794        store.agent_def_insert(&mut def).unwrap();
1795        store
1796            .identity_upsert(&crate::backend::storage::store::IdentityAccount {
1797                id: account_id.to_string(),
1798                name: format!("claude-{account_id}"),
1799                provider: "claude".to_string(),
1800                kind: "pat".to_string(),
1801                display_name: String::new(),
1802                secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: String::new() },
1803                context: serde_json::json!({}),
1804                status: "unknown".to_string(),
1805                created_at: 0,
1806                updated_at: 0,
1807            })
1808            .unwrap();
1809        store.agent_identity_link(agent_id, account_id, "claude").unwrap();
1810    }
1811
1812    /// Unlike `MockAdapter` (a fixed file list captured at construction),
1813    /// this re-scans a real directory on every `discover_files()` call --
1814    /// needed to exercise `refresh()`'s actual re-scan behavior, not just
1815    /// the in-memory index it populates.
1816    struct DynamicMockAdapter {
1817        dir: std::path::PathBuf,
1818        identity_id: String,
1819    }
1820    impl HistoryAdapter for DynamicMockAdapter {
1821        fn provider(&self) -> &str {
1822            "mock"
1823        }
1824        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1825            let entries = std::fs::read_dir(&self.dir).map_err(|e| HistoryError::Other(e.to_string()))?;
1826            Ok(entries
1827                .flatten()
1828                .map(|e| DiscoveredFile { file_path: e.path().to_string_lossy().into_owned(), mtime_ms: 0 })
1829                .collect())
1830        }
1831        fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1832            let id = std::path::Path::new(file_path)
1833                .file_stem()
1834                .map(|s| s.to_string_lossy().into_owned())
1835                .unwrap_or_default();
1836            Ok(Some(SessionMeta {
1837                session_id: id,
1838                file_path: file_path.to_string(),
1839                provider: "mock".to_string(),
1840                model: String::new(),
1841                slug: String::new(),
1842                working_directory: "/proj".to_string(),
1843                created_at: 0,
1844                modified_at: 0,
1845                message_count: 0,
1846                first_user_message: String::new(),
1847                file_size_bytes: 0,
1848                git_branch: String::new(),
1849                total_tokens: 0,
1850                subagent_count: 0,
1851                identity_id: self.identity_id.clone(),
1852                starts_undated: false,
1853            }))
1854        }
1855        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
1856            Ok(None)
1857        }
1858    }
1859
1860    // reagentx P1 on PR #2613: sessions_for_agent's lazy
1861    // refresh-only-if-empty behavior is wrong for a caller claiming
1862    // completeness -- a session created after the index was first
1863    // populated (by ANY prior call) must still show up when
1864    // force_refresh=true, and must NOT show up when force_refresh=false
1865    // (proving the two modes are genuinely different, not that refresh
1866    // just always happens to run).
1867    #[test]
1868    fn force_refresh_true_picks_up_a_session_created_after_first_population_false_does_not() {
1869        let dir = std::env::temp_dir().join(format!("amux-hist-force-refresh-{}", std::process::id()));
1870        std::fs::create_dir_all(&dir).unwrap();
1871        write_session(&dir, "first");
1872
1873        let index = SessionIndex::with_isolated_roots(
1874            vec![Box::new(DynamicMockAdapter { dir: dir.clone(), identity_id: "acct-mine".to_string() })],
1875            vec![dir.clone()],
1876        );
1877        let service = HistoryService::from_index(index);
1878        let store = Store::open_in_memory().unwrap();
1879        insert_test_agent_and_link(&store, "agent-1", "acct-mine");
1880
1881        // First call populates the index (empty -> refresh runs regardless
1882        // of force_refresh).
1883        let (first_pass, total1, _) = service.sessions_for_agent(&store, "agent-1", 0, 10, "created_at", "desc", false).unwrap();
1884        assert_eq!(total1, 1);
1885        assert_eq!(first_pass[0].session_id, "first");
1886
1887        // A new session appears on disk after that first population.
1888        write_session(&dir, "second");
1889
1890        let (_, total_stale, _) =
1891            service.sessions_for_agent(&store, "agent-1", 0, 10, "created_at", "desc", false).unwrap();
1892        assert_eq!(total_stale, 1, "force_refresh=false must NOT see the new session (index already non-empty)");
1893
1894        let (fresh, total_fresh, _) =
1895            service.sessions_for_agent(&store, "agent-1", 0, 10, "created_at", "desc", true).unwrap();
1896        assert_eq!(total_fresh, 2, "force_refresh=true must see the new session");
1897        assert!(fresh.iter().any(|s| s.session_id == "second"));
1898
1899        std::fs::remove_dir_all(&dir).ok();
1900    }
1901}