1use super::{Capability, CapabilityStatus};
7use crate::message::{ContentPart, Message, MessageRole};
8use crate::message_filter::{ExcludedNoticeTransform, MessageFilterProvider, MessageQuery};
9use crate::tool_types::ToolHints;
10use crate::tools::{Tool, ToolExecutionResult};
11use crate::traits::ToolContext;
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use std::cmp::Ordering;
16use std::io::{self, Write};
17use std::sync::Arc;
18
19pub const INFINITY_CONTEXT_CAPABILITY_ID: &str = "infinity_context";
21
22pub struct InfinityContextCapability;
24
25impl Capability for InfinityContextCapability {
26 fn id(&self) -> &str {
27 INFINITY_CONTEXT_CAPABILITY_ID
28 }
29
30 fn name(&self) -> &str {
31 "Infinity Context"
32 }
33
34 fn description(&self) -> &str {
35 r#"Trims older conversation history out of the live prompt while keeping it queryable with `query_history`.
36
37> [!TIP]
38> Use this for long-running sessions where earlier discussion still matters but should not consume prompt budget every turn."#
39 }
40
41 fn status(&self) -> CapabilityStatus {
42 CapabilityStatus::Available
43 }
44
45 fn icon(&self) -> Option<&str> {
46 Some("infinity")
47 }
48
49 fn category(&self) -> Option<&str> {
50 Some("Optimization")
51 }
52
53 fn system_prompt_addition(&self) -> Option<&str> {
54 Some(INFINITY_CONTEXT_SYSTEM_PROMPT)
55 }
56
57 fn tools(&self) -> Vec<Box<dyn Tool>> {
58 vec![Box::new(QueryHistoryTool)]
59 }
60
61 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
62 Some(Arc::new(InfinityContextFilterProvider))
63 }
64}
65
66const INFINITY_CONTEXT_SYSTEM_PROMPT: &str = r#"## Conversation history
67
68Earlier messages may be trimmed from the live prompt. Use `query_history`
69to retrieve them when needed. The window is trimmed automatically; do not
70abandon tasks for token reasons — persist important state via file or
71memory tools when available."#;
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74struct InfinityContextConfig {
75 #[serde(default = "default_context_budget_tokens")]
77 context_budget_tokens: usize,
78
79 #[serde(default = "default_min_recent_messages")]
81 min_recent_messages: usize,
82
83 #[serde(default)]
88 max_recent_messages: Option<usize>,
89}
90
91fn default_context_budget_tokens() -> usize {
92 100_000
93}
94
95fn default_min_recent_messages() -> usize {
96 10
97}
98
99impl Default for InfinityContextConfig {
100 fn default() -> Self {
101 Self {
102 context_budget_tokens: default_context_budget_tokens(),
103 min_recent_messages: default_min_recent_messages(),
104 max_recent_messages: None,
105 }
106 }
107}
108
109const CANDIDATE_AVG_TOKENS_PER_MESSAGE: usize = 250;
110const CANDIDATE_OVERFETCH_FACTOR: usize = 4;
111const CANDIDATE_MAX_MESSAGES: usize = 2_000;
112
113struct InfinityContextFilterProvider;
114
115impl MessageFilterProvider for InfinityContextFilterProvider {
116 fn apply_filters(&self, query: &mut MessageQuery, config: &Value) {
117 let config: InfinityContextConfig =
118 serde_json::from_value(config.clone()).unwrap_or_default();
119
120 query.limit = Some(resolve_candidate_load_limit(&config) as i64);
121 query.prepend_transform = Some(Arc::new(ExcludedNoticeTransform::infinity_context()));
122 }
123
124 fn post_load(&self, messages: &mut Vec<Message>, config: &Value) {
125 let config: InfinityContextConfig =
126 serde_json::from_value(config.clone()).unwrap_or_default();
127 let existing_notice_count = take_existing_excluded_notice(messages);
128 let trimmed_count = trim_messages_to_token_budget(messages, &config);
129 let total_excluded_count = existing_notice_count.saturating_add(trimmed_count);
130 if total_excluded_count > 0 {
131 messages.insert(
132 0,
133 Message::system(
134 ExcludedNoticeTransform::infinity_context()
135 .format
136 .replace("{}", &total_excluded_count.to_string()),
137 ),
138 );
139 }
140 }
141
142 fn priority(&self) -> i32 {
143 100
144 }
145}
146
147fn resolve_candidate_load_limit(config: &InfinityContextConfig) -> usize {
148 let budget_derived_limit = (config.context_budget_tokens / CANDIDATE_AVG_TOKENS_PER_MESSAGE)
152 .saturating_mul(CANDIDATE_OVERFETCH_FACTOR)
153 .max(config.min_recent_messages)
154 .clamp(1, CANDIDATE_MAX_MESSAGES);
155
156 if let Some(max_recent_messages) = config.max_recent_messages {
157 return budget_derived_limit.min(max_recent_messages.max(1));
158 }
159
160 budget_derived_limit
161}
162
163fn estimate_message_tokens(message: &Message) -> usize {
164 const TOKEN_CHARS: usize = 4;
165 let role_overhead = message.role.to_string().len() + 8;
166 let content_len: usize = message
167 .content
168 .iter()
169 .map(|part| match part {
170 ContentPart::Text(text) => text.text.len(),
171 ContentPart::Image(image) => {
172 image.url.as_ref().map_or(0, String::len)
173 + image.base64.as_ref().map_or(50, String::len)
174 + image.media_type.as_ref().map_or(0, String::len)
175 }
176 ContentPart::ImageFile(file) => {
177 file.image_id.to_string().len() + file.filename.as_ref().map_or(0, String::len)
178 }
179 ContentPart::ToolCall(call) => {
180 call.id.len() + call.name.len() + estimate_json_value_len(&call.arguments) + 20
181 }
182 ContentPart::ToolResult(result) => {
183 result.tool_call_id.len()
184 + result.result.as_ref().map_or(0, estimate_json_value_len)
185 + result.error.as_ref().map_or(0, String::len)
186 + 20
187 }
188 })
189 .sum();
190 (role_overhead + content_len) / TOKEN_CHARS
191}
192
193struct CountingWriter {
194 len: usize,
195}
196
197impl Write for CountingWriter {
198 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
199 self.len = self.len.saturating_add(buf.len());
200 Ok(buf.len())
201 }
202
203 fn flush(&mut self) -> io::Result<()> {
204 Ok(())
205 }
206}
207
208fn estimate_json_value_len(value: &Value) -> usize {
209 let mut writer = CountingWriter { len: 0 };
210 serde_json::to_writer(&mut writer, value)
211 .map(|_| writer.len)
212 .unwrap_or(0)
213}
214
215fn take_existing_excluded_notice(messages: &mut Vec<Message>) -> usize {
216 let Some(first) = messages.first() else {
217 return 0;
218 };
219 let Some(count) = parse_excluded_notice_count(first) else {
220 return 0;
221 };
222
223 messages.remove(0);
224 count
225}
226
227fn parse_excluded_notice_count(message: &Message) -> Option<usize> {
228 let text = message.text()?;
229 let rest = text.strip_prefix("[IMPORTANT: ")?;
230 let (count, rest) = rest.split_once(' ')?;
231 if !rest.starts_with("earlier messages are NOT visible in this context.") {
232 return None;
233 }
234 count.parse().ok()
235}
236
237fn trim_messages_to_token_budget(
238 messages: &mut Vec<Message>,
239 config: &InfinityContextConfig,
240) -> usize {
241 if messages.is_empty() {
242 return 0;
243 }
244
245 let original_count = messages.len();
246 if let Some(max_recent_messages) = config.max_recent_messages {
247 let max_recent_messages = max_recent_messages.max(1);
248 if messages.len() > max_recent_messages {
249 let drop_count = messages.len() - max_recent_messages;
250 messages.drain(0..drop_count);
251 }
252 }
253
254 let capped_count = messages.len();
255 let min_recent_start = capped_count.saturating_sub(config.min_recent_messages);
256 let mut selected = Vec::new();
257 let mut selected_tokens = 0usize;
258
259 for (idx, message) in messages.iter().enumerate().skip(min_recent_start) {
260 selected.push((idx, message.clone()));
261 selected_tokens = selected_tokens.saturating_add(estimate_message_tokens(message));
262 }
263
264 let mut remaining_budget = config.context_budget_tokens.saturating_sub(selected_tokens);
265 for (idx, message) in messages[..min_recent_start].iter().enumerate().rev() {
266 let tokens = estimate_message_tokens(message);
267 if tokens <= remaining_budget {
268 selected.push((idx, message.clone()));
269 remaining_budget -= tokens;
270 }
271 }
272
273 selected.sort_by_key(|(idx, _)| *idx);
274 *messages = selected.into_iter().map(|(_, message)| message).collect();
275 original_count.saturating_sub(messages.len())
276}
277
278pub struct QueryHistoryTool;
280
281#[derive(Debug, Deserialize)]
282struct QueryHistoryParams {
283 #[serde(default)]
284 query: Option<String>,
285 #[serde(default)]
286 message_range: Option<MessageRange>,
287 #[serde(default = "default_query_limit")]
288 limit: usize,
289}
290
291#[derive(Debug, Deserialize)]
292struct MessageRange {
293 from: usize,
294 to: usize,
295}
296
297fn default_query_limit() -> usize {
298 20
299}
300
301#[async_trait]
302impl Tool for QueryHistoryTool {
303 fn name(&self) -> &str {
304 "query_history"
305 }
306
307 fn display_name(&self) -> Option<&str> {
308 Some("Query History")
309 }
310
311 fn description(&self) -> &str {
312 "Search or retrieve earlier messages from this conversation that may not be visible in the current prompt."
313 }
314
315 fn parameters_schema(&self) -> Value {
316 json!({
317 "type": "object",
318 "properties": {
319 "query": {
320 "type": "string",
321 "description": "Keyword search over earlier messages"
322 },
323 "message_range": {
324 "type": "object",
325 "properties": {
326 "from": { "type": "integer", "minimum": 0, "description": "Start index (0-based, inclusive)" },
327 "to": { "type": "integer", "minimum": 0, "description": "End index (0-based, exclusive)" }
328 },
329 "required": ["from", "to"],
330 "additionalProperties": false,
331 "description": "Retrieve messages by absolute position in the conversation"
332 },
333 "limit": {
334 "type": "integer",
335 "minimum": 1,
336 "default": 20,
337 "description": "Maximum number of messages to return"
338 }
339 },
340 "additionalProperties": false
341 })
342 }
343
344 fn hints(&self) -> ToolHints {
345 ToolHints::default()
346 .with_readonly(true)
347 .with_idempotent(true)
348 }
349
350 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
351 ToolExecutionResult::tool_error(
352 "query_history requires session context. Execute it with ToolContext.",
353 )
354 }
355
356 fn requires_context(&self) -> bool {
357 true
358 }
359
360 async fn execute_with_context(
361 &self,
362 arguments: Value,
363 context: &ToolContext,
364 ) -> ToolExecutionResult {
365 let params: QueryHistoryParams = match serde_json::from_value(arguments) {
366 Ok(params) => params,
367 Err(error) => {
368 return ToolExecutionResult::tool_error(format!("Invalid parameters: {error}"));
369 }
370 };
371
372 let Some(retriever) = &context.message_retriever else {
373 return ToolExecutionResult::tool_error("No message retriever available");
374 };
375
376 let messages = match retriever.load(context.session_id).await {
377 Ok(messages) => messages,
378 Err(error) => {
379 return ToolExecutionResult::internal_error(error);
380 }
381 };
382
383 if messages.is_empty() {
384 return ToolExecutionResult::success(json!({
385 "count": 0,
386 "message": "No history available."
387 }));
388 }
389
390 let limit = params.limit.min(50);
391 let total = messages.len();
392
393 if let Some(range) = params.message_range {
394 let from = range.from.min(total);
395 let to = range.to.min(total).max(from);
396 let range_messages: Vec<_> = messages[from..to].iter().take(limit).collect();
397 return format_range_result(&range_messages, from, total);
398 }
399
400 if let Some(query) = params.query.as_deref() {
401 let results = search_messages(&messages, query, limit);
402 return format_search_result(&results, total);
403 }
404
405 let recent: Vec<_> = messages.iter().rev().take(limit).collect();
406 format_recent_result(&recent, total)
407 }
408}
409
410struct SearchResult<'a> {
411 index: usize,
412 message: &'a Message,
413 score: f64,
414}
415
416fn search_messages<'a>(
417 messages: &'a [Message],
418 query: &str,
419 limit: usize,
420) -> Vec<SearchResult<'a>> {
421 let query_lower = query.to_lowercase();
422 let mut results = Vec::new();
423
424 for (index, message) in messages.iter().enumerate() {
425 let content = extract_text_content(message).to_lowercase();
426 if !content.contains(&query_lower) {
427 continue;
428 }
429
430 let mut score = 1.0;
431
432 if content.split_whitespace().any(|word| word == query_lower) {
433 score += 0.5;
434 }
435
436 if !messages.is_empty() {
437 score += (index as f64 / messages.len() as f64) * 0.3;
438 }
439
440 match message.role {
441 MessageRole::User | MessageRole::Agent => score += 0.2,
442 MessageRole::System => score += 0.1,
443 MessageRole::ToolResult => {}
444 }
445
446 results.push(SearchResult {
447 index,
448 message,
449 score,
450 });
451 }
452
453 results.sort_by(|left, right| {
454 right
455 .score
456 .partial_cmp(&left.score)
457 .unwrap_or(Ordering::Equal)
458 });
459 results.truncate(limit);
460 results
461}
462
463fn extract_text_content(message: &Message) -> String {
464 message
465 .content
466 .iter()
467 .filter_map(|part| match part {
468 ContentPart::Text(text) => Some(text.text.clone()),
469 ContentPart::ToolResult(result) => result.result.as_ref().map(ToString::to_string),
470 _ => None,
471 })
472 .collect::<Vec<_>>()
473 .join(" ")
474}
475
476fn truncate_content(content: &str, max_len: usize) -> String {
477 let char_count = content.chars().count();
478 if char_count <= max_len {
479 return content.to_string();
480 }
481
482 format!("{}...", content.chars().take(max_len).collect::<String>())
483}
484
485fn format_message(message: &Message, index: usize, total: usize) -> Value {
486 json!({
487 "index": index,
488 "position": format!("{}/{}", index + 1, total),
489 "role": message.role.to_string(),
490 "created_at": message.created_at.to_rfc3339(),
491 "content": truncate_content(&extract_text_content(message), 500)
492 })
493}
494
495fn format_range_result(
496 messages: &[&Message],
497 start_index: usize,
498 total: usize,
499) -> ToolExecutionResult {
500 if messages.is_empty() {
501 return ToolExecutionResult::success(json!({
502 "count": 0,
503 "message": "No messages in the requested range."
504 }));
505 }
506
507 let formatted: Vec<Value> = messages
508 .iter()
509 .enumerate()
510 .map(|(offset, message)| format_message(message, start_index + offset, total))
511 .collect();
512
513 ToolExecutionResult::success(json!({
514 "messages": formatted,
515 "count": messages.len(),
516 "total_in_history": total,
517 "range": format!("{}-{}", start_index + 1, start_index + messages.len())
518 }))
519}
520
521fn format_search_result(results: &[SearchResult<'_>], total: usize) -> ToolExecutionResult {
522 if results.is_empty() {
523 return ToolExecutionResult::success(json!({
524 "count": 0,
525 "message": "No matching messages found."
526 }));
527 }
528
529 let formatted: Vec<Value> = results
530 .iter()
531 .map(|result| {
532 let mut message = format_message(result.message, result.index, total);
533 message["relevance_score"] = json!(format!("{:.2}", result.score));
534 message
535 })
536 .collect();
537
538 ToolExecutionResult::success(json!({
539 "messages": formatted,
540 "count": results.len(),
541 "total_in_history": total
542 }))
543}
544
545fn format_recent_result(messages: &[&Message], total: usize) -> ToolExecutionResult {
546 let formatted: Vec<Value> = messages
547 .iter()
548 .enumerate()
549 .map(|(offset, message)| format_message(message, total - messages.len() + offset, total))
550 .collect();
551
552 ToolExecutionResult::success(json!({
553 "messages": formatted,
554 "count": messages.len(),
555 "total_in_history": total,
556 "note": "Showing most recent history. Use `query` to search or `message_range` to fetch older messages."
557 }))
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::memory::InMemoryMessageRetriever;
564 use crate::typed_id::SessionId;
565
566 #[test]
567 fn test_capability_metadata() {
568 let capability = InfinityContextCapability;
569
570 assert_eq!(capability.id(), INFINITY_CONTEXT_CAPABILITY_ID);
571 assert_eq!(capability.name(), "Infinity Context");
572 assert_eq!(capability.status(), CapabilityStatus::Available);
573 assert_eq!(capability.category(), Some("Optimization"));
574 assert_eq!(capability.tools().len(), 1);
575 assert!(capability.message_filter_provider().is_some());
576 }
577
578 #[test]
579 fn test_filter_provider_sets_bounded_candidate_load_limit_without_hard_cap() {
580 let mut query = MessageQuery::new(SessionId::new());
581 let provider = InfinityContextFilterProvider;
582 provider.apply_filters(
583 &mut query,
584 &json!({"context_budget_tokens": 1_000, "min_recent_messages": 3}),
585 );
586
587 assert_eq!(query.limit, Some(16));
588 assert!(query.prepend_transform.is_some());
589 }
590
591 #[test]
592 fn test_filter_provider_caps_explicit_max_to_bounded_candidate_window() {
593 let mut query = MessageQuery::new(SessionId::new());
594 let provider = InfinityContextFilterProvider;
595 provider.apply_filters(
596 &mut query,
597 &json!({
598 "context_budget_tokens": 500_000,
599 "min_recent_messages": 10,
600 "max_recent_messages": 1_000_000
601 }),
602 );
603
604 assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
605 assert!(query.prepend_transform.is_some());
606 }
607
608 #[test]
609 fn test_filter_provider_caps_large_min_recent_messages() {
610 let mut query = MessageQuery::new(SessionId::new());
611 let provider = InfinityContextFilterProvider;
612 provider.apply_filters(
613 &mut query,
614 &json!({
615 "context_budget_tokens": 1_000,
616 "min_recent_messages": 1_000_000,
617 }),
618 );
619
620 assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
621 assert!(query.prepend_transform.is_some());
622 }
623
624 #[test]
625 fn test_filter_provider_allows_small_public_chat_window() {
626 let mut query = MessageQuery::new(SessionId::new());
627 let provider = InfinityContextFilterProvider;
628 provider.apply_filters(
629 &mut query,
630 &json!({
631 "context_budget_tokens": 10_000,
632 "min_recent_messages": 10,
633 "max_recent_messages": 30
634 }),
635 );
636
637 assert_eq!(query.limit, Some(30));
638 assert!(query.prepend_transform.is_some());
639 }
640
641 #[test]
642 fn test_filter_provider_falls_back_to_defaults_for_invalid_config() {
643 let mut query = MessageQuery::new(SessionId::new());
644 let provider = InfinityContextFilterProvider;
645 provider.apply_filters(
646 &mut query,
647 &json!({"context_budget_tokens": "not-a-number"}),
648 );
649
650 assert_eq!(query.limit, Some(1_600));
651 assert!(query.prepend_transform.is_some());
652 }
653
654 #[test]
655 fn test_filter_provider_trims_loaded_messages_by_token_budget() {
656 let provider = InfinityContextFilterProvider;
657 let mut messages = vec![
658 Message::user("old tiny"),
659 Message::assistant("old ".repeat(400)),
660 Message::user("recent one"),
661 Message::assistant("recent two"),
662 ];
663
664 provider.post_load(
665 &mut messages,
666 &json!({"context_budget_tokens": 1, "min_recent_messages": 2}),
667 );
668
669 assert_eq!(messages.len(), 3);
670 assert!(
671 extract_text_content(&messages[0])
672 .contains("earlier messages are NOT visible in this context")
673 );
674 assert_eq!(extract_text_content(&messages[1]), "recent one");
675 assert_eq!(extract_text_content(&messages[2]), "recent two");
676 }
677
678 #[test]
679 fn test_filter_provider_applies_hard_cap_after_loading() {
680 let provider = InfinityContextFilterProvider;
681 let mut messages = vec![
682 Message::user("one"),
683 Message::assistant("two"),
684 Message::user("three"),
685 ];
686
687 provider.post_load(
688 &mut messages,
689 &json!({
690 "context_budget_tokens": 10_000,
691 "min_recent_messages": 10,
692 "max_recent_messages": 2
693 }),
694 );
695
696 assert_eq!(messages.len(), 3);
697 assert!(
698 extract_text_content(&messages[0])
699 .contains("earlier messages are NOT visible in this context")
700 );
701 assert_eq!(extract_text_content(&messages[1]), "two");
702 assert_eq!(extract_text_content(&messages[2]), "three");
703 }
704
705 #[test]
706 fn test_filter_provider_preserves_hard_cap_notice_through_full_flow() {
707 let provider = InfinityContextFilterProvider;
708 let config = json!({
709 "context_budget_tokens": 10_000,
710 "min_recent_messages": 10,
711 "max_recent_messages": 2
712 });
713 let mut query = MessageQuery::new(SessionId::new());
714 provider.apply_filters(&mut query, &config);
715 let mut messages = vec![
716 Message::user("one"),
717 Message::assistant("two"),
718 Message::user("three"),
719 ];
720
721 query.apply_windowing(&mut messages);
722 provider.post_load(&mut messages, &config);
723
724 assert_eq!(messages.len(), 3);
725 assert!(
726 extract_text_content(&messages[0])
727 .contains("1 earlier messages are NOT visible in this context")
728 );
729 assert_eq!(extract_text_content(&messages[1]), "two");
730 assert_eq!(extract_text_content(&messages[2]), "three");
731 }
732
733 #[test]
734 fn test_estimate_json_value_len_matches_serialized_length() {
735 let value = json!({
736 "stdout": ["alpha", "beta"],
737 "ok": true,
738 "count": 2
739 });
740
741 assert_eq!(
742 estimate_json_value_len(&value),
743 serde_json::to_string(&value).unwrap().len()
744 );
745 }
746
747 #[test]
748 fn test_query_history_requires_context() {
749 let tool = QueryHistoryTool;
750 assert!(tool.requires_context());
751 }
752
753 #[tokio::test]
754 async fn test_query_history_tool_errors_without_retriever() {
755 let tool = QueryHistoryTool;
756 let result = tool
757 .execute_with_context(json!({"query": "api"}), &ToolContext::new(SessionId::new()))
758 .await;
759
760 match result {
761 ToolExecutionResult::ToolError(message) => {
762 assert!(message.contains("No message retriever available"));
763 }
764 other => panic!("expected tool error, got {other:?}"),
765 }
766 }
767
768 #[tokio::test]
769 async fn test_query_history_tool_rejects_invalid_params() {
770 let result = QueryHistoryTool.execute(json!({"limit": "oops"})).await;
771
772 match result {
773 ToolExecutionResult::ToolError(message) => {
774 assert!(message.contains("requires session context"));
775 }
776 other => panic!("expected tool error, got {other:?}"),
777 }
778
779 let session_id = SessionId::new();
780 let retriever = InMemoryMessageRetriever::new();
781 let result = QueryHistoryTool
782 .execute_with_context(
783 json!({"message_range": {"from": "bad", "to": 1}}),
784 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
785 )
786 .await;
787
788 match result {
789 ToolExecutionResult::ToolError(message) => {
790 assert!(message.contains("Invalid parameters"));
791 }
792 other => panic!("expected tool error, got {other:?}"),
793 }
794 }
795
796 #[tokio::test]
797 async fn test_query_history_tool_empty_history() {
798 let session_id = SessionId::new();
799 let retriever = InMemoryMessageRetriever::new();
800
801 let result = QueryHistoryTool
802 .execute_with_context(
803 json!({}),
804 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
805 )
806 .await;
807
808 match result {
809 ToolExecutionResult::Success(value) => {
810 assert_eq!(value["count"], 0);
811 assert_eq!(value["message"], "No history available.");
812 }
813 other => panic!("expected success, got {other:?}"),
814 }
815 }
816
817 #[tokio::test]
818 async fn test_query_history_tool_searches_history() {
819 let session_id = SessionId::new();
820 let retriever = InMemoryMessageRetriever::new();
821 retriever
822 .seed(
823 session_id,
824 vec![
825 Message::user("First topic"),
826 Message::assistant("The API key is abc123"),
827 Message::user("We should keep discussing logging"),
828 ],
829 )
830 .await;
831
832 let result = QueryHistoryTool
833 .execute_with_context(
834 json!({"query": "api key"}),
835 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
836 )
837 .await;
838
839 match result {
840 ToolExecutionResult::Success(value) => {
841 assert_eq!(value["count"], 1);
842 assert_eq!(value["messages"][0]["content"], "The API key is abc123");
843 }
844 other => panic!("expected success, got {other:?}"),
845 }
846 }
847
848 #[tokio::test]
849 async fn test_query_history_tool_search_no_match() {
850 let session_id = SessionId::new();
851 let retriever = InMemoryMessageRetriever::new();
852 retriever
853 .seed(
854 session_id,
855 vec![Message::user("one"), Message::assistant("two")],
856 )
857 .await;
858
859 let result = QueryHistoryTool
860 .execute_with_context(
861 json!({"query": "missing"}),
862 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
863 )
864 .await;
865
866 match result {
867 ToolExecutionResult::Success(value) => {
868 assert_eq!(value["count"], 0);
869 assert_eq!(value["message"], "No matching messages found.");
870 }
871 other => panic!("expected success, got {other:?}"),
872 }
873 }
874
875 #[tokio::test]
876 async fn test_query_history_tool_reads_range() {
877 let session_id = SessionId::new();
878 let retriever = InMemoryMessageRetriever::new();
879 retriever
880 .seed(
881 session_id,
882 vec![
883 Message::user("one"),
884 Message::assistant("two"),
885 Message::user("three"),
886 ],
887 )
888 .await;
889
890 let result = QueryHistoryTool
891 .execute_with_context(
892 json!({"message_range": {"from": 1, "to": 3}, "limit": 10}),
893 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
894 )
895 .await;
896
897 match result {
898 ToolExecutionResult::Success(value) => {
899 assert_eq!(value["count"], 2);
900 assert_eq!(value["messages"][0]["content"], "two");
901 assert_eq!(value["messages"][1]["content"], "three");
902 }
903 other => panic!("expected success, got {other:?}"),
904 }
905 }
906
907 #[tokio::test]
908 async fn test_query_history_tool_clamps_out_of_bounds_range() {
909 let session_id = SessionId::new();
910 let retriever = InMemoryMessageRetriever::new();
911 retriever
912 .seed(
913 session_id,
914 vec![
915 Message::user("one"),
916 Message::assistant("two"),
917 Message::user("three"),
918 ],
919 )
920 .await;
921
922 let result = QueryHistoryTool
923 .execute_with_context(
924 json!({"message_range": {"from": 99, "to": 100}}),
925 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
926 )
927 .await;
928
929 match result {
930 ToolExecutionResult::Success(value) => {
931 assert_eq!(value["count"], 0);
932 assert_eq!(value["message"], "No messages in the requested range.");
933 }
934 other => panic!("expected success, got {other:?}"),
935 }
936 }
937
938 #[test]
939 fn test_truncate_content_is_utf8_safe() {
940 let truncated = truncate_content("hello🙂world", 6);
941 assert_eq!(truncated, "hello🙂...");
942 }
943
944 #[test]
945 fn trim_preserves_locally_unmatched_tool_result_for_stateful_responses() {
946 use crate::tool_types::ToolCall;
947
948 let provider = InfinityContextFilterProvider;
949 let mut messages = vec![
955 Message::user("old question"),
956 Message::assistant_with_tools(
957 "calling tool",
958 vec![ToolCall {
959 id: "call_old".to_string(),
960 name: "edit_file".to_string(),
961 arguments: serde_json::json!({}),
962 }],
963 ),
964 Message::tool_result("call_old", Some(serde_json::json!("done")), None),
966 Message::user("new question"),
967 Message::assistant("answer"),
968 ];
969
970 provider.post_load(
971 &mut messages,
972 &serde_json::json!({"context_budget_tokens": 1, "min_recent_messages": 3}),
973 );
974
975 assert!(
976 messages.iter().any(|m| m.role == MessageRole::ToolResult),
977 "locally unmatched tool result must be preserved until provider serialization"
978 );
979 }
980
981 #[test]
982 fn trim_keeps_tool_result_when_tool_call_is_visible() {
983 use crate::tool_types::ToolCall;
984
985 let provider = InfinityContextFilterProvider;
986 let mut messages = vec![
988 Message::assistant_with_tools(
989 "calling tool",
990 vec![ToolCall {
991 id: "call_1".to_string(),
992 name: "read_file".to_string(),
993 arguments: serde_json::json!({}),
994 }],
995 ),
996 Message::tool_result("call_1", Some(serde_json::json!("content")), None),
997 Message::user("thanks"),
998 ];
999
1000 provider.post_load(
1001 &mut messages,
1002 &serde_json::json!({"context_budget_tokens": 100_000, "min_recent_messages": 10}),
1003 );
1004
1005 assert!(
1006 messages.iter().any(|m| m.role == MessageRole::ToolResult),
1007 "tool result must be kept when its tool call is visible"
1008 );
1009 }
1010}