1pub 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
17fn 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#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct HistoryOwner {
41 pub definition_id: String,
42 pub working_directory: Option<String>,
43}
44
45impl HistoryOwner {
46 pub fn from_slug(store: &crate::backend::storage::store::Store, agent_id: &str) -> Self {
49 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 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 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 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 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
117fn 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
136pub 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#[derive(Debug)]
142pub enum HistorySearchError {
143 IndexBuilding,
146 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
159fn 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
185pub 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
198pub const WARM_DELAY: std::time::Duration = std::time::Duration::from_secs(60);
206
207fn 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
226fn 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
255pub struct HistoryService {
257 index: Arc<SessionIndex>,
258 records: record::AgentRecords,
261 warm_pending: Arc<std::sync::atomic::AtomicBool>,
263 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 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 #[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 pub fn warm_in_background(&self) -> std::thread::JoinHandle<()> {
321 self.warm_in_background_after(WARM_DELAY)
322 }
323
324 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 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 fn ensure_built(&self) {
364 if self.index.refreshed_at_ms() == 0 {
365 self.index.refresh();
366 }
367 }
368
369 fn refresh_for_search(&self) -> Result<(), HistorySearchError> {
386 if self.index.refreshed_at_ms() == 0 {
387 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 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 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 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 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 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 if let Some(dir) = &owner.working_directory {
521 merged.extend(self.index.list_for_working_directory(dir));
522 }
523
524 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 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 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 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 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 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 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 pub fn get(&self, session_id: &str) -> serde_json::Value {
691 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 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 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 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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(unix_time_to_ms(1_790_280_000), 1_790_280_000_000);
890 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 #[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 #[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 #[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 #[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 #[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(); 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 #[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 struct MockAdapter {
1079 files: Vec<DiscoveredFile>,
1080 identity_id: String,
1081 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 #[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 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 #[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 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 #[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 #[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 "/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 store.agent_identity_link("def-uuid-agenty", "acct-mine", "claude").unwrap();
1416
1417 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 #[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 #[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 #[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 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 let store = Store::open_in_memory().unwrap();
1543
1544 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 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 #[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 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 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 #[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 rec("inst-a", "AgentY", "def-a", "dir-a");
1711 rec("inst-b", "AGENTY", "def-b", "dir-b");
1712
1713 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 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 #[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 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 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}