gemini_adk_fluent_rs/compose/
context.rs

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