1use 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 #[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}