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