1use crate::error::{AacodeError, Result};
11use crate::llm::types::{ChatMessage, ToolCall};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SessionMessage {
29 pub role: String,
30 #[serde(default)]
31 pub content: String,
32 #[serde(default)]
33 pub timestamp: String,
34 #[serde(default)]
35 pub tokens: usize,
36 #[serde(skip_serializing_if = "Option::is_none", default)]
37 pub tool_calls: Option<Vec<ToolCall>>,
38 #[serde(skip_serializing_if = "Option::is_none", default)]
39 pub tool_call_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub reasoning_content: Option<String>,
42}
43
44impl SessionMessage {
45 pub fn from_chat(m: &ChatMessage) -> Self {
46 let mut tokens = estimate_tokens(&m.content);
52 if let Some(rc) = &m.reasoning_content {
53 tokens += estimate_tokens(rc);
54 }
55 if let Some(tcs) = &m.tool_calls {
56 if let Ok(json) = serde_json::to_string(tcs) {
57 tokens += estimate_tokens(&json);
58 }
59 }
60 if let Some(id) = &m.tool_call_id {
61 tokens += estimate_tokens(id);
62 }
63 SessionMessage {
64 role: m.role.clone(),
65 content: m.content.clone(),
66 timestamp: now_iso(),
67 tokens,
68 tool_calls: m.tool_calls.clone(),
69 tool_call_id: m.tool_call_id.clone(),
70 reasoning_content: m.reasoning_content.clone(),
71 }
72 }
73
74 pub fn to_chat(&self) -> ChatMessage {
75 ChatMessage {
76 role: self.role.clone(),
77 content: self.content.clone(),
78 tool_calls: self.tool_calls.clone(),
79 tool_call_id: self.tool_call_id.clone(),
80 reasoning_content: self.reasoning_content.clone(),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SessionSummary {
88 pub session_id: String,
89 pub created_at: String,
90 pub last_activity: String,
91 #[serde(default)]
92 pub total_messages: usize,
93 #[serde(default)]
94 pub total_tokens: usize,
95 #[serde(default)]
96 pub title: String,
97 #[serde(default = "default_status")]
98 pub status: String,
99}
100
101fn default_status() -> String {
102 "active".to_string()
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
112struct SessionFile {
113 #[serde(default = "default_schema_version")]
114 schema_version: u32,
115 session_id: String,
116 #[serde(default)]
117 created_at: String,
118 messages: Vec<SessionMessage>,
119}
120
121fn default_schema_version() -> u32 {
122 SCHEMA_VERSION
123}
124
125pub const SCHEMA_VERSION: u32 = 1;
128
129pub fn valid_session_id(sid: &str) -> bool {
132 !sid.is_empty()
133 && !sid.contains('/')
134 && !sid.contains('\\')
135 && !sid.contains("..")
136 && !sid.contains('\0')
137}
138
139pub fn estimate_tokens(text: &str) -> usize {
141 text.chars().count() / 4
142}
143
144pub fn now_iso() -> String {
146 use std::time::{SystemTime, UNIX_EPOCH};
147 let secs = SystemTime::now()
148 .duration_since(UNIX_EPOCH)
149 .map(|d| d.as_secs())
150 .unwrap_or(0);
151 secs.to_string()
153}
154
155pub fn now_iso_ms() -> String {
159 use std::time::{SystemTime, UNIX_EPOCH};
160 let d = SystemTime::now()
161 .duration_since(UNIX_EPOCH)
162 .unwrap_or_default();
163 format!("{}.{:03}", d.as_secs(), d.subsec_millis())
164}
165
166fn atomic_write(path: &Path, contents: &str) -> Result<()> {
168 let parent = path
169 .parent()
170 .ok_or_else(|| AacodeError::Io("no parent".into()))?;
171 std::fs::create_dir_all(parent)?;
172 let tmp = parent.join(format!(".tmp_{}", uuid::Uuid::new_v4().simple()));
173 std::fs::write(&tmp, contents)?;
174 std::fs::rename(&tmp, path)?;
175 Ok(())
176}
177
178pub struct SessionManager {
179 sessions_dir: PathBuf,
180 pub current_session_id: Option<String>,
181 pub messages: Vec<SessionMessage>,
182 flush_batch_size: usize,
184 dirty_count: usize,
186 last_flush: Option<Instant>,
188 flush_interval_secs: f64,
190}
191
192impl SessionManager {
193 pub fn new(project_path: &Path) -> Self {
194 let sessions_dir = project_path.join(".aacode").join("sessions");
195 let _ = std::fs::create_dir_all(&sessions_dir);
197 SessionManager {
198 sessions_dir,
199 current_session_id: None,
200 messages: Vec::new(),
201 flush_batch_size: 10,
202 dirty_count: 0,
203 last_flush: None,
204 flush_interval_secs: 0.5,
205 }
206 }
207
208 fn index_path(&self) -> PathBuf {
209 self.sessions_dir.join("sessions_index.json")
210 }
211
212 fn session_path(&self, id: &str) -> PathBuf {
213 self.sessions_dir.join(format!("{id}.json"))
214 }
215
216 fn load_index(&self) -> std::collections::BTreeMap<String, SessionSummary> {
217 let path = self.index_path();
218 if let Ok(s) = std::fs::read_to_string(&path) {
219 if let Ok(m) = serde_json::from_str(&s) {
220 return m;
221 }
222 }
223 std::collections::BTreeMap::new()
224 }
225
226 fn save_index(&self, index: &std::collections::BTreeMap<String, SessionSummary>) -> Result<()> {
227 let mut merged = self.load_index();
229 for (k, v) in index {
230 merged.insert(k.clone(), v.clone());
231 }
232 atomic_write(&self.index_path(), &serde_json::to_string(&merged)?)
233 }
234
235 pub fn create_session(&mut self, task: &str, title: Option<&str>) -> Result<String> {
237 let id = format!(
238 "session_{}_{}",
239 now_iso(),
240 &uuid::Uuid::new_v4().simple().to_string()[..8]
241 );
242 let title = title
243 .map(|t| t.to_string())
244 .unwrap_or_else(|| truncate_title(task));
245 let now = now_iso();
246 let summary = SessionSummary {
247 session_id: id.clone(),
248 created_at: now.clone(),
249 last_activity: now,
250 total_messages: 0,
251 total_tokens: 0,
252 title,
253 status: "active".to_string(),
254 };
255 self.current_session_id = Some(id.clone());
256 self.messages.clear();
257 if !task.trim().is_empty() {
258 self.messages.push(SessionMessage {
259 role: "user".to_string(),
260 content: task.to_string(),
261 timestamp: now_iso(),
262 tokens: estimate_tokens(task),
263 tool_calls: None,
264 tool_call_id: None,
265 reasoning_content: None,
266 });
267 }
268 let mut index = self.load_index();
269 let mut s = summary;
270 s.total_messages = self.messages.len();
271 s.total_tokens = self.total_tokens();
272 index.insert(id.clone(), s);
273 self.save_index(&index)?;
274 self.save_current()?;
275 self.dirty_count = 0;
276 self.last_flush = Some(Instant::now());
277 Ok(id)
278 }
279
280 pub fn add_message(&mut self, msg: SessionMessage) -> Result<()> {
284 self.messages.push(msg);
285 self.dirty_count += 1;
286 self.maybe_flush()
287 }
288
289 pub fn add_messages(&mut self, msgs: impl IntoIterator<Item = SessionMessage>) -> Result<()> {
291 for m in msgs {
292 self.messages.push(m);
293 self.dirty_count += 1;
294 }
295 self.maybe_flush()
296 }
297
298 pub fn flush(&mut self) -> Result<()> {
300 if self.dirty_count == 0 {
301 return Ok(());
302 }
303 self.save_current()?;
304 self.touch_index()?;
305 self.dirty_count = 0;
306 self.last_flush = Some(Instant::now());
307 Ok(())
308 }
309
310 fn maybe_flush(&mut self) -> Result<()> {
311 if self.dirty_count >= self.flush_batch_size {
312 return self.flush();
313 }
314 match self.last_flush {
315 None => self.flush(),
316 Some(t) if t.elapsed().as_secs_f64() >= self.flush_interval_secs => self.flush(),
317 _ => Ok(()),
318 }
319 }
320
321 fn total_tokens(&self) -> usize {
322 self.messages.iter().map(|m| m.tokens).sum()
323 }
324
325 fn touch_index(&self) -> Result<()> {
326 if let Some(id) = &self.current_session_id {
327 let mut index = self.load_index();
328 if let Some(s) = index.get_mut(id) {
329 s.last_activity = now_iso();
330 s.total_messages = self.messages.len();
331 s.total_tokens = self.total_tokens();
332 if s.title.trim().is_empty() {
333 if let Some(first_user) = self.messages.iter().find(|m| m.role == "user") {
334 s.title = truncate_title(&first_user.content);
335 }
336 }
337 }
338 self.save_index(&index)?;
339 }
340 Ok(())
341 }
342
343 fn save_current(&self) -> Result<()> {
344 if let Some(id) = &self.current_session_id {
345 let created_at = self
346 .load_index()
347 .get(id)
348 .map(|s| s.created_at.clone())
349 .unwrap_or_else(now_iso);
350 let file = SessionFile {
351 schema_version: default_schema_version(),
352 session_id: id.clone(),
353 created_at,
354 messages: self.messages.clone(),
355 };
356 atomic_write(&self.session_path(id), &serde_json::to_string(&file)?)?;
357 }
358 Ok(())
359 }
360
361 pub fn switch_session(&mut self, id: &str) -> Result<bool> {
363 let path = self.session_path(id);
364 if !path.exists() {
365 return Ok(false);
366 }
367 let s = std::fs::read_to_string(&path)?;
368 let file: SessionFile = serde_json::from_str(&s)?;
369 self.current_session_id = Some(id.to_string());
370 self.messages = file.messages;
371 Ok(true)
372 }
373
374 pub fn list_sessions(&self) -> Vec<SessionSummary> {
377 let mut index = self.load_index();
378 if index.is_empty() {
379 if let Ok(entries) = std::fs::read_dir(&self.sessions_dir) {
380 for entry in entries.flatten() {
381 let path = entry.path();
382 if path.extension().and_then(|e| e.to_str()) != Some("json") {
383 continue;
384 }
385 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
386 if stem == "sessions_index" {
387 continue;
388 }
389 if let Ok(s) = std::fs::read_to_string(&path) {
390 if let Ok(file) = serde_json::from_str::<SessionFile>(&s) {
391 let title = file
392 .messages
393 .first()
394 .map(|m| truncate_title(&m.content))
395 .unwrap_or_default();
396 let summary = SessionSummary {
397 session_id: file.session_id.clone(),
398 created_at: file.created_at.clone(),
399 last_activity: file
400 .messages
401 .last()
402 .map(|m| m.timestamp.clone())
403 .unwrap_or_default(),
404 total_messages: file.messages.len(),
405 total_tokens: file.messages.iter().map(|m| m.tokens).sum(),
406 title,
407 status: "active".to_string(),
408 };
409 index.entry(file.session_id).or_insert(summary);
410 }
411 }
412 }
413 }
414 if !index.is_empty() {
416 if let Ok(contents) = serde_json::to_string(&index) {
417 let _ = atomic_write(&self.index_path(), &contents);
418 }
419 }
420 }
421 let mut v: Vec<SessionSummary> = index.into_values().collect();
422 v.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
423 v
424 }
425
426 pub fn delete_session(&mut self, id: &str) -> Result<bool> {
428 let mut index = self.load_index();
429 if index.remove(id).is_none() {
430 return Ok(false);
431 }
432 let _ = std::fs::remove_file(self.session_path(id));
433 atomic_write(&self.index_path(), &serde_json::to_string(&index)?)?;
435 if self.current_session_id.as_deref() == Some(id) {
436 self.current_session_id = None;
437 self.messages.clear();
438 }
439 Ok(true)
440 }
441
442 pub fn read_session_messages(&self, id: &str) -> Vec<SessionMessage> {
444 let path = self.session_path(id);
445 if let Ok(s) = std::fs::read_to_string(&path) {
446 if let Ok(file) = serde_json::from_str::<SessionFile>(&s) {
447 return file.messages;
448 }
449 }
450 Vec::new()
451 }
452
453 pub fn ensure_session(&mut self, sid: &str, title: &str) -> Result<()> {
458 let mut index = self.load_index();
459 let now = now_iso();
460 if let Some(s) = index.get_mut(sid) {
461 s.last_activity = now;
462 if !title.trim().is_empty() {
463 s.title = title.to_string();
464 }
465 self.save_index(&index)?;
466 return Ok(());
467 }
468 let summary = SessionSummary {
469 session_id: sid.to_string(),
470 created_at: now.clone(),
471 last_activity: now.clone(),
472 total_messages: 0,
473 total_tokens: 0,
474 title: title.to_string(),
475 status: "active".to_string(),
476 };
477 index.insert(sid.to_string(), summary);
478 self.save_index(&index)?;
479 let file = SessionFile {
480 schema_version: SCHEMA_VERSION,
481 session_id: sid.to_string(),
482 created_at: now,
483 messages: Vec::new(),
484 };
485 atomic_write(&self.session_path(sid), &serde_json::to_string(&file)?)
486 }
487
488 pub fn rename_session(&mut self, sid: &str, title: &str) -> Result<()> {
490 let mut index = self.load_index();
491 if let Some(s) = index.get_mut(sid) {
492 s.title = title.to_string();
493 self.save_index(&index)?;
494 }
495 Ok(())
496 }
497
498 pub fn touch_session(&mut self, sid: &str) -> Result<()> {
500 let mut index = self.load_index();
501 if let Some(s) = index.get_mut(sid) {
502 s.last_activity = now_iso();
503 self.save_index(&index)?;
504 }
505 Ok(())
506 }
507
508 pub fn append_session_messages(&mut self, sid: &str, msgs: Vec<SessionMessage>) -> Result<()> {
511 let path = self.session_path(sid);
512 let mut existing: Vec<SessionMessage> = if path.exists() {
513 std::fs::read_to_string(&path)
514 .ok()
515 .and_then(|s| serde_json::from_str::<SessionFile>(&s).ok())
516 .map(|f| f.messages)
517 .unwrap_or_default()
518 } else {
519 Vec::new()
520 };
521 existing.extend(msgs);
522
523 let total_messages = existing.len();
524 let total_tokens: usize = existing.iter().map(|m| m.tokens).sum();
525 let fallback_title = existing
526 .iter()
527 .find(|m| m.role == "user")
528 .map(|m| truncate_title(&m.content))
529 .unwrap_or_default();
530
531 let created_at = self
532 .load_index()
533 .get(sid)
534 .map(|s| s.created_at.clone())
535 .unwrap_or_else(now_iso);
536
537 let file = SessionFile {
538 schema_version: SCHEMA_VERSION,
539 session_id: sid.to_string(),
540 created_at,
541 messages: existing,
542 };
543 atomic_write(&path, &serde_json::to_string(&file)?)?;
544
545 let mut index = self.load_index();
546 if let Some(s) = index.get_mut(sid) {
547 s.total_messages = total_messages;
548 s.total_tokens = total_tokens;
549 s.last_activity = now_iso();
550 if s.title.trim().is_empty() && !fallback_title.is_empty() {
551 s.title = fallback_title.clone();
552 }
553 } else {
554 index.insert(
555 sid.to_string(),
556 SessionSummary {
557 session_id: sid.to_string(),
558 created_at: file.created_at.clone(),
559 last_activity: now_iso(),
560 total_messages,
561 total_tokens,
562 title: fallback_title,
563 status: "active".to_string(),
564 },
565 );
566 }
567 self.save_index(&index)
568 }
569
570 pub fn history_chat(&self) -> Vec<ChatMessage> {
575 self.messages.iter().map(|m| m.to_chat()).collect()
576 }
577
578 pub fn ended_mid_tool(&self) -> bool {
580 self.messages
581 .last()
582 .map(|m| m.role == "tool")
583 .unwrap_or(false)
584 }
585}
586
587fn truncate_title(task: &str) -> String {
588 let t: String = task.chars().take(50).collect();
589 if task.chars().count() > 50 {
590 format!("{t}...")
591 } else {
592 t
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 fn tmp_project() -> PathBuf {
601 let d = std::env::temp_dir().join(format!(
602 "aacode_sess_{}_{}",
603 std::process::id(),
604 uuid::Uuid::new_v4().simple()
605 ));
606 std::fs::create_dir_all(&d).unwrap();
607 d
608 }
609
610 #[test]
611 fn create_and_reload() {
612 let proj = tmp_project();
613 let mut sm = SessionManager::new(&proj);
614 let id = sm.create_session("do something big", None).unwrap();
615 assert!(id.starts_with("session_"));
616 assert_eq!(sm.messages.len(), 1);
617
618 let mut sm2 = SessionManager::new(&proj);
620 assert!(sm2.switch_session(&id).unwrap());
621 assert_eq!(sm2.messages[0].content, "do something big");
622 }
623
624 #[test]
625 fn append_and_persist() {
626 let proj = tmp_project();
627 let mut sm = SessionManager::new(&proj);
628 let id = sm.create_session("task", None).unwrap();
629 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("hi")))
630 .unwrap();
631 sm.flush().unwrap();
632 let read = sm.read_session_messages(&id);
633 assert_eq!(read.len(), 2);
634 assert_eq!(read[1].role, "assistant");
635 }
636
637 #[test]
638 fn tool_calls_persist() {
639 let proj = tmp_project();
640 let mut sm = SessionManager::new(&proj);
641 sm.create_session("t", None).unwrap();
642 let msg = ChatMessage::assistant_with_tools(
643 "",
644 vec![ToolCall {
645 id: "c1".into(),
646 name: "run_shell".into(),
647 arguments: "{}".into(),
648 }],
649 );
650 sm.add_message(SessionMessage::from_chat(&msg)).unwrap();
651 sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
652 "c1", "ok",
653 )))
654 .unwrap();
655 assert!(sm.ended_mid_tool());
656 let hist = sm.history_chat();
657 assert!(hist.iter().any(|m| m.tool_calls.is_some()));
658 }
659
660 #[test]
661 fn list_and_delete() {
662 let proj = tmp_project();
663 let mut sm = SessionManager::new(&proj);
664 let id1 = sm.create_session("one", None).unwrap();
665 let _id2 = sm.create_session("two", None).unwrap();
666 assert!(sm.list_sessions().len() >= 2);
667 assert!(sm.delete_session(&id1).unwrap());
668 assert!(!sm.list_sessions().iter().any(|s| s.session_id == id1));
669 }
670
671 #[test]
672 fn history_includes_system() {
673 let proj = tmp_project();
674 let mut sm = SessionManager::new(&proj);
675 sm.create_session("t", None).unwrap();
676 sm.add_message(SessionMessage::from_chat(&ChatMessage::system("sys")))
677 .unwrap();
678 assert!(sm.history_chat().iter().any(|m| m.role == "system"));
681 }
682
683 #[test]
684 fn scheduled_task_saves_to_existing_session() {
685 let proj = tmp_project();
686 let mut sm = SessionManager::new(&proj);
687
688 let id = sm.create_session("previous task", None).unwrap();
689 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
690 .unwrap();
691 sm.flush().unwrap();
692
693 let mut sm2 = SessionManager::new(&proj);
694 assert!(sm2.switch_session(&id).unwrap());
695 assert!(sm2.current_session_id.is_some());
696
697 let scheduled =
698 "[Scheduled: daily-check]\nThis is an automated scheduled run.\n\ncheck git status";
699
700 let already = sm2
701 .messages
702 .last()
703 .map(|m| m.role == "user" && m.content == scheduled)
704 .unwrap_or(false);
705 assert!(!already);
706
707 sm2.add_message(SessionMessage::from_chat(&ChatMessage::user(scheduled)))
708 .unwrap();
709 sm2.flush().unwrap();
710
711 let msgs = sm2.read_session_messages(&id);
712 assert_eq!(msgs.len(), 3);
713 assert_eq!(msgs[0].content, "previous task");
714 assert_eq!(msgs[1].role, "assistant");
715 assert_eq!(msgs[2].role, "user");
716 assert!(msgs[2].content.starts_with("[Scheduled: daily-check]"));
717 }
718
719 #[test]
720 fn dedup_skips_duplicate_user_message() {
721 let proj = tmp_project();
722 let mut sm = SessionManager::new(&proj);
723
724 let task = "[Scheduled: daily]\ncheck";
725 let id = sm.create_session(task, None).unwrap();
726
727 let mut sm2 = SessionManager::new(&proj);
728 sm2.switch_session(&id).unwrap();
729 let already = sm2
730 .messages
731 .last()
732 .map(|m| m.role == "user" && m.content == task)
733 .unwrap_or(false);
734 assert!(already);
735
736 let msgs = sm2.read_session_messages(&id);
737 assert_eq!(msgs.len(), 1);
738 }
739
740 #[test]
741 fn reasoning_content_counted_in_tokens() {
742 let msg = ChatMessage {
743 role: "assistant".into(),
744 content: "hello world".into(),
745 tool_calls: None,
746 tool_call_id: None,
747 reasoning_content: Some("I need to think about this carefully".into()),
748 };
749 let sm = SessionMessage::from_chat(&msg);
750 let content_only = estimate_tokens("hello world");
752 let with_reasoning = estimate_tokens("hello world")
753 + estimate_tokens("I need to think about this carefully");
754 assert!(sm.tokens > content_only);
755 assert_eq!(sm.tokens, with_reasoning);
756 }
757
758 #[test]
759 fn no_reasoning_content_tokens_unchanged() {
760 let msg = ChatMessage {
761 role: "assistant".into(),
762 content: "hello world".into(),
763 tool_calls: None,
764 tool_call_id: None,
765 reasoning_content: None,
766 };
767 let sm = SessionMessage::from_chat(&msg);
768 assert_eq!(sm.tokens, estimate_tokens("hello world"));
769 }
770
771 #[test]
772 fn tool_message_tokens_include_content_and_tool_call_id() {
773 let msg = ChatMessage {
776 role: "tool".into(),
777 content: "result data".into(),
778 tool_calls: None,
779 tool_call_id: Some("call_1".into()),
780 reasoning_content: None,
781 };
782 let sm = SessionMessage::from_chat(&msg);
783 assert_eq!(
784 sm.tokens,
785 estimate_tokens("result data") + estimate_tokens("call_1")
786 );
787 }
788
789 #[test]
790 fn compact_json_roundtrips() {
791 let proj = tmp_project();
792 let mut sm = SessionManager::new(&proj);
793 sm.create_session("compact test", None).unwrap();
794 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
795 "{\"key\": \"value\"}",
796 )))
797 .unwrap();
798 sm.flush().unwrap();
799
800 let id = sm.current_session_id.unwrap();
801 let mut sm2 = SessionManager::new(&proj);
802 assert!(sm2.switch_session(&id).unwrap());
803 assert_eq!(sm2.messages.len(), 2);
804 assert_eq!(sm2.messages[1].content, "{\"key\": \"value\"}");
805
806 let path = sm2.session_path(&id);
807 let raw = std::fs::read_to_string(&path).unwrap();
808 assert!(
809 !raw.contains("\n "),
810 "compact JSON should not have indentation"
811 );
812 let file: SessionFile = serde_json::from_str(&raw).unwrap();
813 assert_eq!(file.messages.len(), 2);
814 }
815
816 #[test]
817 fn persisted_dynamic_system_message_in_history() {
818 let proj = tmp_project();
819 let mut sm = SessionManager::new(&proj);
820 sm.create_session("task", None).unwrap();
821
822 let analysis = SessionMessage::from_chat(&ChatMessage::system(
823 "## Project Analysis\n2 .rs files, 1 .py file",
824 ));
825 sm.add_message(analysis).unwrap();
826 sm.flush().unwrap();
827
828 let history = sm.history_chat();
829 assert!(history
830 .iter()
831 .any(|m| m.role == "system" && m.content.contains("Project Analysis")));
832
833 let id = sm.current_session_id.unwrap();
835 let mut sm2 = SessionManager::new(&proj);
836 sm2.switch_session(&id).unwrap();
837 let history2 = sm2.history_chat();
838 assert!(history2
839 .iter()
840 .any(|m| m.role == "system" && m.content.contains("Project Analysis")));
841 }
842
843 #[test]
844 fn flush_cooldown_batches_messages() {
845 let proj = tmp_project();
846 let mut sm = SessionManager::new(&proj);
847 let id = sm.create_session("task", None).unwrap();
848
849 for i in 0..5 {
851 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
852 &format!("msg{}", i),
853 )))
854 .unwrap();
855 }
856
857 sm.flush().unwrap();
859
860 let msgs = sm.read_session_messages(&id);
861 assert_eq!(msgs.len(), 6); assert_eq!(msgs[5].content, "msg4");
863 }
864
865 #[test]
866 fn flush_idempotent_when_clean() {
867 let proj = tmp_project();
868 let mut sm = SessionManager::new(&proj);
869 sm.create_session("task", None).unwrap();
870 assert!(sm.flush().is_ok());
872 assert!(sm.dirty_count == 0);
873 }
874
875 #[test]
881 fn schema_conformance_fixture_roundtrips() {
882 const FIXTURE: &str = include_str!("../../tests/fixtures/session_v1.json");
883 let file: SessionFile = serde_json::from_str(FIXTURE).expect("fixture must parse");
884
885 assert_eq!(file.schema_version, 1, "schema_version must be pinned at 1");
886 assert_eq!(file.session_id, "session_1700000000_abc12345");
887 assert_eq!(file.created_at, "1700000000");
888 assert_eq!(file.messages.len(), 3);
889
890 assert_eq!(file.messages[0].role, "user");
892 assert_eq!(file.messages[0].content, "Create a hello.py file");
893 assert!(file.messages[0].tool_calls.is_none());
894 assert!(file.messages[0].tool_call_id.is_none());
895 assert!(file.messages[0].reasoning_content.is_none());
896
897 assert_eq!(file.messages[1].role, "assistant");
899 let tcs = file.messages[1]
900 .tool_calls
901 .as_ref()
902 .expect("assistant tool_calls present");
903 assert_eq!(tcs.len(), 1);
904 assert_eq!(tcs[0].id, "call_1");
905 assert_eq!(tcs[0].name, "run_shell");
906 assert_eq!(tcs[0].arguments, r#"{"command":"echo hello"}"#);
907 assert_eq!(
908 file.messages[1].reasoning_content.as_deref(),
909 Some("I should run a shell command")
910 );
911
912 assert_eq!(file.messages[2].role, "tool");
914 assert_eq!(file.messages[2].content, "hello");
915 assert_eq!(file.messages[2].tool_call_id.as_deref(), Some("call_1"));
916
917 let reserialized = serde_json::to_string(&file).unwrap();
919 let reparsed: SessionFile = serde_json::from_str(&reserialized).unwrap();
920 assert_eq!(reparsed.schema_version, 1);
921 assert_eq!(reparsed.messages.len(), 3);
922 assert_eq!(
923 reparsed.messages[1].tool_calls.as_ref().unwrap()[0].name,
924 "run_shell"
925 );
926 }
927
928 #[test]
932 fn legacy_segments_fixture_still_parses_and_is_dropped_on_write() {
933 const FIXTURE: &str = include_str!("../../tests/fixtures/session_v2.json");
934 let file: SessionFile = serde_json::from_str(FIXTURE).expect("legacy fixture must parse");
935 assert_eq!(file.schema_version, 1);
936 assert_eq!(file.messages.len(), 3);
937 assert_eq!(
938 file.messages[1].tool_calls.as_ref().unwrap()[0].name,
939 "run_shell"
940 );
941 let raw = serde_json::to_string(&file).unwrap();
943 assert!(
944 !raw.contains("segments"),
945 "segments must not be re-persisted: {raw}"
946 );
947 }
948
949 #[test]
950 fn persisted_messages_never_contain_segments() {
951 let proj = tmp_project();
952 let mut sm = SessionManager::new(&proj);
953 let id = sm.create_session("t", None).unwrap();
954 sm.add_message(SessionMessage::from_chat(
955 &ChatMessage::assistant_with_tools(
956 "hi",
957 vec![ToolCall {
958 id: "c1".into(),
959 name: "run_shell".into(),
960 arguments: "{}".into(),
961 }],
962 ),
963 ))
964 .unwrap();
965 sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
966 "c1", "out",
967 )))
968 .unwrap();
969 sm.flush().unwrap();
970
971 let raw = std::fs::read_to_string(sm.session_path(&id)).unwrap();
972 assert!(!raw.contains("segments"), "no segments on disk: {raw}");
973 }
974
975 #[test]
976 fn now_iso_ms_has_millisecond_precision() {
977 let s = now_iso_ms();
978 let (secs, ms) = s.split_once('.').expect("must contain fractional part");
979 assert!(secs.parse::<u64>().is_ok(), "integer seconds part: {s}");
980 assert_eq!(ms.len(), 3, "millisecond part must be 3 digits: {s}");
981 assert!(ms.parse::<u32>().is_ok());
982 }
983
984 #[test]
988 fn from_chat_tokens_include_tool_calls() {
989 let plain = SessionMessage::from_chat(&ChatMessage::assistant("hello"));
990 let with_calls = SessionMessage::from_chat(&ChatMessage::assistant_with_tools(
991 "hello",
992 vec![ToolCall {
993 id: "c1".into(),
994 name: "run_shell".into(),
995 arguments: format!("{{\"path\":\"{}\"}}", "a".repeat(200)),
996 }],
997 ));
998 assert!(
999 with_calls.tokens > plain.tokens,
1000 "tool_calls tokens ({}) must exceed plain ({})",
1001 with_calls.tokens,
1002 plain.tokens
1003 );
1004 }
1005
1006 #[test]
1007 fn from_chat_tokens_include_tool_call_id() {
1008 let mut no_id = ChatMessage::tool_result("", "result".to_string());
1009 no_id.tool_call_id = None;
1010 let with_id = ChatMessage::tool_result("call_some_id_here", "result".to_string());
1011 assert!(
1012 SessionMessage::from_chat(&with_id).tokens > SessionMessage::from_chat(&no_id).tokens,
1013 "tool_call_id must add tokens"
1014 );
1015 }
1016
1017 #[test]
1018 fn from_chat_tokens_include_reasoning_content() {
1019 let mut base = ChatMessage::assistant("ok");
1020 let plain = SessionMessage::from_chat(&base);
1021 base.reasoning_content = Some("Let me think".repeat(50));
1022 let with_reasoning = SessionMessage::from_chat(&base);
1023 assert!(
1024 with_reasoning.tokens > plain.tokens,
1025 "reasoning_content must add tokens"
1026 );
1027 }
1028
1029 #[test]
1030 fn tool_call_only_assistant_has_nonzero_tokens() {
1031 let m = SessionMessage::from_chat(&ChatMessage::assistant_with_tools(
1034 "",
1035 vec![ToolCall {
1036 id: "c1".into(),
1037 name: "navigate".into(),
1038 arguments: "{\"url\":\"https://example.com\"}".into(),
1039 }],
1040 ));
1041 assert!(
1042 m.tokens > 0,
1043 "tool-call-only assistant must not be 0 tokens"
1044 );
1045 }
1046
1047 #[test]
1050 fn ensure_session_creates_then_touches() {
1051 let proj = tmp_project();
1052 let mut sm = SessionManager::new(&proj);
1053 sm.ensure_session("s1", "My Session").unwrap();
1054 assert!(
1055 sm.session_path("s1").exists(),
1056 "session file must be created"
1057 );
1058 assert_eq!(sm.load_index()["s1"].title, "My Session");
1059
1060 let first = sm.load_index()["s1"].last_activity.clone();
1062 std::thread::sleep(std::time::Duration::from_millis(1100));
1063 sm.ensure_session("s1", "").unwrap();
1064 let index2 = sm.load_index();
1065 assert!(
1066 index2["s1"].last_activity >= first,
1067 "last_activity must bump"
1068 );
1069 assert_eq!(
1070 index2["s1"].title, "My Session",
1071 "empty title must not clobber"
1072 );
1073 }
1074
1075 #[test]
1076 fn rename_session_sets_title() {
1077 let proj = tmp_project();
1078 let mut sm = SessionManager::new(&proj);
1079 sm.ensure_session("s1", "old").unwrap();
1080 sm.rename_session("s1", "new title").unwrap();
1081 assert_eq!(sm.load_index()["s1"].title, "new title");
1082 sm.rename_session("missing", "x").unwrap();
1084 }
1085
1086 #[test]
1087 fn touch_session_bumps_last_activity() {
1088 let proj = tmp_project();
1089 let mut sm = SessionManager::new(&proj);
1090 sm.ensure_session("s1", "").unwrap();
1091 let before = sm.load_index()["s1"].last_activity.clone();
1092 std::thread::sleep(std::time::Duration::from_millis(1100));
1093 sm.touch_session("s1").unwrap();
1094 assert!(sm.load_index()["s1"].last_activity >= before);
1095 }
1096
1097 #[test]
1098 fn append_session_messages_appends_and_updates_index() {
1099 let proj = tmp_project();
1100 let mut sm = SessionManager::new(&proj);
1101 sm.ensure_session("s1", "").unwrap();
1102 sm.append_session_messages(
1103 "s1",
1104 vec![
1105 SessionMessage::from_chat(&ChatMessage::user("hello")),
1106 SessionMessage::from_chat(&ChatMessage::assistant("hi")),
1107 ],
1108 )
1109 .unwrap();
1110 let msgs = sm.read_session_messages("s1");
1111 assert_eq!(msgs.len(), 2);
1112 assert_eq!(msgs[0].content, "hello");
1113 let index = sm.load_index();
1114 assert_eq!(index["s1"].total_messages, 2);
1115 assert_eq!(
1116 index["s1"].title, "hello",
1117 "title falls back to first user msg"
1118 );
1119 }
1120
1121 #[test]
1122 fn append_to_missing_session_creates_it() {
1123 let proj = tmp_project();
1124 let mut sm = SessionManager::new(&proj);
1125 sm.append_session_messages(
1126 "brand_new",
1127 vec![SessionMessage::from_chat(&ChatMessage::user("first"))],
1128 )
1129 .unwrap();
1130 assert!(sm.session_path("brand_new").exists());
1131 assert_eq!(sm.load_index()["brand_new"].total_messages, 1);
1132 assert_eq!(sm.load_index()["brand_new"].title, "first");
1133 }
1134
1135 #[test]
1136 fn valid_session_id_rejects_path_traversal() {
1137 assert!(valid_session_id("session_1700000000_abc12345"));
1138 assert!(valid_session_id("cron_daily_1"));
1139 assert!(valid_session_id("pending_x"));
1140 assert!(!valid_session_id(""));
1141 assert!(!valid_session_id("../etc"));
1142 assert!(!valid_session_id("a/b"));
1143 assert!(!valid_session_id("a\\b"));
1144 assert!(!valid_session_id(".."));
1145 }
1146
1147 #[test]
1148 fn old_file_without_schema_version_reads_fine() {
1149 let proj = tmp_project();
1150 let sm = SessionManager::new(&proj);
1151 let path = sm.session_path("s_old");
1152 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1153 let old = r#"{"session_id":"s_old","created_at":"1700000000","messages":[{"role":"user","content":"hi","timestamp":"1700000001","tokens":1}]}"#;
1155 std::fs::write(&path, old).unwrap();
1156
1157 let msgs = sm.read_session_messages("s_old");
1158 assert_eq!(msgs.len(), 1);
1159 assert_eq!(msgs[0].content, "hi");
1160
1161 let sessions = sm.list_sessions();
1163 assert!(sessions.iter().any(|s| s.session_id == "s_old"));
1164 }
1165}