Skip to main content

everruns_core/capabilities/
infinity_context.rs

1//! Infinity Context Capability
2//!
3//! Keeps recent conversation turns in prompt context while exposing a
4//! `query_history` tool for older messages that fell out of the active window.
5
6use 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
19/// Capability ID for infinity context.
20pub const INFINITY_CONTEXT_CAPABILITY_ID: &str = "infinity_context";
21
22/// Infinity context capability.
23pub 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    /// Maximum prompt budget reserved for message history.
76    #[serde(default = "default_context_budget_tokens")]
77    context_budget_tokens: usize,
78
79    /// Minimum number of recent messages to keep even when the budget is tight.
80    #[serde(default = "default_min_recent_messages")]
81    min_recent_messages: usize,
82
83    /// Optional hard cap on recent messages kept in the live prompt.
84    ///
85    /// Useful for public support chats where the prompt must stay small even
86    /// when the token-budget estimate would allow more messages.
87    #[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    // Cap the candidate window unconditionally at `CANDIDATE_MAX_MESSAGES` so a
149    // large `min_recent_messages` or `max_recent_messages` cannot turn into an
150    // unbounded DB `LIMIT`.
151    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
278/// Tool for querying earlier conversation history.
279pub 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        // min_recent_messages=3 keeps the last 3 messages. With a 1-token budget the
950        // two older messages are dropped, including the assistant tool call. The
951        // OpenAI Responses path may still have that call in previous_response_id
952        // state, so InfinityContext must not drop the tool output before provider
953        // serialization decides whether stateful continuation is active.
954        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            // This tool result is in the min-recent window but its call is trimmed away.
965            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        // All 3 messages fit in the window: no orphan expected.
987        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}