Skip to main content

agent_framework_core/
compaction.rs

1//! Conversation-history compaction.
2//!
3//! Rust equivalent of (a self-contained subset of) upstream `_compaction.py`
4//! (see `UPSTREAM_DRIFT.md` §9). Upstream's `_compaction.py` is a large
5//! (1500+ line), annotation-driven system: it groups messages into logical
6//! spans (system / user / assistant-text / tool-call), stamps grouping and
7//! token-count metadata onto `Message.additional_properties`, and never
8//! deletes history — it flags messages `_excluded` and lets the client filter
9//! them out when building the payload sent to the model. It ships seven
10//! strategies (`Truncation`, `SlidingWindow`, `SelectiveToolCall`,
11//! `ToolResult`, LLM-backed `Summarization`, `TokenBudgetComposed`,
12//! `ContextWindow`) plus a `CompactionProvider(ContextProvider)` that wires a
13//! strategy into the client's `get_response` loop.
14//!
15//! This module intentionally delivers a smaller, dependency-free surface:
16//! the [`Tokenizer`] and [`CompactionStrategy`] abstractions upstream
17//! defines, plus four concrete, non-LLM strategies that mirror upstream's
18//! `Truncation`, `SlidingWindow`, `ContextWindow`/`TokenBudget`, and
19//! `ToolResult` (renamed [`SelectiveToolResult`] here to avoid confusion with
20//! `Content::FunctionResult`... "tool result" is the plain-English name).
21//! Compaction here works by *returning a reduced list* rather than annotating
22//! messages in place — simpler, and sufficient for the strategies included.
23//! Wiring a strategy into the client's `get_response` loop (upstream's
24//! `CompactionProvider`) is intentionally out of scope for this change; see
25//! `UPSTREAM_DRIFT.md` §9.
26//!
27//! Compaction never errors on content: given any message list it returns a
28//! (possibly unchanged) retained subset that satisfies the strategy's
29//! constraint.
30
31use std::sync::Arc;
32
33use async_trait::async_trait;
34
35use crate::error::Result;
36use crate::memory::{ContextProvider, SessionContext};
37use crate::types::{Content, Message, Role};
38
39/// Counts tokens for a piece of text. Rust equivalent of upstream
40/// `TokenizerProtocol`.
41pub trait Tokenizer: Send + Sync {
42    /// Count the tokens represented by `text`.
43    fn count_tokens(&self, text: &str) -> usize;
44}
45
46/// A dependency-free default tokenizer using a ~4-characters-per-token
47/// heuristic. Mirrors upstream's `CharacterEstimatorTokenizer`.
48#[derive(Debug, Clone, Copy, Default)]
49pub struct ApproxTokenizer;
50
51impl Tokenizer for ApproxTokenizer {
52    fn count_tokens(&self, text: &str) -> usize {
53        text.chars().count().div_ceil(4)
54    }
55}
56
57/// Sum the token counts of a message's text content (text and reasoning
58/// content items) using `tokenizer`.
59pub fn count_message_tokens(tokenizer: &dyn Tokenizer, message: &Message) -> usize {
60    message
61        .contents
62        .iter()
63        .map(|content| count_content_tokens(tokenizer, content))
64        .sum()
65}
66
67/// Token cost of a single content item.
68///
69/// Tool payloads count. They were previously free, because the estimate went
70/// through `Content::as_text`, which exposes only text and reasoning — so a
71/// completed exchange carrying a megabyte of JSON added nothing to the total
72/// and any number of them survived [`TokenBudget`], which exists precisely to
73/// keep the request inside the context window. Tool arguments and results are
74/// literal text in the request and are counted as such.
75///
76/// Media is still not counted: an image's cost is provider-specific and is not
77/// its base64 length, so charging the data URI would be worse than charging
78/// nothing.
79fn count_content_tokens(tokenizer: &dyn Tokenizer, content: &Content) -> usize {
80    fn value_text(value: &serde_json::Value) -> String {
81        match value {
82            serde_json::Value::String(s) => s.clone(),
83            other => other.to_string(),
84        }
85    }
86
87    match content {
88        Content::Text(t) => tokenizer.count_tokens(&t.text),
89        Content::TextReasoning(t) => tokenizer.count_tokens(&t.text),
90        Content::FunctionCall(fc) => {
91            let arguments = match &fc.arguments {
92                Some(crate::types::FunctionArguments::Raw(raw)) => raw.clone(),
93                Some(crate::types::FunctionArguments::Object(map)) => {
94                    serde_json::to_string(map).unwrap_or_default()
95                }
96                None => String::new(),
97            };
98            tokenizer.count_tokens(&fc.name) + tokenizer.count_tokens(&arguments)
99        }
100        Content::FunctionResult(fr) => {
101            let mut total = fr
102                .result
103                .as_ref()
104                .map_or(0, |v| tokenizer.count_tokens(&value_text(v)));
105            if let Some(exception) = &fr.exception {
106                total += tokenizer.count_tokens(exception);
107            }
108            total
109        }
110        _ => 0,
111    }
112}
113
114/// A strategy that reduces a message list to fit some constraint.
115///
116/// Compaction never errors on content — it always returns *some* retained
117/// subset of `messages`, in original order. Rust equivalent of upstream
118/// `CompactionStrategy`.
119pub trait CompactionStrategy: Send + Sync {
120    /// Return the retained messages (in original order) after compaction.
121    fn compact(&self, messages: &[Message], tokenizer: &dyn Tokenizer) -> Vec<Message>;
122}
123
124/// Returns the number of leading messages with `Role::system()`.
125fn leading_system_count(messages: &[Message]) -> usize {
126    messages
127        .iter()
128        .take_while(|m| m.role == Role::system())
129        .count()
130}
131
132/// Keep the most recent `max_messages`, always preserving any leading system
133/// message(s) at the front. Mirrors upstream's `Truncation` strategy.
134#[derive(Debug, Clone, Copy)]
135pub struct Truncation {
136    pub max_messages: usize,
137}
138
139impl Truncation {
140    pub fn new(max_messages: usize) -> Self {
141        Self { max_messages }
142    }
143}
144
145impl CompactionStrategy for Truncation {
146    fn compact(&self, messages: &[Message], _tokenizer: &dyn Tokenizer) -> Vec<Message> {
147        if messages.len() <= self.max_messages {
148            return messages.to_vec();
149        }
150        let sys_count = leading_system_count(messages);
151        let mut out: Vec<Message> = messages[..sys_count].to_vec();
152
153        if sys_count >= self.max_messages {
154            // The system prefix alone already fills (or exceeds) the budget;
155            // keep just the system prefix, truncated to the budget.
156            out.truncate(self.max_messages);
157            return out;
158        }
159
160        let remaining_budget = self.max_messages - sys_count;
161        let rest = &messages[sys_count..];
162        let start = rest.len().saturating_sub(remaining_budget);
163        out.extend_from_slice(&rest[start..]);
164        out
165    }
166}
167
168/// Keep leading system message(s) + the last `window` non-system messages.
169/// Mirrors upstream's `SlidingWindow` strategy.
170#[derive(Debug, Clone, Copy)]
171pub struct SlidingWindow {
172    pub window: usize,
173}
174
175impl SlidingWindow {
176    pub fn new(window: usize) -> Self {
177        Self { window }
178    }
179}
180
181impl CompactionStrategy for SlidingWindow {
182    fn compact(&self, messages: &[Message], _tokenizer: &dyn Tokenizer) -> Vec<Message> {
183        let sys_count = leading_system_count(messages);
184        let mut out: Vec<Message> = messages[..sys_count].to_vec();
185        let rest = &messages[sys_count..];
186        let start = rest.len().saturating_sub(self.window);
187        out.extend_from_slice(&rest[start..]);
188        out
189    }
190}
191
192/// Keep leading system message(s), then walk from the newest message
193/// backward accumulating token counts, keeping messages until adding the
194/// next would exceed `max_tokens`. Returns the kept messages in original
195/// order. Mirrors upstream's `ContextWindow`/token-budget strategy.
196#[derive(Debug, Clone, Copy)]
197pub struct TokenBudget {
198    pub max_tokens: usize,
199}
200
201impl TokenBudget {
202    pub fn new(max_tokens: usize) -> Self {
203        Self { max_tokens }
204    }
205}
206
207impl CompactionStrategy for TokenBudget {
208    fn compact(&self, messages: &[Message], tokenizer: &dyn Tokenizer) -> Vec<Message> {
209        let sys_count = leading_system_count(messages);
210        let system_prefix = &messages[..sys_count];
211        let rest = &messages[sys_count..];
212
213        let mut used: usize = system_prefix
214            .iter()
215            .map(|m| count_message_tokens(tokenizer, m))
216            .sum();
217
218        // Walk from newest to oldest over the non-system tail, keeping
219        // messages until adding the next would exceed the budget. The
220        // newest non-system message is always kept, even if it alone (plus
221        // the system prefix) exceeds the budget — compaction never reduces
222        // a non-empty tail to nothing.
223        let mut kept_rest: Vec<&Message> = Vec::new();
224        for message in rest.iter().rev() {
225            let cost = count_message_tokens(tokenizer, message);
226            if !kept_rest.is_empty() && used + cost > self.max_tokens {
227                break;
228            }
229            used += cost;
230            kept_rest.push(message);
231        }
232        kept_rest.reverse();
233
234        let mut out: Vec<Message> = system_prefix.to_vec();
235        out.extend(kept_rest.into_iter().cloned());
236        out
237    }
238}
239
240/// Whether a message carries any `Content::FunctionResult` (tool-result)
241/// content.
242fn has_tool_result(message: &Message) -> bool {
243    message
244        .contents
245        .iter()
246        .any(|c| matches!(c, Content::FunctionResult(_)))
247}
248
249/// Replace the payload of `Content::FunctionResult` (tool-result) content in
250/// all but the last `keep_last` messages that carry tool results — they are the
251/// bulkiest and least useful once stale. Text and other content is left intact.
252///
253/// The result content itself is **kept**, with its payload swapped for
254/// [`OMITTED_TOOL_RESULT`] — on `result` and, for a failed call, on
255/// `exception` too, since provider converters render `exception` *instead of*
256/// `result` and leaving it would send the original error verbatim — rather
257/// than deleted. Deleting it would leave the
258/// matching assistant `tool_calls` entry unanswered, which providers reject
259/// outright — so the size win would come at the cost of a 400 on the next
260/// request. Mirrors the intent of upstream's `ToolResultCompactionStrategy`,
261/// which likewise *replaces* stale tool groups with a compact stand-in instead
262/// of removing them; upstream summarizes the group with an LLM, while this
263/// port (which has no summarizing strategy) substitutes a fixed marker.
264#[derive(Debug, Clone, Copy)]
265pub struct SelectiveToolResult {
266    pub keep_last: usize,
267}
268
269impl SelectiveToolResult {
270    pub fn new(keep_last: usize) -> Self {
271        Self { keep_last }
272    }
273}
274
275/// Stand-in payload left in place of a tool result this strategy compacts.
276///
277/// The result *content* is kept (only its payload is replaced) so the exchange
278/// stays paired with its function call — see [`SelectiveToolResult`].
279pub const OMITTED_TOOL_RESULT: &str = "[tool result omitted by compaction]";
280
281impl CompactionStrategy for SelectiveToolResult {
282    fn compact(&self, messages: &[Message], _tokenizer: &dyn Tokenizer) -> Vec<Message> {
283        let tool_result_count = messages.iter().filter(|m| has_tool_result(m)).count();
284        let mut strip_budget = tool_result_count.saturating_sub(self.keep_last);
285
286        let mut out = Vec::with_capacity(messages.len());
287        for message in messages {
288            if has_tool_result(message) && strip_budget > 0 {
289                strip_budget -= 1;
290                let mut compacted = message.clone();
291                for content in &mut compacted.contents {
292                    if let Content::FunctionResult(fr) = content {
293                        fr.result =
294                            Some(serde_json::Value::String(OMITTED_TOOL_RESULT.to_string()));
295                        // A failed call carries its payload — often a stack
296                        // trace, the bulkiest thing here — in `exception`, and
297                        // every provider converter renders `exception` *instead
298                        // of* `result`. Replacing only `result` would leave the
299                        // original error sent verbatim and the marker ignored,
300                        // making this a no-op for exactly the results most worth
301                        // compacting. Replaced rather than cleared, so the model
302                        // still sees that the call failed.
303                        if fr.exception.is_some() {
304                            fr.exception = Some(OMITTED_TOOL_RESULT.to_string());
305                        }
306                    }
307                }
308                out.push(compacted);
309            } else {
310                out.push(message.clone());
311            }
312        }
313        out
314    }
315}
316
317/// Strip function-call / function-result contents that lost their counterpart,
318/// dropping any message left empty by the strip.
319///
320/// Compaction cuts a message list at an arbitrary point, so a strategy can
321/// easily retain one half of a tool exchange: [`TokenBudget`] drops an
322/// expensive call-bearing message while keeping its cheap result, and
323/// [`SelectiveToolResult`] strips old results while their calls stay put.
324/// Either half alone is not merely wasteful — it is *invalid*. Providers
325/// require every assistant `tool_calls` entry to be answered by a matching
326/// tool message and reject a tool message that answers nothing, so an orphan
327/// turns the next request into a 400 rather than a slightly worse completion.
328///
329/// Mirrors the invariant upstream added in "Keep call and result occurrences
330/// atomic in compaction" (#7406). Upstream enforces it by linking call and
331/// result into one indivisible span before any strategy runs; this port has no
332/// span/group model, so it enforces the same invariant as a repair pass over
333/// the retained set — the observable guarantee (never emit a half-exchange) is
334/// identical.
335///
336/// Note this *drops* an unmatched call rather than replacing it with a summary
337/// the way upstream's `ToolResultCompactionStrategy` does; this port has no
338/// LLM-summarizing compaction strategy to build that summary with.
339fn drop_orphaned_tool_exchanges(
340    messages: &mut Vec<Message>,
341    origins: &mut Vec<Option<usize>>,
342    pending: &[Message],
343) {
344    use std::collections::HashSet;
345
346    // Pair by *occurrence*, not by id membership. Call ids are not guaranteed
347    // unique across a conversation — providers may reuse one for a later
348    // invocation, and some surfaces derive the id from the tool name — so
349    // comparing sets of ids would call `[call(c1), result(c1), call(c1)]`
350    // balanced and leave the second call unanswered. Each result is instead
351    // matched to the oldest still-unanswered call sharing its id; whatever is
352    // left unmatched on either side is an orphan.
353    let paired: HashSet<Site> = {
354        // `pending` is walked as a continuation of `messages` (its indices start
355        // past the end) so a call here can pair with a result that only arrives
356        // later, but it is never itself modified.
357        let (pairs, _) = pair_tool_exchanges(messages.iter().chain(pending.iter()).enumerate());
358        pairs
359            .into_iter()
360            .flat_map(|(call_site, result_site)| [call_site, result_site])
361            .collect()
362    };
363
364    // Fast path: nothing is orphaned, so the (common) balanced conversation is
365    // returned without rebuilding it.
366    let has_orphan = messages.iter().enumerate().any(|(mi, message)| {
367        message.contents.iter().enumerate().any(|(ci, content)| {
368            matches!(
369                content,
370                Content::FunctionCall(_) | Content::FunctionResult(_)
371            ) && !paired.contains(&(mi, ci))
372        })
373    });
374    if !has_orphan {
375        return;
376    }
377
378    // Rebuilt in place, with `origins` kept index-aligned: a dropped message
379    // must drop its recovered position too, or every later ordering decision
380    // reads the wrong one.
381    let mut mi = 0;
382    let mut kept = 0;
383    messages.retain_mut(|message| {
384        let this = mi;
385        mi += 1;
386        let had_contents = !message.contents.is_empty();
387        let mut ci = 0;
388        message.contents.retain(|content| {
389            let keep = !matches!(
390                content,
391                Content::FunctionCall(_) | Content::FunctionResult(_)
392            ) || paired.contains(&(this, ci));
393            ci += 1;
394            keep
395        });
396        // A message that carried only an orphaned half is dropped outright; one
397        // that was already empty is left alone (not this pass's business).
398        let keep_message = !(had_contents && message.contents.is_empty());
399        if keep_message {
400            origins.swap(kept, this);
401            kept += 1;
402        }
403        keep_message
404    });
405    origins.truncate(kept);
406}
407
408/// Ensure compaction never reduces a conversation to system messages alone.
409///
410/// A budget smaller than the system prefix leaves [`Truncation`] and
411/// [`SlidingWindow`] returning only system messages — a "conversation" with no
412/// turn for the model to answer, which is useless rather than merely short.
413/// Mirrors upstream's `_minimum_retained_group_ids` (#7219): the most recent
414/// non-system message is retained even when that pushes the result back over
415/// the limit.
416///
417/// The fallback is stripped of function-call/result contents before being
418/// reinstated, since its counterpart is by definition not in the retained set —
419/// so this can never manufacture the orphan
420/// [`drop_orphaned_tool_exchanges`] exists to remove.
421fn ensure_non_system_message(
422    original: &[Message],
423    mut retained: Vec<Message>,
424    origins: &[Option<usize>],
425) -> Vec<Message> {
426    // A non-system message that renders nothing does not satisfy this: the
427    // orphan repair can strip a message down to reasoning alone, which reaches
428    // the model as nothing and leaves the request effectively system-only.
429    if retained
430        .iter()
431        .any(|m| m.role != Role::system() && message_renders_on_every_provider(m))
432    {
433        return retained;
434    }
435    // Prefer ordinary content: the latest non-system message with something in
436    // it besides a tool exchange. Its call/result contents are stripped, since
437    // their counterparts are by definition not in the retained set — so this
438    // can never manufacture the orphan `drop_orphaned_tool_exchanges` removes.
439    let plain = original
440        .iter()
441        .enumerate()
442        .rev()
443        // A tool-role message is never standalone content: its text belongs to
444        // the exchange. Stripping its result and keeping the text yields a tool
445        // message with no `tool_call_id`, which the OpenAI converter drops
446        // entirely (leaving the conversation system-only after all) and which
447        // Gemini emits as a bare text part under its `function` role. The
448        // complete-exchange fallback below handles these properly.
449        .filter(|(_, m)| m.role != Role::system() && m.role != Role::tool())
450        .find_map(|(index, m)| {
451            let mut candidate = m.clone();
452            // Reasoning is dropped alongside the exchange halves, not kept as
453            // standalone content. It renders nothing on its own — Gemini's
454            // request builder deliberately skips reasoning parts, and the
455            // OpenAI converter has no mapping for it — so a message reduced to
456            // reasoning alone would qualify here, return early, and leave the
457            // request effectively system-only while bypassing the
458            // complete-exchange fallback below.
459            let role = candidate.role.clone();
460            candidate.contents.retain(|c| {
461                renders_on_every_provider(c)
462                    && !matches!(c, Content::FunctionCall(_) | Content::FunctionResult(_))
463                    // Same role qualifier as message_renders_on_every_provider:
464                    // an assistant image renders nowhere, so it must not make
465                    // this candidate look substantive either.
466                    && !(matches!(c, Content::Data(_)) && role == Role::assistant())
467            });
468            (!candidate.contents.is_empty()).then_some((index, candidate))
469        });
470    if let Some((index, plain)) = plain {
471        let position = chronological_position(origins, index);
472        retained.insert(position, plain);
473        return retained;
474    }
475    // Nothing but a tool exchange, then — a conversation whose only non-system
476    // content is a call and its result. Stripping both halves (as the branch
477    // above does) would leave the result system-only after all, so retain the
478    // latest *complete* exchange instead: both halves together are valid, and
479    // they give the model something to answer.
480    if let Some((origin_index, exchange)) = latest_complete_tool_exchange(original) {
481        let position = chronological_position(origins, origin_index);
482        for (offset, message) in exchange.into_iter().enumerate() {
483            retained.insert(position + offset, message);
484        }
485    }
486    retained
487}
488
489/// A `(message index, content index)` position within a message list.
490type Site = (usize, usize);
491
492/// A call site paired with the result site that answers it.
493type ExchangePair = (Site, Site);
494
495/// A call site left unanswered, tagged with its call id.
496type UnansweredCall<'a> = (Site, &'a str);
497
498/// Pair each function result with the **nearest preceding** unanswered call
499/// sharing its id, walking `messages` in order.
500///
501/// Returns the paired couples (in result order) and the call sites left
502/// unanswered, tagged with their id. Every pairing decision in this module
503/// comes from here. There were previously three separate walks of this shape;
504/// two took the nearest preceding call and one took the oldest, and they
505/// disagreed about which occurrence a result answers when an id is reused.
506fn pair_tool_exchanges<'a>(
507    messages: impl Iterator<Item = (usize, &'a Message)>,
508) -> (Vec<ExchangePair>, Vec<UnansweredCall<'a>>) {
509    use std::collections::{HashMap, VecDeque};
510
511    let mut unanswered: HashMap<&str, VecDeque<Site>> = HashMap::new();
512    let mut pairs: Vec<ExchangePair> = Vec::new();
513    for (mi, message) in messages {
514        for (ci, content) in message.contents.iter().enumerate() {
515            match content {
516                Content::FunctionCall(fc) if !fc.call_id.is_empty() => {
517                    unanswered
518                        .entry(fc.call_id.as_str())
519                        .or_default()
520                        .push_back((mi, ci));
521                }
522                Content::FunctionResult(fr) if !fr.call_id.is_empty() => {
523                    if let Some(call_site) = unanswered
524                        .get_mut(fr.call_id.as_str())
525                        .and_then(VecDeque::pop_back)
526                    {
527                        pairs.push((call_site, (mi, ci)));
528                    }
529                }
530                _ => {}
531            }
532        }
533    }
534    let mut leftover: Vec<UnansweredCall<'_>> = unanswered
535        .into_iter()
536        .flat_map(|(id, sites)| sites.into_iter().map(move |site| (site, id)))
537        .collect();
538    leftover.sort_unstable_by_key(|(site, _)| *site);
539    (pairs, leftover)
540}
541
542/// Recover each retained message's position in `original`.
543///
544/// Called **before** anything mutates the retained values. The orphan repair
545/// strips contents from a message, after which it no longer equals its
546/// original, so recovering positions afterwards silently fails to match and
547/// every downstream ordering decision falls back to appending.
548///
549/// Matching runs from the end backwards. Equal messages recur — two identical
550/// reasoning steps around an older user turn, say — and a forward greedy match
551/// aliases a retained *later* occurrence to the earlier duplicate. Matching from
552/// the end assigns the latest position consistent with order, which is the one a
553/// suffix-retaining strategy actually kept. A strategy that rewrites messages
554/// rather than selecting them matches nothing, and those entries stay `None`.
555fn origin_indices(retained: &[Message], original: &[Message]) -> Vec<Option<usize>> {
556    let mut assigned: Vec<Option<usize>> = vec![None; retained.len()];
557    let mut limit = original.len();
558    for index in (0..retained.len()).rev() {
559        match original[..limit]
560            .iter()
561            .rposition(|candidate| *candidate == retained[index])
562        {
563            Some(found) => {
564                assigned[index] = Some(found);
565                limit = found;
566            }
567            None => break,
568        }
569    }
570    assigned
571}
572
573/// The index at which content originating at `origin_index` belongs
574/// chronologically, given the retained set's recovered `origins`.
575///
576/// Appending unconditionally reverses the conversation when the retained set
577/// holds a *newer* message that failed the rendering check: with
578/// `[user("old"), assistant(reasoning)]` the reasoning is kept and the old user
579/// turn lands after it, so a provider that does render reasoning (Anthropic)
580/// sees a stale request as the latest turn and can answer it a second time.
581///
582/// Both fallback paths go through this; they previously did not.
583fn chronological_position(origins: &[Option<usize>], origin_index: usize) -> usize {
584    origins
585        .iter()
586        .position(|slot| matches!(slot, Some(index) if *index > origin_index))
587        .unwrap_or(origins.len())
588}
589
590/// The most recent complete call/result pair, as one or two messages reduced to
591/// just that pair's contents (one when both halves share a message).
592///
593/// Returns empty when no result has a preceding unanswered call — there is no
594/// complete exchange to reinstate, and a half is worse than nothing.
595fn latest_complete_tool_exchange(messages: &[Message]) -> Option<(usize, Vec<Message>)> {
596    let (pairs, _) = pair_tool_exchanges(
597        messages
598            .iter()
599            .enumerate()
600            .filter(|(_, m)| m.role != Role::system()),
601    );
602    let ((call_mi, call_ci), (result_mi, result_ci)) = pairs.last().copied()?;
603    // A result always pairs with a call that came before it, so emitting the
604    // call's message first preserves the original order.
605    let messages = if call_mi == result_mi {
606        vec![reduced_call_with_signature(
607            &messages[call_mi],
608            call_ci,
609            &[result_ci],
610        )]
611    } else {
612        vec![
613            reduced_call_with_signature(&messages[call_mi], call_ci, &[]),
614            reduced_to(&messages[result_mi], &[result_ci]),
615        ]
616    };
617    Some((call_mi, messages))
618}
619
620/// See [`Content::renders_on_every_provider`] — the contract lives on
621/// `Content`, next to the type the converters consume, and is pinned by
622/// contract tests in the provider crates. This module used to define it
623/// locally and mis-derived it three times (variant-level, then
624/// media-type-level, then trusting a declared type over the payload); it now
625/// only asks.
626fn renders_on_every_provider(content: &Content) -> bool {
627    content.renders_on_every_provider()
628}
629
630/// Whether `message` carries anything that reaches the model on every
631/// provider, **in this message's role**.
632///
633/// The role qualifier exists for images: providers accept image blocks only in
634/// user turns (Bedrock's Converse and Anthropic both reject an assistant image
635/// block outright, and the Bedrock converter accordingly skips them), so an
636/// assistant message whose only universal content is image data renders
637/// nowhere and must not satisfy retention.
638fn message_renders_on_every_provider(message: &Message) -> bool {
639    message.contents.iter().any(|content| {
640        content.renders_on_every_provider()
641            && !(matches!(content, Content::Data(_)) && message.role == Role::assistant())
642    })
643}
644
645/// Clone `message` keeping the function call at `call_ci` (plus any
646/// `also_keep` indices), carrying the call's replay signature across.
647///
648/// Reducing to the call alone drops a Gemini signature sitting on the preceding
649/// reasoning content — the supported fallback placement — and that reasoning is
650/// not retained either, so the request builder would have nothing to backfill
651/// from. Shared by both paths that resurrect a call, which otherwise learn this
652/// separately (and one of them hadn't).
653fn reduced_call_with_signature(message: &Message, call_ci: usize, also_keep: &[usize]) -> Message {
654    let mut keep = vec![call_ci];
655    keep.extend_from_slice(also_keep);
656    keep.sort_unstable();
657    let mut out = reduced_to(message, &keep);
658    if let Some(Content::FunctionCall(fc)) = out
659        .contents
660        .iter_mut()
661        .find(|c| matches!(c, Content::FunctionCall(_)))
662    {
663        if fc.protected_data.is_none() {
664            fc.protected_data = preceding_reasoning_signature(message, call_ci);
665        }
666    }
667    out
668}
669
670/// Clone `message` keeping only the contents at `keep` (content indices).
671fn reduced_to(message: &Message, keep: &[usize]) -> Message {
672    let mut out = message.clone();
673    let mut index = 0;
674    out.contents.retain(|_| {
675        let keep_this = keep.contains(&index);
676        index += 1;
677        keep_this
678    });
679    out
680}
681
682/// Put back a call that the *strategy* dropped but that an incoming result
683/// answers.
684///
685/// Pairing against `pending` stops the repair from stripping a **retained**
686/// call, but the strategy runs first and may have excluded the call from
687/// `retained` altogether — `SlidingWindow::new(0)` over a history holding
688/// `call(c1)` while the run's input holds `result(c1)`, for instance. Nothing
689/// downstream can fix that: the call is gone from the retained set, and
690/// `pending` is the caller's input, which is not ours to edit. The request
691/// would go out with a result answering nothing.
692///
693/// So the call is reinstated from `original` — reduced to just that content, so
694/// no unrelated payload rides back in with it — and appended after the retained
695/// messages, which keeps it ahead of the `pending` result that follows.
696fn reinstate_calls_answered_by_pending(
697    original: &[Message],
698    retained: &mut Vec<Message>,
699    origins: &mut Vec<Option<usize>>,
700    pending: &[Message],
701) {
702    // Only results that `pending` does not answer *itself*. A run's input can
703    // carry a complete exchange of its own, and it may reuse an id that an
704    // older unanswered call also used. Counting every result would reinstate
705    // that stale historical call, and the FIFO repair would then pair the
706    // pending result with it and leave the pending call unanswered — an
707    // invalid exchange assembled out of two valid halves.
708    let mut wanted = unanswered_result_counts(pending);
709    if wanted.is_empty() {
710        return;
711    }
712    // Which of the original's unanswered calls does `retained` already supply?
713    // Compared by *content*, not counted by id: a strategy may retain an older
714    // unanswered occurrence while dropping the newer one an incoming result
715    // answers, and counting by id would treat the old one as sufficient —
716    // reinstating nothing, and letting the nearest-preceding pairing attach the
717    // result to the wrong call's name and arguments.
718    let (_, retained_unanswered) = pair_tool_exchanges(retained.iter().enumerate());
719    let mut supplied: Vec<&crate::types::FunctionCallContent> = retained_unanswered
720        .iter()
721        .filter_map(|((mi, ci), _)| retained[*mi].contents[*ci].as_function_call())
722        .collect();
723
724    // Candidates newest-first: the most recent unanswered occurrence is the one
725    // an incoming result answers.
726    let (_, original_unanswered) = pair_tool_exchanges(original.iter().enumerate());
727    let mut chosen: Vec<Site> = Vec::new();
728    for (site, call_id) in original_unanswered.into_iter().rev() {
729        let Some(call) = original[site.0].contents[site.1].as_function_call() else {
730            continue;
731        };
732        if let Some(position) = supplied.iter().position(|supplied| *supplied == call) {
733            // Already present in the retained set; consume it so a second
734            // identical occurrence is still eligible.
735            supplied.remove(position);
736            continue;
737        }
738        let Some(count) = wanted.get_mut(call_id) else {
739            continue;
740        };
741        if *count == 0 {
742            continue;
743        }
744        *count -= 1;
745        chosen.push(site);
746    }
747    // Append in original order so several recovered calls keep their sequence,
748    // and group by source message: parallel calls declared together in one
749    // assistant turn must be reinstated as *one* message. Splitting them into
750    // `assistant(c1), assistant(c2)` puts an assistant turn between the
751    // declaration and the results, which provider tool protocols reject — the
752    // results must answer the single turn that declared both.
753    chosen.sort_unstable();
754    let mut index = 0;
755    while index < chosen.len() {
756        let (mi, _) = chosen[index];
757        let mut group = Vec::new();
758        while index < chosen.len() && chosen[index].0 == mi {
759            group.push(chosen[index].1);
760            index += 1;
761        }
762        let (first, rest) = group.split_first().expect("a group has at least one call");
763        retained.push(reduced_call_with_signature(&original[mi], *first, rest));
764        origins.push(Some(mi));
765    }
766}
767
768/// The replay signature of the reasoning content immediately preceding the
769/// content at `index`, if any.
770///
771/// Only a *contiguous* run of reasoning content counts, matching the request
772/// builder's rule that a backfilled signature applies solely to a call directly
773/// following its reasoning — any other content in between clears it.
774fn preceding_reasoning_signature(message: &Message, index: usize) -> Option<String> {
775    for content in message.contents[..index].iter().rev() {
776        match content {
777            Content::TextReasoning(t) => {
778                if let Some(signature) = t.protected_data.as_deref().filter(|s| !s.is_empty()) {
779                    return Some(signature.to_string());
780                }
781            }
782            // Sibling calls in the same parallel group do not break the chain:
783            // Gemini declares them together after one thought, so the reasoning
784            // that signed the group still applies to a later member.
785            Content::FunctionCall(_) => {}
786            _ => return None,
787        }
788    }
789    None
790}
791
792/// How many function results in `messages` no preceding call in `messages`
793/// answers, per call id.
794///
795/// The mirror of [`unanswered_call_sites`], pairing by occurrence for the same
796/// reason: ids are reused, so membership alone cannot tell a self-contained
797/// exchange from one that reaches back into history.
798fn unanswered_result_counts(messages: &[Message]) -> std::collections::HashMap<&str, usize> {
799    use std::collections::HashMap;
800
801    let mut available_calls: HashMap<&str, usize> = HashMap::new();
802    let mut unanswered: HashMap<&str, usize> = HashMap::new();
803    for content in messages.iter().flat_map(|m| m.contents.iter()) {
804        match content {
805            Content::FunctionCall(fc) if !fc.call_id.is_empty() => {
806                *available_calls.entry(fc.call_id.as_str()).or_insert(0) += 1;
807            }
808            Content::FunctionResult(fr) if !fr.call_id.is_empty() => {
809                match available_calls.get_mut(fr.call_id.as_str()) {
810                    Some(count) if *count > 0 => *count -= 1,
811                    _ => *unanswered.entry(fr.call_id.as_str()).or_insert(0) += 1,
812                }
813            }
814            _ => {}
815        }
816    }
817    unanswered.retain(|_, count| *count > 0);
818    unanswered
819}
820
821/// Apply the invariants every compaction result must satisfy, whatever
822/// strategy produced it: no half tool exchanges, and never system-only.
823///
824/// `pending` is a list that will be appended *after* this runs and is not ours
825/// to modify — for [`CompactionProvider`] that is the run's own input. It
826/// participates in pairing so a retained call answered by an incoming result is
827/// not mistaken for an orphan, but is never stripped or returned.
828///
829/// Order matters: the orphan repair runs *first*, because it can itself strip
830/// a conversation down to system messages only (a retained tool result whose
831/// call fell outside the budget is removed, and it may have been the sole
832/// non-system message). Running the minimum-retention check afterwards catches
833/// that case too. The reverse order silently leaves a system-only result.
834/// Neither pass can undo the other: the repair is a no-op on the orphan-free
835/// message the fallback reinstates.
836fn finalize_compaction(
837    original: &[Message],
838    retained: Vec<Message>,
839    pending: &[Message],
840) -> Vec<Message> {
841    // Recovered once, before the repair rewrites any retained message.
842    let mut origins = origin_indices(&retained, original);
843    let mut retained = retained;
844    reinstate_calls_answered_by_pending(original, &mut retained, &mut origins, pending);
845    drop_orphaned_tool_exchanges(&mut retained, &mut origins, pending);
846    // A non-system message in `pending` gives the model something to answer —
847    // but only if it actually reaches the provider. An input carrying just an
848    // audio attachment (dropped by Anthropic) or a hosted-file reference
849    // (dropped by Gemini) contributes nothing on the wire, so the rule still
850    // has work to do. Same predicate as the retained-side check, for the same
851    // reason.
852    if pending
853        .iter()
854        .any(|m| m.role != Role::system() && message_renders_on_every_provider(m))
855    {
856        return retained;
857    }
858    ensure_non_system_message(original, retained, &origins)
859}
860
861/// Compact `messages` with `strategy` and `tokenizer`.
862///
863/// This is the supported entry point: it runs the strategy and then enforces
864/// the retention and pairing invariants (`finalize_compaction`). Calling
865/// [`CompactionStrategy::compact`] directly gives the strategy's raw output
866/// without them.
867pub fn compact(
868    messages: &[Message],
869    strategy: &dyn CompactionStrategy,
870    tokenizer: &dyn Tokenizer,
871) -> Vec<Message> {
872    finalize_compaction(messages, strategy.compact(messages, tokenizer), &[])
873}
874
875/// A [`ContextProvider`] that compacts the accumulated message list —
876/// typically the run's history, once a [`HistoryProvider`](crate::history::HistoryProvider)
877/// has prepended it in `before_run` — down to fit a [`CompactionStrategy`]'s
878/// constraint before it reaches the model. Rust equivalent of (a subset of)
879/// upstream's `CompactionProvider` (see module docs and `UPSTREAM_DRIFT.md`
880/// §9).
881///
882/// Register it via [`AgentBuilder::with_compaction`](crate::agent::AgentBuilder::with_compaction),
883/// which attaches it as one of the agent's own context providers — those run
884/// *after* the session's (which is where a history provider, auto-attached
885/// or explicit, lives — see [`Agent::combined_providers`](crate::agent::Agent)),
886/// so compaction always sees the full, history-prepended message list for the
887/// run.
888pub struct CompactionProvider {
889    strategy: Arc<dyn CompactionStrategy>,
890    tokenizer: Box<dyn Tokenizer>,
891}
892
893impl CompactionProvider {
894    /// A compaction provider using `strategy` with the default
895    /// [`ApproxTokenizer`].
896    pub fn new(strategy: impl CompactionStrategy + 'static) -> Self {
897        Self::with_tokenizer(strategy, ApproxTokenizer)
898    }
899
900    /// A compaction provider using `strategy` and an explicit `tokenizer`.
901    pub fn with_tokenizer(
902        strategy: impl CompactionStrategy + 'static,
903        tokenizer: impl Tokenizer + 'static,
904    ) -> Self {
905        Self {
906            strategy: Arc::new(strategy),
907            tokenizer: Box::new(tokenizer),
908        }
909    }
910}
911
912#[async_trait]
913impl ContextProvider for CompactionProvider {
914    /// Replace `ctx.messages` (the accumulated history + any earlier
915    /// provider-injected messages) with the strategy's compacted subset.
916    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
917        let retained = self.strategy.compact(&ctx.messages, &*self.tokenizer);
918        // Pair against the run's input as well as history. `prepare_request`
919        // appends `input_messages` *after* every provider has run, so a
920        // declaration-only (frontend) tool call sitting in history is routinely
921        // answered by a result that arrives in this run's input. Repairing
922        // history alone would see that call as unanswered, drop it, and leave
923        // the incoming result unmatched — manufacturing exactly the invalid
924        // conversation this pass exists to prevent.
925        ctx.messages = finalize_compaction(&ctx.messages, retained, &ctx.input_messages);
926        Ok(())
927    }
928
929    // `after_run` is intentionally a no-op (the default from `ContextProvider`):
930    // compaction only shapes the outgoing request, it never observes or
931    // records the run's outcome.
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use crate::types::FunctionResultContent;
938    use serde_json::json;
939
940    fn text(role: Role, s: &str) -> Message {
941        Message::new(role, s)
942    }
943
944    fn tool_result_message(call_id: &str, result: &str) -> Message {
945        Message::with_contents(
946            Role::tool(),
947            vec![Content::FunctionResult(FunctionResultContent::new(
948                call_id,
949                Some(json!(result)),
950            ))],
951        )
952    }
953
954    fn tool_call_message(call_id: &str, name: &str) -> Message {
955        Message::with_contents(
956            Role::assistant(),
957            vec![Content::FunctionCall(
958                crate::types::FunctionCallContent::new(call_id, name, None),
959            )],
960        )
961    }
962
963    fn call_ids_in(messages: &[Message], f: fn(&Content) -> bool) -> Vec<String> {
964        messages
965            .iter()
966            .flat_map(|m| m.contents.iter())
967            .filter(|c| f(c))
968            .filter_map(|c| match c {
969                Content::FunctionCall(fc) => Some(fc.call_id.clone()),
970                Content::FunctionResult(fr) => Some(fr.call_id.clone()),
971                _ => None,
972            })
973            .collect()
974    }
975
976    // ---- compaction invariants (upstream #7406 / #7219) --------------------
977
978    #[test]
979    fn selective_tool_result_compacts_a_failed_calls_exception_too() {
980        // Every provider converter renders `exception` instead of `result`, so
981        // replacing only `result` left the original stack trace sent verbatim —
982        // a no-op for exactly the results most worth compacting.
983        let failed = Message::with_contents(
984            Role::tool(),
985            vec![Content::FunctionResult(FunctionResultContent {
986                call_id: "c1".into(),
987                result: None,
988                exception: Some("Traceback: ...a very long stack trace...".into()),
989            })],
990        );
991        let messages = vec![
992            tool_call_message("c1", "t1"),
993            failed,
994            tool_call_message("c2", "t2"),
995            tool_result_message("c2", "fresh"),
996        ];
997        let out = compact(&messages, &SelectiveToolResult::new(1), &ApproxTokenizer);
998
999        let compacted = &out[1].function_results()[0];
1000        // Still visibly a failure, just without the payload.
1001        assert_eq!(compacted.exception.as_deref(), Some(OMITTED_TOOL_RESULT));
1002        // The most recent exchange is untouched.
1003        assert_eq!(out[3].function_results()[0].result, Some(json!("fresh")));
1004    }
1005
1006    #[test]
1007    fn selective_tool_result_does_not_invent_an_exception_on_a_successful_result() {
1008        let messages = vec![
1009            tool_call_message("c1", "t1"),
1010            tool_result_message("c1", "stale"),
1011            tool_call_message("c2", "t2"),
1012            tool_result_message("c2", "fresh"),
1013        ];
1014        let out = compact(&messages, &SelectiveToolResult::new(1), &ApproxTokenizer);
1015        assert!(out[1].function_results()[0].exception.is_none());
1016    }
1017
1018    #[test]
1019    fn selective_tool_result_compacts_the_payload_without_orphaning_the_call() {
1020        // Deleting the stale result would leave call_1's assistant `tool_calls`
1021        // entry unanswered, which providers reject with a 400. The result
1022        // content stays; only its payload is replaced.
1023        let messages = vec![
1024            tool_call_message("call_1", "get_weather"),
1025            tool_result_message("call_1", "a very long stale payload"),
1026            tool_call_message("call_2", "get_time"),
1027            tool_result_message("call_2", "noon"),
1028        ];
1029        let out = compact(&messages, &SelectiveToolResult::new(1), &ApproxTokenizer);
1030
1031        assert_eq!(
1032            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1033            vec!["call_1", "call_2"]
1034        );
1035        assert_eq!(
1036            call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1037            vec!["call_1", "call_2"]
1038        );
1039        assert_eq!(
1040            out[1].function_results()[0].result,
1041            Some(json!(OMITTED_TOOL_RESULT))
1042        );
1043        // The most recent exchange keeps its real payload.
1044        assert_eq!(out[3].function_results()[0].result, Some(json!("noon")));
1045    }
1046
1047    #[test]
1048    fn a_budget_cut_between_call_and_result_orphans_neither() {
1049        let mut call_msg = tool_call_message("call_1", "get_weather");
1050        call_msg
1051            .contents
1052            .push(Content::text("let me look that up for you right now"));
1053        let messages = vec![
1054            text(Role::system(), "sys"),
1055            text(Role::user(), "hi"),
1056            call_msg,
1057            tool_result_message("call_1", "sunny"),
1058        ];
1059        let out = compact(&messages, &TokenBudget::new(4), &ApproxTokenizer);
1060        // The expensive call-bearing message fell outside the budget, so its
1061        // cheap result must not survive alone answering nothing.
1062        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))).is_empty());
1063        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1064    }
1065
1066    #[test]
1067    fn a_reused_call_id_is_paired_by_occurrence_not_by_id() {
1068        // Call ids are not guaranteed unique across a conversation. Comparing
1069        // *sets* of ids called this balanced (both sides are {c1}) and returned
1070        // early, leaving the second call unanswered.
1071        let messages = vec![
1072            tool_call_message("c1", "get_weather"),
1073            tool_result_message("c1", "sunny"),
1074            tool_call_message("c1", "get_weather"),
1075        ];
1076        let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1077        assert_eq!(
1078            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1079            vec!["c1"],
1080            "the second, unanswered c1 call must be dropped"
1081        );
1082        assert_eq!(
1083            call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1084            vec!["c1"]
1085        );
1086    }
1087
1088    #[test]
1089    fn two_full_exchanges_reusing_one_call_id_both_survive() {
1090        let messages = vec![
1091            tool_call_message("c1", "get_weather"),
1092            tool_result_message("c1", "sunny"),
1093            tool_call_message("c1", "get_weather"),
1094            tool_result_message("c1", "rainy"),
1095        ];
1096        let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1097        assert_eq!(out.len(), 4);
1098    }
1099
1100    #[test]
1101    fn a_result_preceding_its_call_is_not_treated_as_paired() {
1102        // A result can only answer a call that came before it.
1103        let messages = vec![
1104            tool_result_message("c1", "sunny"),
1105            tool_call_message("c1", "get_weather"),
1106        ];
1107        let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1108        assert!(out.is_empty(), "both halves are orphans, got {out:?}");
1109    }
1110
1111    #[test]
1112    fn an_intact_tool_exchange_is_left_alone() {
1113        let messages = vec![
1114            tool_call_message("call_1", "get_weather"),
1115            tool_result_message("call_1", "sunny"),
1116        ];
1117        let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1118        assert_eq!(out.len(), 2);
1119        assert_eq!(
1120            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1121            vec!["call_1"]
1122        );
1123        assert_eq!(
1124            call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1125            vec!["call_1"]
1126        );
1127    }
1128
1129    #[test]
1130    fn an_orphan_strip_keeps_the_rest_of_its_message() {
1131        // Only the orphaned call content is removed; sibling text survives and
1132        // the message itself is not dropped.
1133        let mut call_msg = tool_call_message("call_1", "get_weather");
1134        call_msg.contents.push(Content::text("checking now"));
1135        let out = compact(&[call_msg], &SlidingWindow::new(10), &ApproxTokenizer);
1136        assert_eq!(out.len(), 1);
1137        assert_eq!(out[0].text(), "checking now");
1138        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1139    }
1140
1141    #[test]
1142    fn compaction_never_returns_system_messages_only() {
1143        // A budget smaller than the system prefix used to leave a
1144        // "conversation" with no turn for the model to answer.
1145        let messages = vec![
1146            text(Role::system(), "sys"),
1147            text(Role::user(), "hello"),
1148            text(Role::assistant(), "hi"),
1149        ];
1150        for out in [
1151            compact(&messages, &Truncation::new(1), &ApproxTokenizer),
1152            compact(&messages, &SlidingWindow::new(0), &ApproxTokenizer),
1153        ] {
1154            assert!(
1155                out.iter().any(|m| m.role != Role::system()),
1156                "expected a non-system message to be retained, got {:?}",
1157                out.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
1158            );
1159            // Upstream accepts exceeding the limit rather than emitting a
1160            // useless projection; the retained turn is the most recent one.
1161            assert_eq!(out.last().unwrap().text(), "hi");
1162        }
1163    }
1164
1165    #[test]
1166    fn the_minimum_retained_message_is_never_a_half_exchange() {
1167        // The only non-system messages are a tool exchange, so the fallback
1168        // must reinstate the call-bearing message's *text*, not the orphan.
1169        let mut call_msg = tool_call_message("call_1", "get_weather");
1170        call_msg.contents.push(Content::text("checking now"));
1171        let messages = vec![
1172            text(Role::system(), "sys"),
1173            call_msg,
1174            tool_result_message("call_1", "sunny"),
1175        ];
1176        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1177        assert!(out.iter().any(|m| m.role != Role::system()));
1178        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))).is_empty());
1179        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1180    }
1181
1182    #[test]
1183    fn a_call_answered_by_the_runs_input_is_not_dropped_as_an_orphan() {
1184        // The frontend/declaration-only tool flow: the model's call sits in
1185        // history, and the caller supplies its result in the *next* run's
1186        // input. `prepare_request` appends that input after every provider has
1187        // run, so a repair that only sees history would drop the call and leave
1188        // the incoming result unmatched — the exact invalid conversation this
1189        // pass exists to prevent.
1190        let history = vec![
1191            text(Role::user(), "what's the weather?"),
1192            tool_call_message("c1", "get_weather"),
1193        ];
1194        let input = vec![tool_result_message("c1", "sunny")];
1195
1196        let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1197        let out = finalize_compaction(&history, retained, &input);
1198
1199        assert_eq!(
1200            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1201            vec!["c1"],
1202            "the call must survive: its result arrives in this run's input"
1203        );
1204        // The input itself is never returned or modified by the repair.
1205        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))).is_empty());
1206
1207        // Contrast, pinning the bug this fixes: without visibility of the
1208        // pending input the same call *is* dropped as an orphan.
1209        let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1210        let blind = finalize_compaction(&history, retained, &[]);
1211        assert!(call_ids_in(&blind, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1212    }
1213
1214    #[test]
1215    fn a_call_the_strategy_dropped_is_reinstated_when_the_input_answers_it() {
1216        // Pairing against pending stops a *retained* call being stripped, but
1217        // the strategy runs first: SlidingWindow(0) excludes the call from
1218        // history entirely, and the incoming result then answers nothing.
1219        let history = vec![
1220            text(Role::user(), "what's the weather?"),
1221            tool_call_message("c1", "get_weather"),
1222        ];
1223        let input = vec![tool_result_message("c1", "sunny")];
1224
1225        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1226        assert!(
1227            call_ids_in(&retained, |c| matches!(c, Content::FunctionCall(_))).is_empty(),
1228            "precondition: the strategy really did drop the call"
1229        );
1230
1231        let out = finalize_compaction(&history, retained, &input);
1232        assert_eq!(
1233            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1234            vec!["c1"],
1235            "the call must be put back so the incoming result answers something"
1236        );
1237    }
1238
1239    #[test]
1240    fn a_reused_call_id_reinstates_the_unanswered_occurrence() {
1241        // `call(c1, old) -> result(c1) -> call(c1, new)`: the first occurrence
1242        // is already answered, so an incoming result answers the *new* call.
1243        // Picking the first id match replayed the old call's name and arguments
1244        // and attached the new result to it.
1245        let mut old_call = tool_call_message("c1", "get_weather");
1246        old_call.contents = vec![Content::FunctionCall(
1247            crate::types::FunctionCallContent::new(
1248                "c1",
1249                "get_weather",
1250                Some(crate::types::FunctionArguments::Raw(
1251                    "{\"city\":\"old\"}".into(),
1252                )),
1253            ),
1254        )];
1255        let mut new_call = tool_call_message("c1", "get_weather");
1256        new_call.contents = vec![Content::FunctionCall(
1257            crate::types::FunctionCallContent::new(
1258                "c1",
1259                "get_weather",
1260                Some(crate::types::FunctionArguments::Raw(
1261                    "{\"city\":\"new\"}".into(),
1262                )),
1263            ),
1264        )];
1265        let history = vec![old_call, tool_result_message("c1", "old answer"), new_call];
1266        let input = vec![tool_result_message("c1", "new answer")];
1267
1268        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1269        let out = finalize_compaction(&history, retained, &input);
1270
1271        let calls: Vec<&crate::types::FunctionCallContent> = out
1272            .iter()
1273            .flat_map(|m| m.contents.iter())
1274            .filter_map(Content::as_function_call)
1275            .collect();
1276        assert_eq!(calls.len(), 1);
1277        let args = calls[0].parse_arguments().unwrap();
1278        assert_eq!(
1279            args.get("city").and_then(|v| v.as_str()),
1280            Some("new"),
1281            "the unanswered (new) occurrence must be the one reinstated"
1282        );
1283    }
1284
1285    #[test]
1286    fn a_self_contained_pending_exchange_reinstates_nothing() {
1287        // The run's input carries its own call *and* result, reusing an id that
1288        // an older unanswered call also used. Counting every pending result
1289        // pulled the stale historical call back, and the FIFO repair then
1290        // paired the pending result with it, leaving the pending call
1291        // unanswered — an invalid exchange assembled from two valid halves.
1292        let history = vec![tool_call_message("c1", "get_weather")];
1293        let input = vec![
1294            tool_call_message("c1", "get_weather"),
1295            tool_result_message("c1", "sunny"),
1296        ];
1297        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1298        let out = finalize_compaction(&history, retained, &input);
1299        assert!(
1300            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty(),
1301            "pending answers itself; nothing should be reinstated, got {out:?}"
1302        );
1303    }
1304
1305    #[test]
1306    fn a_pending_result_beyond_what_pending_answers_still_reinstates() {
1307        // Two results, only one answered within pending: the extra one reaches
1308        // back into history and must still recover its call.
1309        let history = vec![tool_call_message("c1", "get_weather")];
1310        let input = vec![
1311            tool_call_message("c1", "get_weather"),
1312            tool_result_message("c1", "first"),
1313            tool_result_message("c1", "second"),
1314        ];
1315        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1316        let out = finalize_compaction(&history, retained, &input);
1317        assert_eq!(
1318            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1319            vec!["c1"]
1320        );
1321    }
1322
1323    #[test]
1324    fn a_pending_exchange_pairs_with_its_own_call_not_a_historical_one() {
1325        // Retained history holds an unanswered c1 call and the input carries its
1326        // own c1 call + result. Pairing with the *oldest* matching call attached
1327        // the pending result to the historical call, kept that call, and left
1328        // the pending call unanswered — two calls, one result.
1329        let history = vec![tool_call_message("c1", "get_weather")];
1330        let input = vec![
1331            tool_call_message("c1", "get_weather"),
1332            tool_result_message("c1", "sunny"),
1333        ];
1334        let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1335        let out = finalize_compaction(&history, retained, &input);
1336        assert!(
1337            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty(),
1338            "the unanswered historical call must be stripped, got {out:?}"
1339        );
1340    }
1341
1342    #[test]
1343    fn a_reinstated_call_keeps_its_reasoning_signature() {
1344        // The signature sits on the preceding reasoning content (the supported
1345        // fallback placement). Reducing to the call alone stripped the only
1346        // copy, and the reasoning message is not retained either, so the
1347        // request builder had nothing left to backfill from.
1348        let signed = Message::with_contents(
1349            Role::assistant(),
1350            vec![
1351                Content::TextReasoning(crate::types::TextReasoningContent {
1352                    text: "thinking".into(),
1353                    protected_data: Some("c2ln".into()),
1354                    ..Default::default()
1355                }),
1356                Content::FunctionCall(crate::types::FunctionCallContent::new(
1357                    "c1",
1358                    "get_weather",
1359                    None,
1360                )),
1361            ],
1362        );
1363        let history = vec![signed];
1364        let input = vec![tool_result_message("c1", "sunny")];
1365        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1366        let out = finalize_compaction(&history, retained, &input);
1367
1368        let call = out
1369            .iter()
1370            .flat_map(|m| m.contents.iter())
1371            .find_map(Content::as_function_call)
1372            .expect("the call is reinstated");
1373        assert_eq!(call.protected_data.as_deref(), Some("c2ln"));
1374    }
1375
1376    #[test]
1377    fn a_reinstated_call_gains_no_signature_from_unrelated_content() {
1378        let mut msg = Message::with_contents(
1379            Role::assistant(),
1380            vec![
1381                Content::TextReasoning(crate::types::TextReasoningContent {
1382                    text: "thinking".into(),
1383                    protected_data: Some("c2ln".into()),
1384                    ..Default::default()
1385                }),
1386                Content::text("intervening"),
1387            ],
1388        );
1389        msg.contents.push(Content::FunctionCall(
1390            crate::types::FunctionCallContent::new("c1", "get_weather", None),
1391        ));
1392        let history = vec![msg];
1393        let input = vec![tool_result_message("c1", "sunny")];
1394        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1395        let out = finalize_compaction(&history, retained, &input);
1396
1397        let call = out
1398            .iter()
1399            .flat_map(|m| m.contents.iter())
1400            .find_map(Content::as_function_call)
1401            .expect("the call is reinstated");
1402        assert!(call.protected_data.is_none());
1403    }
1404
1405    #[test]
1406    fn the_fallback_restores_the_nearest_reused_call_not_the_oldest() {
1407        // `call(c1, old) -> call(c1, new) -> result(c1)`: the result answers the
1408        // *new* call. A third pairing walk here used FIFO while the repair paths
1409        // used nearest-preceding, so the fallback replayed the old call's
1410        // arguments with the new call's result.
1411        let mk = |city: &str| {
1412            Message::with_contents(
1413                Role::assistant(),
1414                vec![Content::FunctionCall(
1415                    crate::types::FunctionCallContent::new(
1416                        "c1",
1417                        "get_weather",
1418                        Some(crate::types::FunctionArguments::Raw(format!(
1419                            "{{\"city\":\"{city}\"}}"
1420                        ))),
1421                    ),
1422                )],
1423            )
1424        };
1425        let messages = vec![
1426            text(Role::system(), "sys"),
1427            mk("old"),
1428            mk("new"),
1429            tool_result_message("c1", "sunny"),
1430        ];
1431        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1432
1433        let call = out
1434            .iter()
1435            .flat_map(|m| m.contents.iter())
1436            .find_map(Content::as_function_call)
1437            .expect("the exchange is restored");
1438        assert_eq!(
1439            call.parse_arguments()
1440                .unwrap()
1441                .get("city")
1442                .and_then(|v| v.as_str()),
1443            Some("new")
1444        );
1445    }
1446
1447    #[test]
1448    fn unknown_content_does_not_satisfy_the_retention_check() {
1449        // `Content::Unknown` is a forward-compatibility placeholder carrying no
1450        // data; no converter has an arm for it, so a message of only Unknown
1451        // reaches every provider as nothing.
1452        let messages = vec![
1453            text(Role::system(), "sys"),
1454            text(Role::user(), "a real earlier turn"),
1455            Message::with_contents(Role::assistant(), vec![Content::Unknown]),
1456        ];
1457        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1458        assert!(
1459            out.iter()
1460                .any(|m| m.contents.iter().any(|c| matches!(c, Content::Text(_)))
1461                    && m.role != Role::system()),
1462            "expected a real turn to be restored, got {out:?}"
1463        );
1464    }
1465
1466    /// A strategy that keeps only the messages at the given indices — used to
1467    /// reach retention shapes the built-in strategies cannot produce.
1468    struct KeepIndices(Vec<usize>);
1469    impl CompactionStrategy for KeepIndices {
1470        fn compact(&self, messages: &[Message], _t: &dyn Tokenizer) -> Vec<Message> {
1471            messages
1472                .iter()
1473                .enumerate()
1474                .filter(|(i, _)| self.0.contains(i))
1475                .map(|(_, m)| m.clone())
1476                .collect()
1477        }
1478    }
1479
1480    #[test]
1481    fn an_older_retained_occurrence_does_not_suppress_reinstating_the_newer() {
1482        // A custom strategy keeps the *older* unanswered c1 call and drops the
1483        // newer one. Counting retained calls by id treated the old one as
1484        // sufficient, reinstated nothing, and let the incoming result attach to
1485        // the old call's name and arguments.
1486        let mk = |city: &str| {
1487            Message::with_contents(
1488                Role::assistant(),
1489                vec![Content::FunctionCall(
1490                    crate::types::FunctionCallContent::new(
1491                        "c1",
1492                        "get_weather",
1493                        Some(crate::types::FunctionArguments::Raw(format!(
1494                            "{{\"city\":\"{city}\"}}"
1495                        ))),
1496                    ),
1497                )],
1498            )
1499        };
1500        let history = vec![mk("old"), mk("new")];
1501        let input = vec![tool_result_message("c1", "sunny")];
1502
1503        let retained = KeepIndices(vec![0]).compact(&history, &ApproxTokenizer);
1504        let out = finalize_compaction(&history, retained, &input);
1505
1506        let cities: Vec<String> = out
1507            .iter()
1508            .flat_map(|m| m.contents.iter())
1509            .filter_map(Content::as_function_call)
1510            .filter_map(|c| {
1511                c.parse_arguments()
1512                    .ok()?
1513                    .get("city")?
1514                    .as_str()
1515                    .map(str::to_string)
1516            })
1517            .collect();
1518        assert!(
1519            cities.contains(&"new".to_string()),
1520            "the newer occurrence the result answers must be present, got {cities:?}"
1521        );
1522    }
1523
1524    #[test]
1525    fn non_image_media_alone_does_not_satisfy_the_retention_check() {
1526        // Anthropic's image_block_from_data / image_block_from_uri return None
1527        // for anything that is not an image, so audio Data or a non-image Uri
1528        // is dropped there and the request is effectively system-only.
1529        for media in [
1530            Content::Data(crate::types::DataContent::from_bytes(b"aud", "audio/wav")),
1531            Content::Uri(crate::types::UriContent {
1532                uri: "https://example.com/doc.pdf".into(),
1533                media_type: "application/pdf".into(),
1534            }),
1535        ] {
1536            let messages = vec![
1537                text(Role::system(), "sys"),
1538                text(Role::user(), "a real earlier turn"),
1539                Message::with_contents(Role::assistant(), vec![media]),
1540            ];
1541            let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1542            assert!(
1543                out.iter().any(|m| m.role != Role::system()
1544                    && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
1545                "expected a real turn to be restored, got {out:?}"
1546            );
1547        }
1548    }
1549
1550    #[test]
1551    fn a_hosted_file_alone_does_not_satisfy_the_retention_check() {
1552        // Gemini's content_to_part has no HostedFile arm, so a message carrying
1553        // only one reaches it as nothing. A negative-list predicate counted
1554        // every non-reasoning variant as renderable.
1555        let hosted = Message::with_contents(
1556            Role::assistant(),
1557            vec![Content::HostedFile(crate::types::HostedFileContent {
1558                file_id: "file_1".into(),
1559            })],
1560        );
1561        let messages = vec![
1562            text(Role::system(), "sys"),
1563            text(Role::user(), "a real earlier turn"),
1564            hosted,
1565        ];
1566        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1567        assert!(
1568            out.iter().any(|m| m.role != Role::system()
1569                && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
1570            "expected a real turn to be restored, got {out:?}"
1571        );
1572    }
1573
1574    #[test]
1575    fn a_completed_exchange_in_history_is_not_reinstated() {
1576        // Nothing is outstanding, so an incoming result for a *fresh* call id
1577        // pulls back nothing.
1578        let history = vec![
1579            tool_call_message("c1", "get_weather"),
1580            tool_result_message("c1", "sunny"),
1581        ];
1582        let input = vec![tool_result_message("c2", "noon")];
1583        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1584        let out = finalize_compaction(&history, retained, &input);
1585        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1586    }
1587
1588    #[test]
1589    fn a_reinstated_call_carries_nothing_else_from_its_message() {
1590        let mut call_msg = tool_call_message("c1", "get_weather");
1591        call_msg
1592            .contents
1593            .push(Content::text("a large unrelated payload"));
1594        let history = vec![call_msg];
1595        let input = vec![tool_result_message("c1", "sunny")];
1596
1597        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1598        let out = finalize_compaction(&history, retained, &input);
1599        assert_eq!(out.len(), 1);
1600        assert_eq!(out[0].contents.len(), 1, "only the call rides back in");
1601        assert!(out[0].text().is_empty());
1602    }
1603
1604    #[test]
1605    fn nothing_is_reinstated_when_the_input_carries_no_results() {
1606        let history = vec![tool_call_message("c1", "get_weather")];
1607        let input = vec![text(Role::user(), "hello")];
1608        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1609        let out = finalize_compaction(&history, retained, &input);
1610        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1611    }
1612
1613    #[test]
1614    fn an_already_retained_call_is_not_reinstated_twice() {
1615        let history = vec![tool_call_message("c1", "get_weather")];
1616        let input = vec![tool_result_message("c1", "sunny")];
1617        let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1618        let out = finalize_compaction(&history, retained, &input);
1619        assert_eq!(
1620            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1621            vec!["c1"]
1622        );
1623    }
1624
1625    #[test]
1626    fn the_minimum_retention_fallback_never_picks_a_tool_role_message() {
1627        // A tool message's text belongs to its exchange. Keeping it without the
1628        // result yields a tool message with no tool_call_id, which the OpenAI
1629        // converter drops outright (system-only after all) and which Gemini
1630        // emits as a bare text part under its `function` role.
1631        let mut result_msg = tool_result_message("c1", "sunny");
1632        result_msg.contents.push(Content::text("explanatory text"));
1633        let messages = vec![
1634            text(Role::system(), "sys"),
1635            tool_call_message("c1", "get_weather"),
1636            result_msg,
1637        ];
1638        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1639        // The complete exchange comes back instead of a bare tool-role text.
1640        assert_eq!(
1641            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1642            vec!["c1"]
1643        );
1644        assert_eq!(
1645            call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1646            vec!["c1"]
1647        );
1648        for m in &out {
1649            if m.role == Role::tool() {
1650                assert!(
1651                    m.contents
1652                        .iter()
1653                        .any(|c| matches!(c, Content::FunctionResult(_))),
1654                    "a tool-role message must carry its result"
1655                );
1656            }
1657        }
1658    }
1659
1660    #[test]
1661    fn a_call_with_no_answer_anywhere_is_still_dropped() {
1662        let history = vec![tool_call_message("c1", "get_weather")];
1663        let input = vec![text(Role::user(), "never mind")];
1664        let retained = SlidingWindow::new(10).compact(&history, &ApproxTokenizer);
1665        let out = finalize_compaction(&history, retained, &input);
1666        assert!(call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))).is_empty());
1667    }
1668
1669    #[test]
1670    fn pending_input_satisfies_the_minimum_retention_rule() {
1671        // The run's own input already gives the model something to answer, so
1672        // no history turn needs reinstating over the limit.
1673        let history = vec![text(Role::system(), "sys"), text(Role::user(), "older")];
1674        let input = vec![text(Role::user(), "current question")];
1675        let retained = Truncation::new(1).compact(&history, &ApproxTokenizer);
1676        let out = finalize_compaction(&history, retained, &input);
1677        assert!(out.iter().all(|m| m.role == Role::system()));
1678    }
1679
1680    #[test]
1681    fn a_conversation_of_only_a_tool_exchange_retains_it_whole() {
1682        // Stripping both halves (the ordinary-content fallback) would leave the
1683        // result system-only after all, so the complete exchange is reinstated
1684        // atomically instead.
1685        let messages = vec![
1686            text(Role::system(), "sys"),
1687            tool_call_message("c1", "get_weather"),
1688            tool_result_message("c1", "sunny"),
1689        ];
1690        for out in [
1691            compact(&messages, &Truncation::new(1), &ApproxTokenizer),
1692            compact(&messages, &SlidingWindow::new(0), &ApproxTokenizer),
1693        ] {
1694            assert!(
1695                out.iter().any(|m| m.role != Role::system()),
1696                "expected the tool exchange to be retained, got {:?}",
1697                out.iter().map(|m| m.role.as_str()).collect::<Vec<_>>()
1698            );
1699            assert_eq!(
1700                call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1701                vec!["c1"]
1702            );
1703            assert_eq!(
1704                call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1705                vec!["c1"]
1706            );
1707        }
1708    }
1709
1710    #[test]
1711    fn the_latest_complete_exchange_is_the_one_reinstated() {
1712        let messages = vec![
1713            text(Role::system(), "sys"),
1714            tool_call_message("c1", "get_weather"),
1715            tool_result_message("c1", "sunny"),
1716            tool_call_message("c2", "get_time"),
1717            tool_result_message("c2", "noon"),
1718        ];
1719        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1720        assert_eq!(
1721            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1722            vec!["c2"]
1723        );
1724    }
1725
1726    #[test]
1727    fn an_incomplete_tool_exchange_is_not_reinstated() {
1728        // A half exchange is worse than nothing: there is no complete pair to
1729        // fall back to, so the result stays system-only rather than becoming
1730        // invalid.
1731        let messages = vec![
1732            text(Role::system(), "sys"),
1733            tool_call_message("c1", "get_weather"),
1734        ];
1735        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1736        assert!(out.iter().all(|m| m.role == Role::system()));
1737    }
1738
1739    #[test]
1740    fn reasoning_alone_does_not_qualify_as_the_minimum_retained_message() {
1741        // `[TextReasoning, FunctionCall]` + its result: stripping the call left
1742        // reasoning behind, which passed the non-empty check and returned early,
1743        // bypassing the complete-exchange fallback. Reasoning renders nothing
1744        // standalone, so the request was effectively system-only anyway.
1745        let signed = Message::with_contents(
1746            Role::assistant(),
1747            vec![
1748                Content::TextReasoning(crate::types::TextReasoningContent {
1749                    text: "thinking".into(),
1750                    ..Default::default()
1751                }),
1752                Content::FunctionCall(crate::types::FunctionCallContent::new(
1753                    "c1",
1754                    "get_weather",
1755                    None,
1756                )),
1757            ],
1758        );
1759        let messages = vec![
1760            text(Role::system(), "sys"),
1761            signed,
1762            tool_result_message("c1", "sunny"),
1763        ];
1764        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1765
1766        assert_eq!(
1767            call_ids_in(&out, |c| matches!(c, Content::FunctionCall(_))),
1768            vec!["c1"],
1769            "the complete exchange must be restored, got {out:?}"
1770        );
1771        assert_eq!(
1772            call_ids_in(&out, |c| matches!(c, Content::FunctionResult(_))),
1773            vec!["c1"]
1774        );
1775    }
1776
1777    #[test]
1778    fn text_beside_reasoning_still_qualifies() {
1779        let msg = Message::with_contents(
1780            Role::assistant(),
1781            vec![
1782                Content::TextReasoning(crate::types::TextReasoningContent {
1783                    text: "thinking".into(),
1784                    ..Default::default()
1785                }),
1786                Content::text("the answer"),
1787            ],
1788        );
1789        let messages = vec![text(Role::system(), "sys"), msg];
1790        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1791        assert_eq!(out.last().unwrap().text(), "the answer");
1792    }
1793
1794    #[test]
1795    fn the_complete_exchange_fallback_carries_the_reasoning_signature() {
1796        // The reinstatement path copied the signature; this one did not.
1797        let signed = Message::with_contents(
1798            Role::assistant(),
1799            vec![
1800                Content::TextReasoning(crate::types::TextReasoningContent {
1801                    text: "thinking".into(),
1802                    protected_data: Some("c2ln".into()),
1803                    ..Default::default()
1804                }),
1805                Content::FunctionCall(crate::types::FunctionCallContent::new(
1806                    "c1",
1807                    "get_weather",
1808                    None,
1809                )),
1810            ],
1811        );
1812        let messages = vec![
1813            text(Role::system(), "sys"),
1814            signed,
1815            tool_result_message("c1", "sunny"),
1816        ];
1817        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
1818
1819        let call = out
1820            .iter()
1821            .flat_map(|m| m.contents.iter())
1822            .find_map(Content::as_function_call)
1823            .expect("the exchange is restored");
1824        assert_eq!(call.protected_data.as_deref(), Some("c2ln"));
1825    }
1826
1827    #[test]
1828    fn a_retained_message_that_renders_nothing_does_not_satisfy_retention() {
1829        // The orphan repair strips the call and leaves reasoning behind. That
1830        // message is non-system but reaches the model as nothing, so the
1831        // fallback must still run and restore the older complete exchange.
1832        let reasoning_plus_orphan = Message::with_contents(
1833            Role::assistant(),
1834            vec![
1835                Content::TextReasoning(crate::types::TextReasoningContent {
1836                    text: "thinking".into(),
1837                    ..Default::default()
1838                }),
1839                Content::FunctionCall(crate::types::FunctionCallContent::new(
1840                    "orphan",
1841                    "get_weather",
1842                    None,
1843                )),
1844            ],
1845        );
1846        let messages = vec![
1847            text(Role::system(), "sys"),
1848            tool_call_message("c1", "get_weather"),
1849            tool_result_message("c1", "sunny"),
1850            reasoning_plus_orphan,
1851        ];
1852        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1853
1854        assert!(
1855            out.iter().any(|m| m.role != Role::system()
1856                && m.contents
1857                    .iter()
1858                    .any(|c| !matches!(c, Content::TextReasoning(_)))),
1859            "expected something that actually renders, got {out:?}"
1860        );
1861    }
1862
1863    #[test]
1864    fn retained_reasoning_is_never_discarded_by_the_retention_check() {
1865        // The conservative predicate decides whether to *add* a fallback; it
1866        // never removes anything. Providers that do render reasoning (Anthropic
1867        // `thinking` blocks, OpenAI Responses replaying a preserved item) keep
1868        // their context either way.
1869        let reasoning_only = Message::with_contents(
1870            Role::assistant(),
1871            vec![Content::TextReasoning(crate::types::TextReasoningContent {
1872                text: "thinking".into(),
1873                raw_representation: Some(json!({"id": "rs_1"})),
1874                ..Default::default()
1875            })],
1876        );
1877        let messages = vec![text(Role::system(), "sys"), reasoning_only];
1878        let out = compact(&messages, &SlidingWindow::new(10), &ApproxTokenizer);
1879
1880        let kept = out
1881            .iter()
1882            .flat_map(|m| m.contents.iter())
1883            .any(|c| matches!(c, Content::TextReasoning(_)));
1884        assert!(kept, "reasoning must survive, got {out:?}");
1885    }
1886
1887    #[test]
1888    fn the_fallback_is_inserted_in_chronological_order() {
1889        // The retained message is newer but renders nothing universally, so a
1890        // fallback is added. Appending it put a stale user turn *after* the
1891        // assistant's reply — on Anthropic, which does render reasoning, the
1892        // model then sees the old request as the latest turn.
1893        let messages = vec![
1894            text(Role::user(), "old question"),
1895            Message::with_contents(
1896                Role::assistant(),
1897                vec![Content::TextReasoning(crate::types::TextReasoningContent {
1898                    text: "newer thinking".into(),
1899                    ..Default::default()
1900                })],
1901            ),
1902        ];
1903        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
1904        assert_eq!(out.len(), 2);
1905        assert_eq!(out[0].text(), "old question", "got {out:?}");
1906        assert!(matches!(out[1].contents[0], Content::TextReasoning(_)));
1907    }
1908
1909    #[test]
1910    fn a_fallback_newer_than_the_retained_message_is_appended() {
1911        let messages = vec![
1912            Message::with_contents(
1913                Role::assistant(),
1914                vec![Content::TextReasoning(crate::types::TextReasoningContent {
1915                    text: "older thinking".into(),
1916                    ..Default::default()
1917                })],
1918            ),
1919            text(Role::user(), "newer question"),
1920        ];
1921        let retained = KeepIndices(vec![0]).compact(&messages, &ApproxTokenizer);
1922        let out = finalize_compaction(&messages, retained, &[]);
1923        assert_eq!(out.len(), 2);
1924        assert!(matches!(out[0].contents[0], Content::TextReasoning(_)));
1925        assert_eq!(out[1].text(), "newer question");
1926    }
1927
1928    #[test]
1929    fn wire_empty_pending_input_does_not_satisfy_retention() {
1930        // The run's input is a hosted-file reference, which Gemini drops; it
1931        // contributes nothing, so the older real turn must still be restored.
1932        let history = vec![text(Role::system(), "sys"), text(Role::user(), "real turn")];
1933        let input = vec![Message::with_contents(
1934            Role::user(),
1935            vec![Content::HostedFile(crate::types::HostedFileContent {
1936                file_id: "file_1".into(),
1937            })],
1938        )];
1939        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
1940        let out = finalize_compaction(&history, retained, &input);
1941        assert!(
1942            out.iter().any(|m| m.role != Role::system()
1943                && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
1944            "expected the real turn to be restored, got {out:?}"
1945        );
1946    }
1947
1948    #[test]
1949    fn tool_payloads_count_against_the_token_budget() {
1950        // A megabyte-scale JSON result cost zero tokens, so any number of
1951        // completed exchanges survived TokenBudget and the "compacted" request
1952        // still blew the context window.
1953        let t = ApproxTokenizer;
1954        let bulky = Message::with_contents(
1955            Role::tool(),
1956            vec![Content::FunctionResult(FunctionResultContent::new(
1957                "c1",
1958                Some(json!("x".repeat(400))),
1959            ))],
1960        );
1961        assert!(
1962            count_message_tokens(&t, &bulky) >= 100,
1963            "a large tool result must cost tokens, got {}",
1964            count_message_tokens(&t, &bulky)
1965        );
1966
1967        let call = Message::with_contents(
1968            Role::assistant(),
1969            vec![Content::FunctionCall(
1970                crate::types::FunctionCallContent::new(
1971                    "c1",
1972                    "get_weather",
1973                    Some(crate::types::FunctionArguments::Raw("y".repeat(400))),
1974                ),
1975            )],
1976        );
1977        assert!(count_message_tokens(&t, &call) >= 100);
1978
1979        // And the budget now actually excludes them.
1980        let messages = vec![call.clone(), bulky.clone(), text(Role::user(), "tiny")];
1981        let out = TokenBudget::new(10).compact(&messages, &t);
1982        assert!(
1983            out.len() < 3,
1984            "the bulky exchange must not fit a 10-token budget, got {out:?}"
1985        );
1986    }
1987
1988    #[test]
1989    fn duplicate_messages_do_not_alias_the_fallback_position() {
1990        // Identical reasoning messages either side of an older user turn. A
1991        // forward equality match aliased the retained *later* reasoning to the
1992        // earlier duplicate, concluded the fallback was newer, and appended it —
1993        // reversing the conversation again.
1994        let reasoning = || {
1995            Message::with_contents(
1996                Role::assistant(),
1997                vec![Content::TextReasoning(crate::types::TextReasoningContent {
1998                    text: "same thinking".into(),
1999                    ..Default::default()
2000                })],
2001            )
2002        };
2003        let messages = vec![reasoning(), text(Role::user(), "old question"), reasoning()];
2004        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2005        assert_eq!(out.len(), 2);
2006        assert_eq!(
2007            out[0].text(),
2008            "old question",
2009            "the fallback must precede the retained later reasoning, got {out:?}"
2010        );
2011    }
2012
2013    #[test]
2014    fn the_complete_exchange_fallback_is_also_inserted_chronologically() {
2015        // The chronological fix covered only the plain-content fallback; the
2016        // exchange fallback still appended, putting a stale tool turn after a
2017        // newer reasoning message that Anthropic does render.
2018        let messages = vec![
2019            tool_call_message("c1", "get_weather"),
2020            tool_result_message("c1", "sunny"),
2021            Message::with_contents(
2022                Role::assistant(),
2023                vec![Content::TextReasoning(crate::types::TextReasoningContent {
2024                    text: "newer thinking".into(),
2025                    ..Default::default()
2026                })],
2027            ),
2028        ];
2029        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2030
2031        let reasoning_at = out
2032            .iter()
2033            .position(|m| {
2034                m.contents
2035                    .iter()
2036                    .any(|c| matches!(c, Content::TextReasoning(_)))
2037            })
2038            .expect("the retained reasoning is still present");
2039        let call_at = out
2040            .iter()
2041            .position(|m| {
2042                m.contents
2043                    .iter()
2044                    .any(|c| matches!(c, Content::FunctionCall(_)))
2045            })
2046            .expect("the exchange is restored");
2047        assert!(
2048            call_at < reasoning_at,
2049            "the older exchange must precede the newer reasoning, got {out:?}"
2050        );
2051        // ...and its result stays with it, still after the call.
2052        let result_at = out
2053            .iter()
2054            .position(|m| {
2055                m.contents
2056                    .iter()
2057                    .any(|c| matches!(c, Content::FunctionResult(_)))
2058            })
2059            .expect("the result is restored");
2060        assert!(
2061            call_at < result_at && result_at < reasoning_at,
2062            "got {out:?}"
2063        );
2064    }
2065
2066    #[test]
2067    fn parallel_reinstated_calls_stay_in_one_message() {
2068        // Two calls declared together in one assistant turn. Reinstating them
2069        // as separate messages puts an assistant turn between the declaration
2070        // and the results, which provider tool protocols reject.
2071        let parallel = Message::with_contents(
2072            Role::assistant(),
2073            vec![
2074                Content::FunctionCall(crate::types::FunctionCallContent::new(
2075                    "c1",
2076                    "get_weather",
2077                    None,
2078                )),
2079                Content::FunctionCall(crate::types::FunctionCallContent::new(
2080                    "c2", "get_time", None,
2081                )),
2082            ],
2083        );
2084        let history = vec![parallel];
2085        let input = vec![
2086            tool_result_message("c1", "sunny"),
2087            tool_result_message("c2", "noon"),
2088        ];
2089        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
2090        let out = finalize_compaction(&history, retained, &input);
2091
2092        let with_calls: Vec<&Message> = out
2093            .iter()
2094            .filter(|m| {
2095                m.contents
2096                    .iter()
2097                    .any(|c| matches!(c, Content::FunctionCall(_)))
2098            })
2099            .collect();
2100        assert_eq!(
2101            with_calls.len(),
2102            1,
2103            "both calls must be reinstated in one message, got {out:?}"
2104        );
2105        assert_eq!(with_calls[0].contents.len(), 2);
2106    }
2107
2108    #[test]
2109    fn chronology_survives_the_orphan_repair_rewriting_a_message() {
2110        // The repair strips the orphaned call, so the retained message no longer
2111        // equals its original. Recovering positions by equality *after* that
2112        // matched nothing and the older fallback was appended, putting a stale
2113        // user turn last for providers that render reasoning.
2114        let newer = Message::with_contents(
2115            Role::assistant(),
2116            vec![
2117                Content::TextReasoning(crate::types::TextReasoningContent {
2118                    text: "newer thinking".into(),
2119                    ..Default::default()
2120                }),
2121                Content::FunctionCall(crate::types::FunctionCallContent::new(
2122                    "orphan",
2123                    "get_weather",
2124                    None,
2125                )),
2126            ],
2127        );
2128        let messages = vec![text(Role::user(), "old question"), newer];
2129        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2130
2131        assert_eq!(out.len(), 2);
2132        assert_eq!(
2133            out[0].text(),
2134            "old question",
2135            "the fallback must precede the retained (rewritten) message, got {out:?}"
2136        );
2137    }
2138
2139    #[test]
2140    fn an_assistant_image_turn_does_not_satisfy_retention() {
2141        // Image blocks are user-turn content everywhere: Converse and
2142        // Anthropic reject an assistant image block outright, and the Bedrock
2143        // converter skips it — so an assistant message whose only content is
2144        // image data renders nowhere.
2145        let messages = vec![
2146            text(Role::system(), "sys"),
2147            text(Role::user(), "a real earlier turn"),
2148            Message::with_contents(
2149                Role::assistant(),
2150                vec![Content::Data(crate::types::DataContent::from_bytes(
2151                    b"img",
2152                    "image/png",
2153                ))],
2154            ),
2155        ];
2156        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2157        assert!(
2158            out.iter().any(|m| m.role != Role::system()
2159                && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2160            "expected the real turn restored, got {out:?}"
2161        );
2162    }
2163
2164    #[test]
2165    fn an_image_turn_satisfies_the_retention_check() {
2166        // Images render on every converter, so a latest image-only turn needs no
2167        // fallback — appending a stale text turn changes the prompt and
2168        // defeats the window the caller asked for.
2169        let messages = vec![
2170            text(Role::system(), "sys"),
2171            text(Role::user(), "an older turn"),
2172            Message::with_contents(
2173                Role::user(),
2174                vec![Content::Data(crate::types::DataContent::from_bytes(
2175                    b"img",
2176                    "image/png",
2177                ))],
2178            ),
2179        ];
2180        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2181        assert_eq!(out.len(), 2, "no fallback should be added, got {out:?}");
2182        assert!(out[1]
2183            .contents
2184            .iter()
2185            .any(|c| matches!(c, Content::Data(_))));
2186    }
2187
2188    #[test]
2189    fn a_remote_image_uri_does_not_satisfy_retention() {
2190        // Bedrock's Converse API has no remote-URL image source (inline bytes
2191        // or S3 only), so a hosted image reference renders nowhere there — a
2192        // Uri turn is not universal even when it names an image.
2193        let messages = vec![
2194            text(Role::system(), "sys"),
2195            text(Role::user(), "an older turn"),
2196            Message::with_contents(
2197                Role::user(),
2198                vec![Content::Uri(crate::types::UriContent {
2199                    uri: "https://example.com/a.png".into(),
2200                    media_type: "image/png".into(),
2201                })],
2202            ),
2203        ];
2204        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2205        assert!(
2206            out.iter().any(|m| m.role != Role::system()
2207                && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2208            "expected the real turn restored, got {out:?}"
2209        );
2210    }
2211
2212    #[test]
2213    fn an_image_format_outside_the_universal_set_does_not_satisfy_retention() {
2214        // The floor is Bedrock's format set (png/jpeg/gif/webp); an SVG is an
2215        // image but renders nowhere on Converse.
2216        let messages = vec![
2217            text(Role::system(), "sys"),
2218            text(Role::user(), "an older turn"),
2219            Message::with_contents(
2220                Role::user(),
2221                vec![Content::Data(crate::types::DataContent::from_bytes(
2222                    b"<svg/>",
2223                    "image/svg+xml",
2224                ))],
2225            ),
2226        ];
2227        let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2228        assert!(
2229            out.iter().any(|m| m.role != Role::system()
2230                && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2231            "expected the real turn restored, got {out:?}"
2232        );
2233    }
2234
2235    #[test]
2236    fn a_reinstated_parallel_call_inherits_the_groups_reasoning_signature() {
2237        // The signature sits on the reasoning that precedes a parallel group;
2238        // restoring only the second call used to stop at its sibling and find
2239        // nothing.
2240        let group = Message::with_contents(
2241            Role::assistant(),
2242            vec![
2243                Content::TextReasoning(crate::types::TextReasoningContent {
2244                    text: "thinking".into(),
2245                    protected_data: Some("c2ln".into()),
2246                    ..Default::default()
2247                }),
2248                Content::FunctionCall(crate::types::FunctionCallContent::new(
2249                    "c1",
2250                    "get_weather",
2251                    None,
2252                )),
2253                Content::FunctionCall(crate::types::FunctionCallContent::new(
2254                    "c2", "get_time", None,
2255                )),
2256            ],
2257        );
2258        let history = vec![group];
2259        // Only c2 is answered by the incoming input.
2260        let input = vec![tool_result_message("c2", "noon")];
2261        let retained = SlidingWindow::new(0).compact(&history, &ApproxTokenizer);
2262        let out = finalize_compaction(&history, retained, &input);
2263
2264        let call = out
2265            .iter()
2266            .flat_map(|m| m.contents.iter())
2267            .find_map(Content::as_function_call)
2268            .expect("c2 is reinstated");
2269        assert_eq!(call.call_id, "c2");
2270        assert_eq!(call.protected_data.as_deref(), Some("c2ln"));
2271    }
2272
2273    #[test]
2274    fn an_image_type_on_a_malformed_uri_does_not_satisfy_retention() {
2275        // Anthropic and Gemini both parse the data URI before emitting, so a
2276        // declared image media type over a broken URI reaches neither.
2277        for uri in ["not-a-data-uri", "data:image/png,AAAA", ""] {
2278            let messages = vec![
2279                text(Role::system(), "sys"),
2280                text(Role::user(), "a real earlier turn"),
2281                Message::with_contents(
2282                    Role::user(),
2283                    vec![Content::Data(crate::types::DataContent {
2284                        uri: uri.into(),
2285                        media_type: Some("image/png".into()),
2286                    })],
2287                ),
2288            ];
2289            let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2290            assert!(
2291                out.iter().any(|m| m.role != Role::system()
2292                    && m.contents.iter().any(|c| matches!(c, Content::Text(_)))),
2293                "expected a real turn restored for {uri:?}, got {out:?}"
2294            );
2295        }
2296    }
2297
2298    #[test]
2299    fn blank_text_does_not_satisfy_the_retention_check() {
2300        // `Text("")` is worse than unrendered — Anthropic rejects an empty
2301        // text block outright — and a whitespace turn gives the model nothing
2302        // either way, so the older substantive turn must be restored.
2303        for blank in ["", "   \n\t"] {
2304            let messages = vec![
2305                text(Role::system(), "sys"),
2306                text(Role::user(), "a real earlier turn"),
2307                text(Role::assistant(), blank),
2308            ];
2309            let out = compact(&messages, &SlidingWindow::new(1), &ApproxTokenizer);
2310            assert!(
2311                out.iter()
2312                    .any(|m| m.role != Role::system() && !m.text().trim().is_empty()),
2313                "expected the real turn restored for {blank:?}, got {out:?}"
2314            );
2315        }
2316    }
2317
2318    #[test]
2319    fn an_all_system_conversation_stays_all_system() {
2320        // Nothing to reinstate: the fallback must not invent a turn.
2321        let messages = vec![text(Role::system(), "a"), text(Role::system(), "b")];
2322        let out = compact(&messages, &Truncation::new(1), &ApproxTokenizer);
2323        assert!(out.iter().all(|m| m.role == Role::system()));
2324    }
2325
2326    // ---- ApproxTokenizer -------------------------------------------------
2327
2328    #[test]
2329    fn approx_tokenizer_uses_four_chars_per_token_ceiling() {
2330        let t = ApproxTokenizer;
2331        assert_eq!(t.count_tokens(""), 0);
2332        assert_eq!(t.count_tokens("abcd"), 1);
2333        assert_eq!(t.count_tokens("abcde"), 2); // ceil(5/4) = 2
2334        assert_eq!(t.count_tokens("abcdefgh"), 2);
2335        assert_eq!(t.count_tokens("abcdefghi"), 3); // ceil(9/4) = 3
2336    }
2337
2338    #[test]
2339    fn approx_tokenizer_does_not_inflate_non_ascii_text() {
2340        // Upstream counted tokens off a JSON serialization with ensure_ascii=True,
2341        // so CJK text was measured as inflated `\uXXXX` escapes rather than the
2342        // characters the model actually sees (#7124). This port counts the
2343        // characters directly, so the escape inflation never existed here — pin
2344        // that: 4 CJK characters cost the same as 4 ASCII ones, not 6x more.
2345        let t = ApproxTokenizer;
2346        assert_eq!(t.count_tokens("日本語訳"), t.count_tokens("abcd"));
2347
2348        let msg = Message::with_contents(Role::user(), vec![Content::text("日本語訳")]);
2349        assert_eq!(count_message_tokens(&t, &msg), 1);
2350    }
2351
2352    #[test]
2353    fn count_message_tokens_sums_text_content() {
2354        let t = ApproxTokenizer;
2355        let msg = Message::with_contents(
2356            Role::user(),
2357            vec![Content::text("abcd"), Content::text("abcdefgh")],
2358        );
2359        // 1 + 2 = 3
2360        assert_eq!(count_message_tokens(&t, &msg), 3);
2361    }
2362
2363    // ---- Truncation --------------------------------------------------------
2364
2365    #[test]
2366    fn truncation_keeps_most_recent_messages() {
2367        let messages = vec![
2368            text(Role::user(), "1"),
2369            text(Role::assistant(), "2"),
2370            text(Role::user(), "3"),
2371            text(Role::assistant(), "4"),
2372        ];
2373        let strategy = Truncation::new(2);
2374        let out = compact(&messages, &strategy, &ApproxTokenizer);
2375        assert_eq!(out.len(), 2);
2376        assert_eq!(out[0].text(), "3");
2377        assert_eq!(out[1].text(), "4");
2378    }
2379
2380    #[test]
2381    fn truncation_preserves_leading_system_messages() {
2382        let messages = vec![
2383            text(Role::system(), "sys"),
2384            text(Role::user(), "1"),
2385            text(Role::assistant(), "2"),
2386            text(Role::user(), "3"),
2387            text(Role::assistant(), "4"),
2388        ];
2389        let strategy = Truncation::new(2);
2390        let out = compact(&messages, &strategy, &ApproxTokenizer);
2391        // system preserved + 1 most recent (budget of 2 total)
2392        assert_eq!(out.len(), 2);
2393        assert_eq!(out[0].role, Role::system());
2394        assert_eq!(out[0].text(), "sys");
2395        assert_eq!(out[1].text(), "4");
2396    }
2397
2398    #[test]
2399    fn truncation_preserves_multiple_leading_system_messages() {
2400        let messages = vec![
2401            text(Role::system(), "sys1"),
2402            text(Role::system(), "sys2"),
2403            text(Role::user(), "1"),
2404            text(Role::assistant(), "2"),
2405        ];
2406        let strategy = Truncation::new(3);
2407        let out = compact(&messages, &strategy, &ApproxTokenizer);
2408        assert_eq!(out.len(), 3);
2409        assert_eq!(out[0].text(), "sys1");
2410        assert_eq!(out[1].text(), "sys2");
2411        assert_eq!(out[2].text(), "2");
2412    }
2413
2414    #[test]
2415    fn truncation_noop_when_under_budget() {
2416        let messages = vec![text(Role::user(), "1"), text(Role::assistant(), "2")];
2417        let strategy = Truncation::new(10);
2418        let out = compact(&messages, &strategy, &ApproxTokenizer);
2419        assert_eq!(out, messages);
2420    }
2421
2422    // ---- SlidingWindow -------------------------------------------------
2423
2424    #[test]
2425    fn sliding_window_keeps_system_plus_last_n_non_system() {
2426        let messages = vec![
2427            text(Role::system(), "sys"),
2428            text(Role::user(), "1"),
2429            text(Role::assistant(), "2"),
2430            text(Role::user(), "3"),
2431        ];
2432        let strategy = SlidingWindow::new(2);
2433        let out = compact(&messages, &strategy, &ApproxTokenizer);
2434        assert_eq!(out.len(), 3);
2435        assert_eq!(out[0].text(), "sys");
2436        assert_eq!(out[1].text(), "2");
2437        assert_eq!(out[2].text(), "3");
2438    }
2439
2440    #[test]
2441    fn sliding_window_with_no_system_message() {
2442        let messages = vec![
2443            text(Role::user(), "1"),
2444            text(Role::assistant(), "2"),
2445            text(Role::user(), "3"),
2446        ];
2447        let strategy = SlidingWindow::new(1);
2448        let out = compact(&messages, &strategy, &ApproxTokenizer);
2449        assert_eq!(out.len(), 1);
2450        assert_eq!(out[0].text(), "3");
2451    }
2452
2453    // ---- TokenBudget --------------------------------------------------
2454
2455    /// A tokenizer with a fixed per-message-call cost, for deterministic
2456    /// tests independent of exact text length.
2457    struct FixedTokenizer(usize);
2458    impl Tokenizer for FixedTokenizer {
2459        fn count_tokens(&self, _text: &str) -> usize {
2460            self.0
2461        }
2462    }
2463
2464    #[test]
2465    fn token_budget_keeps_only_what_fits_from_the_newest_backward() {
2466        let messages = vec![
2467            text(Role::user(), "1"),
2468            text(Role::assistant(), "2"),
2469            text(Role::user(), "3"),
2470            text(Role::assistant(), "4"),
2471        ];
2472        // Each message costs a fixed 10 tokens; budget for 2 messages.
2473        let tokenizer = FixedTokenizer(10);
2474        let strategy = TokenBudget::new(25);
2475        let out = compact(&messages, &strategy, &tokenizer);
2476        assert_eq!(out.len(), 2);
2477        assert_eq!(out[0].text(), "3");
2478        assert_eq!(out[1].text(), "4");
2479    }
2480
2481    #[test]
2482    fn token_budget_preserves_leading_system_message_and_counts_it() {
2483        let messages = vec![
2484            text(Role::system(), "sys"),
2485            text(Role::user(), "1"),
2486            text(Role::assistant(), "2"),
2487            text(Role::user(), "3"),
2488        ];
2489        let tokenizer = FixedTokenizer(10);
2490        // System (10) + budget for one more message (<=20 total).
2491        let strategy = TokenBudget::new(20);
2492        let out = compact(&messages, &strategy, &tokenizer);
2493        assert_eq!(out.len(), 2);
2494        assert_eq!(out[0].role, Role::system());
2495        assert_eq!(out[1].text(), "3");
2496    }
2497
2498    #[test]
2499    fn token_budget_keeps_at_least_the_newest_message_even_if_it_alone_exceeds_budget() {
2500        let messages = vec![text(Role::user(), "1"), text(Role::assistant(), "2")];
2501        let tokenizer = FixedTokenizer(100);
2502        let strategy = TokenBudget::new(1);
2503        let out = compact(&messages, &strategy, &tokenizer);
2504        assert_eq!(out.len(), 1);
2505        assert_eq!(out[0].text(), "2");
2506    }
2507
2508    #[test]
2509    fn token_budget_keeps_everything_when_it_all_fits() {
2510        let messages = vec![text(Role::user(), "1"), text(Role::assistant(), "2")];
2511        let tokenizer = FixedTokenizer(1);
2512        let strategy = TokenBudget::new(1000);
2513        let out = compact(&messages, &strategy, &tokenizer);
2514        assert_eq!(out, messages);
2515    }
2516
2517    // ---- SelectiveToolResult --------------------------------------------
2518
2519    #[test]
2520    fn selective_tool_result_strips_stale_results_and_keeps_recent_ones() {
2521        let messages = vec![
2522            tool_call_message("c1", "t1"),
2523            tool_result_message("c1", "result 1"),
2524            tool_call_message("c2", "t2"),
2525            tool_result_message("c2", "result 2"),
2526            tool_call_message("c3", "t3"),
2527            tool_result_message("c3", "result 3"),
2528        ];
2529        let strategy = SelectiveToolResult::new(1);
2530        let out = compact(&messages, &strategy, &ApproxTokenizer);
2531
2532        // Every message survives — the two oldest results keep their content
2533        // (so their calls stay answered) with only the payload replaced.
2534        assert_eq!(out.len(), 6);
2535        assert_eq!(
2536            out[1].function_results()[0].result,
2537            Some(json!(OMITTED_TOOL_RESULT))
2538        );
2539        assert_eq!(
2540            out[3].function_results()[0].result,
2541            Some(json!(OMITTED_TOOL_RESULT))
2542        );
2543        assert_eq!(out[5].function_results()[0].result, Some(json!("result 3")));
2544    }
2545
2546    #[test]
2547    fn selective_tool_result_keeps_text_alongside_a_stripped_tool_result() {
2548        let mixed = Message::with_contents(
2549            Role::tool(),
2550            vec![
2551                Content::text("some accompanying text"),
2552                Content::FunctionResult(FunctionResultContent::new("c1", Some(json!("r1")))),
2553            ],
2554        );
2555        let messages = vec![
2556            tool_call_message("c1", "t1"),
2557            mixed,
2558            tool_call_message("c2", "t2"),
2559            tool_result_message("c2", "result 2"),
2560            tool_call_message("c3", "t3"),
2561            tool_result_message("c3", "result 3"),
2562        ];
2563        let strategy = SelectiveToolResult::new(2);
2564        let out = compact(&messages, &strategy, &ApproxTokenizer);
2565
2566        // The first message's tool-result payload is compacted (only the two
2567        // most recent tool-result-bearing messages keep theirs), but its
2568        // accompanying text survives untouched alongside it.
2569        assert_eq!(out.len(), 6);
2570        assert_eq!(out[1].text(), "some accompanying text");
2571        assert_eq!(
2572            out[1].function_results()[0].result,
2573            Some(json!(OMITTED_TOOL_RESULT))
2574        );
2575        assert_eq!(out[3].function_results()[0].result, Some(json!("result 2")));
2576        assert_eq!(out[5].function_results()[0].result, Some(json!("result 3")));
2577    }
2578
2579    #[test]
2580    fn selective_tool_result_noop_when_keep_last_covers_all() {
2581        let messages = vec![
2582            tool_call_message("c1", "t1"),
2583            tool_result_message("c1", "result 1"),
2584            tool_call_message("c2", "t2"),
2585            tool_result_message("c2", "result 2"),
2586        ];
2587        let strategy = SelectiveToolResult::new(5);
2588        let out = compact(&messages, &strategy, &ApproxTokenizer);
2589        assert_eq!(out, messages);
2590    }
2591
2592    #[test]
2593    fn selective_tool_result_ignores_messages_without_tool_results() {
2594        let messages = vec![
2595            text(Role::system(), "sys"),
2596            text(Role::user(), "hi"),
2597            text(Role::assistant(), "hello"),
2598        ];
2599        let strategy = SelectiveToolResult::new(0);
2600        let out = compact(&messages, &strategy, &ApproxTokenizer);
2601        assert_eq!(out, messages);
2602    }
2603
2604    // ---- CompactionProvider ---------------------------------------------
2605
2606    #[tokio::test]
2607    async fn compaction_provider_before_run_replaces_ctx_messages_with_compacted_subset() {
2608        let provider = CompactionProvider::new(Truncation::new(2));
2609        let mut ctx = SessionContext::new(vec![]);
2610        ctx.messages = vec![
2611            text(Role::user(), "1"),
2612            text(Role::assistant(), "2"),
2613            text(Role::user(), "3"),
2614            text(Role::assistant(), "4"),
2615        ];
2616        provider.before_run(&mut ctx).await.unwrap();
2617        assert_eq!(ctx.messages.len(), 2);
2618        assert_eq!(ctx.messages[0].text(), "3");
2619        assert_eq!(ctx.messages[1].text(), "4");
2620    }
2621
2622    #[tokio::test]
2623    async fn compaction_provider_with_tokenizer_uses_the_supplied_tokenizer() {
2624        struct FixedTokenizer(usize);
2625        impl Tokenizer for FixedTokenizer {
2626            fn count_tokens(&self, _text: &str) -> usize {
2627                self.0
2628            }
2629        }
2630        let provider = CompactionProvider::with_tokenizer(TokenBudget::new(25), FixedTokenizer(10));
2631        let mut ctx = SessionContext::new(vec![]);
2632        ctx.messages = vec![
2633            text(Role::user(), "1"),
2634            text(Role::assistant(), "2"),
2635            text(Role::user(), "3"),
2636            text(Role::assistant(), "4"),
2637        ];
2638        provider.before_run(&mut ctx).await.unwrap();
2639        // Budget of 25 with a fixed 10-token cost per message keeps 2 messages.
2640        assert_eq!(ctx.messages.len(), 2);
2641        assert_eq!(ctx.messages[0].text(), "3");
2642        assert_eq!(ctx.messages[1].text(), "4");
2643    }
2644
2645    #[tokio::test]
2646    async fn compaction_provider_after_run_is_a_noop() {
2647        let provider = CompactionProvider::new(Truncation::new(1));
2648        provider
2649            .after_run(&[Message::new(Role::user(), "hi")], &[], None)
2650            .await
2651            .unwrap();
2652    }
2653}