Skip to main content

clawft_types/
session.rs

1//! Conversation session types.
2//!
3//! [`Session`] stores an append-only message history for a single
4//! channel + chat_id pair. It is designed for LLM cache efficiency:
5//! consolidation writes summaries to external files but never mutates
6//! the in-memory message list.
7
8use std::collections::HashMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13/// A conversation session.
14///
15/// Messages are append-only for LLM cache efficiency. The consolidation
16/// process writes summaries to `MEMORY.md` / `HISTORY.md` but does **not**
17/// modify the messages list or [`get_history`](Session::get_history) output.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Session {
20    /// Session key, typically `"{channel}:{chat_id}"`.
21    pub key: String,
22
23    /// Ordered list of messages (append-only).
24    #[serde(default)]
25    pub messages: Vec<serde_json::Value>,
26
27    /// When the session was first created.
28    #[serde(default = "Utc::now")]
29    pub created_at: DateTime<Utc>,
30
31    /// When the session was last updated.
32    #[serde(default = "Utc::now")]
33    pub updated_at: DateTime<Utc>,
34
35    /// Arbitrary session metadata.
36    #[serde(default)]
37    pub metadata: HashMap<String, serde_json::Value>,
38
39    /// Number of messages already consolidated to external files.
40    #[serde(default)]
41    pub last_consolidated: usize,
42}
43
44impl Session {
45    /// Create a new empty session with the given key.
46    pub fn new(key: impl Into<String>) -> Self {
47        let now = Utc::now();
48        Self {
49            key: key.into(),
50            messages: Vec::new(),
51            created_at: now,
52            updated_at: now,
53            metadata: HashMap::new(),
54            last_consolidated: 0,
55        }
56    }
57
58    /// Append a message to the session.
59    ///
60    /// Extra fields can be passed via `extras` and will be merged into
61    /// the message object alongside `role`, `content`, and `timestamp`.
62    pub fn add_message(
63        &mut self,
64        role: &str,
65        content: &str,
66        extras: Option<HashMap<String, serde_json::Value>>,
67    ) {
68        let mut msg = serde_json::json!({
69            "role": role,
70            "content": content,
71            "timestamp": Utc::now().to_rfc3339(),
72        });
73
74        if let Some(extras) = extras
75            && let Some(obj) = msg.as_object_mut()
76        {
77            for (k, v) in extras {
78                obj.insert(k, v);
79            }
80        }
81
82        self.messages.push(msg);
83        self.updated_at = Utc::now();
84    }
85
86    /// Get recent messages in LLM format.
87    ///
88    /// Returns at most `max_messages` entries from the end of the history.
89    /// Preserves `tool_call_id` and `tool_calls` metadata when present so
90    /// that the LLM can correctly associate tool results with tool calls.
91    pub fn get_history(&self, max_messages: usize) -> Vec<serde_json::Value> {
92        let start = self.messages.len().saturating_sub(max_messages);
93        self.messages[start..]
94            .iter()
95            .map(|m| {
96                let mut msg = serde_json::json!({
97                    "role": m.get("role").and_then(|v| v.as_str()).unwrap_or("user"),
98                    "content": m.get("content").and_then(|v| v.as_str()).unwrap_or(""),
99                });
100                if let Some(tool_call_id) = m.get("tool_call_id").filter(|v| !v.is_null()) {
101                    msg["tool_call_id"] = tool_call_id.clone();
102                }
103                if let Some(tool_calls) = m.get("tool_calls").filter(|v| !v.is_null()) {
104                    msg["tool_calls"] = tool_calls.clone();
105                }
106                msg
107            })
108            .collect()
109    }
110
111    /// Clear all messages and reset consolidation state.
112    pub fn clear(&mut self) {
113        self.messages.clear();
114        self.last_consolidated = 0;
115        self.updated_at = Utc::now();
116    }
117}
118
119impl Default for Session {
120    fn default() -> Self {
121        Self::new("")
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn new_session() {
131        let s = Session::new("telegram:123");
132        assert_eq!(s.key, "telegram:123");
133        assert!(s.messages.is_empty());
134        assert_eq!(s.last_consolidated, 0);
135    }
136
137    #[test]
138    fn add_message_basic() {
139        let mut s = Session::new("test");
140        s.add_message("user", "hello", None);
141        s.add_message("assistant", "hi there", None);
142        assert_eq!(s.messages.len(), 2);
143        assert_eq!(s.messages[0]["role"], "user");
144        assert_eq!(s.messages[1]["content"], "hi there");
145    }
146
147    #[test]
148    fn add_message_with_extras() {
149        let mut s = Session::new("test");
150        let mut extras = HashMap::new();
151        extras.insert("tool_calls".into(), serde_json::json!([{"id": "tc1"}]));
152        s.add_message("assistant", "let me check", Some(extras));
153        assert!(s.messages[0].get("tool_calls").is_some());
154    }
155
156    #[test]
157    fn get_history_all() {
158        let mut s = Session::new("test");
159        s.add_message("user", "one", None);
160        s.add_message("assistant", "two", None);
161        let hist = s.get_history(500);
162        assert_eq!(hist.len(), 2);
163        assert_eq!(hist[0]["role"], "user");
164        assert_eq!(hist[1]["content"], "two");
165    }
166
167    #[test]
168    fn get_history_truncated() {
169        let mut s = Session::new("test");
170        for i in 0..10 {
171            s.add_message("user", &format!("msg {i}"), None);
172        }
173        let hist = s.get_history(3);
174        assert_eq!(hist.len(), 3);
175        assert_eq!(hist[0]["content"], "msg 7");
176        assert_eq!(hist[2]["content"], "msg 9");
177    }
178
179    #[test]
180    fn clear_resets_state() {
181        let mut s = Session::new("test");
182        s.add_message("user", "hello", None);
183        s.last_consolidated = 1;
184        s.clear();
185        assert!(s.messages.is_empty());
186        assert_eq!(s.last_consolidated, 0);
187    }
188
189    #[test]
190    fn serde_roundtrip() {
191        let mut s = Session::new("slack:C123");
192        s.add_message("user", "test", None);
193        s.last_consolidated = 0;
194
195        let json = serde_json::to_string(&s).unwrap();
196        let restored: Session = serde_json::from_str(&json).unwrap();
197        assert_eq!(restored.key, "slack:C123");
198        assert_eq!(restored.messages.len(), 1);
199    }
200
201    #[test]
202    fn default_session() {
203        let s = Session::default();
204        assert_eq!(s.key, "");
205        assert!(s.messages.is_empty());
206    }
207}