1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum CacheStrategy {
19 #[default]
21 AnthropicPrefixCache,
22 SystemPrompt,
24 Disabled,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ChatSession {
31 pub session_id: Uuid,
32 #[serde(default)]
34 pub system: Option<String>,
35 pub messages: Vec<Message>,
37 #[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 pub fn push_user(&mut self, content: impl Into<String>) {
67 self.messages.push(Message::user_text(content));
68 }
69
70 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 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 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 #[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 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 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 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
163pub 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 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 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 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}