Skip to main content

motosan_agent_loop/message/
meta.rs

1//! Per-message metadata: strong-typed fields + open extension slot.
2
3use std::collections::BTreeMap;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7
8/// Per-message bookkeeping — never sent to the LLM provider, only used
9/// by the loop core and registered extensions.
10///
11/// Most extensions should write to strong-typed fields below. The
12/// `extensions` slot is a last-resort escape hatch for extension-specific
13/// state that isn't worth adding to `core` (e.g., a third-party extension
14/// stashing a custom tag). Prefer adding a strong-typed field via PR.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct MessageMeta {
17    /// Creation time, as Unix ms. Serialised as integer for stable JSON.
18    pub created_at_ms: u64,
19    /// If set, the time at which a compaction policy marked this message
20    /// as compacted (e.g. OpenCode-style reversible marking). Policies
21    /// may choose to hide such messages from the provider without deleting
22    /// them from storage.
23    pub compacted_at_ms: Option<u64>,
24    /// Hint to prompt-cache-aware projection policies: if `true`, the
25    /// message is part of the cache-stable prefix and must not be mutated
26    /// for the current turn.
27    pub cache_stable: bool,
28    /// Opaque tags. Extensions may filter / route on these.
29    pub tags: Vec<String>,
30    /// Open extension slot. Key is an extension name, value is any JSON.
31    /// Use sparingly — prefer strong-typed fields.
32    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33    pub extensions: BTreeMap<String, serde_json::Value>,
34}
35
36impl Default for MessageMeta {
37    fn default() -> Self {
38        let created_at_ms = SystemTime::now()
39            .duration_since(UNIX_EPOCH)
40            .map(|d| d.as_millis() as u64)
41            .unwrap_or(0);
42        Self {
43            created_at_ms,
44            compacted_at_ms: None,
45            cache_stable: false,
46            tags: Vec::new(),
47            extensions: BTreeMap::new(),
48        }
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn default_has_nonzero_created_at() {
58        let m = MessageMeta::default();
59        assert!(m.created_at_ms > 0);
60        assert!(m.compacted_at_ms.is_none());
61        assert!(!m.cache_stable);
62        assert!(m.tags.is_empty());
63        assert!(m.extensions.is_empty());
64    }
65
66    #[test]
67    fn round_trips_json_without_extensions_noise() {
68        let m = MessageMeta::default();
69        let s = serde_json::to_string(&m).unwrap();
70        assert!(
71            !s.contains("extensions"),
72            "empty extensions should not serialize: {s}"
73        );
74        let back: MessageMeta = serde_json::from_str(&s).unwrap();
75        assert_eq!(m, back);
76    }
77
78    #[test]
79    fn extensions_slot_round_trips() {
80        let mut m = MessageMeta::default();
81        m.extensions
82            .insert("my_ext".into(), serde_json::json!({"score": 7}));
83        let s = serde_json::to_string(&m).unwrap();
84        let back: MessageMeta = serde_json::from_str(&s).unwrap();
85        assert_eq!(back.extensions["my_ext"]["score"], 7);
86    }
87}