1use serde::{Deserialize, Serialize};
4
5use crate::plugins::{Plugin, PluginDescriptor, PluginRegistrar};
6use crate::state::{MutationBatch, StateKey, StateKeyOptions};
7
8pub const CONTEXT_COMPACTION_PLUGIN_ID: &str = "context_compaction";
10
11#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
20pub struct CompactionConfig {
21 pub summarizer_system_prompt: String,
23 pub summarizer_user_prompt: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub summary_max_tokens: Option<u32>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub summary_model: Option<String>,
31 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
47pub struct CompactionConfigKey;
49
50impl awaken_contract::registry_spec::PluginConfigKey for CompactionConfigKey {
51 const KEY: &'static str = "compaction";
52 type Config = CompactionConfig;
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct CompactionBoundary {
62 pub summary: String,
64 pub pre_tokens: usize,
66 pub post_tokens: usize,
68 pub timestamp_ms: u64,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct CompactionInFlight {
77 pub task_id: String,
79 pub boundary_message_id: String,
83 pub started_at_ms: u64,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
93pub struct CompactionState {
94 pub boundaries: Vec<CompactionBoundary>,
96 pub total_compactions: u64,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub in_flight: Option<CompactionInFlight>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum CompactionAction {
107 RecordBoundary(CompactionBoundary),
109 SetInFlight(CompactionInFlight),
111 ClearInFlight,
113 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 pub fn latest_boundary(&self) -> Option<&CompactionBoundary> {
140 self.boundaries.last()
141 }
142
143 pub fn is_compacting(&self) -> bool {
145 self.in_flight.is_some()
146 }
147}
148
149pub 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#[derive(Debug, Clone, Default)]
172pub struct CompactionPlugin {
173 pub config: CompactionConfig,
175}
176
177impl CompactionPlugin {
178 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
205pub const CONTEXT_TRANSFORM_PLUGIN_ID: &str = "context_transform";
211
212pub 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 #[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 #[test]
571 fn compaction_fires_at_threshold() {
572 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, 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, 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 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 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 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 let store = StateStore::new();
653 store.install_plugin(CompactionPlugin::default()).unwrap();
654
655 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 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 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 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 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 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 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 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 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 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 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}