Skip to main content

behest_runtime/
context.rs

1//! Context pipeline for runtime.
2//!
3//! Wraps the existing `ContextFactory` with runtime-specific adapters
4//! for session history, compaction-aware message filtering, and
5//! token-budget management.
6
7use std::sync::Arc;
8
9use uuid::Uuid;
10
11use behest_core::cache::{CacheControl, CacheControlKind};
12use behest_core::token::estimate_message_tokens;
13
14use super::token::estimate_records_tokens;
15use behest_context::{ContextAdapter, ContextFactory, ContextInput, ContextOutput};
16use behest_provider::{ChatRequest, ContentPart, Message, ModelName, ToolSpec};
17use behest_store::MessageRecord;
18
19use super::error::RuntimeResult;
20use super::policy::PromptCacheConfig;
21
22/// Runtime context pipeline that composes context from multiple sources.
23///
24/// The pipeline:
25/// 1. Loads session history from the store
26/// 2. Applies compaction filter (reorder post-compaction messages)
27/// 3. Invokes registered context adapters (system prompt, RAG, etc.)
28/// 4. Applies token-budget trimming as a safety net
29/// 5. Produces a final [`ChatRequest`] or [`ContextOutput`]
30pub struct ContextPipeline {
31    factory: ContextFactory,
32    max_history_messages: usize,
33    max_history_tokens: usize,
34    enable_compaction_filter: bool,
35    cache_strategy: PromptCacheConfig,
36}
37
38impl ContextPipeline {
39    /// Creates a new context pipeline with default settings.
40    #[must_use]
41    pub fn new() -> Self {
42        Self {
43            factory: ContextFactory::new(),
44            max_history_messages: 50,
45            max_history_tokens: 64_000,
46            enable_compaction_filter: true,
47            cache_strategy: PromptCacheConfig::default(),
48        }
49    }
50
51    /// Creates a context pipeline with an existing context factory.
52    #[must_use]
53    pub fn with_factory(factory: ContextFactory) -> Self {
54        Self {
55            factory,
56            max_history_messages: 50,
57            max_history_tokens: 64_000,
58            enable_compaction_filter: true,
59            cache_strategy: PromptCacheConfig::default(),
60        }
61    }
62
63    /// Sets the maximum number of history messages to include as a fallback limit.
64    ///
65    /// This limit applies before token-based trimming. Default: 50.
66    #[must_use]
67    pub fn with_max_history(mut self, max: usize) -> Self {
68        self.max_history_messages = max;
69        self
70    }
71
72    /// Sets the maximum token budget for historical messages before trimming.
73    ///
74    /// Older messages are dropped first when the budget is exceeded.
75    /// Default: 64,000 tokens.
76    #[must_use]
77    pub fn with_max_history_tokens(mut self, max: usize) -> Self {
78        self.max_history_tokens = max;
79        self
80    }
81
82    /// Enables or disables the compaction message filter.
83    ///
84    /// When enabled, compacted message pairs are replaced with a synthetic
85    /// checkpoint system message. Enabled by default.
86    #[must_use]
87    pub fn with_compaction_filter(mut self, enable: bool) -> Self {
88        self.enable_compaction_filter = enable;
89        self
90    }
91
92    /// Sets the prompt cache strategy used to auto-place cache breakpoints.
93    ///
94    /// When `cache_strategy.enabled` is `true` and
95    /// `cache_strategy.auto_breakpoints` is `true`, the pipeline places up
96    /// to three cache markers on each [`ChatRequest`]:
97    ///
98    /// 1. The end of the last system message (if it has at least
99    ///    `min_system_tokens` tokens).
100    /// 2. The end of the conversation tail (the last message before the
101    ///    current user turn).
102    /// 3. Each tool definition in the request.
103    #[must_use]
104    pub fn with_cache_strategy(mut self, config: PromptCacheConfig) -> Self {
105        self.cache_strategy = config;
106        self
107    }
108
109    /// Registers a context adapter.
110    pub fn register<A>(&mut self, adapter: A)
111    where
112        A: ContextAdapter + 'static,
113    {
114        self.factory.register(adapter);
115    }
116
117    /// Registers an already shared context adapter.
118    pub fn register_arc(&mut self, adapter: Arc<dyn ContextAdapter>) {
119        self.factory.register_arc(adapter);
120    }
121
122    /// Returns adapter names in registration order.
123    pub fn adapter_names(&self) -> impl Iterator<Item = &str> {
124        self.factory.adapter_names()
125    }
126
127    /// Builds a [`ChatRequest`] from session history, registered adapters,
128    /// and the current user message.
129    ///
130    /// The pipeline loads session messages, applies compaction filtering,
131    /// token-budget trimming, runs registered adapters, and assembles the
132    /// final request with optional tool specs.
133    ///
134    /// # Errors
135    ///
136    /// Returns `RuntimeError` on adapter failure or storage errors.
137    pub async fn build(
138        &self,
139        store: &super::store::RuntimeStore,
140        session_id: Uuid,
141        model: ModelName,
142        user_message: Option<&str>,
143        tools: Option<&[ToolSpec]>,
144    ) -> RuntimeResult<ChatRequest> {
145        let input = ContextInput {
146            user_message: user_message.map(str::to_owned),
147            session_id: Some(session_id.to_string()),
148            metadata: serde_json::Value::Null,
149        };
150
151        let mut output = self.factory.build(&input).await.map_err(|e| {
152            super::error::RuntimeError::Context(behest_core::error::ContextError::AdapterFailed {
153                adapter: "pipeline".to_owned(),
154                message: e.to_string(),
155            })
156        })?;
157
158        let records = store
159            .sessions()
160            .list_messages(&session_id)
161            .await
162            .map_err(super::error::RuntimeError::Storage)?;
163
164        let records = if self.enable_compaction_filter {
165            apply_compaction_filter(records)
166        } else {
167            records
168        };
169
170        let records = trim_by_tokens(records, self.max_history_tokens);
171
172        let history: Vec<Message> = records
173            .into_iter()
174            .filter_map(super::store::record_to_message)
175            .collect();
176
177        output.extend(history);
178
179        if let Some(text) = user_message {
180            output.extend([Message::user_text(text)]);
181        }
182
183        if self.cache_strategy.enabled && self.cache_strategy.auto_breakpoints {
184            apply_message_cache_breakpoints(
185                output.messages_mut(),
186                &self.cache_strategy,
187                is_system_token_count,
188            );
189        }
190
191        let request = match tools {
192            Some(specs) => {
193                let mut specs = specs.to_vec();
194                if self.cache_strategy.enabled && self.cache_strategy.auto_breakpoints {
195                    apply_tool_cache_breakpoints(&mut specs, &self.cache_strategy);
196                }
197                output.into_request_with_tools(model, &specs)
198            }
199            None => output.into_request(model),
200        };
201
202        Ok(request)
203    }
204
205    /// Builds context output without creating a chat request.
206    ///
207    /// Equivalent to [`build`](Self::build) but returns the raw
208    /// [`ContextOutput`] instead of wrapping it in a [`ChatRequest`].
209    /// Useful when the caller needs access to the composed message list
210    /// without binding to a specific model or tools.
211    ///
212    /// # Errors
213    ///
214    /// Returns `RuntimeError` on adapter failure or storage errors.
215    pub async fn build_context(
216        &self,
217        store: &super::store::RuntimeStore,
218        session_id: Uuid,
219        user_message: Option<&str>,
220    ) -> RuntimeResult<ContextOutput> {
221        let input = ContextInput {
222            user_message: user_message.map(str::to_owned),
223            session_id: Some(session_id.to_string()),
224            metadata: serde_json::Value::Null,
225        };
226
227        let mut output = self.factory.build(&input).await.map_err(|e| {
228            super::error::RuntimeError::Context(behest_core::error::ContextError::AdapterFailed {
229                adapter: "pipeline".to_owned(),
230                message: e.to_string(),
231            })
232        })?;
233
234        let records = store
235            .sessions()
236            .list_messages(&session_id)
237            .await
238            .map_err(super::error::RuntimeError::Storage)?;
239
240        let records = if self.enable_compaction_filter {
241            apply_compaction_filter(records)
242        } else {
243            records
244        };
245
246        let records = trim_by_tokens(records, self.max_history_tokens);
247
248        let history: Vec<Message> = records
249            .into_iter()
250            .filter_map(super::store::record_to_message)
251            .collect();
252
253        output.extend(history);
254
255        if let Some(text) = user_message {
256            output.extend([Message::user_text(text)]);
257        }
258
259        if self.cache_strategy.enabled && self.cache_strategy.auto_breakpoints {
260            apply_message_cache_breakpoints(
261                output.messages_mut(),
262                &self.cache_strategy,
263                is_system_token_count,
264            );
265        }
266
267        Ok(output)
268    }
269}
270
271impl Default for ContextPipeline {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277/// Applies the compaction message filter to session history.
278///
279/// Finds the latest valid compaction pair (compaction marker + summary) and
280/// reorders messages so that the compacted head is replaced by a synthetic
281/// checkpoint system message while the retained tail and post-compaction
282/// messages remain visible.
283///
284/// Returns true when the system-message token count meets the configured
285/// minimum threshold.
286fn is_system_token_count(msg: &Message, min_tokens: usize) -> bool {
287    if !matches!(msg, Message::System { .. }) {
288        return false;
289    }
290    estimate_message_tokens(msg) >= min_tokens
291}
292
293/// Auto-places cache breakpoints on the assembled message list.
294///
295/// - System-end: places a marker on the last content part of the last
296///   system message (when token count exceeds the configured minimum).
297/// - Conversation-tail: places a marker on the last content part of the
298///   second-to-last message (the last conversation message before the
299///   current user turn), if that message is not itself the current user
300///   turn.
301fn apply_message_cache_breakpoints(
302    messages: &mut [Message],
303    config: &PromptCacheConfig,
304    system_check: fn(&Message, usize) -> bool,
305) {
306    let ctrl = CacheControl {
307        kind: CacheControlKind::Ephemeral,
308        ttl: config.default_ttl,
309    };
310
311    // 1. System-end breakpoint.
312    if let Some(last_system_idx) = messages
313        .iter()
314        .rposition(|m| matches!(m, Message::System { .. }))
315    {
316        let qualifies = system_check(&messages[last_system_idx], config.min_system_tokens);
317        if qualifies
318            && let Some(last_part) = last_content_part_mut(&mut messages[last_system_idx])
319            && last_part.cache_control().is_none()
320        {
321            last_part.set_cache_control(ctrl);
322        }
323    }
324
325    // 2. Conversation-tail breakpoint. Place on the last message that is
326    //    NOT the trailing user turn.
327    if messages.len() >= 2
328        && let Some(tail_idx) = messages
329            .iter()
330            .rposition(|m| !matches!(m, Message::User { .. }))
331        && tail_idx < messages.len() - 1
332        && let Some(last_part) = last_content_part_mut(&mut messages[tail_idx])
333        && last_part.cache_control().is_none()
334    {
335        last_part.set_cache_control(ctrl);
336    }
337}
338
339/// Auto-places cache markers on each tool definition.
340fn apply_tool_cache_breakpoints(specs: &mut [ToolSpec], config: &PromptCacheConfig) {
341    let ctrl = CacheControl {
342        kind: CacheControlKind::Ephemeral,
343        ttl: config.default_ttl,
344    };
345    for spec in specs.iter_mut() {
346        if spec.cache_control.is_none() {
347            spec.cache_control = Some(ctrl);
348        }
349    }
350}
351
352/// Returns a mutable reference to the last [`ContentPart`] in a message's
353/// content, regardless of message variant.
354fn last_content_part_mut(msg: &mut Message) -> Option<&mut ContentPart> {
355    let content = match msg {
356        Message::System { content } | Message::User { content } | Message::Tool { content, .. } => {
357            content
358        }
359        Message::Assistant { content, .. } => content,
360        _ => return None,
361    };
362    content.last_mut()
363}
364
365/// Returns the filtered message list with the compacted head removed.
366///
367/// Ported from OpenCode V1's `filterCompacted()`.
368fn apply_compaction_filter(records: Vec<MessageRecord>) -> Vec<MessageRecord> {
369    if records.is_empty() {
370        return records;
371    }
372
373    // Walk backwards to find the latest completed compaction pair:
374    //   summary_assistant (is_summary=true) immediately after
375    //   compaction_user (is_compaction=true)
376    let mut summary_idx: Option<usize> = None;
377    let mut compaction_idx: Option<usize> = None;
378
379    for i in (0..records.len()).rev() {
380        let rec = &records[i];
381        if rec.is_summary && summary_idx.is_none() {
382            summary_idx = Some(i);
383        } else if rec.is_compaction && compaction_idx.is_none() && summary_idx.is_some() {
384            // Found the compaction user that precedes this summary
385            compaction_idx = Some(i);
386            break;
387        } else if !rec.is_summary && !rec.is_compaction && summary_idx.is_some() {
388            // We found a summary but the preceding message is NOT a compaction user
389            // Reset — this is not a valid compaction pair
390            summary_idx = None;
391        }
392    }
393
394    let (Some(c_idx), Some(s_idx)) = (compaction_idx, summary_idx) else {
395        return records;
396    };
397
398    let tail_start_id = records[c_idx]
399        .compaction_meta
400        .as_ref()
401        .and_then(|m| m.tail_start_id);
402
403    let Some(tail_start) = tail_start_id else {
404        return records;
405    };
406
407    // Find the index of tail_start_id
408    let tail_idx = records.iter().position(|r| r.id == tail_start);
409
410    // Split records into three groups:
411    //   before: [0..tail_idx) — the compacted head (EXCLUDED)
412    //   tail:   [tail_idx..compact_end) — the retained tail
413    //   after:  [compact_end..) — post-compaction messages
414    //
415    // compact_end = first non-compaction message after summary_idx
416    let compact_end = records
417        .iter()
418        .skip(s_idx + 1)
419        .position(|r| !r.is_compaction && !r.is_summary)
420        .map_or(records.len(), |p| s_idx + 1 + p);
421
422    // Build result:
423    //   1. Compaction checkpoint as a synthetic system message
424    //   2. Retained tail (from tail_idx to between compaction and summary)
425    //   3. Post-compaction messages (after compact_end)
426
427    let mut result = Vec::with_capacity(records.len());
428
429    // Phase 1: Synthetic compaction checkpoint
430    // Build a system message from the compaction pair
431    if let Some(summary_meta) = &records[s_idx].compaction_meta
432        && let Some(summary_text) = &summary_meta.summary_text
433    {
434        let checkpoint = MessageRecord {
435            id: Uuid::now_v7(),
436            session_id: records[c_idx].session_id,
437            role: behest_store::MessageRole::System,
438            content: vec![ContentPart::text(format!(
439                "<conversation-checkpoint>\n<summary>\n{summary_text}\n</summary>\n</conversation-checkpoint>"
440            ))],
441            tool_calls: Vec::new(),
442            tool_call_id: None,
443            tool_name: None,
444            usage: None,
445            created_at: records[s_idx].created_at,
446            is_compaction: false,
447            is_summary: false,
448            compaction_meta: None,
449        };
450        result.push(checkpoint);
451    }
452
453    // Phase 2: Retained tail (messages between tail_start and compaction_user)
454    if let Some(ti) = tail_idx {
455        let tail_end = c_idx.min(records.len());
456        for rec in records.iter().skip(ti).take(tail_end.saturating_sub(ti)) {
457            if !rec.is_compaction && !rec.is_summary {
458                result.push(rec.clone());
459            }
460        }
461    }
462
463    // Phase 3: Post-compaction messages (everything after the summary)
464    for rec in records.iter().skip(compact_end) {
465        result.push(rec.clone());
466    }
467
468    result
469}
470
471/// Trims message history to stay within a token budget.
472///
473/// Walks from the end of the list forward, accumulating token estimates,
474/// and drops the oldest messages when the budget is exceeded.
475///
476/// Returns the trimmed message list ordered from oldest to newest.
477fn trim_by_tokens(records: Vec<MessageRecord>, max_tokens: usize) -> Vec<MessageRecord> {
478    if records.is_empty() {
479        return records;
480    }
481
482    let total = estimate_records_tokens(&records);
483    if total <= max_tokens {
484        return records;
485    }
486
487    // Note: first system message preservation is handled implicitly —
488    // since we walk backwards, the earliest messages (including system)
489    // are the first ones dropped when budget is exceeded.
490
491    // Walk backwards, keeping messages until budget exceeded
492    let mut kept = Vec::new();
493    let mut tokens = 0usize;
494
495    for rec in records.into_iter().rev() {
496        let rec_tokens = super::token::estimate_record_tokens(&rec);
497        if tokens + rec_tokens > max_tokens && !kept.is_empty() {
498            // Don't add this message — it would exceed budget
499            // But keep the system message if we haven't included it yet
500            break;
501        }
502        tokens += rec_tokens;
503        kept.push(rec);
504    }
505
506    kept.reverse();
507
508    // Re-prepend the system message if it was dropped
509    // If we dropped the system message due to extreme length, that's acceptable
510
511    kept
512}
513
514#[cfg(test)]
515#[allow(clippy::unwrap_used)]
516mod tests {
517    use super::*;
518    use crate::memory::MemoryRunStore;
519    use behest_context::StaticAdapter;
520    use behest_provider::ContentPart;
521    use behest_store::memory::{MemoryExecutionStore, MemorySessionStore};
522    use behest_store::{CompactionMeta, MessageRole, Session};
523
524    fn make_store() -> super::super::store::RuntimeStore {
525        let sessions = MemorySessionStore::new();
526        let executions = MemoryExecutionStore::new();
527        let runs = MemoryRunStore::new();
528        super::super::store::RuntimeStore::new(
529            Box::new(sessions),
530            Box::new(executions),
531            Box::new(runs),
532        )
533    }
534
535    fn make_record_for(session_id: Uuid, role: MessageRole, text: &str) -> MessageRecord {
536        MessageRecord::new(session_id, role, vec![ContentPart::text(text)])
537    }
538
539    #[tokio::test]
540    async fn pipeline_should_compose_system_and_history() {
541        let store = make_store();
542
543        let session = Session::new("Test", ModelName::new("gpt-4"));
544        store
545            .sessions()
546            .create_session(session.clone())
547            .await
548            .unwrap();
549
550        let user_msg = make_record_for(session.id, MessageRole::User, "Hello");
551        store.sessions().append_message(user_msg).await.unwrap();
552
553        let mut pipeline = ContextPipeline::new();
554        pipeline.register(StaticAdapter::system("You are helpful."));
555
556        let request = pipeline
557            .build(
558                &store,
559                session.id,
560                ModelName::new("gpt-4"),
561                Some("How are you?"),
562                None,
563            )
564            .await
565            .unwrap();
566
567        assert_eq!(request.messages.len(), 3);
568        assert!(matches!(request.messages[0], Message::System { .. }));
569        assert!(matches!(request.messages[1], Message::User { .. }));
570        assert!(matches!(request.messages[2], Message::User { .. }));
571    }
572
573    #[tokio::test]
574    async fn pipeline_should_apply_token_trim() {
575        let store = make_store();
576
577        let session = Session::new("Test", ModelName::new("gpt-4"));
578        store
579            .sessions()
580            .create_session(session.clone())
581            .await
582            .unwrap();
583
584        for i in 0..10 {
585            let msg = make_record_for(session.id, MessageRole::User, &format!("Message {i}"));
586            store.sessions().append_message(msg).await.unwrap();
587        }
588
589        // Very restrictive token budget — should only keep a few messages
590        let pipeline = ContextPipeline::new().with_max_history_tokens(50);
591
592        let request = pipeline
593            .build(&store, session.id, ModelName::new("gpt-4"), None, None)
594            .await
595            .unwrap();
596
597        // Should have fewer messages than the original 10
598        assert!(request.messages.len() < 10);
599    }
600
601    #[tokio::test]
602    async fn pipeline_should_filter_compacted_head() {
603        let store = make_store();
604
605        let session = Session::new("Test", ModelName::new("gpt-4"));
606        store
607            .sessions()
608            .create_session(session.clone())
609            .await
610            .unwrap();
611
612        let sid = session.id;
613
614        // Create messages: m1, m2 (head), m3, m4 (tail)
615        let m1 = make_record_for(sid, MessageRole::User, "m1");
616        let m2 = make_record_for(sid, MessageRole::Assistant, "m2");
617        let m3 = make_record_for(sid, MessageRole::User, "m3");
618        let m4 = make_record_for(sid, MessageRole::Assistant, "m4");
619
620        let m3_id = m3.id;
621
622        store.sessions().append_message(m1).await.unwrap();
623        store.sessions().append_message(m2).await.unwrap();
624        store.sessions().append_message(m3).await.unwrap();
625        store.sessions().append_message(m4).await.unwrap();
626
627        // Append compaction pair
628        let compaction_user = MessageRecord {
629            id: Uuid::now_v7(),
630            session_id: sid,
631            role: MessageRole::User,
632            content: vec![ContentPart::text("[compaction]")],
633            tool_calls: Vec::new(),
634            tool_call_id: None,
635            tool_name: None,
636            usage: None,
637            created_at: chrono::Utc::now(),
638            is_compaction: true,
639            is_summary: false,
640            compaction_meta: Some(CompactionMeta::new(m3_id)),
641        };
642        store
643            .sessions()
644            .append_message(compaction_user)
645            .await
646            .unwrap();
647
648        let summary_msg = MessageRecord {
649            id: Uuid::now_v7(),
650            session_id: sid,
651            role: MessageRole::Assistant,
652            content: vec![ContentPart::text("Summary of m1-m2")],
653            tool_calls: Vec::new(),
654            tool_call_id: None,
655            tool_name: None,
656            usage: None,
657            created_at: chrono::Utc::now(),
658            is_compaction: false,
659            is_summary: true,
660            compaction_meta: Some(
661                CompactionMeta::new(m3_id).with_summary("Summary of m1-m2".to_owned()),
662            ),
663        };
664        store.sessions().append_message(summary_msg).await.unwrap();
665
666        // Append post-compaction message
667        let m5 = make_record_for(sid, MessageRole::User, "m5");
668        store.sessions().append_message(m5).await.unwrap();
669
670        let pipeline = ContextPipeline::new();
671
672        let request = pipeline
673            .build(&store, sid, ModelName::new("gpt-4"), None, None)
674            .await
675            .unwrap();
676
677        // Should NOT include m1, m2 (compacted head)
678        // Should include: checkpoint system message, m3, m4, m5
679        let has_m1 = request.messages.iter().any(|m| {
680            matches!(m, Message::User { content } if content.iter().any(|p| matches!(p, ContentPart::Text { text, .. } if text == "m1")))
681        });
682        let has_m3 = request.messages.iter().any(|m| {
683            matches!(m, Message::User { content } if content.iter().any(|p| matches!(p, ContentPart::Text { text, .. } if text == "m3")))
684        });
685        let has_checkpoint = request.messages.iter().any(|m| {
686            matches!(m, Message::System { content } if content.iter().any(|p| matches!(p, ContentPart::Text { text, .. } if text.contains("conversation-checkpoint"))))
687        });
688
689        assert!(!has_m1, "compacted head should be excluded");
690        assert!(has_m3, "retained tail should be included");
691        assert!(has_checkpoint, "compaction checkpoint should be present");
692    }
693
694    #[tokio::test]
695    async fn pipeline_no_compaction_returns_all() {
696        let store = make_store();
697
698        let session = Session::new("Test", ModelName::new("gpt-4"));
699        store
700            .sessions()
701            .create_session(session.clone())
702            .await
703            .unwrap();
704
705        for i in 0..5 {
706            let msg = make_record_for(session.id, MessageRole::User, &format!("msg{i}"));
707            store.sessions().append_message(msg).await.unwrap();
708        }
709
710        let pipeline = ContextPipeline::new().with_compaction_filter(true);
711
712        let request = pipeline
713            .build(&store, session.id, ModelName::new("gpt-4"), None, None)
714            .await
715            .unwrap();
716
717        assert_eq!(request.messages.len(), 5);
718    }
719
720    #[test]
721    fn trim_by_tokens_preserves_tail() {
722        let sid = Uuid::now_v7();
723        let records: Vec<MessageRecord> = (0..20)
724            .map(|i| make_record_for(sid, MessageRole::User, &format!("msg{i:03}")))
725            .collect();
726
727        let trimmed = trim_by_tokens(records, 100);
728
729        // Should have fewer records, but not empty
730        assert!(!trimmed.is_empty());
731        assert!(trimmed.len() < 20);
732    }
733
734    #[test]
735    fn trim_by_tokens_all_when_under_budget() {
736        let sid = Uuid::now_v7();
737        let records = vec![
738            make_record_for(sid, MessageRole::User, "hi"),
739            make_record_for(sid, MessageRole::Assistant, "hello"),
740        ];
741
742        let trimmed = trim_by_tokens(records.clone(), 1000);
743        assert_eq!(trimmed.len(), 2);
744    }
745
746    #[test]
747    fn filter_compacted_no_compaction_returns_unchanged() {
748        let sid = Uuid::now_v7();
749        let records = vec![
750            make_record_for(sid, MessageRole::User, "a"),
751            make_record_for(sid, MessageRole::Assistant, "b"),
752        ];
753
754        let filtered = apply_compaction_filter(records.clone());
755        assert_eq!(filtered.len(), 2);
756    }
757
758    #[tokio::test]
759    async fn pipeline_should_place_system_cache_breakpoint() {
760        let store = make_store();
761        let session = Session::new("Test", ModelName::new("claude-3-sonnet"));
762        store
763            .sessions()
764            .create_session(session.clone())
765            .await
766            .unwrap();
767
768        // Long system prompt (over 1024 chars / ~256 tokens) so the
769        // min_system_tokens check passes.
770        let long_prompt = "x".repeat(2000);
771        let mut pipeline = ContextPipeline::new()
772            .with_cache_strategy(PromptCacheConfig::new().with_min_system_tokens(10));
773        pipeline.register(StaticAdapter::system(long_prompt));
774
775        let request = pipeline
776            .build(
777                &store,
778                session.id,
779                ModelName::new("claude-3-sonnet"),
780                Some("Hello"),
781                None,
782            )
783            .await
784            .unwrap();
785
786        // First message is system and must carry a cache marker.
787        if let Message::System { content } = &request.messages[0] {
788            assert!(
789                content.last().unwrap().cache_control().is_some(),
790                "system-end cache breakpoint should be set"
791            );
792        } else {
793            panic!("expected first message to be system");
794        }
795    }
796
797    #[tokio::test]
798    async fn pipeline_should_skip_cache_breakpoint_when_disabled() {
799        let store = make_store();
800        let session = Session::new("Test", ModelName::new("claude-3-sonnet"));
801        store
802            .sessions()
803            .create_session(session.clone())
804            .await
805            .unwrap();
806
807        let long_prompt = "x".repeat(2000);
808        let mut pipeline =
809            ContextPipeline::new().with_cache_strategy(PromptCacheConfig::disabled());
810        pipeline.register(StaticAdapter::system(long_prompt));
811
812        let request = pipeline
813            .build(
814                &store,
815                session.id,
816                ModelName::new("claude-3-sonnet"),
817                Some("Hello"),
818                None,
819            )
820            .await
821            .unwrap();
822
823        if let Message::System { content } = &request.messages[0] {
824            assert!(
825                content.last().unwrap().cache_control().is_none(),
826                "cache breakpoint should not be set when disabled"
827            );
828        } else {
829            panic!("expected first message to be system");
830        }
831    }
832
833    #[tokio::test]
834    async fn pipeline_should_place_tool_cache_breakpoint() {
835        let store = make_store();
836        let session = Session::new("Test", ModelName::new("claude-3-sonnet"));
837        store
838            .sessions()
839            .create_session(session.clone())
840            .await
841            .unwrap();
842
843        let pipeline = ContextPipeline::new();
844        let tools = vec![ToolSpec::new(
845            "echo",
846            "Echo",
847            serde_json::json!({"type": "object"}),
848        )];
849
850        let request = pipeline
851            .build(
852                &store,
853                session.id,
854                ModelName::new("claude-3-sonnet"),
855                Some("Hello"),
856                Some(&tools),
857            )
858            .await
859            .unwrap();
860
861        assert_eq!(request.tools.len(), 1);
862        assert!(
863            request.tools[0].cache_control.is_some(),
864            "tool cache marker should be set"
865        );
866    }
867}