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, CapabilityLocalization, CapabilityStatus};
7use crate::message::{ContentPart, Message, MessageRole};
8use crate::message_filter::{
9    ExcludedNoticeTransform, MessageFilterProvider, MessageQuery, anchored_window,
10};
11use crate::tool_types::ToolHints;
12use crate::tools::{Tool, ToolExecutionResult};
13use crate::traits::{ToolContext, ToolContextService};
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use std::cmp::Ordering;
18use std::io::{self, Write};
19use std::sync::Arc;
20
21/// Capability ID for infinity context.
22pub const INFINITY_CONTEXT_CAPABILITY_ID: &str = "infinity_context";
23
24/// Infinity context capability.
25pub struct InfinityContextCapability;
26
27/// Provider-bound Infinity Context view used when history retrieval is unsafe.
28///
29/// This preserves prompt-window filtering without advertising or exposing the
30/// `query_history` tool.
31pub struct InfinityContextFilterOnlyCapability;
32
33impl Capability for InfinityContextFilterOnlyCapability {
34    fn id(&self) -> &str {
35        INFINITY_CONTEXT_CAPABILITY_ID
36    }
37
38    fn name(&self) -> &str {
39        "Infinity Context"
40    }
41
42    fn description(&self) -> &str {
43        "Trims older conversation history out of the live provider prompt."
44    }
45
46    fn status(&self) -> CapabilityStatus {
47        CapabilityStatus::Available
48    }
49
50    fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
51        Some(Arc::new(InfinityContextFilterProvider))
52    }
53}
54
55impl Capability for InfinityContextCapability {
56    fn id(&self) -> &str {
57        INFINITY_CONTEXT_CAPABILITY_ID
58    }
59
60    fn name(&self) -> &str {
61        "Infinity Context"
62    }
63
64    fn description(&self) -> &str {
65        r#"Trims older conversation history out of the live prompt while keeping it queryable with `query_history`.
66
67> [!TIP]
68> Use this for long-running sessions where earlier discussion still matters but should not consume prompt budget every turn."#
69    }
70
71    fn status(&self) -> CapabilityStatus {
72        CapabilityStatus::Available
73    }
74
75    fn icon(&self) -> Option<&str> {
76        Some("infinity")
77    }
78
79    fn category(&self) -> Option<&str> {
80        Some("Optimization")
81    }
82
83    fn system_prompt_addition(&self) -> Option<&str> {
84        Some(INFINITY_CONTEXT_SYSTEM_PROMPT)
85    }
86
87    fn tools(&self) -> Vec<Box<dyn Tool>> {
88        vec![Box::new(QueryHistoryTool)]
89    }
90
91    fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
92        Some(Arc::new(InfinityContextFilterProvider))
93    }
94
95    /// All three `InfinityContextConfig` fields are simple numeric knobs users
96    /// tune, so all are exposed. Candidate-load sizing (overfetch factor,
97    /// per-message token estimate, hard DB limit) is internal and derived from
98    /// these values, so it has no schema surface.
99    fn config_schema(&self) -> Option<Value> {
100        Some(json!({
101            "type": "object",
102            "properties": {
103                "context_budget_tokens": {
104                    "type": "integer",
105                    "title": "Context budget (tokens)",
106                    "description": "Maximum prompt budget reserved for message history.",
107                    "minimum": 1,
108                    "default": default_context_budget_tokens()
109                },
110                "min_recent_messages": {
111                    "type": "integer",
112                    "title": "Minimum recent messages",
113                    "description": "Number of recent messages always kept, even when the token budget is tight.",
114                    "minimum": 1,
115                    "default": default_min_recent_messages()
116                },
117                "max_recent_messages": {
118                    "type": "integer",
119                    "title": "Maximum recent messages",
120                    "description": "Optional hard cap on recent messages kept in the live prompt.",
121                    "minimum": 1
122                },
123                "keep_first_messages": {
124                    "type": "integer",
125                    "title": "Anchored first messages",
126                    "description": "Optional leading messages kept as an anchor (the original task), even under a tight budget. Additional to the maximum recent messages. The anchor is fetched as a bounded head+tail load (capped at 16), so it is guaranteed even for histories far longer than the candidate load window. Defaults to 0 so untrusted first messages cannot bypass the configured token budget or recent-message cap; raise it only for trusted sessions where anchoring leading context is worth the extra prompt cost.",
127                    "minimum": 0,
128                    "maximum": MAX_KEEP_FIRST_MESSAGES,
129                    "default": default_keep_first_messages()
130                }
131            }
132        }))
133    }
134
135    fn validate_config(&self, config: &Value) -> Result<(), String> {
136        if config.is_null() {
137            return Ok(());
138        }
139        let typed: InfinityContextConfig = serde_json::from_value(config.clone())
140            .map_err(|e| format!("invalid infinity_context config: {e}"))?;
141        if typed.context_budget_tokens == 0 {
142            return Err("context_budget_tokens must be >= 1".to_string());
143        }
144        if typed.min_recent_messages == 0 {
145            return Err("min_recent_messages must be >= 1".to_string());
146        }
147        if typed.max_recent_messages == Some(0) {
148            return Err("max_recent_messages must be >= 1".to_string());
149        }
150        if typed.keep_first_messages > MAX_KEEP_FIRST_MESSAGES {
151            return Err(format!(
152                "keep_first_messages must be <= {MAX_KEEP_FIRST_MESSAGES}"
153            ));
154        }
155        Ok(())
156    }
157
158    fn localizations(&self) -> Vec<CapabilityLocalization> {
159        vec![
160            CapabilityLocalization {
161                locale: "en",
162                name: None,
163                description: None,
164                config_description: Some(
165                    "Controls the token budget for history and the minimum/maximum number \
166                     of recent messages kept in the prompt.",
167                ),
168                config_overlay: None,
169            },
170            CapabilityLocalization {
171                locale: "uk",
172                name: Some("Нескінченний контекст"),
173                description: Some(
174                    "Прибирає старішу історію розмови з активного запиту, зберігаючи її \
175                     доступною через інструмент query_history.",
176                ),
177                config_description: Some(
178                    "Визначає бюджет токенів для історії та мінімальну й максимальну \
179                     кількість останніх повідомлень у запиті.",
180                ),
181                config_overlay: Some(json!({
182                    "properties": {
183                        "context_budget_tokens": {
184                            "title": "Бюджет контексту (токени)",
185                            "description": "Максимальний бюджет запиту, зарезервований для історії повідомлень."
186                        },
187                        "min_recent_messages": {
188                            "title": "Мінімум останніх повідомлень",
189                            "description": "Кількість останніх повідомлень, які зберігаються завжди, навіть коли бюджет токенів обмежений."
190                        },
191                        "max_recent_messages": {
192                            "title": "Максимум останніх повідомлень",
193                            "description": "Необов'язкове жорстке обмеження кількості останніх повідомлень в активному запиті."
194                        }
195                    }
196                })),
197            },
198        ]
199    }
200}
201
202const INFINITY_CONTEXT_SYSTEM_PROMPT: &str = r#"## Conversation history
203
204Earlier messages may be trimmed from the live prompt. Use `query_history`
205to retrieve them when needed. The window is trimmed automatically; do not
206abandon tasks for token reasons — persist important state via file or
207memory tools when available."#;
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210struct InfinityContextConfig {
211    /// Maximum prompt budget reserved for message history.
212    #[serde(default = "default_context_budget_tokens")]
213    context_budget_tokens: usize,
214
215    /// Minimum number of recent messages to keep even when the budget is tight.
216    #[serde(default = "default_min_recent_messages")]
217    min_recent_messages: usize,
218
219    /// Optional hard cap on recent messages kept in the live prompt.
220    ///
221    /// Useful for public support chats where the prompt must stay small even
222    /// when the token-budget estimate would allow more messages.
223    #[serde(default)]
224    max_recent_messages: Option<usize>,
225
226    /// Optional leading messages kept as an anchor (the original task / goal),
227    /// regardless of token budget. Defaults to 0 so untrusted first messages
228    /// cannot bypass the configured token budget or recent-message cap. The
229    /// anchor is additional to `max_recent_messages` when explicitly enabled.
230    #[serde(default = "default_keep_first_messages")]
231    keep_first_messages: usize,
232
233    /// Derived (not user-facing): set by capability collection when the
234    /// `compaction` capability is also enabled. When true, infinity context
235    /// stops doing token-budget eviction and lets compaction own reduction, so
236    /// compaction's summary — not a bare "hidden" notice — covers old turns.
237    #[serde(default)]
238    compaction_active: bool,
239}
240
241fn default_context_budget_tokens() -> usize {
242    100_000
243}
244
245fn default_min_recent_messages() -> usize {
246    10
247}
248
249fn default_keep_first_messages() -> usize {
250    0
251}
252
253impl Default for InfinityContextConfig {
254    fn default() -> Self {
255        Self {
256            context_budget_tokens: default_context_budget_tokens(),
257            min_recent_messages: default_min_recent_messages(),
258            max_recent_messages: None,
259            keep_first_messages: default_keep_first_messages(),
260            compaction_active: false,
261        }
262    }
263}
264
265const CANDIDATE_AVG_TOKENS_PER_MESSAGE: usize = 250;
266const CANDIDATE_OVERFETCH_FACTOR: usize = 4;
267const CANDIDATE_MAX_MESSAGES: usize = 2_000;
268const MAX_KEEP_FIRST_MESSAGES: usize = 16;
269
270struct InfinityContextFilterProvider;
271
272impl MessageFilterProvider for InfinityContextFilterProvider {
273    fn apply_filters(&self, query: &mut MessageQuery, config: &Value) {
274        let config: InfinityContextConfig =
275            serde_json::from_value(config.clone()).unwrap_or_default();
276
277        query.limit = Some(resolve_candidate_load_limit(&config) as i64);
278        // When explicitly configured (`keep_first_messages > 0`), fetch the first
279        // messages alongside the latest-N tail so the task/goal anchor survives
280        // even when history is far longer than the candidate load window (the
281        // tail-only LIMIT would otherwise never fetch the genuine first message).
282        // The default is 0 so an untrusted first message cannot bypass the token
283        // budget or recent-message cap, and the value is clamped to
284        // `MAX_KEEP_FIRST_MESSAGES` to bound the extra head load.
285        let keep_first_messages = resolve_keep_first_messages(&config);
286        if keep_first_messages > 0 {
287            query.keep_head = Some(keep_first_messages);
288        }
289        query.prepend_transform = Some(Arc::new(ExcludedNoticeTransform::infinity_context()));
290    }
291
292    fn post_load(&self, messages: &mut Vec<Message>, config: &Value) {
293        let config: InfinityContextConfig =
294            serde_json::from_value(config.clone()).unwrap_or_default();
295        let existing_notice_count = take_existing_excluded_notice(messages);
296
297        // P2 composition: when compaction is the active reducer, defer
298        // token-budget eviction to it. Only re-surface any candidate-window
299        // overflow notice (messages the DB load could not fetch).
300        if config.compaction_active {
301            if existing_notice_count > 0 {
302                insert_excluded_notice(
303                    messages,
304                    resolve_keep_first_messages(&config),
305                    existing_notice_count,
306                );
307            }
308            return;
309        }
310
311        let outcome = trim_messages_to_token_budget(messages, &config);
312        let mut total_excluded_count = existing_notice_count.saturating_add(outcome.hidden_count);
313        if total_excluded_count > 0 {
314            // The reduction must not leave a visible call whose result was
315            // evicted. Result-only deltas remain eligible here because a
316            // stateful Responses request may reference their calls through
317            // `previous_response_id`; ReasonAtom resolves that distinction at
318            // the final runtime boundary.
319            let head_ids: std::collections::HashSet<String> = messages
320                .iter()
321                .take(outcome.head_len)
322                .map(|message| message.id.to_string())
323                .collect();
324            let count_before_integrity = messages.len();
325            *messages =
326                crate::tool_call_integrity::retain_complete_message_tool_exchanges(messages, true);
327            total_excluded_count = total_excluded_count
328                .saturating_add(count_before_integrity.saturating_sub(messages.len()));
329            let retained_head_len = messages
330                .iter()
331                .take_while(|message| head_ids.contains(&message.id.to_string()))
332                .count();
333            // Place the notice right after the anchored head so the layout reads
334            // [task anchor] -> [N hidden] -> [recent window].
335            insert_excluded_notice(messages, retained_head_len, total_excluded_count);
336        }
337    }
338
339    fn priority(&self) -> i32 {
340        100
341    }
342}
343
344fn resolve_keep_first_messages(config: &InfinityContextConfig) -> usize {
345    // Bound the always-preserved head anchor independently of validation: filters
346    // can run over configs loaded before the cap existed or provided by tests.
347    config.keep_first_messages.min(MAX_KEEP_FIRST_MESSAGES)
348}
349
350fn resolve_candidate_load_limit(config: &InfinityContextConfig) -> usize {
351    // Cap the candidate window unconditionally at `CANDIDATE_MAX_MESSAGES` so a
352    // large `min_recent_messages` or `max_recent_messages` cannot turn into an
353    // unbounded DB `LIMIT`.
354    let budget_derived_limit = (config.context_budget_tokens / CANDIDATE_AVG_TOKENS_PER_MESSAGE)
355        .saturating_mul(CANDIDATE_OVERFETCH_FACTOR)
356        .max(config.min_recent_messages)
357        .clamp(1, CANDIDATE_MAX_MESSAGES);
358
359    if let Some(max_recent_messages) = config.max_recent_messages {
360        return budget_derived_limit.min(max_recent_messages.max(1));
361    }
362
363    budget_derived_limit
364}
365
366fn estimate_message_tokens(message: &Message) -> usize {
367    const TOKEN_CHARS: usize = 4;
368    let role_overhead = message.role.to_string().len() + 8;
369    let content_len: usize = message
370        .content
371        .iter()
372        .map(|part| match part {
373            ContentPart::Text(text) => text.text.len(),
374            ContentPart::Image(image) => {
375                image.url.as_ref().map_or(0, String::len)
376                    + image.base64.as_ref().map_or(50, String::len)
377                    + image.media_type.as_ref().map_or(0, String::len)
378            }
379            ContentPart::ImageFile(file) => {
380                file.image_id.to_string().len() + file.filename.as_ref().map_or(0, String::len)
381            }
382            ContentPart::ToolCall(call) => {
383                call.id.len() + call.name.len() + estimate_json_value_len(&call.arguments) + 20
384            }
385            ContentPart::ToolResult(result) => {
386                result.tool_call_id.len()
387                    + result.result.as_ref().map_or(0, estimate_json_value_len)
388                    + result.error.as_ref().map_or(0, String::len)
389                    + 20
390            }
391        })
392        .sum();
393    (role_overhead + content_len) / TOKEN_CHARS
394}
395
396struct CountingWriter {
397    len: usize,
398}
399
400impl Write for CountingWriter {
401    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
402        self.len = self.len.saturating_add(buf.len());
403        Ok(buf.len())
404    }
405
406    fn flush(&mut self) -> io::Result<()> {
407        Ok(())
408    }
409}
410
411fn estimate_json_value_len(value: &Value) -> usize {
412    let mut writer = CountingWriter { len: 0 };
413    serde_json::to_writer(&mut writer, value)
414        .map(|_| writer.len)
415        .unwrap_or(0)
416}
417
418fn take_existing_excluded_notice(messages: &mut Vec<Message>) -> usize {
419    let Some(first) = messages.first() else {
420        return 0;
421    };
422    let Some(count) = parse_excluded_notice_count(first) else {
423        return 0;
424    };
425
426    messages.remove(0);
427    count
428}
429
430fn parse_excluded_notice_count(message: &Message) -> Option<usize> {
431    let text = message.text()?;
432    let rest = text.strip_prefix("[IMPORTANT: ")?;
433    let (count, rest) = rest.split_once(' ')?;
434    if !rest.starts_with("earlier messages are NOT visible in this context.") {
435        return None;
436    }
437    count.parse().ok()
438}
439
440/// Outcome of token-budget trimming.
441#[derive(Default)]
442struct TrimOutcome {
443    /// Messages dropped from the middle.
444    hidden_count: usize,
445    /// Length of the preserved leading anchor (where the notice is inserted).
446    head_len: usize,
447}
448
449/// Insert the hidden-history notice at `position`, clamped to the message list.
450fn insert_excluded_notice(messages: &mut Vec<Message>, position: usize, count: usize) {
451    let text = ExcludedNoticeTransform::infinity_context()
452        .format
453        .replace("{}", &count.to_string());
454    messages.insert(position.min(messages.len()), Message::system(text));
455}
456
457/// Trim the live window to the token budget while always keeping the first
458/// `keep_first_messages` (the original task/goal) and the recent tail. Drops a
459/// single contiguous block from the middle and reports how many were hidden.
460fn trim_messages_to_token_budget(
461    messages: &mut Vec<Message>,
462    config: &InfinityContextConfig,
463) -> TrimOutcome {
464    if messages.is_empty() {
465        return TrimOutcome::default();
466    }
467
468    let costs: Vec<usize> = messages.iter().map(estimate_message_tokens).collect();
469    let window = anchored_window(
470        &costs,
471        resolve_keep_first_messages(config),
472        config.min_recent_messages,
473        config.max_recent_messages,
474        config.context_budget_tokens,
475    );
476
477    let hidden_count = window.hidden();
478    if hidden_count > 0 {
479        // Rebuild as [0, head_len) ++ [recent_start, len) without cloning.
480        let tail = messages.split_off(window.recent_start);
481        messages.truncate(window.head_len);
482        messages.extend(tail);
483    }
484
485    TrimOutcome {
486        hidden_count,
487        head_len: window.head_len,
488    }
489}
490
491/// Tool for querying earlier conversation history.
492pub struct QueryHistoryTool;
493
494#[derive(Debug, Deserialize)]
495struct QueryHistoryParams {
496    #[serde(default)]
497    query: Option<String>,
498    #[serde(default)]
499    message_range: Option<MessageRange>,
500    #[serde(default = "default_query_limit")]
501    limit: usize,
502}
503
504#[derive(Debug, Deserialize)]
505struct MessageRange {
506    from: usize,
507    to: usize,
508}
509
510fn default_query_limit() -> usize {
511    20
512}
513
514#[async_trait]
515impl Tool for QueryHistoryTool {
516    fn narrate(
517        &self,
518        tool_call: &crate::tool_types::ToolCall,
519        phase: crate::tool_narration::ToolNarrationPhase,
520        locale: Option<&str>,
521        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
522    ) -> Option<String> {
523        Some(crate::tool_narration::narrate_query_history(
524            &tool_call.arguments,
525            phase,
526            locale,
527        ))
528    }
529
530    fn name(&self) -> &str {
531        "query_history"
532    }
533
534    fn display_name(&self) -> Option<&str> {
535        Some("Query History")
536    }
537
538    fn description(&self) -> &str {
539        "Search or retrieve earlier messages from this conversation that may not be visible in the current prompt."
540    }
541
542    fn parameters_schema(&self) -> Value {
543        json!({
544            "type": "object",
545            "properties": {
546                "query": {
547                    "type": "string",
548                    "description": "Keyword search over earlier messages"
549                },
550                "message_range": {
551                    "type": "object",
552                    "properties": {
553                        "from": { "type": "integer", "minimum": 0, "description": "Start index (0-based, inclusive)" },
554                        "to": { "type": "integer", "minimum": 0, "description": "End index (0-based, exclusive)" }
555                    },
556                    "required": ["from", "to"],
557                    "additionalProperties": false,
558                    "description": "Retrieve messages by absolute position in the conversation"
559                },
560                "limit": {
561                    "type": "integer",
562                    "minimum": 1,
563                    "default": 20,
564                    "description": "Maximum number of messages to return"
565                }
566            },
567            "additionalProperties": false
568        })
569    }
570
571    fn hints(&self) -> ToolHints {
572        ToolHints::default()
573            .with_readonly(true)
574            .with_idempotent(true)
575    }
576
577    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
578        ToolExecutionResult::tool_error(
579            "query_history requires session context. Execute it with ToolContext.",
580        )
581    }
582
583    fn requires_context(&self) -> bool {
584        true
585    }
586
587    fn required_context_services(&self) -> &'static [ToolContextService] {
588        &[ToolContextService::MessageRetriever]
589    }
590
591    async fn execute_with_context(
592        &self,
593        arguments: Value,
594        context: &ToolContext,
595    ) -> ToolExecutionResult {
596        let params: QueryHistoryParams = match serde_json::from_value(arguments) {
597            Ok(params) => params,
598            Err(error) => {
599                return ToolExecutionResult::tool_error(format!("Invalid parameters: {error}"));
600            }
601        };
602
603        let Some(retriever) = &context.message_retriever else {
604            return ToolExecutionResult::tool_error("No message retriever available");
605        };
606
607        let messages = match retriever.load(context.session_id).await {
608            Ok(messages) => messages,
609            Err(error) => {
610                return ToolExecutionResult::internal_error(error);
611            }
612        };
613
614        if messages.is_empty() {
615            return ToolExecutionResult::success(json!({
616                "count": 0,
617                "message": "No history available."
618            }));
619        }
620
621        let limit = params.limit.min(50);
622        let total = messages.len();
623
624        if let Some(range) = params.message_range {
625            let from = range.from.min(total);
626            let to = range.to.min(total).max(from);
627            let range_messages: Vec<_> = messages[from..to].iter().take(limit).collect();
628            return format_range_result(&range_messages, from, total);
629        }
630
631        if let Some(query) = params.query.as_deref() {
632            let results = search_messages(&messages, query, limit);
633            return format_search_result(&results, total);
634        }
635
636        let recent: Vec<_> = messages.iter().rev().take(limit).collect();
637        format_recent_result(&recent, total)
638    }
639}
640
641struct SearchResult<'a> {
642    index: usize,
643    message: &'a Message,
644    score: f64,
645}
646
647fn search_messages<'a>(
648    messages: &'a [Message],
649    query: &str,
650    limit: usize,
651) -> Vec<SearchResult<'a>> {
652    let query_lower = query.to_lowercase();
653    let mut results = Vec::new();
654
655    for (index, message) in messages.iter().enumerate() {
656        let content = extract_text_content(message).to_lowercase();
657        if !content.contains(&query_lower) {
658            continue;
659        }
660
661        let mut score = 1.0;
662
663        if content.split_whitespace().any(|word| word == query_lower) {
664            score += 0.5;
665        }
666
667        if !messages.is_empty() {
668            score += (index as f64 / messages.len() as f64) * 0.3;
669        }
670
671        match message.role {
672            MessageRole::User | MessageRole::Agent => score += 0.2,
673            MessageRole::System => score += 0.1,
674            MessageRole::ToolResult => {}
675        }
676
677        results.push(SearchResult {
678            index,
679            message,
680            score,
681        });
682    }
683
684    results.sort_by(|left, right| {
685        right
686            .score
687            .partial_cmp(&left.score)
688            .unwrap_or(Ordering::Equal)
689    });
690    results.truncate(limit);
691    results
692}
693
694fn extract_text_content(message: &Message) -> String {
695    message
696        .content
697        .iter()
698        .filter_map(|part| match part {
699            ContentPart::Text(text) => Some(text.text.clone()),
700            ContentPart::ToolResult(result) => result.result.as_ref().map(ToString::to_string),
701            _ => None,
702        })
703        .collect::<Vec<_>>()
704        .join(" ")
705}
706
707fn truncate_content(content: &str, max_len: usize) -> String {
708    let char_count = content.chars().count();
709    if char_count <= max_len {
710        return content.to_string();
711    }
712
713    format!("{}...", content.chars().take(max_len).collect::<String>())
714}
715
716fn format_message(message: &Message, index: usize, total: usize) -> Value {
717    json!({
718        "index": index,
719        "position": format!("{}/{}", index + 1, total),
720        "role": message.role.to_string(),
721        "created_at": message.created_at.to_rfc3339(),
722        "content": truncate_content(&extract_text_content(message), 500)
723    })
724}
725
726fn format_range_result(
727    messages: &[&Message],
728    start_index: usize,
729    total: usize,
730) -> ToolExecutionResult {
731    if messages.is_empty() {
732        return ToolExecutionResult::success(json!({
733            "count": 0,
734            "message": "No messages in the requested range."
735        }));
736    }
737
738    let formatted: Vec<Value> = messages
739        .iter()
740        .enumerate()
741        .map(|(offset, message)| format_message(message, start_index + offset, total))
742        .collect();
743
744    ToolExecutionResult::success(json!({
745        "messages": formatted,
746        "count": messages.len(),
747        "total_in_history": total,
748        "range": format!("{}-{}", start_index + 1, start_index + messages.len())
749    }))
750}
751
752fn format_search_result(results: &[SearchResult<'_>], total: usize) -> ToolExecutionResult {
753    if results.is_empty() {
754        return ToolExecutionResult::success(json!({
755            "count": 0,
756            "message": "No matching messages found."
757        }));
758    }
759
760    let formatted: Vec<Value> = results
761        .iter()
762        .map(|result| {
763            let mut message = format_message(result.message, result.index, total);
764            message["relevance_score"] = json!(format!("{:.2}", result.score));
765            message
766        })
767        .collect();
768
769    ToolExecutionResult::success(json!({
770        "messages": formatted,
771        "count": results.len(),
772        "total_in_history": total
773    }))
774}
775
776fn format_recent_result(messages: &[&Message], total: usize) -> ToolExecutionResult {
777    let formatted: Vec<Value> = messages
778        .iter()
779        .enumerate()
780        .map(|(offset, message)| format_message(message, total - messages.len() + offset, total))
781        .collect();
782
783    ToolExecutionResult::success(json!({
784        "messages": formatted,
785        "count": messages.len(),
786        "total_in_history": total,
787        "note": "Showing most recent history. Use `query` to search or `message_range` to fetch older messages."
788    }))
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use crate::in_memory::InMemoryMessageRetriever;
795    use crate::typed_id::SessionId;
796
797    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
798
799    #[test]
800    fn test_provides_message_filter() {
801        let capability = InfinityContextCapability;
802        assert!(capability.message_filter_provider().is_some());
803    }
804
805    #[test]
806    fn filter_only_view_preserves_filter_without_model_contributions() {
807        let capability = InfinityContextFilterOnlyCapability;
808
809        assert!(capability.message_filter_provider().is_some());
810        assert!(capability.tools().is_empty());
811        assert!(capability.system_prompt_addition().is_none());
812    }
813
814    #[test]
815    fn test_config_schema_and_validate_config() {
816        let capability = InfinityContextCapability;
817
818        let schema = capability.config_schema().expect("config schema");
819        assert_eq!(schema["type"], "object");
820        assert!(schema["properties"]["context_budget_tokens"].is_object());
821        assert!(schema["properties"]["min_recent_messages"].is_object());
822        assert!(schema["properties"]["max_recent_messages"].is_object());
823        assert_eq!(
824            schema["properties"]["keep_first_messages"]["maximum"],
825            MAX_KEEP_FIRST_MESSAGES
826        );
827
828        // Null, empty, and valid configs are accepted.
829        assert!(capability.validate_config(&Value::Null).is_ok());
830        assert!(capability.validate_config(&json!({})).is_ok());
831        assert!(
832            capability
833                .validate_config(&json!({
834                    "context_budget_tokens": 50_000,
835                    "min_recent_messages": 5,
836                    "max_recent_messages": 100
837                }))
838                .is_ok()
839        );
840
841        // Wrong types and out-of-range values are rejected.
842        assert!(
843            capability
844                .validate_config(&json!({"context_budget_tokens": "lots"}))
845                .is_err()
846        );
847        assert!(
848            capability
849                .validate_config(&json!({"context_budget_tokens": 0}))
850                .is_err()
851        );
852        assert!(
853            capability
854                .validate_config(&json!({"max_recent_messages": 0}))
855                .is_err()
856        );
857        assert!(
858            capability
859                .validate_config(&json!({
860                    "keep_first_messages": MAX_KEEP_FIRST_MESSAGES + 1
861                }))
862                .is_err()
863        );
864    }
865
866    #[test]
867    fn test_localizations_resolve_uk() {
868        let capability = InfinityContextCapability;
869        assert_eq!(
870            capability.localized_name(Some("uk-UA")),
871            "Нескінченний контекст"
872        );
873        assert!(capability.describe_schema(None).is_some());
874    }
875
876    #[test]
877    fn test_filter_provider_sets_bounded_candidate_load_limit_without_hard_cap() {
878        let mut query = MessageQuery::new(SessionId::new());
879        let provider = InfinityContextFilterProvider;
880        provider.apply_filters(
881            &mut query,
882            &json!({"context_budget_tokens": 1_000, "min_recent_messages": 3}),
883        );
884
885        assert_eq!(query.limit, Some(16));
886        assert!(query.prepend_transform.is_some());
887        // The safe default omits the head anchor so a large untrusted first
888        // message cannot bypass the configured budget or recent-message cap.
889        assert_eq!(query.keep_head, None);
890    }
891
892    #[test]
893    fn test_filter_provider_sets_keep_head_from_keep_first_messages() {
894        let mut query = MessageQuery::new(SessionId::new());
895        let provider = InfinityContextFilterProvider;
896        provider.apply_filters(
897            &mut query,
898            &json!({"context_budget_tokens": 1_000, "keep_first_messages": 3}),
899        );
900        assert_eq!(query.keep_head, Some(3));
901    }
902
903    #[test]
904    fn test_filter_provider_omits_keep_head_when_zero() {
905        let mut query = MessageQuery::new(SessionId::new());
906        let provider = InfinityContextFilterProvider;
907        provider.apply_filters(
908            &mut query,
909            &json!({"context_budget_tokens": 1_000, "keep_first_messages": 0}),
910        );
911        assert_eq!(query.keep_head, None);
912    }
913
914    #[test]
915    fn test_filter_provider_caps_keep_head_for_unvalidated_config() {
916        let mut query = MessageQuery::new(SessionId::new());
917        let provider = InfinityContextFilterProvider;
918        provider.apply_filters(
919            &mut query,
920            &json!({
921                "context_budget_tokens": 1_000,
922                "keep_first_messages": usize::MAX
923            }),
924        );
925
926        assert_eq!(query.keep_head, Some(MAX_KEEP_FIRST_MESSAGES));
927    }
928
929    #[test]
930    fn test_filter_provider_caps_explicit_max_to_bounded_candidate_window() {
931        let mut query = MessageQuery::new(SessionId::new());
932        let provider = InfinityContextFilterProvider;
933        provider.apply_filters(
934            &mut query,
935            &json!({
936                "context_budget_tokens": 500_000,
937                "min_recent_messages": 10,
938                "max_recent_messages": 1_000_000
939            }),
940        );
941
942        assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
943        assert!(query.prepend_transform.is_some());
944    }
945
946    #[test]
947    fn test_filter_provider_caps_large_min_recent_messages() {
948        let mut query = MessageQuery::new(SessionId::new());
949        let provider = InfinityContextFilterProvider;
950        provider.apply_filters(
951            &mut query,
952            &json!({
953                "context_budget_tokens": 1_000,
954                "min_recent_messages": 1_000_000,
955            }),
956        );
957
958        assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
959        assert!(query.prepend_transform.is_some());
960    }
961
962    #[test]
963    fn test_filter_provider_allows_small_public_chat_window() {
964        let mut query = MessageQuery::new(SessionId::new());
965        let provider = InfinityContextFilterProvider;
966        provider.apply_filters(
967            &mut query,
968            &json!({
969                "context_budget_tokens": 10_000,
970                "min_recent_messages": 10,
971                "max_recent_messages": 30
972            }),
973        );
974
975        assert_eq!(query.limit, Some(30));
976        assert!(query.prepend_transform.is_some());
977    }
978
979    #[test]
980    fn test_filter_provider_falls_back_to_defaults_for_invalid_config() {
981        let mut query = MessageQuery::new(SessionId::new());
982        let provider = InfinityContextFilterProvider;
983        provider.apply_filters(
984            &mut query,
985            &json!({"context_budget_tokens": "not-a-number"}),
986        );
987
988        assert_eq!(query.limit, Some(1_600));
989        assert!(query.prepend_transform.is_some());
990    }
991
992    #[test]
993    fn test_filter_provider_trims_loaded_messages_by_token_budget() {
994        let provider = InfinityContextFilterProvider;
995        let mut messages = vec![
996            Message::user("the original task"),
997            Message::assistant("old ".repeat(400)),
998            Message::user("recent one"),
999            Message::assistant("recent two"),
1000        ];
1001
1002        provider.post_load(
1003            &mut messages,
1004            &json!({"context_budget_tokens": 1, "min_recent_messages": 2}),
1005        );
1006
1007        // Default configuration has no head anchor: a first user message must not
1008        // bypass the configured token budget or recent-message cap.
1009        assert_eq!(messages.len(), 3);
1010        assert!(
1011            extract_text_content(&messages[0])
1012                .contains("2 earlier messages are NOT visible in this context")
1013        );
1014        assert_eq!(extract_text_content(&messages[1]), "recent one");
1015        assert_eq!(extract_text_content(&messages[2]), "recent two");
1016        assert!(
1017            !messages
1018                .iter()
1019                .any(|m| extract_text_content(m) == "the original task")
1020        );
1021    }
1022
1023    #[test]
1024    fn test_filter_provider_applies_hard_cap_after_loading() {
1025        let provider = InfinityContextFilterProvider;
1026        let mut messages = vec![
1027            Message::user("one"),
1028            Message::assistant("two"),
1029            Message::user("three"),
1030            Message::assistant("four"),
1031            Message::user("five"),
1032        ];
1033
1034        provider.post_load(
1035            &mut messages,
1036            &json!({
1037                "context_budget_tokens": 10_000,
1038                "min_recent_messages": 10,
1039                "max_recent_messages": 2
1040            }),
1041        );
1042
1043        // Hard cap keeps only the 2 recent messages by default.
1044        assert_eq!(messages.len(), 3);
1045        assert!(
1046            extract_text_content(&messages[0])
1047                .contains("3 earlier messages are NOT visible in this context")
1048        );
1049        assert_eq!(extract_text_content(&messages[1]), "four");
1050        assert_eq!(extract_text_content(&messages[2]), "five");
1051    }
1052
1053    #[test]
1054    fn test_filter_provider_anchors_task_through_full_flow() {
1055        let provider = InfinityContextFilterProvider;
1056        // Budget large enough that the candidate load fetches every message, but
1057        // small enough that the big middle turns are dropped by token budget.
1058        let config = json!({
1059            "context_budget_tokens": 600,
1060            "min_recent_messages": 2,
1061            "keep_first_messages": 1
1062        });
1063        let mut query = MessageQuery::new(SessionId::new());
1064        provider.apply_filters(&mut query, &config);
1065        let mut messages = vec![
1066            Message::user("TASK: build the widget"),
1067            Message::assistant("X".repeat(2000)),
1068            Message::assistant("Y".repeat(2000)),
1069            Message::user("recent a"),
1070            Message::assistant("recent b"),
1071        ];
1072
1073        query.apply_windowing(&mut messages);
1074        provider.post_load(&mut messages, &config);
1075
1076        // The original task is anchored at the front, the notice follows it, and
1077        // the recent tail is intact — the model still knows what it is doing.
1078        assert_eq!(extract_text_content(&messages[0]), "TASK: build the widget");
1079        assert!(
1080            extract_text_content(&messages[1])
1081                .contains("earlier messages are NOT visible in this context")
1082        );
1083        assert_eq!(extract_text_content(messages.last().unwrap()), "recent b");
1084        // The huge first assistant turn was dropped from the middle.
1085        assert!(
1086            !messages
1087                .iter()
1088                .any(|m| extract_text_content(m).starts_with("XXX"))
1089        );
1090    }
1091
1092    #[test]
1093    fn test_filter_provider_defers_eviction_to_compaction() {
1094        let provider = InfinityContextFilterProvider;
1095        let mut messages = vec![
1096            Message::user("task"),
1097            Message::assistant("old ".repeat(400)),
1098            Message::user("recent one"),
1099            Message::assistant("recent two"),
1100        ];
1101
1102        provider.post_load(
1103            &mut messages,
1104            &json!({
1105                "context_budget_tokens": 1,
1106                "min_recent_messages": 2,
1107                "compaction_active": true
1108            }),
1109        );
1110
1111        // Compaction owns reduction: infinity context neither trims nor injects a
1112        // notice when compaction is active.
1113        assert_eq!(messages.len(), 4);
1114        assert!(
1115            messages
1116                .iter()
1117                .all(|m| !extract_text_content(m).contains("NOT visible"))
1118        );
1119    }
1120
1121    #[test]
1122    fn test_filter_provider_caps_keep_first_messages_during_post_load() {
1123        let provider = InfinityContextFilterProvider;
1124        let mut messages: Vec<Message> = (0..20)
1125            .map(|idx| Message::user(format!("message {idx}")))
1126            .collect();
1127
1128        provider.post_load(
1129            &mut messages,
1130            &json!({
1131                "context_budget_tokens": 1,
1132                "min_recent_messages": 1,
1133                "keep_first_messages": usize::MAX
1134            }),
1135        );
1136
1137        assert_eq!(extract_text_content(&messages[0]), "message 0");
1138        assert_eq!(
1139            extract_text_content(&messages[MAX_KEEP_FIRST_MESSAGES - 1]),
1140            format!("message {}", MAX_KEEP_FIRST_MESSAGES - 1)
1141        );
1142        assert!(extract_text_content(&messages[MAX_KEEP_FIRST_MESSAGES]).contains("NOT visible"));
1143        assert_eq!(extract_text_content(messages.last().unwrap()), "message 19");
1144    }
1145
1146    #[test]
1147    fn test_filter_provider_default_drops_oversized_first_message() {
1148        let provider = InfinityContextFilterProvider;
1149        let mut messages = vec![
1150            Message::user("attacker ".repeat(20_000)),
1151            Message::assistant("middle"),
1152            Message::user("recent one"),
1153            Message::assistant("recent two"),
1154        ];
1155
1156        provider.post_load(
1157            &mut messages,
1158            &json!({
1159                "context_budget_tokens": 10,
1160                "min_recent_messages": 2,
1161                "max_recent_messages": 2
1162            }),
1163        );
1164
1165        assert_eq!(messages.len(), 3);
1166        assert!(
1167            extract_text_content(&messages[0])
1168                .contains("2 earlier messages are NOT visible in this context")
1169        );
1170        assert_eq!(extract_text_content(&messages[1]), "recent one");
1171        assert_eq!(extract_text_content(&messages[2]), "recent two");
1172        assert!(
1173            !messages
1174                .iter()
1175                .any(|m| extract_text_content(m).starts_with("attacker"))
1176        );
1177    }
1178
1179    #[test]
1180    fn test_filter_provider_keep_first_messages_anchors_multiple() {
1181        let provider = InfinityContextFilterProvider;
1182        let mut messages = vec![
1183            Message::user("anchor one"),
1184            Message::user("anchor two"),
1185            Message::assistant("mid ".repeat(400)),
1186            Message::user("recent"),
1187        ];
1188
1189        provider.post_load(
1190            &mut messages,
1191            &json!({
1192                "context_budget_tokens": 1,
1193                "min_recent_messages": 1,
1194                "keep_first_messages": 2
1195            }),
1196        );
1197
1198        assert_eq!(extract_text_content(&messages[0]), "anchor one");
1199        assert_eq!(extract_text_content(&messages[1]), "anchor two");
1200        assert!(extract_text_content(&messages[2]).contains("NOT visible"));
1201        assert_eq!(extract_text_content(messages.last().unwrap()), "recent");
1202    }
1203
1204    #[test]
1205    fn test_estimate_json_value_len_matches_serialized_length() {
1206        let value = json!({
1207            "stdout": ["alpha", "beta"],
1208            "ok": true,
1209            "count": 2
1210        });
1211
1212        assert_eq!(
1213            estimate_json_value_len(&value),
1214            serde_json::to_string(&value).unwrap().len()
1215        );
1216    }
1217
1218    #[test]
1219    fn test_query_history_requires_context() {
1220        let tool = QueryHistoryTool;
1221        assert!(tool.requires_context());
1222    }
1223
1224    #[tokio::test]
1225    async fn test_query_history_tool_errors_without_retriever() {
1226        let tool = QueryHistoryTool;
1227        let result = tool
1228            .execute_with_context(json!({"query": "api"}), &ToolContext::new(SessionId::new()))
1229            .await;
1230
1231        match result {
1232            ToolExecutionResult::ToolError(message) => {
1233                assert!(message.contains("No message retriever available"));
1234            }
1235            other => panic!("expected tool error, got {other:?}"),
1236        }
1237    }
1238
1239    #[tokio::test]
1240    async fn test_query_history_tool_rejects_invalid_params() {
1241        let result = QueryHistoryTool.execute(json!({"limit": "oops"})).await;
1242
1243        match result {
1244            ToolExecutionResult::ToolError(message) => {
1245                assert!(message.contains("requires session context"));
1246            }
1247            other => panic!("expected tool error, got {other:?}"),
1248        }
1249
1250        let session_id = SessionId::new();
1251        let retriever = InMemoryMessageRetriever::new();
1252        let result = QueryHistoryTool
1253            .execute_with_context(
1254                json!({"message_range": {"from": "bad", "to": 1}}),
1255                &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1256            )
1257            .await;
1258
1259        match result {
1260            ToolExecutionResult::ToolError(message) => {
1261                assert!(message.contains("Invalid parameters"));
1262            }
1263            other => panic!("expected tool error, got {other:?}"),
1264        }
1265    }
1266
1267    #[tokio::test]
1268    async fn test_query_history_tool_empty_history() {
1269        let session_id = SessionId::new();
1270        let retriever = InMemoryMessageRetriever::new();
1271
1272        let result = QueryHistoryTool
1273            .execute_with_context(
1274                json!({}),
1275                &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1276            )
1277            .await;
1278
1279        match result {
1280            ToolExecutionResult::Success(value) => {
1281                assert_eq!(value["count"], 0);
1282                assert_eq!(value["message"], "No history available.");
1283            }
1284            other => panic!("expected success, got {other:?}"),
1285        }
1286    }
1287
1288    #[tokio::test]
1289    async fn test_query_history_tool_searches_history() {
1290        let session_id = SessionId::new();
1291        let retriever = InMemoryMessageRetriever::new();
1292        retriever
1293            .seed(
1294                session_id,
1295                vec![
1296                    Message::user("First topic"),
1297                    Message::assistant("The API key is abc123"),
1298                    Message::user("We should keep discussing logging"),
1299                ],
1300            )
1301            .await;
1302
1303        let result = QueryHistoryTool
1304            .execute_with_context(
1305                json!({"query": "api key"}),
1306                &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1307            )
1308            .await;
1309
1310        match result {
1311            ToolExecutionResult::Success(value) => {
1312                assert_eq!(value["count"], 1);
1313                assert_eq!(value["messages"][0]["content"], "The API key is abc123");
1314            }
1315            other => panic!("expected success, got {other:?}"),
1316        }
1317    }
1318
1319    #[tokio::test]
1320    async fn test_query_history_tool_search_no_match() {
1321        let session_id = SessionId::new();
1322        let retriever = InMemoryMessageRetriever::new();
1323        retriever
1324            .seed(
1325                session_id,
1326                vec![Message::user("one"), Message::assistant("two")],
1327            )
1328            .await;
1329
1330        let result = QueryHistoryTool
1331            .execute_with_context(
1332                json!({"query": "missing"}),
1333                &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1334            )
1335            .await;
1336
1337        match result {
1338            ToolExecutionResult::Success(value) => {
1339                assert_eq!(value["count"], 0);
1340                assert_eq!(value["message"], "No matching messages found.");
1341            }
1342            other => panic!("expected success, got {other:?}"),
1343        }
1344    }
1345
1346    #[tokio::test]
1347    async fn test_query_history_tool_reads_range() {
1348        let session_id = SessionId::new();
1349        let retriever = InMemoryMessageRetriever::new();
1350        retriever
1351            .seed(
1352                session_id,
1353                vec![
1354                    Message::user("one"),
1355                    Message::assistant("two"),
1356                    Message::user("three"),
1357                ],
1358            )
1359            .await;
1360
1361        let result = QueryHistoryTool
1362            .execute_with_context(
1363                json!({"message_range": {"from": 1, "to": 3}, "limit": 10}),
1364                &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1365            )
1366            .await;
1367
1368        match result {
1369            ToolExecutionResult::Success(value) => {
1370                assert_eq!(value["count"], 2);
1371                assert_eq!(value["messages"][0]["content"], "two");
1372                assert_eq!(value["messages"][1]["content"], "three");
1373            }
1374            other => panic!("expected success, got {other:?}"),
1375        }
1376    }
1377
1378    #[tokio::test]
1379    async fn test_query_history_tool_clamps_out_of_bounds_range() {
1380        let session_id = SessionId::new();
1381        let retriever = InMemoryMessageRetriever::new();
1382        retriever
1383            .seed(
1384                session_id,
1385                vec![
1386                    Message::user("one"),
1387                    Message::assistant("two"),
1388                    Message::user("three"),
1389                ],
1390            )
1391            .await;
1392
1393        let result = QueryHistoryTool
1394            .execute_with_context(
1395                json!({"message_range": {"from": 99, "to": 100}}),
1396                &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1397            )
1398            .await;
1399
1400        match result {
1401            ToolExecutionResult::Success(value) => {
1402                assert_eq!(value["count"], 0);
1403                assert_eq!(value["message"], "No messages in the requested range.");
1404            }
1405            other => panic!("expected success, got {other:?}"),
1406        }
1407    }
1408
1409    #[test]
1410    fn test_truncate_content_is_utf8_safe() {
1411        let truncated = truncate_content("hello🙂world", 6);
1412        assert_eq!(truncated, "hello🙂...");
1413    }
1414
1415    #[test]
1416    fn trim_preserves_locally_unmatched_tool_result_for_stateful_responses() {
1417        use crate::tool_types::ToolCall;
1418
1419        let provider = InfinityContextFilterProvider;
1420        // min_recent_messages=3 keeps the last 3 messages. With a 1-token budget the
1421        // two older messages are dropped, including the assistant tool call. The
1422        // OpenAI Responses path may still have that call in previous_response_id
1423        // state, so InfinityContext must not drop the tool output before provider
1424        // serialization decides whether stateful continuation is active.
1425        let mut messages = vec![
1426            Message::user("old question"),
1427            Message::assistant_with_tools(
1428                "calling tool",
1429                vec![ToolCall {
1430                    id: "call_old".to_string(),
1431                    name: "edit_file".to_string(),
1432                    arguments: serde_json::json!({}),
1433                }],
1434            ),
1435            // This tool result is in the min-recent window but its call is trimmed away.
1436            Message::tool_result("call_old", Some(serde_json::json!("done")), None),
1437            Message::user("new question"),
1438            Message::assistant("answer"),
1439        ];
1440
1441        provider.post_load(
1442            &mut messages,
1443            &serde_json::json!({"context_budget_tokens": 1, "min_recent_messages": 3}),
1444        );
1445
1446        assert!(
1447            messages.iter().any(|m| m.role == MessageRole::ToolResult),
1448            "locally unmatched tool result must be preserved until provider serialization"
1449        );
1450
1451        let llm_messages = messages
1452            .iter()
1453            .map(crate::llm_conversions::llm_message_from_message)
1454            .collect();
1455        let stateless_view =
1456            crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1457                llm_messages,
1458                false,
1459            );
1460        assert!(
1461            stateless_view
1462                .iter()
1463                .all(|message| message.tool_call_id.is_none()),
1464            "a stateless runtime view must drop a result whose call was trimmed"
1465        );
1466    }
1467
1468    #[test]
1469    fn trim_removes_a_visible_call_when_its_result_is_evicted() {
1470        use crate::tool_types::ToolCall;
1471
1472        let provider = InfinityContextFilterProvider;
1473        let mut messages = vec![
1474            Message::user("original task"),
1475            Message::assistant_with_tools(
1476                "calling tool",
1477                vec![ToolCall {
1478                    id: "call_old".to_string(),
1479                    name: "bash".to_string(),
1480                    arguments: serde_json::json!({}),
1481                }],
1482            ),
1483            Message::tool_result(
1484                "call_old",
1485                Some(serde_json::json!("large result ".repeat(500))),
1486                None,
1487            ),
1488            Message::user("recent question"),
1489            Message::assistant("recent answer"),
1490        ];
1491
1492        provider.post_load(
1493            &mut messages,
1494            &serde_json::json!({
1495                "context_budget_tokens": 1,
1496                "min_recent_messages": 2,
1497                "keep_first_messages": 2
1498            }),
1499        );
1500
1501        assert!(
1502            messages
1503                .iter()
1504                .flat_map(Message::tool_calls)
1505                .next()
1506                .is_none(),
1507            "the anchored assistant message must not retain a call after its result is hidden"
1508        );
1509    }
1510
1511    #[test]
1512    fn trim_keeps_tool_result_when_tool_call_is_visible() {
1513        use crate::tool_types::ToolCall;
1514
1515        let provider = InfinityContextFilterProvider;
1516        // All 3 messages fit in the window: no orphan expected.
1517        let mut messages = vec![
1518            Message::assistant_with_tools(
1519                "calling tool",
1520                vec![ToolCall {
1521                    id: "call_1".to_string(),
1522                    name: "read_file".to_string(),
1523                    arguments: serde_json::json!({}),
1524                }],
1525            ),
1526            Message::tool_result("call_1", Some(serde_json::json!("content")), None),
1527            Message::user("thanks"),
1528        ];
1529
1530        provider.post_load(
1531            &mut messages,
1532            &serde_json::json!({"context_budget_tokens": 100_000, "min_recent_messages": 10}),
1533        );
1534
1535        assert!(
1536            messages.iter().any(|m| m.role == MessageRole::ToolResult),
1537            "tool result must be kept when its tool call is visible"
1538        );
1539    }
1540}