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