Skip to main content

awaken_runtime/context/
plugin.rs

1//! CompactionPlugin, CompactionConfig, and compaction state tracking.
2
3use serde::{Deserialize, Serialize};
4
5use crate::plugins::{Plugin, PluginDescriptor, PluginRegistrar};
6use crate::state::{MutationBatch, StateKey, StateKeyOptions};
7
8/// Plugin ID for context compaction.
9pub const CONTEXT_COMPACTION_PLUGIN_ID: &str = "context_compaction";
10
11// ---------------------------------------------------------------------------
12// CompactionConfig — configurable prompts and thresholds
13// ---------------------------------------------------------------------------
14
15/// Configuration for the compaction subsystem.
16///
17/// Controls summarizer prompts, model selection, and savings thresholds.
18/// Stored in `AgentSpec.sections["compaction"]` and read via `PluginConfigKey`.
19#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
20pub struct CompactionConfig {
21    /// System prompt for the summarizer LLM call.
22    pub summarizer_system_prompt: String,
23    /// User prompt template. `{messages}` is replaced with the conversation transcript.
24    pub summarizer_user_prompt: String,
25    /// Maximum tokens for the summary response.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub summary_max_tokens: Option<u32>,
28    /// Model to use for summarization (if different from the agent's model).
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub summary_model: Option<String>,
31    /// Minimum token savings ratio to accept a compaction (0.0-1.0).
32    pub min_savings_ratio: f64,
33}
34
35impl Default for CompactionConfig {
36    fn default() -> Self {
37        Self {
38            summarizer_system_prompt: "You are a conversation summarizer. Preserve all key facts, decisions, tool results, and action items. Be concise but complete.".into(),
39            summarizer_user_prompt: "Summarize the following conversation:\n\n{messages}".into(),
40            summary_max_tokens: None,
41            summary_model: None,
42            min_savings_ratio: 0.3,
43        }
44    }
45}
46
47/// Plugin config key for [`CompactionConfig`].
48pub struct CompactionConfigKey;
49
50impl awaken_contract::registry_spec::PluginConfigKey for CompactionConfigKey {
51    const KEY: &'static str = "compaction";
52    type Config = CompactionConfig;
53}
54
55// ---------------------------------------------------------------------------
56// Compaction boundary tracking
57// ---------------------------------------------------------------------------
58
59/// A recorded compaction boundary — snapshot of a single compaction event.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct CompactionBoundary {
62    /// Summary text produced by the compaction pass.
63    pub summary: String,
64    /// Estimated tokens before compaction (in the compacted range).
65    pub pre_tokens: usize,
66    /// Estimated tokens after compaction (summary message tokens).
67    pub post_tokens: usize,
68    /// Timestamp of the compaction event (millis since UNIX epoch).
69    pub timestamp_ms: u64,
70}
71
72/// Pointer to a single in-flight background compaction pass. Used as a
73/// single-flight guard so the runtime never spawns a second compaction
74/// while one is still summarizing.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct CompactionInFlight {
77    /// Background task id of the running compaction.
78    pub task_id: String,
79    /// Stable message id of the boundary message at trigger time. Used
80    /// to locate the cut point against the current message list when the
81    /// summary lands — robust to messages appended during the window.
82    pub boundary_message_id: String,
83    /// Wall-clock millis when the task was spawned.
84    pub started_at_ms: u64,
85}
86
87/// Durable state for context compaction tracking.
88///
89/// Stores a history of compaction boundaries so that load-time trimming
90/// and plugin queries can identify already-summarized ranges, plus a
91/// single-flight guard for background compaction passes.
92#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
93pub struct CompactionState {
94    /// Ordered list of compaction boundaries (most recent last).
95    pub boundaries: Vec<CompactionBoundary>,
96    /// Total number of compaction passes performed.
97    pub total_compactions: u64,
98    /// Currently running background compaction, if any.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub in_flight: Option<CompactionInFlight>,
101}
102
103/// Reducer actions for [`CompactionState`].
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum CompactionAction {
107    /// Record a new compaction boundary.
108    RecordBoundary(CompactionBoundary),
109    /// Mark a background compaction as in flight.
110    SetInFlight(CompactionInFlight),
111    /// Clear the in-flight marker (called on success and failure).
112    ClearInFlight,
113    /// Clear all tracked boundaries (e.g. on thread reset).
114    Clear,
115}
116
117impl CompactionState {
118    fn reduce(&mut self, action: CompactionAction) {
119        match action {
120            CompactionAction::RecordBoundary(boundary) => {
121                self.boundaries.push(boundary);
122                self.total_compactions += 1;
123            }
124            CompactionAction::SetInFlight(in_flight) => {
125                self.in_flight = Some(in_flight);
126            }
127            CompactionAction::ClearInFlight => {
128                self.in_flight = None;
129            }
130            CompactionAction::Clear => {
131                self.boundaries.clear();
132                self.total_compactions = 0;
133                self.in_flight = None;
134            }
135        }
136    }
137
138    /// Latest compaction boundary, if any.
139    pub fn latest_boundary(&self) -> Option<&CompactionBoundary> {
140        self.boundaries.last()
141    }
142
143    /// True when a background compaction pass is already running.
144    pub fn is_compacting(&self) -> bool {
145        self.in_flight.is_some()
146    }
147}
148
149/// State key for context compaction state.
150pub struct CompactionStateKey;
151
152impl StateKey for CompactionStateKey {
153    const KEY: &'static str = "__context_compaction";
154    type Value = CompactionState;
155    type Update = CompactionAction;
156
157    fn apply(value: &mut Self::Value, update: Self::Update) {
158        value.reduce(update);
159    }
160}
161
162// ---------------------------------------------------------------------------
163// CompactionPlugin
164// ---------------------------------------------------------------------------
165
166/// Plugin that integrates context compaction state into the plugin system.
167///
168/// Registers the [`CompactionStateKey`] state key so that compaction boundaries
169/// are tracked durably and available to other plugins and external observers.
170/// Accepts an optional [`CompactionConfig`] for configurable prompts and thresholds.
171#[derive(Debug, Clone, Default)]
172pub struct CompactionPlugin {
173    /// Compaction configuration (prompts, model, thresholds).
174    pub config: CompactionConfig,
175}
176
177impl CompactionPlugin {
178    /// Create with explicit config.
179    pub fn new(config: CompactionConfig) -> Self {
180        Self { config }
181    }
182}
183
184impl Plugin for CompactionPlugin {
185    fn descriptor(&self) -> PluginDescriptor {
186        PluginDescriptor {
187            name: CONTEXT_COMPACTION_PLUGIN_ID,
188        }
189    }
190
191    fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), awaken_contract::StateError> {
192        registrar.register_key::<CompactionStateKey>(StateKeyOptions::default())?;
193        Ok(())
194    }
195
196    fn on_activate(
197        &self,
198        _agent_spec: &awaken_contract::registry_spec::AgentSpec,
199        _patch: &mut MutationBatch,
200    ) -> Result<(), awaken_contract::StateError> {
201        Ok(())
202    }
203}
204
205// ---------------------------------------------------------------------------
206// ContextTransformPlugin — registers the context truncation request transform
207// ---------------------------------------------------------------------------
208
209/// Plugin ID for context truncation transform.
210pub const CONTEXT_TRANSFORM_PLUGIN_ID: &str = "context_transform";
211
212/// Plugin that registers the built-in context truncation request transform.
213///
214/// Wraps a `ContextWindowPolicy` and registers a `ContextTransform` via
215/// `register_request_transform()` during plugin registration. This ensures
216/// the transform flows through the standard plugin mechanism (ADR-0001)
217/// instead of being manually appended post-hoc.
218pub struct ContextTransformPlugin {
219    policy: awaken_contract::contract::inference::ContextWindowPolicy,
220}
221
222impl ContextTransformPlugin {
223    pub fn new(policy: awaken_contract::contract::inference::ContextWindowPolicy) -> Self {
224        Self { policy }
225    }
226}
227
228impl Plugin for ContextTransformPlugin {
229    fn descriptor(&self) -> PluginDescriptor {
230        PluginDescriptor {
231            name: CONTEXT_TRANSFORM_PLUGIN_ID,
232        }
233    }
234
235    fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), awaken_contract::StateError> {
236        registrar.register_request_transform(
237            CONTEXT_TRANSFORM_PLUGIN_ID,
238            super::ContextTransform::new(self.policy.clone()),
239        );
240        Ok(())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::state::StateStore;
248    use awaken_contract::contract::message::Message;
249
250    #[test]
251    fn compaction_state_record_boundary() {
252        let mut state = CompactionState::default();
253        assert_eq!(state.total_compactions, 0);
254        assert!(state.boundaries.is_empty());
255
256        state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
257            summary: "User asked to implement feature X.".into(),
258            pre_tokens: 5000,
259            post_tokens: 200,
260            timestamp_ms: 1234567890,
261        }));
262
263        assert_eq!(state.total_compactions, 1);
264        assert_eq!(state.boundaries.len(), 1);
265        assert_eq!(
266            state.latest_boundary().unwrap().summary,
267            "User asked to implement feature X."
268        );
269    }
270
271    #[test]
272    fn compaction_state_multiple_boundaries() {
273        let mut state = CompactionState::default();
274
275        for i in 0..3 {
276            state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
277                summary: format!("summary {i}"),
278                pre_tokens: 1000 * (i + 1),
279                post_tokens: 100 * (i + 1),
280                timestamp_ms: 1000 + i as u64,
281            }));
282        }
283
284        assert_eq!(state.total_compactions, 3);
285        assert_eq!(state.boundaries.len(), 3);
286        assert_eq!(state.latest_boundary().unwrap().summary, "summary 2");
287    }
288
289    #[test]
290    fn compaction_state_clear() {
291        let mut state = CompactionState {
292            boundaries: vec![CompactionBoundary {
293                summary: "old".into(),
294                pre_tokens: 100,
295                post_tokens: 10,
296                timestamp_ms: 1,
297            }],
298            total_compactions: 1,
299            in_flight: None,
300        };
301
302        state.reduce(CompactionAction::Clear);
303        assert!(state.boundaries.is_empty());
304        assert_eq!(state.total_compactions, 0);
305    }
306
307    #[test]
308    fn compaction_state_latest_boundary_empty() {
309        let state = CompactionState::default();
310        assert!(state.latest_boundary().is_none());
311    }
312
313    #[test]
314    fn compaction_state_serde_roundtrip() {
315        let state = CompactionState {
316            boundaries: vec![
317                CompactionBoundary {
318                    summary: "first".into(),
319                    pre_tokens: 5000,
320                    post_tokens: 200,
321                    timestamp_ms: 1000,
322                },
323                CompactionBoundary {
324                    summary: "second".into(),
325                    pre_tokens: 3000,
326                    post_tokens: 150,
327                    timestamp_ms: 2000,
328                },
329            ],
330            total_compactions: 2,
331            in_flight: None,
332        };
333
334        let json = serde_json::to_string(&state).unwrap();
335        let parsed: CompactionState = serde_json::from_str(&json).unwrap();
336        assert_eq!(parsed, state);
337    }
338
339    #[test]
340    fn compaction_plugin_registers_key() {
341        let store = StateStore::new();
342        store.install_plugin(CompactionPlugin::default()).unwrap();
343        let registry = store.registry.lock();
344        assert!(registry.keys_by_name.contains_key("__context_compaction"));
345    }
346
347    #[test]
348    fn compaction_plugin_state_via_store() {
349        let store = StateStore::new();
350        store.install_plugin(CompactionPlugin::default()).unwrap();
351
352        let mut patch = store.begin_mutation();
353        patch.update::<CompactionStateKey>(super::super::record_compaction_boundary(
354            CompactionBoundary {
355                summary: "test summary".into(),
356                pre_tokens: 4000,
357                post_tokens: 180,
358                timestamp_ms: 9999,
359            },
360        ));
361        store.commit(patch).unwrap();
362
363        let state = store.read::<CompactionStateKey>().unwrap();
364        assert_eq!(state.total_compactions, 1);
365        assert_eq!(state.boundaries[0].summary, "test summary");
366    }
367
368    #[test]
369    fn record_compaction_boundary_constructor() {
370        let action = super::super::record_compaction_boundary(CompactionBoundary {
371            summary: "s".into(),
372            pre_tokens: 100,
373            post_tokens: 10,
374            timestamp_ms: 0,
375        });
376        assert!(matches!(action, CompactionAction::RecordBoundary(_)));
377    }
378
379    #[test]
380    fn compaction_state_record_then_clear_then_record() {
381        let mut state = CompactionState::default();
382
383        state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
384            summary: "first".into(),
385            pre_tokens: 1000,
386            post_tokens: 100,
387            timestamp_ms: 1,
388        }));
389        assert_eq!(state.total_compactions, 1);
390
391        state.reduce(CompactionAction::Clear);
392        assert_eq!(state.total_compactions, 0);
393        assert!(state.boundaries.is_empty());
394        assert!(state.latest_boundary().is_none());
395
396        state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
397            summary: "after clear".into(),
398            pre_tokens: 2000,
399            post_tokens: 150,
400            timestamp_ms: 2,
401        }));
402        assert_eq!(state.total_compactions, 1);
403        assert_eq!(state.latest_boundary().unwrap().summary, "after clear");
404    }
405
406    #[test]
407    fn compaction_state_key_properties() {
408        assert_eq!(CompactionStateKey::KEY, "__context_compaction");
409    }
410
411    #[test]
412    fn compaction_state_key_apply() {
413        let mut state = CompactionState::default();
414        CompactionStateKey::apply(
415            &mut state,
416            CompactionAction::RecordBoundary(CompactionBoundary {
417                summary: "via apply".into(),
418                pre_tokens: 500,
419                post_tokens: 50,
420                timestamp_ms: 42,
421            }),
422        );
423        assert_eq!(state.total_compactions, 1);
424        assert_eq!(state.boundaries[0].summary, "via apply");
425    }
426
427    #[test]
428    fn compaction_plugin_descriptor_name() {
429        let plugin = CompactionPlugin::default();
430        assert_eq!(plugin.descriptor().name, CONTEXT_COMPACTION_PLUGIN_ID);
431    }
432
433    #[test]
434    fn compaction_plugin_new_with_config() {
435        let config = CompactionConfig {
436            min_savings_ratio: 0.8,
437            ..Default::default()
438        };
439        let plugin = CompactionPlugin::new(config);
440        assert!((plugin.config.min_savings_ratio - 0.8).abs() < f64::EPSILON);
441    }
442
443    #[test]
444    fn compaction_boundary_equality() {
445        let a = CompactionBoundary {
446            summary: "s".into(),
447            pre_tokens: 100,
448            post_tokens: 10,
449            timestamp_ms: 0,
450        };
451        let b = a.clone();
452        assert_eq!(a, b);
453    }
454
455    #[test]
456    fn compaction_boundary_serde_roundtrip() {
457        let boundary = CompactionBoundary {
458            summary: "test summary".into(),
459            pre_tokens: 3000,
460            post_tokens: 200,
461            timestamp_ms: 1234567890,
462        };
463        let json = serde_json::to_string(&boundary).unwrap();
464        let parsed: CompactionBoundary = serde_json::from_str(&json).unwrap();
465        assert_eq!(parsed, boundary);
466    }
467
468    // -----------------------------------------------------------------------
469    // Migrated from uncarve: additional compaction state tests
470    // -----------------------------------------------------------------------
471
472    #[test]
473    fn compaction_state_default_is_empty() {
474        let state = CompactionState::default();
475        assert!(state.boundaries.is_empty());
476        assert_eq!(state.total_compactions, 0);
477        assert!(state.latest_boundary().is_none());
478    }
479
480    #[test]
481    fn compaction_state_boundary_ordering_preserved() {
482        let mut state = CompactionState::default();
483        for i in 0..5 {
484            state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
485                summary: format!("boundary_{i}"),
486                pre_tokens: 1000,
487                post_tokens: 100,
488                timestamp_ms: i as u64,
489            }));
490        }
491        assert_eq!(state.boundaries.len(), 5);
492        assert_eq!(state.total_compactions, 5);
493        for (i, b) in state.boundaries.iter().enumerate() {
494            assert_eq!(b.summary, format!("boundary_{i}"));
495            assert_eq!(b.timestamp_ms, i as u64);
496        }
497    }
498
499    #[test]
500    fn compaction_state_clear_twice_is_idempotent() {
501        let mut state = CompactionState::default();
502        state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
503            summary: "s".into(),
504            pre_tokens: 1,
505            post_tokens: 1,
506            timestamp_ms: 0,
507        }));
508        state.reduce(CompactionAction::Clear);
509        state.reduce(CompactionAction::Clear);
510        assert!(state.boundaries.is_empty());
511        assert_eq!(state.total_compactions, 0);
512    }
513
514    #[test]
515    fn compaction_config_default_has_sane_values() {
516        let config = CompactionConfig::default();
517        assert!(!config.summarizer_system_prompt.is_empty());
518        assert!(config.summarizer_user_prompt.contains("{messages}"));
519        assert!(config.min_savings_ratio > 0.0);
520        assert!(config.min_savings_ratio < 1.0);
521        assert!(config.summary_max_tokens.is_none());
522        assert!(config.summary_model.is_none());
523    }
524
525    #[test]
526    fn compaction_config_serde_roundtrip() {
527        let config = CompactionConfig {
528            summarizer_system_prompt: "custom system".into(),
529            summarizer_user_prompt: "custom user: {messages}".into(),
530            summary_max_tokens: Some(512),
531            summary_model: Some("claude-3-haiku".into()),
532            min_savings_ratio: 0.5,
533        };
534        let json = serde_json::to_string(&config).unwrap();
535        let parsed: CompactionConfig = serde_json::from_str(&json).unwrap();
536        assert_eq!(
537            parsed.summarizer_system_prompt,
538            config.summarizer_system_prompt
539        );
540        assert_eq!(parsed.summary_max_tokens, Some(512));
541        assert_eq!(parsed.summary_model.as_deref(), Some("claude-3-haiku"));
542    }
543
544    #[test]
545    fn compaction_state_pre_post_tokens_preserved() {
546        let mut state = CompactionState::default();
547        state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
548            summary: "test".into(),
549            pre_tokens: 10_000,
550            post_tokens: 500,
551            timestamp_ms: 99,
552        }));
553        let b = state.latest_boundary().unwrap();
554        assert_eq!(b.pre_tokens, 10_000);
555        assert_eq!(b.post_tokens, 500);
556        assert_eq!(b.timestamp_ms, 99);
557    }
558
559    #[test]
560    fn context_transform_plugin_descriptor_name() {
561        let policy = awaken_contract::contract::inference::ContextWindowPolicy::default();
562        let plugin = ContextTransformPlugin::new(policy);
563        assert_eq!(plugin.descriptor().name, CONTEXT_TRANSFORM_PLUGIN_ID);
564    }
565
566    // -----------------------------------------------------------------------
567    // Additional compaction tests
568    // -----------------------------------------------------------------------
569
570    #[test]
571    fn compaction_fires_at_threshold() {
572        // Verify savings ratio check: only accept compaction when savings >= min_savings_ratio
573        let config = CompactionConfig {
574            min_savings_ratio: 0.5,
575            ..Default::default()
576        };
577        let boundary_good = CompactionBoundary {
578            summary: "good".into(),
579            pre_tokens: 1000,
580            post_tokens: 400, // 60% savings > 50% threshold
581            timestamp_ms: 1,
582        };
583        let savings_good =
584            1.0 - (boundary_good.post_tokens as f64 / boundary_good.pre_tokens as f64);
585        assert!(
586            savings_good >= config.min_savings_ratio,
587            "60% savings should meet 50% threshold"
588        );
589
590        let boundary_bad = CompactionBoundary {
591            summary: "bad".into(),
592            pre_tokens: 1000,
593            post_tokens: 600, // 40% savings < 50% threshold
594            timestamp_ms: 2,
595        };
596        let savings_bad = 1.0 - (boundary_bad.post_tokens as f64 / boundary_bad.pre_tokens as f64);
597        assert!(
598            savings_bad < config.min_savings_ratio,
599            "40% savings should not meet 50% threshold"
600        );
601    }
602
603    #[test]
604    fn compaction_state_tracks_across_multiple_rounds() {
605        let mut state = CompactionState::default();
606        // Simulate 5 compaction rounds with increasing pre-token counts
607        for round in 1..=5u64 {
608            state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
609                summary: format!("round {round}"),
610                pre_tokens: 1000 * round as usize,
611                post_tokens: 100 * round as usize,
612                timestamp_ms: round * 1000,
613            }));
614            assert_eq!(state.total_compactions, round);
615            assert_eq!(state.boundaries.len(), round as usize);
616        }
617        // Latest boundary should be the last round
618        assert_eq!(state.latest_boundary().unwrap().summary, "round 5");
619        assert_eq!(state.latest_boundary().unwrap().pre_tokens, 5000);
620    }
621
622    #[test]
623    fn compaction_config_serialization_omits_none_fields() {
624        let config = CompactionConfig::default();
625        let json = serde_json::to_value(&config).unwrap();
626        // summary_max_tokens and summary_model are None, should be omitted via skip_serializing_if
627        assert!(
628            !json.as_object().unwrap().contains_key("summary_max_tokens"),
629            "None fields should be omitted"
630        );
631        assert!(
632            !json.as_object().unwrap().contains_key("summary_model"),
633            "None fields should be omitted"
634        );
635    }
636
637    #[test]
638    fn compaction_config_serialization_includes_some_fields() {
639        let config = CompactionConfig {
640            summary_max_tokens: Some(1024),
641            summary_model: Some("claude-3-sonnet".into()),
642            ..Default::default()
643        };
644        let json = serde_json::to_value(&config).unwrap();
645        assert_eq!(json["summary_max_tokens"], 1024);
646        assert_eq!(json["summary_model"], "claude-3-sonnet");
647    }
648
649    #[test]
650    fn compaction_with_tool_messages_records_correctly() {
651        // Simulate compaction that includes tool messages in the summarized range
652        let store = StateStore::new();
653        store.install_plugin(CompactionPlugin::default()).unwrap();
654
655        // Record a boundary representing a range that included tool messages
656        let mut patch = store.begin_mutation();
657        patch.update::<CompactionStateKey>(super::super::record_compaction_boundary(
658            CompactionBoundary {
659                summary: "User asked to search files. Tool search returned 3 results. Assistant presented findings.".into(),
660                pre_tokens: 8000,
661                post_tokens: 200,
662                timestamp_ms: 1000,
663            },
664        ));
665        store.commit(patch).unwrap();
666
667        let state = store.read::<CompactionStateKey>().unwrap();
668        assert_eq!(state.total_compactions, 1);
669        assert!(state.boundaries[0].summary.contains("Tool search"));
670        assert_eq!(state.boundaries[0].pre_tokens, 8000);
671    }
672
673    #[test]
674    fn context_transform_plugin_registers_transform() {
675        use crate::plugins::PluginRegistrar;
676        let policy = awaken_contract::contract::inference::ContextWindowPolicy::default();
677        let plugin = ContextTransformPlugin::new(policy);
678        let mut registrar = PluginRegistrar::new();
679        plugin.register(&mut registrar).unwrap();
680        assert_eq!(
681            registrar.request_transforms.len(),
682            1,
683            "should have registered one transform"
684        );
685        assert_eq!(
686            registrar.request_transforms[0].plugin_id,
687            CONTEXT_TRANSFORM_PLUGIN_ID
688        );
689    }
690
691    #[test]
692    fn transform_ordering_compaction_then_context() {
693        use crate::plugins::PluginRegistrar;
694        // Compaction plugin should register no transforms
695        let mut reg_compaction = PluginRegistrar::new();
696        CompactionPlugin::default()
697            .register(&mut reg_compaction)
698            .unwrap();
699        assert!(
700            reg_compaction.request_transforms.is_empty(),
701            "CompactionPlugin should not register request transforms"
702        );
703        // ContextTransformPlugin should register exactly one transform
704        let policy = awaken_contract::contract::inference::ContextWindowPolicy::default();
705        let mut reg_transform = PluginRegistrar::new();
706        ContextTransformPlugin::new(policy)
707            .register(&mut reg_transform)
708            .unwrap();
709        assert_eq!(reg_transform.request_transforms.len(), 1);
710    }
711
712    #[test]
713    fn token_count_estimation_for_various_content_types() {
714        use awaken_contract::contract::transform::estimate_message_tokens;
715
716        // Text message
717        let text_msg = Message::user("Hello, this is a test message with some content.");
718        let text_tokens = estimate_message_tokens(&text_msg);
719        assert!(
720            text_tokens > 4,
721            "text message should have tokens beyond overhead"
722        );
723
724        // Empty content message
725        let empty_msg = Message::user("");
726        let empty_tokens = estimate_message_tokens(&empty_msg);
727        assert_eq!(
728            empty_tokens, 4,
729            "empty message should have only overhead tokens"
730        );
731
732        // Very long message
733        let long_msg = Message::user("x".repeat(4000));
734        let long_tokens = estimate_message_tokens(&long_msg);
735        assert!(
736            long_tokens >= 1000,
737            "4000-char message should estimate >= 1000 tokens, got {long_tokens}"
738        );
739    }
740
741    #[test]
742    fn enable_prompt_cache_flag_in_policy() {
743        let policy_cached = awaken_contract::contract::inference::ContextWindowPolicy {
744            enable_prompt_cache: true,
745            ..Default::default()
746        };
747        assert!(policy_cached.enable_prompt_cache);
748
749        let policy_uncached = awaken_contract::contract::inference::ContextWindowPolicy {
750            enable_prompt_cache: false,
751            ..Default::default()
752        };
753        assert!(!policy_uncached.enable_prompt_cache);
754
755        // Both should create valid transform plugins
756        let _ = ContextTransformPlugin::new(policy_cached);
757        let _ = ContextTransformPlugin::new(policy_uncached);
758    }
759
760    #[test]
761    fn autocompact_threshold_check() {
762        use awaken_contract::contract::transform::estimate_tokens;
763
764        let policy_with_threshold = awaken_contract::contract::inference::ContextWindowPolicy {
765            autocompact_threshold: Some(500),
766            ..Default::default()
767        };
768
769        // Simulate checking if messages exceed autocompact threshold
770        let messages = vec![Message::user("short"), Message::assistant("reply")];
771        let total = estimate_tokens(&messages);
772        assert!(
773            total < policy_with_threshold.autocompact_threshold.unwrap(),
774            "short conversation should be under threshold"
775        );
776
777        // Longer conversation should exceed threshold
778        let long_messages: Vec<Message> = (0..100)
779            .map(|i| Message::user(format!("message {i} with some filler text to add tokens")))
780            .collect();
781        let long_total = estimate_tokens(&long_messages);
782        assert!(
783            long_total > policy_with_threshold.autocompact_threshold.unwrap(),
784            "100-message conversation should exceed threshold of 500, got {long_total}"
785        );
786    }
787
788    #[test]
789    fn in_flight_set_and_clear_round_trip() {
790        let mut state = CompactionState::default();
791        assert!(!state.is_compacting());
792
793        state.reduce(CompactionAction::SetInFlight(CompactionInFlight {
794            task_id: "bg_42".into(),
795            boundary_message_id: "01HZ-msg-01".into(),
796            started_at_ms: 100,
797        }));
798        let live = state.in_flight.as_ref().expect("in-flight set");
799        assert_eq!(live.task_id, "bg_42");
800        assert_eq!(live.boundary_message_id, "01HZ-msg-01");
801        assert!(state.is_compacting());
802
803        state.reduce(CompactionAction::ClearInFlight);
804        assert!(state.in_flight.is_none());
805        assert!(!state.is_compacting());
806    }
807
808    #[test]
809    fn clear_action_resets_in_flight_too() {
810        let mut state = CompactionState::default();
811        state.reduce(CompactionAction::SetInFlight(CompactionInFlight {
812            task_id: "bg_1".into(),
813            boundary_message_id: "msg-id".into(),
814            started_at_ms: 1,
815        }));
816        state.reduce(CompactionAction::Clear);
817        assert!(state.in_flight.is_none());
818        assert!(state.boundaries.is_empty());
819    }
820
821    #[test]
822    fn record_boundary_does_not_touch_in_flight() {
823        let mut state = CompactionState::default();
824        state.reduce(CompactionAction::SetInFlight(CompactionInFlight {
825            task_id: "bg_99".into(),
826            boundary_message_id: "msg".into(),
827            started_at_ms: 1,
828        }));
829        state.reduce(CompactionAction::RecordBoundary(CompactionBoundary {
830            summary: "s".into(),
831            pre_tokens: 10,
832            post_tokens: 1,
833            timestamp_ms: 2,
834        }));
835        // RecordBoundary alone does not clear the marker — the inbox
836        // event router is responsible for the explicit ClearInFlight.
837        assert!(state.is_compacting());
838        assert_eq!(state.boundaries.len(), 1);
839    }
840
841    #[test]
842    fn compaction_action_serde_roundtrip() {
843        let actions = vec![
844            CompactionAction::RecordBoundary(CompactionBoundary {
845                summary: "s".into(),
846                pre_tokens: 1,
847                post_tokens: 1,
848                timestamp_ms: 0,
849            }),
850            CompactionAction::Clear,
851        ];
852        for action in actions {
853            let json = serde_json::to_string(&action).unwrap();
854            let parsed: CompactionAction = serde_json::from_str(&json).unwrap();
855            // Verify the action type roundtrips
856            match (&action, &parsed) {
857                (CompactionAction::Clear, CompactionAction::Clear) => {}
858                (CompactionAction::RecordBoundary(a), CompactionAction::RecordBoundary(b)) => {
859                    assert_eq!(a.summary, b.summary);
860                }
861                _ => panic!("action type mismatch after serde roundtrip"),
862            }
863        }
864    }
865}