Skip to main content

ailoop_history/
snapshot.rs

1//! [`ConversationSnapshot`] — versioned wire format used by
2//! [`HistoryStore`](crate::HistoryStore) backends.
3
4use ailoop_core::Message;
5use serde::{Deserialize, Serialize};
6
7use crate::errors::FromMessagesError;
8
9/// Persistable image of a conversation's logical state.
10///
11/// Carries only the data needed to rebuild a `Conversation`'s history:
12/// the message vector and the parallel `pinned` mask. Runtime state
13/// (model, tools, middlewares, per-turn `Usage` / `RunId`) is
14/// deliberately excluded — the model is re-supplied at resume time and
15/// telemetry is the caller's concern.
16///
17/// Versioned via `version` so the on-disk shape can evolve without
18/// breaking older payloads. Today only `version == 1` is accepted;
19/// deserializing any other value fails with an explicit error.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[serde(try_from = "RawConversationSnapshot")]
22#[non_exhaustive]
23pub struct ConversationSnapshot {
24    /// Wire-format version. Set by [`ConversationSnapshot::new`] to
25    /// [`ConversationSnapshot::VERSION`]; deserializing any other
26    /// value rejects the payload at parse time so callers fail loudly
27    /// on a forward-incompatible file.
28    pub version: u32,
29    /// History messages, in chronological order.
30    pub messages: Vec<Message>,
31    /// Parallel pin mask — `pinned[i]` is the survivor flag for
32    /// `messages[i]`. Same length as `messages`; the
33    /// `#[serde(try_from)]` arm rejects payloads where the two
34    /// vectors disagree, so post-deserialize the invariant holds.
35    pub pinned: Vec<bool>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct RawConversationSnapshot {
40    version: u32,
41    messages: Vec<Message>,
42    pinned: Vec<bool>,
43}
44
45impl ConversationSnapshot {
46    /// Current wire-format version. Bumped when the on-disk shape
47    /// changes in a backward-incompatible way; older payloads then
48    /// fail to deserialize until a migration step lands.
49    pub const VERSION: u32 = 1;
50
51    /// Bundle `messages` and `pinned` into a snapshot tagged with
52    /// the current [`VERSION`](Self::VERSION). Returns
53    /// [`FromMessagesError::LengthMismatch`] when the two vectors
54    /// disagree — same invariant the deserialize path enforces, so
55    /// snapshots produced via this constructor always round-trip.
56    pub fn new(messages: Vec<Message>, pinned: Vec<bool>) -> Result<Self, FromMessagesError> {
57        if messages.len() != pinned.len() {
58            return Err(FromMessagesError::LengthMismatch {
59                messages: messages.len(),
60                pinned: pinned.len(),
61            });
62        }
63        Ok(Self {
64            version: Self::VERSION,
65            messages,
66            pinned,
67        })
68    }
69}
70
71impl TryFrom<RawConversationSnapshot> for ConversationSnapshot {
72    type Error = String;
73
74    fn try_from(raw: RawConversationSnapshot) -> Result<Self, Self::Error> {
75        if raw.version != Self::VERSION {
76            return Err(format!(
77                "unsupported snapshot version {} (expected {})",
78                raw.version,
79                Self::VERSION
80            ));
81        }
82        if raw.messages.len() != raw.pinned.len() {
83            return Err(format!(
84                "messages/pinned length mismatch: messages={}, pinned={}",
85                raw.messages.len(),
86                raw.pinned.len()
87            ));
88        }
89        Ok(Self {
90            version: raw.version,
91            messages: raw.messages,
92            pinned: raw.pinned,
93        })
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use ailoop_core::{AssistantBlock, Message};
101    use serde_json::json;
102
103    #[test]
104    fn round_trip_through_json() {
105        let snap = ConversationSnapshot::new(
106            vec![
107                Message::user("hi"),
108                Message::Assistant {
109                    blocks: vec![AssistantBlock::tool_call("c1", "fetch", json!({"x": 1}))],
110                },
111            ],
112            vec![true, false],
113        )
114        .expect("valid lengths");
115
116        let s = serde_json::to_string(&snap).unwrap();
117        let back: ConversationSnapshot = serde_json::from_str(&s).unwrap();
118        assert_eq!(back, snap);
119    }
120
121    #[test]
122    fn deserialize_rejects_unsupported_version() {
123        let bad = json!({
124            "version": 999,
125            "messages": [],
126            "pinned": []
127        })
128        .to_string();
129        let err = serde_json::from_str::<ConversationSnapshot>(&bad)
130            .expect_err("expected version mismatch error");
131        let msg = err.to_string();
132        assert!(
133            msg.contains("unsupported snapshot version 999"),
134            "unexpected error message: {msg}"
135        );
136    }
137
138    #[test]
139    fn deserialize_rejects_length_mismatch() {
140        let bad = json!({
141            "version": 1,
142            "messages": [{ "User": { "blocks": [{ "Text": { "text": "hi" } }] } }],
143            "pinned": []
144        })
145        .to_string();
146        let err = serde_json::from_str::<ConversationSnapshot>(&bad)
147            .expect_err("expected length mismatch error");
148        assert!(
149            err.to_string().contains("length mismatch"),
150            "unexpected error: {err}"
151        );
152    }
153
154    #[test]
155    fn new_rejects_length_mismatch() {
156        let err = ConversationSnapshot::new(vec![Message::user("hi")], vec![]).unwrap_err();
157        assert!(matches!(
158            err,
159            FromMessagesError::LengthMismatch {
160                messages: 1,
161                pinned: 0
162            }
163        ));
164    }
165}