Skip to main content

atman_runtime/
history_store.rs

1//! Abstract history store — tools call this trait, never touching storage directly.
2//!
3//! `HistoryStoreImpl` routes internally: SQLite (priority) → messages_full
4//! (fallback) → events.jsonl replay (last resort).
5
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use crate::error::RuntimeError;
10use crate::event::EventEnvelope;
11use crate::index::AnchorIndex;
12use crate::message::Message;
13use crate::projection::message_window::replay_all_messages_with_seq;
14use crate::session::SessionOpenError;
15
16pub enum SearchScope {
17    Session,
18    Project,
19}
20
21pub struct HistoryQuery {
22    pub session_id: String,
23    pub offset: usize,
24    pub limit: usize,
25    pub role_filter: Option<Vec<String>>,
26}
27
28#[derive(Debug)]
29pub struct HistoryPage {
30    pub total: u64,
31    pub offset: usize,
32    pub limit: usize,
33    pub items: Vec<Message>,
34}
35
36#[derive(Debug)]
37pub struct SearchHit {
38    pub session_id: String,
39    pub seq: u64,
40    pub ts: String,
41    pub kind: String,
42    pub snippet: String,
43}
44
45#[derive(Debug)]
46pub struct SearchResult {
47    pub total: u64,
48    pub hits: Vec<SearchHit>,
49}
50
51pub trait HistoryStore: Send + Sync {
52    fn count(&self, session_id: &str, role_filter: Option<&[&str]>) -> Result<u64, RuntimeError>;
53    fn read(&self, query: HistoryQuery) -> Result<HistoryPage, RuntimeError>;
54    fn search(
55        &self,
56        query: &str,
57        scope: SearchScope,
58        limit: usize,
59    ) -> Result<SearchResult, RuntimeError>;
60    fn recent(&self, n: usize) -> Result<(u64, u64, Vec<Message>), RuntimeError>;
61}
62
63pub struct HistoryStoreImpl {
64    project_index: Option<Arc<AnchorIndex>>,
65    session: Option<Arc<crate::session::Session>>,
66    current_session_id: Option<String>,
67    sessions_root: Option<PathBuf>,
68}
69
70impl HistoryStoreImpl {
71    pub fn new(
72        project_index: Option<Arc<AnchorIndex>>,
73        session: Option<Arc<crate::session::Session>>,
74        current_session_id: Option<String>,
75        sessions_root: Option<PathBuf>,
76    ) -> Self {
77        Self {
78            project_index,
79            session,
80            current_session_id,
81            sessions_root,
82        }
83    }
84
85    fn is_current_session(&self, session_id: &str) -> bool {
86        self.current_session_id
87            .as_deref()
88            .is_some_and(|sid| sid == session_id)
89    }
90
91    fn sqlite_available(&self) -> bool {
92        self.project_index.is_some()
93    }
94
95    fn session_dir(&self, session_id: &str) -> Result<PathBuf, RuntimeError> {
96        self.sessions_root
97            .as_ref()
98            .map(|root| root.join(session_id))
99            .ok_or_else(|| {
100                RuntimeError::ToolFailed(format!(
101                    "history: no sessions_root to resolve session `{session_id}`"
102                ))
103            })
104    }
105
106    fn replay_from_jsonl(
107        &self,
108        session_id: &str,
109        role_filter: Option<&[&str]>,
110    ) -> Result<Vec<Message>, RuntimeError> {
111        let dir = self.session_dir(session_id)?;
112        let path = dir.join("events.jsonl");
113        let msgs = replay_all_messages_with_seq(&path).map_err(|e| {
114            RuntimeError::ToolFailed(format!("history: replay {}: {e}", path.display()))
115        })?;
116        let msgs: Vec<Message> = msgs.into_iter().map(|(_, m)| m).collect();
117        Ok(filter_messages_by_role(msgs, role_filter))
118    }
119}
120
121fn role_to_kind(role: &str) -> Option<&'static str> {
122    match role {
123        "user" => Some("user_msg"),
124        "assistant" => Some("assistant_msg"),
125        "tool" => Some("tool_result_msg"),
126        "system" => Some("system_msg"),
127        _ => None,
128    }
129}
130
131const MESSAGE_KINDS: &[&str] = &[
132    "user_msg",
133    "deferred_form_applied",
134    "assistant_msg",
135    "tool_result_msg",
136    "system_msg",
137];
138
139fn roles_to_kinds(roles: Option<&[&str]>) -> Vec<&'static str> {
140    match roles {
141        Some(rs) if !rs.is_empty() => rs
142            .iter()
143            .flat_map(|role| {
144                let mut kinds = role_to_kind(role).into_iter().collect::<Vec<_>>();
145                if *role == "user" {
146                    kinds.push("deferred_form_applied");
147                }
148                kinds
149            })
150            .collect::<Vec<_>>(),
151        _ => MESSAGE_KINDS.to_vec(),
152    }
153}
154
155fn filter_messages_by_role(msgs: Vec<Message>, roles: Option<&[&str]>) -> Vec<Message> {
156    match roles {
157        Some(rs) if !rs.is_empty() => msgs
158            .into_iter()
159            .filter(|m| rs.iter().any(|r| *r == m.role.as_str()))
160            .collect(),
161        _ => msgs,
162    }
163}
164
165fn extract_message_from_payload(payload: &str) -> Option<Message> {
166    let env: EventEnvelope = serde_json::from_str(payload).ok()?;
167    match env.event {
168        crate::event::Event::UserMsg { message, .. }
169        | crate::event::Event::AssistantMsg { message, .. }
170        | crate::event::Event::ToolResultMsg { message, .. }
171        | crate::event::Event::SystemMsg { message, .. } => Some(message),
172        crate::event::Event::DeferredFormApplied { message, .. } => Some(message),
173        _ => None,
174    }
175}
176
177fn rows_to_messages(rows: Vec<crate::index::ProjectEventRow>) -> Vec<Message> {
178    rows.into_iter()
179        .filter_map(|r| extract_message_from_payload(&r.payload))
180        .collect()
181}
182
183impl HistoryStore for HistoryStoreImpl {
184    fn count(&self, session_id: &str, role_filter: Option<&[&str]>) -> Result<u64, RuntimeError> {
185        let kinds = roles_to_kinds(role_filter);
186
187        if let Some(idx) = &self.project_index
188            && self.sqlite_available()
189        {
190            return idx
191                .count_events(session_id, Some(&kinds))
192                .map_err(|e| RuntimeError::ToolFailed(format!("history.count: {e}")));
193        }
194
195        if self.is_current_session(session_id)
196            && let Some(session) = &self.session
197        {
198            let msgs = session.messages_full();
199            let filtered = filter_messages_by_role(msgs.to_vec(), role_filter);
200            return Ok(filtered.len() as u64);
201        }
202
203        let msgs = self.replay_from_jsonl(session_id, role_filter)?;
204        Ok(msgs.len() as u64)
205    }
206
207    fn read(&self, query: HistoryQuery) -> Result<HistoryPage, RuntimeError> {
208        let HistoryQuery {
209            session_id,
210            offset,
211            limit,
212            role_filter,
213        } = query;
214        let role_strs: Option<Vec<&str>> = role_filter
215            .as_ref()
216            .map(|rs| rs.iter().map(|s| s.as_str()).collect());
217        let kinds = roles_to_kinds(role_strs.as_deref());
218        let offset0 = offset.saturating_sub(1);
219
220        if let Some(idx) = &self.project_index
221            && self.sqlite_available()
222        {
223            let total = idx
224                .count_events(&session_id, Some(&kinds))
225                .map_err(|e| RuntimeError::ToolFailed(format!("history.read count: {e}")))?;
226            let rows = idx
227                .read_events_paginated(&session_id, offset0, limit, Some(&kinds))
228                .map_err(|e| RuntimeError::ToolFailed(format!("history.read: {e}")))?;
229            let items = rows_to_messages(rows);
230            return Ok(HistoryPage {
231                total,
232                offset,
233                limit,
234                items,
235            });
236        }
237
238        let msgs = if self.is_current_session(&session_id)
239            && let Some(session) = &self.session
240        {
241            session.messages_full().to_vec()
242        } else {
243            self.replay_from_jsonl(&session_id, None)?
244        };
245        let filtered = filter_messages_by_role(msgs, role_strs.as_deref());
246        let total = filtered.len() as u64;
247        let end = (offset0 + limit).min(filtered.len());
248        let items = if offset0 >= filtered.len() {
249            Vec::new()
250        } else {
251            filtered[offset0..end].to_vec()
252        };
253        Ok(HistoryPage {
254            total,
255            offset,
256            limit,
257            items,
258        })
259    }
260
261    fn search(
262        &self,
263        query: &str,
264        scope: SearchScope,
265        limit: usize,
266    ) -> Result<SearchResult, RuntimeError> {
267        if query.trim().is_empty() {
268            return Err(RuntimeError::ToolFailed(
269                "history.search: empty query".into(),
270            ));
271        }
272
273        let session_filter = match &scope {
274            SearchScope::Project => None,
275            SearchScope::Session => self.current_session_id.clone(),
276        };
277
278        if let Some(idx) = &self.project_index {
279            let total = idx
280                .count_search_hits(query, session_filter.as_deref())
281                .map_err(|e| RuntimeError::ToolFailed(format!("history.search count: {e}")))?;
282            let rows = idx
283                .fts_search_project_events(query, session_filter.as_deref(), limit)
284                .map_err(|e| RuntimeError::ToolFailed(format!("history.search: {e}")))?;
285            let hits = rows
286                .into_iter()
287                .map(|r| {
288                    let snippet: String = r
289                        .payload
290                        .chars()
291                        .take(200)
292                        .collect::<String>()
293                        .replace('\n', " ");
294                    SearchHit {
295                        session_id: r.session_id,
296                        seq: r.seq,
297                        ts: r.ts,
298                        kind: r.kind,
299                        snippet,
300                    }
301                })
302                .collect();
303            return Ok(SearchResult { total, hits });
304        }
305
306        if matches!(scope, SearchScope::Project) {
307            return Err(RuntimeError::ToolFailed(
308                "history.search: project scope requires project index".into(),
309            ));
310        }
311
312        if let Some(session) = &self.session {
313            let msgs = session.messages_full();
314            let query_lower = query.to_lowercase();
315            let mut hits = Vec::new();
316            let sid = self.current_session_id.clone().unwrap_or_default();
317            for (i, msg) in msgs.iter().enumerate() {
318                let text = msg.text_concat();
319                if text.to_lowercase().contains(&query_lower) {
320                    let snippet: String = text.chars().take(200).collect();
321                    hits.push(SearchHit {
322                        session_id: sid.clone(),
323                        seq: i as u64,
324                        ts: String::new(),
325                        kind: msg.role.as_str().to_string(),
326                        snippet,
327                    });
328                    if hits.len() >= limit {
329                        break;
330                    }
331                }
332            }
333            let total = hits.len() as u64;
334            return Ok(SearchResult { total, hits });
335        }
336
337        Err(RuntimeError::ToolFailed(
338            "history.search: no project index or session on context".into(),
339        ))
340    }
341
342    fn recent(&self, n: usize) -> Result<(u64, u64, Vec<Message>), RuntimeError> {
343        let msgs = if let Some(session) = &self.session {
344            session.messages_full().to_vec()
345        } else if let Some(session_id) = &self.current_session_id {
346            self.replay_from_jsonl(session_id, None)?
347        } else {
348            return Err(RuntimeError::ToolFailed(
349                "memory.recent_turns: no session available".into(),
350            ));
351        };
352        let (turn_count, items) = recent_turn_messages(&msgs, n);
353        Ok((msgs.len() as u64, turn_count, items))
354    }
355}
356
357pub(crate) fn recent_turn_messages(messages: &[Message], n: usize) -> (u64, Vec<Message>) {
358    let messages = messages
359        .iter()
360        .filter(|message| message.origin != crate::message::MessageOrigin::Internal)
361        .collect::<Vec<_>>();
362    if messages.is_empty() || n == 0 {
363        let mut turn_ids = Vec::new();
364        for message in &messages {
365            if !turn_ids.contains(&message.turn_id) {
366                turn_ids.push(message.turn_id.clone());
367            }
368        }
369        let total = turn_ids.len() as u64;
370        return (total, Vec::new());
371    }
372    let mut turns: Vec<(crate::event::TurnId, Vec<Message>)> = Vec::new();
373    for message in messages {
374        if let Some((turn_id, items)) = turns.last_mut()
375            && *turn_id == message.turn_id
376        {
377            items.push(message.clone());
378        } else {
379            turns.push((message.turn_id.clone(), vec![message.clone()]));
380        }
381    }
382    let total = turns.len() as u64;
383    let start = turns.len().saturating_sub(n);
384    let items = turns[start..]
385        .iter()
386        .flat_map(|(_, items)| items.iter().cloned())
387        .collect();
388    (total, items)
389}
390
391impl From<SessionOpenError> for RuntimeError {
392    fn from(e: SessionOpenError) -> Self {
393        RuntimeError::ToolFailed(format!("{e}"))
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::index::{AnchorIndex, ProjectEventInsert};
401    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
402    use tempfile::TempDir;
403
404    fn user_msg(text: &str) -> Message {
405        Message {
406            role: MessageRole::User,
407            parts: vec![MessagePart::Text {
408                text: text.to_string(),
409            }],
410            turn_id: crate::event::TurnId::now(),
411            origin: MessageOrigin::User,
412        }
413    }
414
415    fn assistant_msg(text: &str) -> Message {
416        Message {
417            role: MessageRole::Assistant,
418            parts: vec![MessagePart::Text {
419                text: text.to_string(),
420            }],
421            turn_id: crate::event::TurnId::now(),
422            origin: MessageOrigin::User,
423        }
424    }
425
426    fn seed(idx: &AnchorIndex, sid: &str, seq: i64, kind: &str, text: &str) {
427        let payload = serde_json::json!({
428            "type": kind,
429            "seq": seq,
430            "turn_id": "019f0000-0000-7000-0000-000000000001",
431            "message": {
432                "role": if kind == "user_msg" { "user" } else { "assistant" },
433                "parts": [{"type": "text", "text": text}],
434                "turn_id": "019f0000-0000-7000-0000-000000000001"
435            },
436            "ts": "2026-07-08T00:00:00Z"
437        });
438        idx.insert_project_event_raw(ProjectEventInsert {
439            session_id: sid,
440            seq,
441            ts: "2026-07-08T00:00:00Z",
442            kind,
443            turn_id: Some("019f0000-0000-7000-0000-000000000001"),
444            flow_run_id: None,
445            text_content: text,
446            payload_json: &payload.to_string(),
447        })
448        .unwrap();
449    }
450
451    #[test]
452    fn recent_groups_messages_by_turn() {
453        let turn_a = crate::event::TurnId::now();
454        let turn_b = crate::event::TurnId::now();
455        let messages = vec![
456            Message {
457                turn_id: turn_a.clone(),
458                ..user_msg("request")
459            },
460            Message {
461                turn_id: turn_a.clone(),
462                ..assistant_msg("answer")
463            },
464            Message {
465                turn_id: turn_a,
466                role: MessageRole::Tool,
467                ..user_msg("tool output")
468            },
469            Message {
470                turn_id: turn_b,
471                ..user_msg("next request")
472            },
473        ];
474        let (turns, recent) = recent_turn_messages(&messages, 1);
475        assert_eq!(turns, 2);
476        assert_eq!(recent.len(), 1);
477        assert_eq!(recent[0].text_concat(), "next request");
478        let (_, recent) = recent_turn_messages(&messages, 2);
479        assert_eq!(recent.len(), 4);
480    }
481
482    #[test]
483    fn recent_turns_can_exclude_internal_loop_controls_before_grouping() {
484        let turn = crate::event::TurnId::now();
485        let mut control = user_msg("loop control");
486        control.turn_id = turn.clone();
487        control.origin = MessageOrigin::Internal;
488        let messages = vec![user_msg("request"), control, assistant_msg("answer")];
489
490        let (_, recent) = recent_turn_messages(&messages, 2);
491
492        assert_eq!(recent.len(), 2);
493        assert!(
494            recent
495                .iter()
496                .all(|message| message.text_concat() != "loop control")
497        );
498    }
499
500    #[test]
501    fn count_via_sqlite() {
502        let dir = TempDir::new().unwrap();
503        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
504        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
505        seed(
506            store.project_index.as_ref().unwrap(),
507            "s1",
508            1,
509            "user_msg",
510            "hello",
511        );
512        seed(
513            store.project_index.as_ref().unwrap(),
514            "s1",
515            2,
516            "assistant_msg",
517            "hi",
518        );
519        seed(
520            store.project_index.as_ref().unwrap(),
521            "s1",
522            3,
523            "user_msg",
524            "bye",
525        );
526        assert_eq!(store.count("s1", None).unwrap(), 3);
527        assert_eq!(store.count("s1", Some(&["user"])).unwrap(), 2);
528        assert_eq!(store.count("s1", Some(&["assistant"])).unwrap(), 1);
529    }
530
531    #[test]
532    fn read_via_sqlite_paginated() {
533        let dir = TempDir::new().unwrap();
534        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
535        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
536        for i in 1..=5 {
537            seed(
538                store.project_index.as_ref().unwrap(),
539                "s1",
540                i,
541                "user_msg",
542                &format!("msg {i}"),
543            );
544        }
545        let page = store
546            .read(HistoryQuery {
547                session_id: "s1".into(),
548                offset: 2,
549                limit: 2,
550                role_filter: None,
551            })
552            .unwrap();
553        assert_eq!(page.total, 5);
554        assert_eq!(page.offset, 2);
555        assert_eq!(page.limit, 2);
556        assert_eq!(page.items.len(), 2);
557        assert_eq!(page.items[0].text_concat(), "msg 2");
558        assert_eq!(page.items[1].text_concat(), "msg 3");
559    }
560
561    #[test]
562    fn read_via_sqlite_role_filter() {
563        let dir = TempDir::new().unwrap();
564        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
565        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
566        seed(
567            store.project_index.as_ref().unwrap(),
568            "s1",
569            1,
570            "user_msg",
571            "u1",
572        );
573        seed(
574            store.project_index.as_ref().unwrap(),
575            "s1",
576            2,
577            "assistant_msg",
578            "a1",
579        );
580        seed(
581            store.project_index.as_ref().unwrap(),
582            "s1",
583            3,
584            "user_msg",
585            "u2",
586        );
587        let page = store
588            .read(HistoryQuery {
589                session_id: "s1".into(),
590                offset: 1,
591                limit: 100,
592                role_filter: Some(vec!["user".into()]),
593            })
594            .unwrap();
595        assert_eq!(page.total, 2);
596        assert_eq!(page.items.len(), 2);
597        assert_eq!(page.items[0].text_concat(), "u1");
598        assert_eq!(page.items[1].text_concat(), "u2");
599    }
600
601    #[test]
602    fn search_returns_total_and_hits() {
603        let dir = TempDir::new().unwrap();
604        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
605        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
606        seed(
607            store.project_index.as_ref().unwrap(),
608            "s1",
609            1,
610            "user_msg",
611            "hello world",
612        );
613        seed(
614            store.project_index.as_ref().unwrap(),
615            "s1",
616            2,
617            "user_msg",
618            "hello again",
619        );
620        seed(
621            store.project_index.as_ref().unwrap(),
622            "s1",
623            3,
624            "user_msg",
625            "goodbye",
626        );
627        let result = store.search("hello", SearchScope::Session, 10).unwrap();
628        assert_eq!(result.total, 2);
629        assert_eq!(result.hits.len(), 2);
630    }
631
632    #[test]
633    fn search_cjk() {
634        let dir = TempDir::new().unwrap();
635        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
636        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
637        seed(
638            store.project_index.as_ref().unwrap(),
639            "s1",
640            1,
641            "user_msg",
642            "浮动面板设计",
643        );
644        let result = store.search("浮动", SearchScope::Session, 10).unwrap();
645        assert_eq!(result.total, 1);
646        assert_eq!(result.hits.len(), 1);
647    }
648
649    #[test]
650    fn count_empty_session_via_sqlite() {
651        let dir = TempDir::new().unwrap();
652        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
653        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
654        assert_eq!(store.count("no-such", None).unwrap(), 0);
655    }
656
657    #[test]
658    fn read_empty_session_via_sqlite() {
659        let dir = TempDir::new().unwrap();
660        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
661        let store = HistoryStoreImpl::new(Some(idx), None, Some("s1".into()), None);
662        let page = store
663            .read(HistoryQuery {
664                session_id: "no-such".into(),
665                offset: 1,
666                limit: 10,
667                role_filter: None,
668            })
669            .unwrap();
670        assert_eq!(page.total, 0);
671        assert!(page.items.is_empty());
672    }
673
674    // Fallback: no project_index, but session has messages_full
675    // We can't easily construct a Session in unit tests without a tempdir + events.jsonl,
676    // so the messages_full fallback path is covered by integration tests.
677    // Here we test the jsonl replay fallback (no session, no index, but sessions_root points to jsonl).
678
679    #[test]
680    fn fallback_replay_from_jsonl() {
681        let dir = TempDir::new().unwrap();
682        let sessions_root = dir.path().join("sessions");
683        let sid = "test-sid";
684        let session_dir = sessions_root.join(sid);
685        std::fs::create_dir_all(&session_dir).unwrap();
686        let events_path = session_dir.join("events.jsonl");
687        let u1 = user_msg("first");
688        let a1 = assistant_msg("second");
689        let env1 = crate::event::EventEnvelope::new(
690            1,
691            crate::event::Event::UserMsg {
692                turn_id: u1.turn_id.clone(),
693                flow_run_id: None,
694                message: u1,
695            },
696        );
697        let env2 = crate::event::EventEnvelope::new(
698            2,
699            crate::event::Event::AssistantMsg {
700                turn_id: a1.turn_id.clone(),
701                flow_run_id: None,
702                message: a1,
703            },
704        );
705        let line1 = serde_json::to_string(&env1).unwrap();
706        let line2 = serde_json::to_string(&env2).unwrap();
707        std::fs::write(&events_path, format!("{line1}\n{line2}\n")).unwrap();
708
709        let store = HistoryStoreImpl::new(None, None, Some(sid.into()), Some(sessions_root));
710        assert_eq!(store.count(sid, None).unwrap(), 2);
711        assert_eq!(store.count(sid, Some(&["user"])).unwrap(), 1);
712        assert_eq!(store.count(sid, Some(&["assistant"])).unwrap(), 1);
713
714        let page = store
715            .read(HistoryQuery {
716                session_id: sid.into(),
717                offset: 1,
718                limit: 10,
719                role_filter: None,
720            })
721            .unwrap();
722        assert_eq!(page.total, 2);
723        assert_eq!(page.items.len(), 2);
724        assert_eq!(page.items[0].text_concat(), "first");
725        assert_eq!(page.items[1].text_concat(), "second");
726    }
727
728    #[test]
729    fn fallback_replay_paginated() {
730        let dir = TempDir::new().unwrap();
731        let sessions_root = dir.path().join("sessions");
732        let sid = "test-sid";
733        let session_dir = sessions_root.join(sid);
734        std::fs::create_dir_all(&session_dir).unwrap();
735        let events_path = session_dir.join("events.jsonl");
736        let mut lines = Vec::new();
737        for i in 1..=5 {
738            let m = user_msg(&format!("msg {i}"));
739            let env = crate::event::EventEnvelope::new(
740                i,
741                crate::event::Event::UserMsg {
742                    turn_id: m.turn_id.clone(),
743                    flow_run_id: None,
744                    message: m,
745                },
746            );
747            lines.push(serde_json::to_string(&env).unwrap());
748        }
749        std::fs::write(&events_path, lines.join("\n") + "\n").unwrap();
750
751        let store = HistoryStoreImpl::new(None, None, Some(sid.into()), Some(sessions_root));
752        let page = store
753            .read(HistoryQuery {
754                session_id: sid.into(),
755                offset: 2,
756                limit: 2,
757                role_filter: None,
758            })
759            .unwrap();
760        assert_eq!(page.total, 5);
761        assert_eq!(page.items.len(), 2);
762        assert_eq!(page.items[0].text_concat(), "msg 2");
763        assert_eq!(page.items[1].text_concat(), "msg 3");
764    }
765
766    #[test]
767    fn search_empty_query_errors() {
768        let dir = TempDir::new().unwrap();
769        let idx = Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
770        let store = HistoryStoreImpl::new(Some(idx), None, None, None);
771        let err = store.search("", SearchScope::Session, 10).unwrap_err();
772        assert!(matches!(err, RuntimeError::ToolFailed(_)));
773    }
774
775    #[test]
776    fn search_project_scope_without_index_errors() {
777        let store = HistoryStoreImpl::new(None, None, None, None);
778        let err = store.search("hello", SearchScope::Project, 10).unwrap_err();
779        assert!(matches!(err, RuntimeError::ToolFailed(_)));
780    }
781}