gemini_adk_fluent_rs/compose/
context.rs

1//! C — Context engineering.
2//!
3//! Compose context policies additively with `+`.
4
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use gemini_adk_rs::error::AgentError;
9use gemini_adk_rs::llm::LlmRequest;
10use gemini_adk_rs::middleware::Middleware;
11use gemini_genai_rs::prelude::Content;
12
13/// A context policy that filters/transforms conversation history.
14#[derive(Clone)]
15pub struct ContextPolicy {
16    name: &'static str,
17    #[allow(clippy::type_complexity)]
18    filter: Arc<dyn Fn(&[Content]) -> Vec<Content> + Send + Sync>,
19}
20
21impl ContextPolicy {
22    fn new(
23        name: &'static str,
24        f: impl Fn(&[Content]) -> Vec<Content> + Send + Sync + 'static,
25    ) -> Self {
26        Self {
27            name,
28            filter: Arc::new(f),
29        }
30    }
31
32    /// Apply this policy to conversation history.
33    pub fn apply(&self, history: &[Content]) -> Vec<Content> {
34        (self.filter)(history)
35    }
36
37    /// Name of this policy.
38    pub fn name(&self) -> &str {
39        self.name
40    }
41}
42
43impl std::fmt::Debug for ContextPolicy {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("ContextPolicy")
46            .field("name", &self.name)
47            .finish()
48    }
49}
50
51/// Compose two context policies additively with `+`.
52/// The combined policy applies both filters and merges (deduplicates) results.
53impl std::ops::Add for ContextPolicy {
54    type Output = ContextPolicyChain;
55
56    fn add(self, rhs: ContextPolicy) -> Self::Output {
57        ContextPolicyChain {
58            policies: vec![self, rhs],
59        }
60    }
61}
62
63/// A chain of context policies applied in combination.
64#[derive(Clone)]
65pub struct ContextPolicyChain {
66    /// The ordered list of policies in this chain.
67    pub policies: Vec<ContextPolicy>,
68}
69
70impl ContextPolicyChain {
71    /// Apply all policies in sequence, piping each policy's output into the
72    /// next. For `C::window(10) + C::user_only()` this means "take the last 10
73    /// turns, then keep only the user turns" — the additive `+` composes the
74    /// transforms rather than unioning their independent results.
75    pub fn apply(&self, history: &[Content]) -> Vec<Content> {
76        let mut current = history.to_vec();
77        for policy in &self.policies {
78            current = policy.apply(&current);
79        }
80        current
81    }
82
83    /// Adapt this policy chain into a `transform_request` middleware layer that
84    /// rewrites the request's conversation history before it reaches the model.
85    pub fn into_middleware(self) -> Arc<dyn Middleware> {
86        Arc::new(ContextMiddleware { chain: self })
87    }
88}
89
90/// A single policy is a one-element chain, so `.context(C::window(10))` works
91/// without an explicit `+`.
92impl From<ContextPolicy> for ContextPolicyChain {
93    fn from(policy: ContextPolicy) -> Self {
94        ContextPolicyChain {
95            policies: vec![policy],
96        }
97    }
98}
99
100/// Middleware adapter that applies a [`ContextPolicyChain`] to the request
101/// history on every model call.
102struct ContextMiddleware {
103    chain: ContextPolicyChain,
104}
105
106#[async_trait]
107impl Middleware for ContextMiddleware {
108    fn name(&self) -> &str {
109        "context"
110    }
111
112    async fn transform_request(&self, request: &mut LlmRequest) -> Result<(), AgentError> {
113        request.contents = self.chain.apply(&request.contents);
114        Ok(())
115    }
116}
117
118impl std::ops::Add<ContextPolicy> for ContextPolicyChain {
119    type Output = ContextPolicyChain;
120
121    fn add(mut self, rhs: ContextPolicy) -> Self::Output {
122        self.policies.push(rhs);
123        self
124    }
125}
126
127/// The `C` namespace — static factory methods for context policies.
128pub struct C;
129
130impl C {
131    /// Keep only the last `n` messages.
132    pub fn window(n: usize) -> ContextPolicy {
133        ContextPolicy::new("window", move |history| {
134            if history.len() > n {
135                history[history.len() - n..].to_vec()
136            } else {
137                history.to_vec()
138            }
139        })
140    }
141
142    /// Keep only messages with role "user".
143    pub fn user_only() -> ContextPolicy {
144        use gemini_genai_rs::prelude::Role;
145        ContextPolicy::new("user_only", move |history| {
146            history
147                .iter()
148                .filter(|c| c.role == Some(Role::User))
149                .cloned()
150                .collect()
151        })
152    }
153
154    /// Apply a custom filter function.
155    pub fn custom(f: impl Fn(&[Content]) -> Vec<Content> + Send + Sync + 'static) -> ContextPolicy {
156        ContextPolicy::new("custom", f)
157    }
158
159    /// Keep only messages with role "model".
160    pub fn model_only() -> ContextPolicy {
161        use gemini_genai_rs::prelude::Role;
162        ContextPolicy::new("model_only", move |history| {
163            history
164                .iter()
165                .filter(|c| c.role == Some(Role::Model))
166                .cloned()
167                .collect()
168        })
169    }
170
171    /// Keep the first `n` messages (head).
172    pub fn head(n: usize) -> ContextPolicy {
173        ContextPolicy::new("head", move |history| {
174            history.iter().take(n).cloned().collect()
175        })
176    }
177
178    /// Keep every `n`-th message (sampling).
179    pub fn sample(n: usize) -> ContextPolicy {
180        ContextPolicy::new("sample", move |history| {
181            history
182                .iter()
183                .enumerate()
184                .filter(|(i, _)| i % n == 0)
185                .map(|(_, c)| c.clone())
186                .collect()
187        })
188    }
189
190    /// Exclude messages that contain tool-related parts (function calls/responses).
191    pub fn exclude_tools() -> ContextPolicy {
192        use gemini_genai_rs::prelude::Part;
193        ContextPolicy::new("exclude_tools", move |history| {
194            history
195                .iter()
196                .filter(|c| {
197                    c.parts.iter().all(|p| {
198                        !matches!(p, Part::FunctionCall { .. } | Part::FunctionResponse { .. })
199                    })
200                })
201                .cloned()
202                .collect()
203        })
204    }
205
206    /// Prepend a system message to the context.
207    pub fn prepend(content: Content) -> ContextPolicy {
208        ContextPolicy::new("prepend", move |history| {
209            let mut result = vec![content.clone()];
210            result.extend(history.iter().cloned());
211            result
212        })
213    }
214
215    /// Append a content to the context.
216    pub fn append(content: Content) -> ContextPolicy {
217        ContextPolicy::new("append", move |history| {
218            let mut result = history.to_vec();
219            result.push(content.clone());
220            result
221        })
222    }
223
224    /// Keep only messages that contain text parts.
225    pub fn text_only() -> ContextPolicy {
226        use gemini_genai_rs::prelude::Part;
227        ContextPolicy::new("text_only", move |history| {
228            history
229                .iter()
230                .filter(|c| c.parts.iter().any(|p| matches!(p, Part::Text { .. })))
231                .cloned()
232                .collect()
233        })
234    }
235
236    /// Filter messages by a predicate on Content.
237    pub fn filter(f: impl Fn(&Content) -> bool + Send + Sync + 'static) -> ContextPolicy {
238        ContextPolicy::new("filter", move |history| {
239            history.iter().filter(|c| f(c)).cloned().collect()
240        })
241    }
242
243    /// Map/transform each message in the context.
244    pub fn map(f: impl Fn(&Content) -> Content + Send + Sync + 'static) -> ContextPolicy {
245        ContextPolicy::new("map", move |history| history.iter().map(&f).collect())
246    }
247
248    /// Truncate context to approximately `max_chars` total characters of text.
249    pub fn truncate(max_chars: usize) -> ContextPolicy {
250        use gemini_genai_rs::prelude::Part;
251        ContextPolicy::new("truncate", move |history| {
252            let mut total = 0;
253            let mut result = Vec::new();
254            // Work backwards to keep most recent messages
255            for c in history.iter().rev() {
256                let text_len: usize = c
257                    .parts
258                    .iter()
259                    .filter_map(|p| match p {
260                        Part::Text { text } => Some(text.len()),
261                        _ => None,
262                    })
263                    .sum();
264                if total + text_len > max_chars && !result.is_empty() {
265                    break;
266                }
267                total += text_len;
268                result.push(c.clone());
269            }
270            result.reverse();
271            result
272        })
273    }
274
275    /// Keep messages within a time window (by index offset from end).
276    /// Alias for `window` — provided for API symmetry.
277    pub fn last(n: usize) -> ContextPolicy {
278        Self::window(n)
279    }
280
281    /// Return an empty context (useful for isolated agents).
282    pub fn empty() -> ContextPolicy {
283        ContextPolicy::new("empty", |_| Vec::new())
284    }
285
286    /// Inject state values as context preamble.
287    ///
288    /// Bridges Channel 2 (State) → Channel 1 (Conversation History) by prepending
289    /// formatted state values as a system context message.
290    ///
291    /// # Example
292    /// ```ignore
293    /// C::from_state(&["user:name", "app:account_balance", "derived:risk"])
294    /// // Produces: "[Context: name=John, account_balance=$5230, risk=0.72]"
295    /// ```
296    pub fn from_state(keys: &[&str]) -> ContextPolicy {
297        let owned_keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
298        ContextPolicy::new("from_state", move |history| {
299            // Note: This policy captures keys but cannot access State at filter time.
300            // The actual state injection happens at the Live session level via
301            // instruction_template or on_turn_boundary. This policy prepends a
302            // placeholder that the runtime populates.
303            let mut result = Vec::new();
304            if !owned_keys.is_empty() {
305                let key_list = owned_keys.join(", ");
306                result.push(Content::user(format!("[Context keys: {}]", key_list)));
307            }
308            result.extend(history.iter().cloned());
309            result
310        })
311    }
312
313    /// Alias for [`empty`](Self::empty) — matches upstream Python `C.none()`.
314    pub fn none() -> ContextPolicy {
315        Self::empty()
316    }
317
318    /// Alias for [`window`](Self::window) — matches upstream Python `C.recent()`.
319    pub fn recent(n: usize) -> ContextPolicy {
320        Self::window(n)
321    }
322
323    /// Template-based context injection with `{key}` placeholders.
324    ///
325    /// Replaces placeholders in the template with state key references.
326    pub fn template(tpl: &str) -> ContextPolicy {
327        let tpl = tpl.to_string();
328        ContextPolicy::new("template", move |history| {
329            let mut result = vec![Content::user(tpl.clone())];
330            result.extend(history.iter().cloned());
331            result
332        })
333    }
334
335    /// Conditional context — applies inner policy only when predicate is true.
336    ///
337    /// Falls back to passing history through unchanged.
338    pub fn when(
339        predicate: impl Fn() -> bool + Send + Sync + 'static,
340        inner: ContextPolicy,
341    ) -> ContextPolicy {
342        ContextPolicy::new("when", move |history| {
343            if predicate() {
344                inner.apply(history)
345            } else {
346                history.to_vec()
347            }
348        })
349    }
350
351    /// Rolling window — keeps last N messages (alias with summarization hint).
352    pub fn rolling(n: usize) -> ContextPolicy {
353        Self::window(n)
354    }
355
356    /// Compact context — removes tool call/response parts to reduce token usage.
357    pub fn compact() -> ContextPolicy {
358        Self::exclude_tools()
359    }
360
361    /// Budget context — truncate to approximate token count.
362    ///
363    /// Rough estimate: 4 chars per token.
364    pub fn budget(max_tokens: usize) -> ContextPolicy {
365        Self::truncate(max_tokens * 4)
366    }
367
368    /// Freshness filter — keep only messages within the last N entries.
369    pub fn fresh(max_entries: usize) -> ContextPolicy {
370        Self::window(max_entries)
371    }
372
373    /// Redact patterns from context messages.
374    pub fn redact(patterns: &[&str]) -> ContextPolicy {
375        use gemini_genai_rs::prelude::Part;
376        let patterns: Vec<String> = patterns.iter().map(|p| p.to_string()).collect();
377        ContextPolicy::new("redact", move |history| {
378            history
379                .iter()
380                .map(|c| {
381                    let parts: Vec<Part> = c
382                        .parts
383                        .iter()
384                        .map(|p| match p {
385                            Part::Text { text } => {
386                                let mut redacted = text.clone();
387                                for pattern in &patterns {
388                                    redacted = redacted.replace(pattern.as_str(), "[REDACTED]");
389                                }
390                                Part::Text { text: redacted }
391                            }
392                            other => other.clone(),
393                        })
394                        .collect();
395                    Content {
396                        role: c.role,
397                        parts,
398                    }
399                })
400                .collect()
401        })
402    }
403
404    /// LLM-powered context summarization.
405    ///
406    /// Stores a summarization prompt that the runtime uses to condense context
407    /// via an LLM call before passing it to the agent. The policy prepends a
408    /// marker so the runtime knows summarization is requested.
409    ///
410    /// # Example
411    /// ```ignore
412    /// C::summarize("Summarize the conversation focusing on action items")
413    /// ```
414    pub fn summarize(prompt: &str) -> ContextPolicy {
415        let prompt = prompt.to_string();
416        ContextPolicy::new("summarize", move |history| {
417            let mut result = vec![Content::user(format!("[Summarize context: {}]", prompt))];
418            result.extend(history.iter().cloned());
419            result
420        })
421    }
422
423    /// Keep only context relevant to a state key.
424    ///
425    /// Marker policy for LLM-powered relevance filtering. The runtime uses
426    /// the referenced state key's value to filter context entries by relevance.
427    ///
428    /// # Example
429    /// ```ignore
430    /// C::relevant("user:current_topic")
431    /// ```
432    pub fn relevant(query_key: &str) -> ContextPolicy {
433        let key = query_key.to_string();
434        ContextPolicy::new("relevant", move |history| {
435            let mut result = vec![Content::user(format!(
436                "[Filter context relevant to state key: {}]",
437                key
438            ))];
439            result.extend(history.iter().cloned());
440            result
441        })
442    }
443
444    /// Extract specific information from context.
445    ///
446    /// Marker policy that signals the runtime to extract only the named
447    /// pieces of information from the conversation history via an LLM call.
448    ///
449    /// # Example
450    /// ```ignore
451    /// C::extract(&["customer_name", "order_id", "complaint"])
452    /// ```
453    pub fn extract(keys: &[&str]) -> ContextPolicy {
454        let owned_keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
455        ContextPolicy::new("extract", move |history| {
456            let mut result = vec![Content::user(format!(
457                "[Extract from context: {}]",
458                owned_keys.join(", ")
459            ))];
460            result.extend(history.iter().cloned());
461            result
462        })
463    }
464
465    /// Distill context to essential information.
466    ///
467    /// Marker policy for LLM-powered distillation. Similar to `summarize` but
468    /// focused on extracting only the essential facts per the given instruction.
469    ///
470    /// # Example
471    /// ```ignore
472    /// C::distill("Keep only decisions made and their rationale")
473    /// ```
474    pub fn distill(instruction: &str) -> ContextPolicy {
475        let instruction = instruction.to_string();
476        ContextPolicy::new("distill", move |history| {
477            let mut result = vec![Content::user(format!("[Distill context: {}]", instruction))];
478            result.extend(history.iter().cloned());
479            result
480        })
481    }
482
483    /// Priority-weighted context selection.
484    ///
485    /// Assigns weights to context entries by role or content pattern. Higher
486    /// weight entries are kept preferentially when context must be truncated.
487    /// Weights are encoded as a marker for runtime processing.
488    ///
489    /// # Example
490    /// ```ignore
491    /// C::priority(&[("user", 1.0), ("model", 0.5), ("tool", 0.2)])
492    /// ```
493    pub fn priority(weights: &[(&str, f64)]) -> ContextPolicy {
494        let owned_weights: Vec<(String, f64)> =
495            weights.iter().map(|(k, v)| (k.to_string(), *v)).collect();
496        ContextPolicy::new("priority", move |history| {
497            let weight_str = owned_weights
498                .iter()
499                .map(|(k, v)| format!("{}={}", k, v))
500                .collect::<Vec<_>>()
501                .join(", ");
502            let mut result = vec![Content::user(format!("[Priority weights: {}]", weight_str))];
503            result.extend(history.iter().cloned());
504            result
505        })
506    }
507
508    /// Fit context to a token budget with smart truncation.
509    ///
510    /// Similar to [`budget`](Self::budget) but adds a truncation marker so the
511    /// runtime knows content was trimmed, allowing the agent to request more
512    /// context if needed.
513    ///
514    /// # Example
515    /// ```ignore
516    /// C::fit(4096)
517    /// ```
518    pub fn fit(max_tokens: usize) -> ContextPolicy {
519        use gemini_genai_rs::prelude::Part;
520        let max_chars = max_tokens * 4; // rough estimate: 4 chars per token
521        ContextPolicy::new("fit", move |history| {
522            let mut total = 0;
523            let mut result = Vec::new();
524            // Work backwards to keep most recent messages
525            for c in history.iter().rev() {
526                let text_len: usize = c
527                    .parts
528                    .iter()
529                    .filter_map(|p| match p {
530                        Part::Text { text } => Some(text.len()),
531                        _ => None,
532                    })
533                    .sum();
534                if total + text_len > max_chars && !result.is_empty() {
535                    // Prepend truncation marker
536                    result.push(Content::user(format!(
537                        "[Context truncated to fit ~{} token budget; {} earlier messages omitted]",
538                        max_tokens,
539                        history.len() - result.len()
540                    )));
541                    break;
542                }
543                total += text_len;
544                result.push(c.clone());
545            }
546            result.reverse();
547            result
548        })
549    }
550
551    /// Project/keep only specific fields from context.
552    ///
553    /// Marker policy that signals the runtime to retain only the named fields
554    /// from structured content in the conversation history.
555    ///
556    /// # Example
557    /// ```ignore
558    /// C::project(&["name", "status", "priority"])
559    /// ```
560    pub fn project(fields: &[&str]) -> ContextPolicy {
561        let owned_fields: Vec<String> = fields.iter().map(|f| f.to_string()).collect();
562        ContextPolicy::new("project", move |history| {
563            let mut result = vec![Content::user(format!(
564                "[Project fields: {}]",
565                owned_fields.join(", ")
566            ))];
567            result.extend(history.iter().cloned());
568            result
569        })
570    }
571
572    /// Select context entries matching a predicate.
573    ///
574    /// Similar to [`filter`](Self::filter) but with select semantics: entries
575    /// matching the predicate are included (positive selection).
576    pub fn select(predicate: impl Fn(&Content) -> bool + Send + Sync + 'static) -> ContextPolicy {
577        ContextPolicy::new("select", move |history| {
578            history.iter().filter(|c| predicate(c)).cloned().collect()
579        })
580    }
581
582    /// Include context only from specific agents.
583    ///
584    /// Filters context to keep only messages attributed to the named agents.
585    /// Agent attribution is detected via `[Agent: name]` markers in content.
586    ///
587    /// # Example
588    /// ```ignore
589    /// C::from_agents(&["researcher", "analyst"])
590    /// ```
591    pub fn from_agents(names: &[&str]) -> ContextPolicy {
592        let owned_names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
593        ContextPolicy::new("from_agents", move |history| {
594            history
595                .iter()
596                .filter(|c| {
597                    c.parts.iter().any(|p| match p {
598                        gemini_genai_rs::prelude::Part::Text { text } => owned_names
599                            .iter()
600                            .any(|name| text.contains(&format!("[Agent: {}]", name))),
601                        _ => false,
602                    })
603                })
604                .cloned()
605                .collect()
606        })
607    }
608
609    /// Exclude context from specific agents.
610    ///
611    /// Filters out messages attributed to the named agents. Agent attribution
612    /// is detected via `[Agent: name]` markers in content.
613    ///
614    /// # Example
615    /// ```ignore
616    /// C::exclude_agents(&["logger", "debugger"])
617    /// ```
618    pub fn exclude_agents(names: &[&str]) -> ContextPolicy {
619        let owned_names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
620        ContextPolicy::new("exclude_agents", move |history| {
621            history
622                .iter()
623                .filter(|c| {
624                    !c.parts.iter().any(|p| match p {
625                        gemini_genai_rs::prelude::Part::Text { text } => owned_names
626                            .iter()
627                            .any(|name| text.contains(&format!("[Agent: {}]", name))),
628                        _ => false,
629                    })
630                })
631                .cloned()
632                .collect()
633        })
634    }
635
636    /// Scratchpad: read notes from a state key as context.
637    ///
638    /// Prepends the value of a state key as a notes/scratchpad section in the
639    /// context. Useful for maintaining running notes across turns.
640    ///
641    /// # Example
642    /// ```ignore
643    /// C::notes("session:scratchpad")
644    /// ```
645    pub fn notes(key: &str) -> ContextPolicy {
646        let key = key.to_string();
647        ContextPolicy::new("notes", move |history| {
648            let mut result = vec![Content::user(format!(
649                "[Scratchpad from state key: {}]",
650                key
651            ))];
652            result.extend(history.iter().cloned());
653            result
654        })
655    }
656
657    /// Pipeline-aware context that adapts based on pipeline position.
658    ///
659    /// Marker policy that signals the runtime to adjust context based on
660    /// where the current agent sits in a pipeline. Early stages receive full
661    /// context; later stages receive only the outputs of preceding stages.
662    ///
663    /// # Example
664    /// ```ignore
665    /// C::pipeline_aware()
666    /// ```
667    pub fn pipeline_aware() -> ContextPolicy {
668        ContextPolicy::new("pipeline_aware", |history| {
669            let mut result = vec![Content::user(
670                "[Pipeline-aware: adapt context to pipeline position]".to_string(),
671            )];
672            result.extend(history.iter().cloned());
673            result
674        })
675    }
676
677    /// Deduplicate adjacent messages with identical text content.
678    pub fn dedup() -> ContextPolicy {
679        use gemini_genai_rs::prelude::Part;
680        ContextPolicy::new("dedup", |history| {
681            fn extract_text(c: &Content) -> String {
682                c.parts
683                    .iter()
684                    .filter_map(|p| match p {
685                        Part::Text { text } => Some(text.as_str()),
686                        _ => None,
687                    })
688                    .collect()
689            }
690            let mut result: Vec<Content> = Vec::new();
691            for c in history {
692                let dominated = result.last().is_some_and(|prev| {
693                    let prev_text = extract_text(prev);
694                    let curr_text = extract_text(c);
695                    prev_text == curr_text && !prev_text.is_empty()
696                });
697                if !dominated {
698                    result.push(c.clone());
699                }
700            }
701            result
702        })
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use gemini_genai_rs::prelude::Content;
710
711    #[test]
712    fn window_limits_messages() {
713        let history = vec![Content::user("a"), Content::model("b"), Content::user("c")];
714        let result = C::window(2).apply(&history);
715        assert_eq!(result.len(), 2);
716    }
717
718    #[test]
719    fn window_keeps_all_if_under_limit() {
720        let history = vec![Content::user("a")];
721        let result = C::window(5).apply(&history);
722        assert_eq!(result.len(), 1);
723    }
724
725    #[test]
726    fn user_only_filters() {
727        let history = vec![Content::user("a"), Content::model("b"), Content::user("c")];
728        let result = C::user_only().apply(&history);
729        assert_eq!(result.len(), 2);
730    }
731
732    #[test]
733    fn compose_with_add() {
734        let chain = C::window(10) + C::user_only();
735        assert_eq!(chain.policies.len(), 2);
736    }
737
738    #[test]
739    fn chain_extends_with_add() {
740        let chain = C::window(10) + C::user_only() + C::custom(|h| h.to_vec());
741        assert_eq!(chain.policies.len(), 3);
742    }
743
744    #[test]
745    fn model_only_filters() {
746        let history = vec![Content::user("a"), Content::model("b"), Content::user("c")];
747        let result = C::model_only().apply(&history);
748        assert_eq!(result.len(), 1);
749    }
750
751    #[test]
752    fn head_keeps_first_n() {
753        let history = vec![Content::user("a"), Content::model("b"), Content::user("c")];
754        let result = C::head(2).apply(&history);
755        assert_eq!(result.len(), 2);
756    }
757
758    #[test]
759    fn sample_every_nth() {
760        let history = vec![
761            Content::user("a"),
762            Content::model("b"),
763            Content::user("c"),
764            Content::model("d"),
765        ];
766        let result = C::sample(2).apply(&history);
767        assert_eq!(result.len(), 2);
768    }
769
770    #[test]
771    fn empty_returns_nothing() {
772        let history = vec![Content::user("a"), Content::model("b")];
773        let result = C::empty().apply(&history);
774        assert!(result.is_empty());
775    }
776
777    #[test]
778    fn last_is_alias_for_window() {
779        let history = vec![Content::user("a"), Content::model("b"), Content::user("c")];
780        let result = C::last(1).apply(&history);
781        assert_eq!(result.len(), 1);
782    }
783
784    #[test]
785    fn text_only_filters_non_text() {
786        let history = vec![Content::user("text msg")];
787        let result = C::text_only().apply(&history);
788        assert_eq!(result.len(), 1);
789    }
790
791    #[test]
792    fn filter_with_predicate() {
793        use gemini_genai_rs::prelude::Part;
794        let history = vec![
795            Content::user("keep"),
796            Content::user("skip"),
797            Content::user("keep this too"),
798        ];
799        let result = C::filter(|c| {
800            c.parts.iter().any(|p| match p {
801                Part::Text { text } => text.contains("keep"),
802                _ => false,
803            })
804        })
805        .apply(&history);
806        assert_eq!(result.len(), 2);
807    }
808
809    #[test]
810    fn dedup_removes_adjacent_duplicates() {
811        let history = vec![
812            Content::user("hello"),
813            Content::user("hello"),
814            Content::user("world"),
815            Content::user("world"),
816            Content::user("world"),
817        ];
818        let result = C::dedup().apply(&history);
819        assert_eq!(result.len(), 2);
820    }
821
822    #[test]
823    fn prepend_adds_to_front() {
824        let history = vec![Content::user("existing")];
825        let result = C::prepend(Content::model("system")).apply(&history);
826        assert_eq!(result.len(), 2);
827    }
828
829    #[test]
830    fn append_adds_to_back() {
831        let history = vec![Content::user("existing")];
832        let result = C::append(Content::model("suffix")).apply(&history);
833        assert_eq!(result.len(), 2);
834    }
835
836    #[test]
837    fn from_state_prepends_context() {
838        let history = vec![Content::user("hello")];
839        let result = C::from_state(&["user:name", "app:balance"]).apply(&history);
840        assert_eq!(result.len(), 2);
841        // First message should be the context keys
842        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
843            assert!(text.contains("user:name"));
844            assert!(text.contains("app:balance"));
845        } else {
846            panic!("Expected text part");
847        }
848    }
849
850    #[test]
851    fn summarize_prepends_marker() {
852        let history = vec![Content::user("hello"), Content::model("hi")];
853        let result = C::summarize("Focus on action items").apply(&history);
854        assert_eq!(result.len(), 3);
855        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
856            assert!(text.contains("Summarize context"));
857            assert!(text.contains("action items"));
858        } else {
859            panic!("Expected text part");
860        }
861    }
862
863    #[test]
864    fn relevant_prepends_key_marker() {
865        let history = vec![Content::user("hello")];
866        let result = C::relevant("user:topic").apply(&history);
867        assert_eq!(result.len(), 2);
868        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
869            assert!(text.contains("user:topic"));
870        } else {
871            panic!("Expected text part");
872        }
873    }
874
875    #[test]
876    fn extract_prepends_keys_marker() {
877        let history = vec![Content::user("hello")];
878        let result = C::extract(&["name", "order_id"]).apply(&history);
879        assert_eq!(result.len(), 2);
880        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
881            assert!(text.contains("name"));
882            assert!(text.contains("order_id"));
883        } else {
884            panic!("Expected text part");
885        }
886    }
887
888    #[test]
889    fn distill_prepends_instruction_marker() {
890        let history = vec![Content::user("hello")];
891        let result = C::distill("Keep only decisions").apply(&history);
892        assert_eq!(result.len(), 2);
893        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
894            assert!(text.contains("Distill context"));
895            assert!(text.contains("decisions"));
896        } else {
897            panic!("Expected text part");
898        }
899    }
900
901    #[test]
902    fn priority_prepends_weights_marker() {
903        let history = vec![Content::user("hello")];
904        let result = C::priority(&[("user", 1.0), ("model", 0.5)]).apply(&history);
905        assert_eq!(result.len(), 2);
906        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
907            assert!(text.contains("Priority weights"));
908            assert!(text.contains("user=1"));
909            assert!(text.contains("model=0.5"));
910        } else {
911            panic!("Expected text part");
912        }
913    }
914
915    #[test]
916    fn fit_truncates_with_marker() {
917        // Create history that exceeds budget
918        let long_msg = "a".repeat(500);
919        let history = vec![
920            Content::user(long_msg.clone()),
921            Content::user(long_msg.clone()),
922            Content::user("recent"),
923        ];
924        // Budget of 10 tokens ~ 40 chars, only "recent" fits
925        let result = C::fit(10).apply(&history);
926        // Should have the recent message + truncation marker
927        assert!(result.len() <= 3);
928        // Check that truncation marker exists somewhere
929        let has_marker = result.iter().any(|c| {
930            c.parts.iter().any(|p| match p {
931                gemini_genai_rs::prelude::Part::Text { text } => text.contains("truncated"),
932                _ => false,
933            })
934        });
935        assert!(has_marker);
936    }
937
938    #[test]
939    fn fit_keeps_all_when_under_budget() {
940        let history = vec![Content::user("hi"), Content::model("hello")];
941        let result = C::fit(1000).apply(&history);
942        assert_eq!(result.len(), 2);
943    }
944
945    #[test]
946    fn project_prepends_fields_marker() {
947        let history = vec![Content::user("hello")];
948        let result = C::project(&["name", "status"]).apply(&history);
949        assert_eq!(result.len(), 2);
950        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
951            assert!(text.contains("Project fields"));
952            assert!(text.contains("name"));
953            assert!(text.contains("status"));
954        } else {
955            panic!("Expected text part");
956        }
957    }
958
959    #[test]
960    fn select_filters_matching() {
961        use gemini_genai_rs::prelude::Role;
962        let history = vec![
963            Content::user("keep"),
964            Content::model("skip"),
965            Content::user("also keep"),
966        ];
967        let result = C::select(|c| c.role == Some(Role::User)).apply(&history);
968        assert_eq!(result.len(), 2);
969    }
970
971    #[test]
972    fn from_agents_filters_by_agent_marker() {
973        let history = vec![
974            Content::user("[Agent: researcher] Found data"),
975            Content::user("[Agent: logger] Debug info"),
976            Content::user("[Agent: researcher] More data"),
977        ];
978        let result = C::from_agents(&["researcher"]).apply(&history);
979        assert_eq!(result.len(), 2);
980    }
981
982    #[test]
983    fn exclude_agents_removes_agent_messages() {
984        let history = vec![
985            Content::user("[Agent: researcher] Found data"),
986            Content::user("[Agent: logger] Debug info"),
987            Content::user("[Agent: researcher] More data"),
988        ];
989        let result = C::exclude_agents(&["logger"]).apply(&history);
990        assert_eq!(result.len(), 2);
991    }
992
993    #[test]
994    fn notes_prepends_scratchpad_marker() {
995        let history = vec![Content::user("hello")];
996        let result = C::notes("session:scratchpad").apply(&history);
997        assert_eq!(result.len(), 2);
998        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
999            assert!(text.contains("Scratchpad"));
1000            assert!(text.contains("session:scratchpad"));
1001        } else {
1002            panic!("Expected text part");
1003        }
1004    }
1005
1006    #[test]
1007    fn pipeline_aware_prepends_marker() {
1008        let history = vec![Content::user("hello")];
1009        let result = C::pipeline_aware().apply(&history);
1010        assert_eq!(result.len(), 2);
1011        if let gemini_genai_rs::prelude::Part::Text { text } = &result[0].parts[0] {
1012            assert!(text.contains("Pipeline-aware"));
1013        } else {
1014            panic!("Expected text part");
1015        }
1016    }
1017}