Skip to main content

roma_core/
session.rs

1//! Chat session: message history and persistence.
2//!
3//! Replaces GenericAgent's `BaseSession` inheritance chain. A `ChatSession`
4//! owns the conversation history, a cache-injection strategy, and a session
5//! id. History compression is left to `HistoryCompressor` (Phase 3).
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::error::ClassifiedError;
13use crate::types::{ContentBlock, Message, NormalizedResponse, Role, ToolResultMessage};
14
15/// Where memory snapshots should be injected by [`crate::inject_memory`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum CacheStrategy {
19    /// Inject in the last user message; protects Anthropic prefix cache.
20    #[default]
21    AnthropicPrefixCache,
22    /// Inject into the system prompt (OpenAI-style).
23    SystemPrompt,
24    /// Do not inject.
25    Disabled,
26}
27
28/// Conversation state.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ChatSession {
31    pub session_id: Uuid,
32    /// Optional system prompt.
33    #[serde(default)]
34    pub system: Option<String>,
35    /// Message history in normalized form.
36    pub messages: Vec<Message>,
37    /// Cache-injection strategy.
38    #[serde(default)]
39    pub cache_strategy: CacheStrategy,
40}
41
42impl ChatSession {
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            session_id: Uuid::new_v4(),
47            system: None,
48            messages: Vec::new(),
49            cache_strategy: CacheStrategy::default(),
50        }
51    }
52
53    #[must_use]
54    pub fn with_system(mut self, system: impl Into<String>) -> Self {
55        self.system = Some(system.into());
56        self
57    }
58
59    #[must_use]
60    pub fn with_cache_strategy(mut self, strategy: CacheStrategy) -> Self {
61        self.cache_strategy = strategy;
62        self
63    }
64
65    /// Append a user text message.
66    pub fn push_user(&mut self, content: impl Into<String>) {
67        self.messages.push(Message::user_text(content));
68    }
69
70    /// Append the assistant's response content blocks verbatim.
71    pub fn push_assistant(&mut self, response: NormalizedResponse) {
72        self.messages.push(Message {
73            role: Role::Assistant,
74            content: response.content_blocks,
75        });
76    }
77
78    /// Append tool results as a single user message containing
79    /// [`ContentBlock::ToolResult`] blocks. Providers translate the exact
80    /// shape at the wire layer.
81    pub fn push_tool_results(&mut self, results: Vec<ToolResultMessage>) {
82        if results.is_empty() {
83            return;
84        }
85        let content = results.into_iter().map(ContentBlock::from).collect();
86        self.messages.push(Message {
87            role: Role::User,
88            content,
89        });
90    }
91
92    /// Append text to the tail of the most recent user message as a new
93    /// [`ContentBlock::Text`] block. Used for mid-turn injections (stream
94    /// rules) that must NOT create a bare user message after tool results
95    /// (provider alternation rules — spec D7). Falls back to pushing a new
96    /// user message when no user message exists; that only happens with an
97    /// empty, system-only, or assistant-only history, where a user message
98    /// is alternation-safe.
99    ///
100    /// Caller contract: intended for mid-turn injection while the tail
101    /// message is a user message (before any assistant commit). If the
102    /// tail is an assistant message, the text lands on an older user
103    /// message mid-history.
104    pub fn append_to_last_user(&mut self, text: impl Into<String>) {
105        let text = text.into();
106        if text.is_empty() {
107            return;
108        }
109        match self.messages.iter_mut().rfind(|m| m.role == Role::User) {
110            Some(msg) => msg.content.push(ContentBlock::Text { text }),
111            None => self.messages.push(Message::user_text(text)),
112        }
113    }
114
115    /// Total message count (including system messages if pushed manually).
116    #[must_use]
117    pub fn len(&self) -> usize {
118        self.messages.len()
119    }
120
121    #[must_use]
122    pub fn is_empty(&self) -> bool {
123        self.messages.is_empty()
124    }
125
126    /// Compress history in place to fit within `target_tokens` using the
127    /// provided compressor and tokenizer. Returns the token count after
128    /// compression (may still exceed `target_tokens` if no further moves
129    /// were safe).
130    pub fn compress(
131        &mut self,
132        target_tokens: u32,
133        compressor: &dyn crate::HistoryCompressor,
134        tokenizer: &dyn crate::Tokenizer,
135    ) -> u32 {
136        compressor.compress(&mut self.messages, target_tokens, tokenizer)
137    }
138
139    /// Persist the session to disk as JSON (supports `/resume`).
140    pub fn save(&self, path: &Path) -> Result<(), ClassifiedError> {
141        let json = serde_json::to_string_pretty(self)
142            .map_err(|e| ClassifiedError::Parse(format!("save session: {e}")))?;
143        let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4()));
144        std::fs::write(&tmp, &json)?;
145        std::fs::rename(&tmp, path)?;
146        Ok(())
147    }
148
149    /// Load a session from disk.
150    pub fn load(path: &Path) -> Result<Self, ClassifiedError> {
151        let text = std::fs::read_to_string(path)?;
152        serde_json::from_str(&text)
153            .map_err(|e| ClassifiedError::Parse(format!("load session: {e}")))
154    }
155}
156
157impl Default for ChatSession {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163/// Inject a memory snapshot into the session according to the configured
164/// [`CacheStrategy`].
165///
166/// - **AnthropicPrefixCache**: Prepend to the last user message (or insert a
167///   new one if none exists). This preserves the Anthropic prefix cache — the
168///   system prompt and all prior turns remain intact — while avoiding
169///   consecutive user messages that violate Anthropic's turn-alternation rule.
170/// - **SystemPrompt**: Append to the system prompt.
171/// - **Disabled**: No-op.
172pub fn inject_memory(session: &mut ChatSession, snapshot: &str) {
173    match session.cache_strategy {
174        CacheStrategy::AnthropicPrefixCache => {
175            let last_user = session.messages.iter_mut().rfind(|m| m.role == Role::User);
176            match last_user {
177                Some(msg) => {
178                    // If the last user message contains only ToolResult blocks,
179                    // prepend a new Text block with memory. This avoids injecting
180                    // text into a message that is semantically tool-result-only.
181                    let has_text = msg
182                        .content
183                        .iter()
184                        .any(|b| matches!(b, ContentBlock::Text { .. }));
185                    if !has_text {
186                        msg.content.insert(
187                            0,
188                            ContentBlock::Text {
189                                text: format!("[memory]\n{snapshot}"),
190                            },
191                        );
192                    } else {
193                        // Prepend memory into the first Text block to avoid
194                        // consecutive user messages (Anthropic requires
195                        // strict user/assistant alternation). Preserve any
196                        // ToolResult blocks so that tool_use/tool_result pairing
197                        // stays intact.
198                        let mut new_blocks: Vec<ContentBlock> = Vec::new();
199                        let mut text_injected = false;
200                        for block in msg.content.drain(..) {
201                            match block {
202                                ContentBlock::Text { text } => {
203                                    if text_injected {
204                                        new_blocks.push(ContentBlock::Text { text });
205                                    } else {
206                                        text_injected = true;
207                                        new_blocks.push(ContentBlock::Text {
208                                            text: format!("[memory]\n{snapshot}\n\n{text}"),
209                                        });
210                                    }
211                                }
212                                other => new_blocks.push(other),
213                            }
214                        }
215                        msg.content = new_blocks;
216                    }
217                }
218                None => {
219                    session
220                        .messages
221                        .push(Message::user_text(format!("[memory]\n{snapshot}")));
222                }
223            }
224        }
225        CacheStrategy::SystemPrompt => match &mut session.system {
226            Some(sys) => {
227                sys.push_str("\n\n");
228                sys.push_str(snapshot);
229            }
230            None => {
231                session.system = Some(snapshot.to_string());
232            }
233        },
234        CacheStrategy::Disabled => {}
235    }
236}
237
238#[cfg(test)]
239#[allow(clippy::expect_used, clippy::unwrap_used)]
240mod tests {
241    use super::*;
242    use crate::types::{StopReason, Usage};
243
244    #[test]
245    fn push_user_appends_text_message() {
246        let mut s = ChatSession::new();
247        s.push_user("hello");
248        assert_eq!(s.len(), 1);
249        assert_eq!(s.messages[0].role, Role::User);
250    }
251
252    #[test]
253    fn push_assistant_preserves_content_blocks() {
254        let mut s = ChatSession::new();
255        s.push_assistant(NormalizedResponse {
256            content_blocks: vec![
257                ContentBlock::Text { text: "ok".into() },
258                ContentBlock::ToolUse {
259                    id: "t1".into(),
260                    name: "file_read".into(),
261                    input: serde_json::json!({}),
262                },
263            ],
264            stop_reason: StopReason::ToolUse,
265            usage: Some(Usage::default()),
266        });
267        assert_eq!(s.messages[0].role, Role::Assistant);
268        assert_eq!(s.messages[0].content.len(), 2);
269    }
270
271    #[test]
272    fn push_tool_results_emits_single_user_message_with_blocks() {
273        let mut s = ChatSession::new();
274        s.push_tool_results(vec![
275            ToolResultMessage {
276                tool_use_id: "t1".into(),
277                content: "a".into(),
278                is_error: false,
279            },
280            ToolResultMessage {
281                tool_use_id: "t2".into(),
282                content: "b".into(),
283                is_error: true,
284            },
285        ]);
286        assert_eq!(s.len(), 1);
287        assert_eq!(s.messages[0].role, Role::User);
288        assert_eq!(s.messages[0].content.len(), 2);
289    }
290
291    #[test]
292    fn empty_tool_results_is_noop() {
293        let mut s = ChatSession::new();
294        s.push_tool_results(vec![]);
295        assert!(s.is_empty());
296    }
297
298    #[test]
299    fn save_and_load_roundtrips() {
300        let mut s = ChatSession::new()
301            .with_system("sys")
302            .with_cache_strategy(CacheStrategy::SystemPrompt);
303        s.push_user("hi");
304        let tmp = tempdir_path("roma_session_test.json");
305        s.save(&tmp).unwrap();
306        let loaded = ChatSession::load(&tmp).unwrap();
307        assert_eq!(loaded.session_id, s.session_id);
308        assert_eq!(loaded.system.as_deref(), Some("sys"));
309        assert_eq!(loaded.cache_strategy, CacheStrategy::SystemPrompt);
310        assert_eq!(loaded.messages.len(), 1);
311        let _ = std::fs::remove_file(&tmp);
312    }
313
314    #[test]
315    fn inject_anthropic_prefix_prepends_to_last_user() {
316        let mut s = ChatSession::new().with_cache_strategy(CacheStrategy::AnthropicPrefixCache);
317        s.push_user("first");
318        s.push_assistant(NormalizedResponse {
319            content_blocks: vec![ContentBlock::Text { text: "hi".into() }],
320            stop_reason: StopReason::EndTurn,
321            usage: None,
322        });
323        s.push_user("latest");
324        inject_memory(&mut s, "[mem] insight");
325        // Memory should be prepended into the last user message (not a new message).
326        assert_eq!(s.messages.len(), 3);
327        assert_eq!(s.messages[2].role, Role::User);
328        let text = s.messages[2].text_content();
329        assert!(text.starts_with("[memory]\n[mem] insight"));
330        assert!(text.ends_with("latest"));
331    }
332
333    #[test]
334    fn inject_system_prompt_appends_to_system() {
335        let mut s = ChatSession::new()
336            .with_system("base")
337            .with_cache_strategy(CacheStrategy::SystemPrompt);
338        inject_memory(&mut s, "[mem] insight");
339        assert_eq!(s.system.as_deref(), Some("base\n\n[mem] insight"));
340    }
341
342    #[test]
343    fn inject_disabled_is_noop() {
344        let mut s = ChatSession::new().with_cache_strategy(CacheStrategy::Disabled);
345        s.push_user("hi");
346        inject_memory(&mut s, "[mem] insight");
347        assert_eq!(s.len(), 1);
348    }
349
350    #[test]
351    fn multi_turn_save_load_roundtrip_preserves_tool_results() {
352        let mut s = ChatSession::new()
353            .with_system("sys")
354            .with_cache_strategy(CacheStrategy::SystemPrompt);
355        s.push_user("read foo.rs");
356        s.push_assistant(NormalizedResponse {
357            content_blocks: vec![
358                ContentBlock::Text {
359                    text: "Reading file.".into(),
360                },
361                ContentBlock::ToolUse {
362                    id: "t1".into(),
363                    name: "file_read".into(),
364                    input: serde_json::json!({"path": "foo.rs"}),
365                },
366            ],
367            stop_reason: StopReason::ToolUse,
368            usage: Some(Usage {
369                input_tokens: 10,
370                output_tokens: 5,
371                cache_read_tokens: 0,
372                cache_write_tokens: 0,
373            }),
374        });
375        s.push_tool_results(vec![ToolResultMessage {
376            tool_use_id: "t1".into(),
377            content: "fn main() {}".into(),
378            is_error: false,
379        }]);
380        s.push_assistant(NormalizedResponse {
381            content_blocks: vec![ContentBlock::Text {
382                text: "The file contains a main function.".into(),
383            }],
384            stop_reason: StopReason::EndTurn,
385            usage: None,
386        });
387
388        let tmp = tempdir_path("roma_multi_turn_test.json");
389        s.save(&tmp).unwrap();
390        let loaded = ChatSession::load(&tmp).unwrap();
391        let _ = std::fs::remove_file(&tmp);
392
393        assert_eq!(loaded.session_id, s.session_id);
394        assert_eq!(loaded.system.as_deref(), Some("sys"));
395        assert_eq!(loaded.cache_strategy, CacheStrategy::SystemPrompt);
396        assert_eq!(loaded.messages.len(), 4);
397        assert_eq!(loaded.messages[0].role, Role::User);
398        assert_eq!(loaded.messages[1].role, Role::Assistant);
399        assert_eq!(loaded.messages[2].role, Role::User);
400        assert!(matches!(
401            &loaded.messages[2].content[0],
402            ContentBlock::ToolResult { .. }
403        ));
404        assert_eq!(loaded.messages[3].role, Role::Assistant);
405    }
406
407    #[test]
408    fn save_resume_continues_conversation() {
409        let mut s = ChatSession::new();
410        s.push_user("first question");
411
412        let tmp = tempdir_path("roma_resume_test.json");
413        s.save(&tmp).unwrap();
414        let mut loaded = ChatSession::load(&tmp).unwrap();
415
416        loaded.push_assistant(NormalizedResponse {
417            content_blocks: vec![ContentBlock::Text {
418                text: "first answer".into(),
419            }],
420            stop_reason: StopReason::EndTurn,
421            usage: None,
422        });
423        loaded.push_user("second question");
424        loaded.save(&tmp).unwrap();
425
426        let resumed = ChatSession::load(&tmp).unwrap();
427        let _ = std::fs::remove_file(&tmp);
428
429        assert_eq!(resumed.session_id, s.session_id);
430        assert_eq!(resumed.messages.len(), 3);
431        assert_eq!(resumed.messages[0].text_content(), "first question");
432        assert_eq!(resumed.messages[2].text_content(), "second question");
433    }
434
435    #[test]
436    fn inject_memory_after_load_preserves_history() {
437        let mut s = ChatSession::new()
438            .with_system("base")
439            .with_cache_strategy(CacheStrategy::SystemPrompt);
440        s.push_user("hello");
441
442        let tmp = tempdir_path("roma_inject_test.json");
443        s.save(&tmp).unwrap();
444        let mut loaded = ChatSession::load(&tmp).unwrap();
445        let _ = std::fs::remove_file(&tmp);
446
447        inject_memory(&mut loaded, "[mem] insight");
448        assert!(loaded.system.as_deref().unwrap().contains("[mem] insight"));
449        assert_eq!(loaded.messages.len(), 1, "no new messages from inject");
450    }
451
452    #[test]
453    fn append_to_last_user_appends_text_block_to_newest_user_message() {
454        let mut s = ChatSession::new();
455        s.push_user("first");
456        s.push_assistant(NormalizedResponse {
457            content_blocks: vec![ContentBlock::Text { text: "ok".into() }],
458            stop_reason: StopReason::EndTurn,
459            usage: None,
460        });
461        s.push_user("second");
462        s.append_to_last_user("\n\n[stream rule `r` fired]\n\nbody");
463        assert_eq!(s.len(), 3, "no new message may be created");
464        let last = &s.messages[2];
465        assert_eq!(last.content.len(), 2);
466        assert!(
467            last.text_content()
468                .ends_with("[stream rule `r` fired]\n\nbody")
469        );
470        assert_eq!(s.messages[0].text_content(), "first");
471    }
472
473    #[test]
474    fn append_to_last_user_works_on_tool_result_only_message() {
475        let mut s = ChatSession::new();
476        s.push_tool_results(vec![ToolResultMessage {
477            tool_use_id: "t1".into(),
478            content: "out".into(),
479            is_error: false,
480        }]);
481        s.append_to_last_user("note");
482        assert_eq!(s.len(), 1);
483        assert_eq!(s.messages[0].content.len(), 2);
484        assert!(matches!(
485            s.messages[0].content[1],
486            ContentBlock::Text { .. }
487        ));
488    }
489
490    #[test]
491    fn append_to_last_user_falls_back_to_new_message_on_empty_history() {
492        let mut s = ChatSession::new();
493        s.append_to_last_user("note");
494        assert_eq!(s.len(), 1);
495        assert_eq!(s.messages[0].role, Role::User);
496        assert_eq!(s.messages[0].text_content(), "note");
497    }
498
499    fn tempdir_path(name: &str) -> std::path::PathBuf {
500        let mut p = std::env::temp_dir();
501        p.push(name);
502        p
503    }
504}