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] = &[
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 #[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}