1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct MessageMetadata {
20 timestamp: u64,
22
23 importance_score: f64,
29
30 compression_status: CompressionStatus,
32
33 estimated_tokens: usize,
36
37 source: Option<String>,
40
41 #[serde(default, skip_serializing_if = "Option::is_none")]
47 intent_id: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 event_item_id: Option<String>,
51}
52
53impl MessageMetadata {
54 pub fn user_input(timestamp: u64, estimated_tokens: usize) -> Self {
56 Self {
57 timestamp,
58 importance_score: 0.5,
59 compression_status: CompressionStatus::Uncompressed,
60 estimated_tokens,
61 source: Some("user_input".into()),
62 intent_id: None,
63 event_item_id: None,
64 }
65 }
66
67 pub fn llm_response(timestamp: u64, estimated_tokens: usize) -> Self {
69 Self {
70 timestamp,
71 importance_score: 0.6,
72 compression_status: CompressionStatus::Uncompressed,
73 estimated_tokens,
74 source: Some("llm_response".into()),
75 intent_id: None,
76 event_item_id: None,
77 }
78 }
79
80 pub fn tool_result(timestamp: u64, estimated_tokens: usize) -> Self {
82 Self {
83 timestamp,
84 importance_score: 0.4,
85 compression_status: CompressionStatus::Uncompressed,
86 estimated_tokens,
87 source: Some("tool_result".into()),
88 intent_id: None,
89 event_item_id: None,
90 }
91 }
92
93 pub fn system(timestamp: u64, estimated_tokens: usize) -> Self {
95 Self {
96 timestamp,
97 importance_score: 1.0,
98 compression_status: CompressionStatus::Uncompressed,
99 estimated_tokens,
100 source: Some("system".into()),
101 intent_id: None,
102 event_item_id: None,
103 }
104 }
105
106 pub fn synthetic(timestamp: u64, estimated_tokens: usize) -> Self {
108 Self {
109 timestamp,
110 importance_score: 0.3,
111 compression_status: CompressionStatus::Uncompressed,
112 estimated_tokens,
113 source: Some("synthetic".into()),
114 intent_id: None,
115 event_item_id: None,
116 }
117 }
118
119 #[must_use]
126 pub fn harness_notice(timestamp: u64, estimated_tokens: usize) -> Self {
127 Self::synthetic(timestamp, estimated_tokens)
128 }
129
130 #[must_use]
133 pub fn is_synthetic(&self) -> bool {
134 self.source.as_deref() == Some("synthetic")
135 }
136
137 #[must_use]
140 pub fn source(&self) -> Option<&str> {
141 self.source.as_deref()
142 }
143
144 #[must_use]
146 pub fn with_intent_id(mut self, intent_id: impl Into<String>) -> Self {
147 self.intent_id = Some(intent_id.into());
148 self
149 }
150
151 #[must_use]
153 pub fn intent_id(&self) -> Option<&str> {
154 self.intent_id.as_deref()
155 }
156
157 pub fn set_event_item_id(&mut self, item_id: String) {
159 self.event_item_id = Some(item_id);
160 }
161
162 #[must_use]
164 pub fn event_item_id(&self) -> Option<&str> {
165 self.event_item_id.as_deref()
166 }
167
168 fn mark_compressed(&mut self, original_tokens: usize, compressed_tokens: usize) {
170 self.compression_status = CompressionStatus::Compressed {
171 original_token_count: original_tokens,
172 summary_token_count: compressed_tokens,
173 };
174 self.estimated_tokens = compressed_tokens;
175 }
176
177 fn mark_summarized(&mut self, original_tokens: usize, summary_tokens: usize) {
179 self.compression_status = CompressionStatus::Summarized {
180 original_token_count: original_tokens,
181 summary_token_count: summary_tokens,
182 };
183 self.estimated_tokens = summary_tokens;
184 }
185
186 fn set_importance(&mut self, score: f64) {
188 self.importance_score = score.clamp(0.0, 1.0);
189 }
190
191 fn original_token_count(&self) -> usize {
194 match self.compression_status {
195 CompressionStatus::Uncompressed => self.estimated_tokens,
196 CompressionStatus::Compressed { original_token_count, .. }
197 | CompressionStatus::Summarized { original_token_count, .. } => original_token_count,
198 CompressionStatus::Dropped => 0,
199 }
200 }
201
202 fn effective_token_count(&self) -> usize {
204 match self.compression_status {
205 CompressionStatus::Uncompressed => self.estimated_tokens,
206 CompressionStatus::Compressed { summary_token_count, .. }
207 | CompressionStatus::Summarized { summary_token_count, .. } => summary_token_count,
208 CompressionStatus::Dropped => 0,
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum CompressionStatus {
217 Uncompressed,
219 Compressed {
221 original_token_count: usize,
222 summary_token_count: usize,
223 },
224 Summarized {
226 original_token_count: usize,
227 summary_token_count: usize,
228 },
229 Dropped,
232}
233
234#[allow(
235 clippy::derivable_impls,
236 reason = "Intentional compatibility, platform, or test-only suppression."
237)]
238impl Default for CompressionStatus {
239 fn default() -> Self {
240 Self::Uncompressed
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_create_user_metadata() {
250 let meta = MessageMetadata::user_input(1000, 50);
251 assert_eq!(meta.timestamp, 1000);
252 assert!((meta.importance_score - 0.5).abs() < f64::EPSILON);
253 assert_eq!(meta.compression_status, CompressionStatus::Uncompressed);
254 assert_eq!(meta.estimated_tokens, 50);
255 assert_eq!(meta.source.as_deref(), Some("user_input"));
256 assert_eq!(meta.intent_id(), None);
257 }
258
259 #[test]
260 fn test_create_llm_response_metadata() {
261 let meta = MessageMetadata::llm_response(2000, 150);
262 assert!((meta.importance_score - 0.6).abs() < f64::EPSILON);
263 }
264
265 #[test]
266 fn test_mark_compressed() {
267 let mut meta = MessageMetadata::user_input(1000, 200);
268 meta.mark_compressed(200, 50);
269 assert_eq!(meta.estimated_tokens, 50);
270 assert_eq!(meta.effective_token_count(), 50);
271 assert_eq!(meta.original_token_count(), 200);
272 }
273
274 #[test]
275 fn test_mark_summarized() {
276 let mut meta = MessageMetadata::user_input(1000, 300);
277 meta.mark_summarized(300, 30);
278 assert_eq!(meta.effective_token_count(), 30);
279 assert_eq!(meta.original_token_count(), 300);
280 }
281
282 #[test]
283 fn test_set_importance_clamps() {
284 let mut meta = MessageMetadata::user_input(1000, 50);
285 meta.set_importance(1.5);
286 assert!((meta.importance_score - 1.0).abs() < f64::EPSILON);
287 meta.set_importance(-0.5);
288 assert!((meta.importance_score - 0.0).abs() < f64::EPSILON);
289 }
290
291 #[test]
292 fn test_compression_status_serde_roundtrip() {
293 let status = CompressionStatus::Compressed { original_token_count: 200, summary_token_count: 50 };
294 let json = serde_json::to_string(&status).unwrap();
295 let deserialized: CompressionStatus = serde_json::from_str(&json).unwrap();
296 assert_eq!(status, deserialized);
297 }
298
299 #[test]
300 fn test_intent_id_is_optional_and_serde_compatible() {
301 let meta = MessageMetadata::user_input(1000, 50).with_intent_id("intent-1");
302 let json = serde_json::to_string(&meta).unwrap();
303 let restored: MessageMetadata = serde_json::from_str(&json).unwrap();
304 assert_eq!(restored.intent_id(), Some("intent-1"));
305
306 let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
307 let restored: MessageMetadata = serde_json::from_str(legacy).unwrap();
308 assert_eq!(restored.intent_id(), None);
309 }
310
311 #[test]
312 fn event_item_identity_round_trips_and_legacy_metadata_has_none() {
313 let mut metadata = MessageMetadata::llm_response(42, 7);
314 metadata.set_event_item_id("event-1".into());
315 let encoded = serde_json::to_string(&metadata).unwrap();
316 let decoded: MessageMetadata = serde_json::from_str(&encoded).unwrap();
317 assert_eq!(decoded.event_item_id(), Some("event-1"));
318 let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
319 let decoded: MessageMetadata = serde_json::from_str(legacy).unwrap();
320 assert_eq!(decoded.event_item_id(), None);
321 }
322}