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}
21
22/// Context compression lifecycle events.
23///
24/// Emitted as `UserEvent::Structured { event_type: "compression", data: ... }`
25/// so consumers can match on the typed enum for compile-time safety.
26///
27/// ## Lifecycle
28///
29/// ```text
30/// Preparing → Started → Progress (0..N) → Completed | Failed
31/// ```
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
33#[serde(tag = "phase", rename_all = "snake_case")]
34pub enum CompressionEvent {
35    /// Pre-processing — saving snapshot, about to start.
36    Preparing {
37        /// Session that owns this compression.
38        session_id: u64,
39        /// Estimated token count before compression.
40        tokens_before: usize,
41        /// Number of messages in the session.
42        msg_count: usize,
43        /// What triggered this compression.
44        trigger: CompressionTrigger,
45    },
46    /// Compression started — LLM call about to begin.
47    Started {
48        /// Session that owns this compression.
49        session_id: u64,
50        /// Estimated token count before compression.
51        tokens_before: usize,
52        /// Number of messages in the session.
53        msg_count: usize,
54        /// What triggered this compression.
55        trigger: CompressionTrigger,
56    },
57    /// Streaming progress — cumulative character count from LLM.
58    Progress {
59        /// Session that owns this compression.
60        session_id: u64,
61        /// Cumulative characters received from the summarisation LLM so far.
62        chars: usize,
63    },
64    /// Compression completed — session messages replaced.
65    Completed {
66        /// Session that owns this compression.
67        session_id: u64,
68        /// Estimated token count before compression.
69        tokens_before: usize,
70        /// Estimated token count after compression.
71        tokens_after: usize,
72        /// Reduction percentage (positive = saved tokens).
73        reduction_pct: i32,
74        /// Message count before compression.
75        msg_count_before: usize,
76        /// Message count after compression.
77        msg_count_after: usize,
78        /// What triggered this compression.
79        trigger: CompressionTrigger,
80    },
81    /// Compression failed — session restored from snapshot.
82    Failed {
83        /// Session that owns this compression.
84        session_id: u64,
85        /// Human-readable error description.
86        error: String,
87        /// What triggered this compression.
88        trigger: CompressionTrigger,
89    },
90}
91
92impl CompressionEvent {
93    /// Convert into an [`agent_base::UserEvent::Structured`] for transport.
94    pub fn into_user_event(self) -> agent_base::UserEvent {
95        agent_base::UserEvent::Structured {
96            event_type: "compression".to_string(),
97            data: serde_json::to_value(self).expect("CompressionEvent is always serializable"),
98        }
99    }
100
101    /// Try to extract a [`CompressionEvent`] from a [`agent_base::UserEvent`].
102    ///
103    /// Returns `None` if the event is not a compression event or if
104    /// deserialization fails.
105    pub fn from_user_event(event: &agent_base::UserEvent) -> Option<Self> {
106        match event {
107            agent_base::UserEvent::Structured { event_type, data }
108                if event_type == "compression" =>
109            {
110                serde_json::from_value(data.clone()).ok()
111            }
112            _ => None,
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    // ── Serialization round-trip ─────────────────────────────────────────
122
123    #[test]
124    fn test_preparing_roundtrip() {
125        let event = CompressionEvent::Preparing {
126            session_id: 42,
127            tokens_before: 4200,
128            msg_count: 8,
129            trigger: CompressionTrigger::Auto,
130        };
131        let json = serde_json::to_string(&event).unwrap();
132        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
133        assert_eq!(event, parsed);
134    }
135
136    #[test]
137    fn test_started_roundtrip() {
138        let event = CompressionEvent::Started {
139            session_id: 42,
140            tokens_before: 4200,
141            msg_count: 8,
142            trigger: CompressionTrigger::Manual,
143        };
144        let json = serde_json::to_string(&event).unwrap();
145        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
146        assert_eq!(event, parsed);
147    }
148
149    #[test]
150    fn test_progress_roundtrip() {
151        let event = CompressionEvent::Progress {
152            session_id: 42,
153            chars: 755,
154        };
155        let json = serde_json::to_string(&event).unwrap();
156        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
157        assert_eq!(event, parsed);
158    }
159
160    #[test]
161    fn test_completed_roundtrip() {
162        let event = CompressionEvent::Completed {
163            session_id: 42,
164            tokens_before: 4200,
165            tokens_after: 3800,
166            reduction_pct: 10,
167            msg_count_before: 20,
168            msg_count_after: 8,
169            trigger: CompressionTrigger::Auto,
170        };
171        let json = serde_json::to_string(&event).unwrap();
172        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
173        assert_eq!(event, parsed);
174    }
175
176    #[test]
177    fn test_failed_roundtrip() {
178        let event = CompressionEvent::Failed {
179            session_id: 42,
180            error: "LLM timeout".to_string(),
181            trigger: CompressionTrigger::Manual,
182        };
183        let json = serde_json::to_string(&event).unwrap();
184        let parsed: CompressionEvent = serde_json::from_str(&json).unwrap();
185        assert_eq!(event, parsed);
186    }
187
188    // ── JSON shape (snake_case wire format) ──────────────────────────────
189
190    #[test]
191    fn test_json_uses_snake_case() {
192        let event = CompressionEvent::Completed {
193            session_id: 1,
194            tokens_before: 100,
195            tokens_after: 50,
196            reduction_pct: 50,
197            msg_count_before: 10,
198            msg_count_after: 5,
199            trigger: CompressionTrigger::Auto,
200        };
201        let json = serde_json::to_value(&event).unwrap();
202
203        // phase tag
204        assert_eq!(json["phase"], "completed");
205        // snake_case keys
206        assert!(json.get("tokens_before").is_some(), "expected snake_case");
207        assert!(
208            json.get("tokensAfter").is_none(),
209            "camelCase must not appear"
210        );
211        assert!(json.get("msg_count_before").is_some());
212        assert!(json.get("msg_count_after").is_some());
213        assert!(json.get("reduction_pct").is_some());
214        assert!(json.get("session_id").is_some());
215    }
216
217    #[test]
218    fn test_trigger_json_values() {
219        let auto = serde_json::to_value(&CompressionTrigger::Auto).unwrap();
220        assert_eq!(auto, "auto");
221
222        let manual = serde_json::to_value(&CompressionTrigger::Manual).unwrap();
223        assert_eq!(manual, "manual");
224    }
225
226    // ── into_user_event / from_user_event ────────────────────────────────
227
228    #[test]
229    fn test_into_user_event_and_back() {
230        let event = CompressionEvent::Preparing {
231            session_id: 42,
232            tokens_before: 4200,
233            msg_count: 8,
234            trigger: CompressionTrigger::Auto,
235        };
236        let user_event = event.clone().into_user_event();
237
238        // Verify the wrapper.
239        match &user_event {
240            agent_base::UserEvent::Structured { event_type, .. } => {
241                assert_eq!(event_type, "compression");
242            }
243            other => panic!("expected Structured, got {:?}", other),
244        }
245
246        // Round-trip back.
247        let recovered = CompressionEvent::from_user_event(&user_event).unwrap();
248        assert_eq!(event, recovered);
249    }
250
251    #[test]
252    fn test_from_user_event_ignores_non_compression() {
253        let other = agent_base::UserEvent::Structured {
254            event_type: "other".to_string(),
255            data: serde_json::json!({}),
256        };
257        assert!(CompressionEvent::from_user_event(&other).is_none());
258    }
259
260    #[test]
261    fn test_from_user_event_returns_none_on_malformed_data() {
262        let bad = agent_base::UserEvent::Structured {
263            event_type: "compression".to_string(),
264            data: serde_json::json!({ "phase": "unknown_phase" }),
265        };
266        assert!(CompressionEvent::from_user_event(&bad).is_none());
267    }
268
269    // ── Deserialization from legacy inline JSON (compatibility) ──────────
270
271    #[test]
272    fn test_deserialize_progress_from_json() {
273        // Matches the existing inline JSON shape emitted by the middleware.
274        let json = serde_json::json!({
275            "phase": "progress",
276            "session_id": 1,
277            "chars": 42,
278        });
279        let event: CompressionEvent = serde_json::from_value(json).unwrap();
280        assert_eq!(
281            event,
282            CompressionEvent::Progress {
283                session_id: 1,
284                chars: 42
285            }
286        );
287    }
288
289    #[test]
290    fn test_deserialize_start_from_json() {
291        let json = serde_json::json!({
292            "phase": "start",
293            "tokens_before": 4200,
294            "msg_count": 8,
295        });
296        // Note: current middleware uses "start", but we use "started" in the enum.
297        // This test verifies what happens with the old "start" value.
298        let result: Result<CompressionEvent, _> = serde_json::from_value(json);
299        // "start" != "started", so this should fail — documenting the breaking change.
300        assert!(result.is_err(), "\"start\" should not match \"started\"");
301    }
302}