Skip to main content

agent_works/compression/
events.rs

1//! Typed compression lifecycle events.
2//!
3//! [`CompressionEvent`] replaces inline `serde_json::json!()` construction in
4//! the middleware with a compile-time-safe enum.  Events are transported via
5//! [`agent_base::UserEvent::Structured`] so no changes to `agent-base` are
6//! required.
7//!
8//! Lifecycle: `Preparing → Started → Progress (0..N) → Completed | Failed`
9
10use serde::{Deserialize, Serialize};
11
12/// Compression trigger type.
13#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "camelCase")]
15pub enum CompressionTrigger {
16    /// Automatic compression when token threshold is reached.
17    Auto,
18    /// Manual compression via /compact command.
19    Manual,
20    /// Inline compaction within the react loop (after tool execution).
21    InlineCompaction,
22}
23
24/// Context compression lifecycle events.
25///
26/// Emitted as `UserEvent::Structured { event_type: "compression", data: ... }`
27/// so consumers can match on the typed enum for compile-time safety.
28///
29/// ## Lifecycle
30///
31/// ```text
32/// Preparing → Started → Progress (0..N) → Completed | Failed
33/// ```
34#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
35#[serde(tag = "phase", rename_all = "snake_case")]
36pub enum CompressionEvent {
37    /// Pre-processing — saving snapshot, about to start.
38    Preparing {
39        /// Session that owns this compression.
40        session_id: u64,
41        /// Estimated token count before compression.
42        tokens_before: usize,
43        /// Number of messages in the session.
44        msg_count: usize,
45        /// What triggered this compression.
46        trigger: CompressionTrigger,
47    },
48    /// Compression started — LLM call about to begin.
49    Started {
50        /// Session that owns this compression.
51        session_id: u64,
52        /// Estimated token count before compression.
53        tokens_before: usize,
54        /// Number of messages in the session.
55        msg_count: usize,
56        /// What triggered this compression.
57        trigger: CompressionTrigger,
58    },
59    /// Streaming progress — cumulative character count from LLM.
60    Progress {
61        /// Session that owns this compression.
62        session_id: u64,
63        /// Cumulative characters received from the summarisation LLM so far.
64        chars: usize,
65    },
66    /// Compression completed — session messages replaced.
67    Completed {
68        /// Session that owns this compression.
69        session_id: u64,
70        /// Estimated token count before compression.
71        tokens_before: usize,
72        /// Estimated token count after compression.
73        tokens_after: usize,
74        /// Reduction percentage (positive = saved tokens).
75        reduction_pct: i32,
76        /// Message count before compression.
77        msg_count_before: usize,
78        /// Message count after compression.
79        msg_count_after: usize,
80        /// What triggered this compression.
81        trigger: CompressionTrigger,
82    },
83    /// Compression failed — session restored from snapshot.
84    Failed {
85        /// Session that owns this compression.
86        session_id: u64,
87        /// Human-readable error description.
88        error: String,
89        /// What triggered this compression.
90        trigger: CompressionTrigger,
91    },
92}
93
94impl CompressionEvent {
95    /// Convert into an [`agent_base::UserEvent::Structured`] for transport.
96    pub fn into_user_event(self) -> agent_base::UserEvent {
97        agent_base::UserEvent::Structured {
98            event_type: "compression".to_string(),
99            data: serde_json::to_value(self).expect("CompressionEvent is always serializable"),
100        }
101    }
102
103    /// Try to extract a [`CompressionEvent`] from a [`agent_base::UserEvent`].
104    ///
105    /// Returns `None` if the event is not a compression event or if
106    /// deserialization fails.
107    pub fn from_user_event(event: &agent_base::UserEvent) -> Option<Self> {
108        match event {
109            agent_base::UserEvent::Structured { event_type, data }
110                if event_type == "compression" =>
111            {
112                serde_json::from_value(data.clone()).ok()
113            }
114            _ => None,
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    // ── Serialization round-trip ─────────────────────────────────────────
124
125    #[test]
126    fn test_preparing_roundtrip() {
127        let event = CompressionEvent::Preparing {
128            session_id: 42,
129            tokens_before: 4200,
130            msg_count: 8,
131            trigger: CompressionTrigger::Auto,
132        };
133        let json = serde_json::to_string(&event).unwrap();
134        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
135        assert_eq!(event, parsed);
136    }
137
138    #[test]
139    fn test_started_roundtrip() {
140        let event = CompressionEvent::Started {
141            session_id: 42,
142            tokens_before: 4200,
143            msg_count: 8,
144            trigger: CompressionTrigger::Manual,
145        };
146        let json = serde_json::to_string(&event).unwrap();
147        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
148        assert_eq!(event, parsed);
149    }
150
151    #[test]
152    fn test_progress_roundtrip() {
153        let event = CompressionEvent::Progress {
154            session_id: 42,
155            chars: 755,
156        };
157        let json = serde_json::to_string(&event).unwrap();
158        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
159        assert_eq!(event, parsed);
160    }
161
162    #[test]
163    fn test_completed_roundtrip() {
164        let event = CompressionEvent::Completed {
165            session_id: 42,
166            tokens_before: 4200,
167            tokens_after: 3800,
168            reduction_pct: 10,
169            msg_count_before: 20,
170            msg_count_after: 8,
171            trigger: CompressionTrigger::Auto,
172        };
173        let json = serde_json::to_string(&event).unwrap();
174        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
175        assert_eq!(event, parsed);
176    }
177
178    #[test]
179    fn test_failed_roundtrip() {
180        let event = CompressionEvent::Failed {
181            session_id: 42,
182            error: "LLM timeout".to_string(),
183            trigger: CompressionTrigger::Manual,
184        };
185        let json = serde_json::to_string(&event).unwrap();
186        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
187        assert_eq!(event, parsed);
188    }
189
190    // ── JSON shape (snake_case wire format) ──────────────────────────────
191
192    #[test]
193    fn test_json_uses_snake_case() {
194        let event = CompressionEvent::Completed {
195            session_id: 1,
196            tokens_before: 100,
197            tokens_after: 50,
198            reduction_pct: 50,
199            msg_count_before: 10,
200            msg_count_after: 5,
201            trigger: CompressionTrigger::Auto,
202        };
203        let json = serde_json::to_value(&event).unwrap();
204
205        // phase tag
206        assert_eq!(json["phase"], "completed");
207        // snake_case keys
208        assert!(json.get("tokens_before").is_some(), "expected snake_case");
209        assert!(
210            json.get("tokensAfter").is_none(),
211            "camelCase must not appear"
212        );
213        assert!(json.get("msg_count_before").is_some());
214        assert!(json.get("msg_count_after").is_some());
215        assert!(json.get("reduction_pct").is_some());
216        assert!(json.get("session_id").is_some());
217    }
218
219    #[test]
220    fn test_trigger_json_values() {
221        let auto = serde_json::to_value(&CompressionTrigger::Auto).unwrap();
222        assert_eq!(auto, "auto");
223
224        let manual = serde_json::to_value(&CompressionTrigger::Manual).unwrap();
225        assert_eq!(manual, "manual");
226    }
227
228    // ── into_user_event / from_user_event ────────────────────────────────
229
230    #[test]
231    fn test_into_user_event_and_back() {
232        let event = CompressionEvent::Preparing {
233            session_id: 42,
234            tokens_before: 4200,
235            msg_count: 8,
236            trigger: CompressionTrigger::Auto,
237        };
238        let user_event = event.clone().into_user_event();
239
240        // Verify the wrapper.
241        match &user_event {
242            agent_base::UserEvent::Structured { event_type, .. } => {
243                assert_eq!(event_type, "compression");
244            }
245            other => panic!("expected Structured, got {:?}", other),
246        }
247
248        // Round-trip back.
249        let recovered = CompressionEvent::from_user_event(&user_event).unwrap();
250        assert_eq!(event, recovered);
251    }
252
253    #[test]
254    fn test_from_user_event_ignores_non_compression() {
255        let other = agent_base::UserEvent::Structured {
256            event_type: "other".to_string(),
257            data: serde_json::json!({}),
258        };
259        assert!(CompressionEvent::from_user_event(&other).is_none());
260    }
261
262    #[test]
263    fn test_from_user_event_returns_none_on_malformed_data() {
264        let bad = agent_base::UserEvent::Structured {
265            event_type: "compression".to_string(),
266            data: serde_json::json!({ "phase": "unknown_phase" }),
267        };
268        assert!(CompressionEvent::from_user_event(&bad).is_none());
269    }
270
271    // ── Deserialization from legacy inline JSON (compatibility) ──────────
272
273    #[test]
274    fn test_deserialize_progress_from_json() {
275        // Matches the existing inline JSON shape emitted by the middleware.
276        let json = serde_json::json!({
277            "phase": "progress",
278            "session_id": 1,
279            "chars": 42,
280        });
281        let event: CompressionEvent = serde_json::from_value(json).unwrap();
282        assert_eq!(
283            event,
284            CompressionEvent::Progress {
285                session_id: 1,
286                chars: 42
287            }
288        );
289    }
290
291    #[test]
292    fn test_deserialize_start_from_json() {
293        let json = serde_json::json!({
294            "phase": "start",
295            "tokens_before": 4200,
296            "msg_count": 8,
297        });
298        // Note: current middleware uses "start", but we use "started" in the enum.
299        // This test verifies what happens with the old "start" value.
300        let result: Result<CompressionEvent, _> = serde_json::from_value(json);
301        // "start" != "started", so this should fail — documenting the breaking change.
302        assert!(result.is_err(), "\"start\" should not match \"started\"");
303    }
304
305    // ── proptest: CompressionEvent serde roundtrip ──
306
307    mod proptest_tests {
308        use super::*;
309        use proptest::prelude::*;
310
311        /// Generate a random CompressionTrigger.
312        fn arb_trigger() -> impl Strategy<Value = CompressionTrigger> {
313            prop_oneof![
314                Just(CompressionTrigger::Auto),
315                Just(CompressionTrigger::Manual),
316                Just(CompressionTrigger::InlineCompaction),
317            ]
318        }
319
320        /// Generate a random CompressionEvent.
321        fn arb_event() -> impl Strategy<Value = CompressionEvent> {
322            prop_oneof![
323                (0u64..10000, 0usize..100000, 0usize..1000, arb_trigger()).prop_map(
324                    |(sid, tokens, msgs, trigger)| CompressionEvent::Preparing {
325                        session_id: sid,
326                        tokens_before: tokens,
327                        msg_count: msgs,
328                        trigger,
329                    }
330                ),
331                (0u64..10000, 0usize..100000, 0usize..1000, arb_trigger()).prop_map(
332                    |(sid, tokens, msgs, trigger)| CompressionEvent::Started {
333                        session_id: sid,
334                        tokens_before: tokens,
335                        msg_count: msgs,
336                        trigger,
337                    }
338                ),
339                (0u64..10000, 0usize..100000).prop_map(|(sid, chars)| CompressionEvent::Progress {
340                    session_id: sid,
341                    chars,
342                }),
343                (
344                    0u64..10000,
345                    0usize..100000,
346                    0usize..100000,
347                    -100i32..100,
348                    0usize..1000,
349                    0usize..1000,
350                    arb_trigger()
351                )
352                    .prop_map(|(sid, tb, ta, pct, mb, ma, trigger)| {
353                        CompressionEvent::Completed {
354                            session_id: sid,
355                            tokens_before: tb,
356                            tokens_after: ta,
357                            reduction_pct: pct,
358                            msg_count_before: mb,
359                            msg_count_after: ma,
360                            trigger,
361                        }
362                    }),
363                (0u64..10000, "[a-z ]{0,50}", arb_trigger()).prop_map(|(sid, err, trigger)| {
364                    CompressionEvent::Failed {
365                        session_id: sid,
366                        error: err,
367                        trigger,
368                    }
369                }),
370            ]
371        }
372
373        proptest! {
374            #[test]
375            fn serde_roundtrip(event in arb_event()) {
376                let json = serde_json::to_value(&event).unwrap();
377                let reparsed: CompressionEvent = serde_json::from_value(json).unwrap();
378                assert_eq!(event, reparsed, "serde roundtrip failed");
379            }
380
381            #[test]
382            fn json_keys_are_snake_case(event in arb_event()) {
383                let json = serde_json::to_value(&event).unwrap();
384                if let Some(obj) = json.as_object() {
385                    for key in obj.keys() {
386                        // No uppercase letters in keys (camelCase would have them)
387                        assert!(!key.chars().any(|c| c.is_ascii_uppercase()),
388                            "key {:?} is not snake_case in {:?}", key, json);
389                    }
390                }
391            }
392
393            #[test]
394            fn phase_tag_always_present(event in arb_event()) {
395                let json = serde_json::to_value(&event).unwrap();
396                assert!(json.get("phase").is_some(),
397                    "missing 'phase' tag in {:?}", json);
398                assert!(json["phase"].is_string(),
399                    "'phase' should be a string in {:?}", json);
400            }
401        }
402    }
403}