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