1use crate::error::{AacodeError, Result};
11use crate::llm::types::{ChatMessage, ToolCall};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct SessionMessage {
30 pub role: String,
31 #[serde(default)]
32 pub content: String,
33 #[serde(default)]
34 pub timestamp: String,
35 #[serde(default)]
36 pub tokens: usize,
37 #[serde(skip_serializing_if = "Option::is_none", default)]
38 pub tool_calls: Option<Vec<ToolCall>>,
39 #[serde(skip_serializing_if = "Option::is_none", default)]
40 pub tool_call_id: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none", default)]
42 pub reasoning_content: Option<String>,
43}
44
45impl SessionMessage {
46 pub fn from_chat(m: &ChatMessage) -> Self {
47 let mut tokens = estimate_tokens(&m.content);
53 if let Some(rc) = &m.reasoning_content {
54 tokens += estimate_tokens(rc);
55 }
56 if let Some(tcs) = &m.tool_calls {
57 if let Ok(json) = serde_json::to_string(tcs) {
58 tokens += estimate_tokens(&json);
59 }
60 }
61 if let Some(id) = &m.tool_call_id {
62 tokens += estimate_tokens(id);
63 }
64 SessionMessage {
65 role: m.role.clone(),
66 content: m.content.clone(),
67 timestamp: now_iso(),
68 tokens,
69 tool_calls: m.tool_calls.clone(),
70 tool_call_id: m.tool_call_id.clone(),
71 reasoning_content: m.reasoning_content.clone(),
72 }
73 }
74
75 pub fn to_chat(&self) -> ChatMessage {
76 ChatMessage {
77 role: self.role.clone(),
78 content: self.content.clone(),
79 tool_calls: self.tool_calls.clone(),
80 tool_call_id: self.tool_call_id.clone(),
81 reasoning_content: self.reasoning_content.clone(),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SessionSummary {
89 pub session_id: String,
90 pub created_at: String,
91 pub last_activity: String,
92 #[serde(default)]
93 pub total_messages: usize,
94 #[serde(default)]
95 pub total_tokens: usize,
96 #[serde(default)]
97 pub title: String,
98 #[serde(default = "default_status")]
99 pub status: String,
100}
101
102fn default_status() -> String {
103 "active".to_string()
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
113struct SessionFile {
114 #[serde(default = "default_schema_version")]
115 schema_version: u32,
116 session_id: String,
117 #[serde(default)]
118 created_at: String,
119 messages: Vec<SessionMessage>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
123 context_checkpoint: Option<Value>,
124}
125
126fn default_schema_version() -> u32 {
127 SCHEMA_VERSION
128}
129
130pub const SCHEMA_VERSION: u32 = 1;
133
134pub fn valid_session_id(sid: &str) -> bool {
137 !sid.is_empty()
138 && !sid.contains('/')
139 && !sid.contains('\\')
140 && !sid.contains("..")
141 && !sid.contains('\0')
142}
143
144pub fn estimate_tokens(text: &str) -> usize {
146 text.chars().count() / 4
147}
148
149pub fn now_iso() -> String {
151 use std::time::{SystemTime, UNIX_EPOCH};
152 let secs = SystemTime::now()
153 .duration_since(UNIX_EPOCH)
154 .map(|d| d.as_secs())
155 .unwrap_or(0);
156 secs.to_string()
158}
159
160pub fn now_iso_ms() -> String {
164 use std::time::{SystemTime, UNIX_EPOCH};
165 let d = SystemTime::now()
166 .duration_since(UNIX_EPOCH)
167 .unwrap_or_default();
168 format!("{}.{:03}", d.as_secs(), d.subsec_millis())
169}
170
171fn atomic_write(path: &Path, contents: &str) -> Result<()> {
173 let parent = path
174 .parent()
175 .ok_or_else(|| AacodeError::Io("no parent".into()))?;
176 std::fs::create_dir_all(parent)?;
177 let tmp = parent.join(format!(".tmp_{}", uuid::Uuid::new_v4().simple()));
178 std::fs::write(&tmp, contents)?;
179 std::fs::rename(&tmp, path)?;
180 Ok(())
181}
182
183pub struct SessionManager {
184 sessions_dir: PathBuf,
185 pub current_session_id: Option<String>,
186 pub messages: Vec<SessionMessage>,
187 pub context_checkpoint: Option<Value>,
189 flush_batch_size: usize,
191 dirty_count: usize,
193 last_flush: Option<Instant>,
195 flush_interval_secs: f64,
197}
198
199impl SessionManager {
200 pub fn new(project_path: &Path) -> Self {
201 let sessions_dir = project_path.join(".aacode").join("sessions");
202 let _ = std::fs::create_dir_all(&sessions_dir);
204 SessionManager {
205 sessions_dir,
206 current_session_id: None,
207 messages: Vec::new(),
208 context_checkpoint: None,
209 flush_batch_size: 10,
210 dirty_count: 0,
211 last_flush: None,
212 flush_interval_secs: 0.5,
213 }
214 }
215
216 fn index_path(&self) -> PathBuf {
217 self.sessions_dir.join("sessions_index.json")
218 }
219
220 pub fn project_path(&self) -> PathBuf {
222 self.sessions_dir
223 .parent()
224 .and_then(|p| p.parent())
225 .map(|p| p.to_path_buf())
226 .unwrap_or_default()
227 }
228
229 fn session_path(&self, id: &str) -> PathBuf {
230 self.sessions_dir.join(format!("{id}.json"))
231 }
232
233 fn load_index(&self) -> std::collections::BTreeMap<String, SessionSummary> {
234 let path = self.index_path();
235 if let Ok(s) = std::fs::read_to_string(&path) {
236 if let Ok(m) = serde_json::from_str(&s) {
237 return m;
238 }
239 }
240 std::collections::BTreeMap::new()
241 }
242
243 fn save_index(&self, index: &std::collections::BTreeMap<String, SessionSummary>) -> Result<()> {
244 let mut merged = self.load_index();
246 for (k, v) in index {
247 merged.insert(k.clone(), v.clone());
248 }
249 atomic_write(&self.index_path(), &serde_json::to_string(&merged)?)
250 }
251
252 pub fn create_session(&mut self, task: &str, title: Option<&str>) -> Result<String> {
254 let id = format!(
255 "session_{}_{}",
256 now_iso(),
257 &uuid::Uuid::new_v4().simple().to_string()[..8]
258 );
259 let title = title
260 .map(|t| t.to_string())
261 .unwrap_or_else(|| truncate_title(task));
262 let now = now_iso();
263 let summary = SessionSummary {
264 session_id: id.clone(),
265 created_at: now.clone(),
266 last_activity: now,
267 total_messages: 0,
268 total_tokens: 0,
269 title,
270 status: "active".to_string(),
271 };
272 self.current_session_id = Some(id.clone());
273 self.messages.clear();
274 self.context_checkpoint = None;
275 if !task.trim().is_empty() {
276 self.messages.push(SessionMessage {
277 role: "user".to_string(),
278 content: task.to_string(),
279 timestamp: now_iso(),
280 tokens: estimate_tokens(task),
281 tool_calls: None,
282 tool_call_id: None,
283 reasoning_content: None,
284 });
285 }
286 let mut index = self.load_index();
287 let mut s = summary;
288 s.total_messages = self.messages.len();
289 s.total_tokens = self.total_tokens();
290 index.insert(id.clone(), s);
291 self.save_index(&index)?;
292 self.save_current()?;
293 self.dirty_count = 0;
294 self.last_flush = Some(Instant::now());
295 Ok(id)
296 }
297
298 pub fn add_message(&mut self, msg: SessionMessage) -> Result<()> {
302 self.messages.push(msg);
303 self.dirty_count += 1;
304 self.maybe_flush()
305 }
306
307 pub fn add_messages(&mut self, msgs: impl IntoIterator<Item = SessionMessage>) -> Result<()> {
309 for m in msgs {
310 self.messages.push(m);
311 self.dirty_count += 1;
312 }
313 self.maybe_flush()
314 }
315
316 pub fn flush(&mut self) -> Result<()> {
318 if self.dirty_count == 0 {
319 return Ok(());
320 }
321 self.save_current()?;
322 self.touch_index()?;
323 self.dirty_count = 0;
324 self.last_flush = Some(Instant::now());
325 Ok(())
326 }
327
328 fn maybe_flush(&mut self) -> Result<()> {
329 if self.dirty_count >= self.flush_batch_size {
330 return self.flush();
331 }
332 match self.last_flush {
333 None => self.flush(),
334 Some(t) if t.elapsed().as_secs_f64() >= self.flush_interval_secs => self.flush(),
335 _ => Ok(()),
336 }
337 }
338
339 fn total_tokens(&self) -> usize {
340 self.messages.iter().map(|m| m.tokens).sum()
341 }
342
343 fn touch_index(&self) -> Result<()> {
344 if let Some(id) = &self.current_session_id {
345 let mut index = self.load_index();
346 if let Some(s) = index.get_mut(id) {
347 s.last_activity = now_iso();
348 s.total_messages = self.messages.len();
349 s.total_tokens = self.total_tokens();
350 if s.title.trim().is_empty() {
351 if let Some(first_user) = self.messages.iter().find(|m| m.role == "user") {
352 s.title = truncate_title(&first_user.content);
353 }
354 }
355 }
356 self.save_index(&index)?;
357 }
358 Ok(())
359 }
360
361 fn save_current(&self) -> Result<()> {
362 if let Some(id) = &self.current_session_id {
363 let created_at = self
364 .load_index()
365 .get(id)
366 .map(|s| s.created_at.clone())
367 .unwrap_or_else(now_iso);
368 let file = SessionFile {
369 schema_version: default_schema_version(),
370 session_id: id.clone(),
371 created_at,
372 messages: self.messages.clone(),
373 context_checkpoint: self.context_checkpoint.clone(),
374 };
375 atomic_write(&self.session_path(id), &serde_json::to_string(&file)?)?;
376 }
377 Ok(())
378 }
379
380 pub fn switch_session(&mut self, id: &str) -> Result<bool> {
382 let path = self.session_path(id);
383 if !path.exists() {
384 return Ok(false);
385 }
386 let s = std::fs::read_to_string(&path)?;
387 let file: SessionFile = serde_json::from_str(&s)?;
388 self.current_session_id = Some(id.to_string());
389 self.messages = file.messages;
390 self.context_checkpoint = file.context_checkpoint;
391 Ok(true)
392 }
393
394 pub fn context_checkpoint(&self) -> Option<Value> {
396 self.context_checkpoint.clone()
397 }
398
399 pub fn set_context_checkpoint(&mut self, checkpoint: Option<Value>) -> Result<()> {
401 self.context_checkpoint = checkpoint;
402 self.save_current()
403 }
404
405 pub fn export_jsonl(&self, id: &str) -> Result<String> {
412 let path = self.session_path(id);
413 let text = std::fs::read_to_string(&path)?;
414 let file: SessionFile = serde_json::from_str(&text)?;
415 let mut out = String::new();
416 out.push_str(
417 &serde_json::json!({
418 "type": "header",
419 "format": "aacode.session-export",
420 "formatVersion": 1,
421 "session_id": file.session_id,
422 "created_at": file.created_at,
423 })
424 .to_string(),
425 );
426 out.push('\n');
427 for (index, message) in file.messages.iter().enumerate() {
428 let mut line = serde_json::to_value(message)?;
429 line["type"] = Value::String("message".into());
430 line["index"] = Value::from(index);
431 out.push_str(&line.to_string());
432 out.push('\n');
433 }
434 out.push_str(
435 &serde_json::json!({
436 "type": "footer",
437 "messages": file.messages.len(),
438 })
439 .to_string(),
440 );
441 out.push('\n');
442 Ok(out)
443 }
444
445 pub fn fork_session(&mut self, source_id: &str, upto: Option<usize>) -> Result<String> {
453 let path = self.session_path(source_id);
454 let text = std::fs::read_to_string(&path)?;
455 let source: SessionFile = serde_json::from_str(&text)?;
456 let mut end = upto.unwrap_or(source.messages.len());
457 end = end.min(source.messages.len());
458 end = safe_prefix_end(&source.messages, end);
459 if end == 0 {
460 return Err(AacodeError::Tool(
461 "cannot fork before the first message".into(),
462 ));
463 }
464 if end > 8192 {
465 return Err(AacodeError::Tool(
466 "fork prefix exceeds 8192 messages".into(),
467 ));
468 }
469 let id = format!(
470 "session_{}_{}",
471 now_iso(),
472 &uuid::Uuid::new_v4().simple().to_string()[..8]
473 );
474 let prefix: Vec<SessionMessage> = source.messages[..end].to_vec();
475 let title = prefix
476 .iter()
477 .find(|m| m.role == "user" && !m.content.is_empty())
478 .map(|m| format!("Fork: {}", truncate_title(&m.content)))
479 .unwrap_or_else(|| format!("Fork of {source_id}"));
480 let now = now_iso();
481 let file = SessionFile {
482 schema_version: SCHEMA_VERSION,
483 session_id: id.clone(),
484 created_at: now.clone(),
485 messages: prefix,
486 context_checkpoint: None,
487 };
488 atomic_write(&self.session_path(&id), &serde_json::to_string(&file)?)?;
489
490 let mut index = self.load_index();
491 index.insert(
492 id.clone(),
493 SessionSummary {
494 session_id: id.clone(),
495 created_at: now.clone(),
496 last_activity: now,
497 total_messages: file.messages.len(),
498 total_tokens: file.messages.iter().map(|m| m.tokens).sum(),
499 title,
500 status: "active".to_string(),
501 },
502 );
503 self.save_index(&index)?;
504 Ok(id)
505 }
506
507 pub fn list_sessions(&self) -> Vec<SessionSummary> {
510 let mut index = self.load_index();
511 if index.is_empty() {
512 if let Ok(entries) = std::fs::read_dir(&self.sessions_dir) {
513 for entry in entries.flatten() {
514 let path = entry.path();
515 if path.extension().and_then(|e| e.to_str()) != Some("json") {
516 continue;
517 }
518 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
519 if stem == "sessions_index" {
520 continue;
521 }
522 if let Ok(s) = std::fs::read_to_string(&path) {
523 if let Ok(file) = serde_json::from_str::<SessionFile>(&s) {
524 let title = file
525 .messages
526 .first()
527 .map(|m| truncate_title(&m.content))
528 .unwrap_or_default();
529 let summary = SessionSummary {
530 session_id: file.session_id.clone(),
531 created_at: file.created_at.clone(),
532 last_activity: file
533 .messages
534 .last()
535 .map(|m| m.timestamp.clone())
536 .unwrap_or_default(),
537 total_messages: file.messages.len(),
538 total_tokens: file.messages.iter().map(|m| m.tokens).sum(),
539 title,
540 status: "active".to_string(),
541 };
542 index.entry(file.session_id).or_insert(summary);
543 }
544 }
545 }
546 }
547 if !index.is_empty() {
549 if let Ok(contents) = serde_json::to_string(&index) {
550 let _ = atomic_write(&self.index_path(), &contents);
551 }
552 }
553 }
554 let mut v: Vec<SessionSummary> = index.into_values().collect();
555 v.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
556 v
557 }
558
559 pub fn delete_session(&mut self, id: &str) -> Result<bool> {
561 let mut index = self.load_index();
562 if index.remove(id).is_none() {
563 return Ok(false);
564 }
565 let _ = std::fs::remove_file(self.session_path(id));
566 atomic_write(&self.index_path(), &serde_json::to_string(&index)?)?;
568 if self.current_session_id.as_deref() == Some(id) {
569 self.current_session_id = None;
570 self.messages.clear();
571 }
572 Ok(true)
573 }
574
575 pub fn read_session_messages(&self, id: &str) -> Vec<SessionMessage> {
577 let path = self.session_path(id);
578 if let Ok(s) = std::fs::read_to_string(&path) {
579 if let Ok(file) = serde_json::from_str::<SessionFile>(&s) {
580 return file.messages;
581 }
582 }
583 Vec::new()
584 }
585
586 pub fn ensure_session(&mut self, sid: &str, title: &str) -> Result<()> {
591 let mut index = self.load_index();
592 let now = now_iso();
593 if let Some(s) = index.get_mut(sid) {
594 s.last_activity = now;
595 if !title.trim().is_empty() {
596 s.title = title.to_string();
597 }
598 self.save_index(&index)?;
599 return Ok(());
600 }
601 let summary = SessionSummary {
602 session_id: sid.to_string(),
603 created_at: now.clone(),
604 last_activity: now.clone(),
605 total_messages: 0,
606 total_tokens: 0,
607 title: title.to_string(),
608 status: "active".to_string(),
609 };
610 index.insert(sid.to_string(), summary);
611 self.save_index(&index)?;
612 let file = SessionFile {
613 schema_version: SCHEMA_VERSION,
614 session_id: sid.to_string(),
615 created_at: now,
616 messages: Vec::new(),
617 context_checkpoint: None,
618 };
619 atomic_write(&self.session_path(sid), &serde_json::to_string(&file)?)
620 }
621
622 pub fn rename_session(&mut self, sid: &str, title: &str) -> Result<()> {
624 let mut index = self.load_index();
625 if let Some(s) = index.get_mut(sid) {
626 s.title = title.to_string();
627 self.save_index(&index)?;
628 }
629 Ok(())
630 }
631
632 pub fn touch_session(&mut self, sid: &str) -> Result<()> {
634 let mut index = self.load_index();
635 if let Some(s) = index.get_mut(sid) {
636 s.last_activity = now_iso();
637 self.save_index(&index)?;
638 }
639 Ok(())
640 }
641
642 pub fn append_session_messages(&mut self, sid: &str, msgs: Vec<SessionMessage>) -> Result<()> {
645 let path = self.session_path(sid);
646 let mut existing: Vec<SessionMessage> = if path.exists() {
647 std::fs::read_to_string(&path)
648 .ok()
649 .and_then(|s| serde_json::from_str::<SessionFile>(&s).ok())
650 .map(|f| f.messages)
651 .unwrap_or_default()
652 } else {
653 Vec::new()
654 };
655 existing.extend(msgs);
656
657 let total_messages = existing.len();
658 let total_tokens: usize = existing.iter().map(|m| m.tokens).sum();
659 let fallback_title = existing
660 .iter()
661 .find(|m| m.role == "user")
662 .map(|m| truncate_title(&m.content))
663 .unwrap_or_default();
664
665 let created_at = self
666 .load_index()
667 .get(sid)
668 .map(|s| s.created_at.clone())
669 .unwrap_or_else(now_iso);
670
671 let file = SessionFile {
672 schema_version: SCHEMA_VERSION,
673 session_id: sid.to_string(),
674 created_at,
675 messages: existing,
676 context_checkpoint: None,
677 };
678 atomic_write(&path, &serde_json::to_string(&file)?)?;
679
680 let mut index = self.load_index();
681 if let Some(s) = index.get_mut(sid) {
682 s.total_messages = total_messages;
683 s.total_tokens = total_tokens;
684 s.last_activity = now_iso();
685 if s.title.trim().is_empty() && !fallback_title.is_empty() {
686 s.title = fallback_title.clone();
687 }
688 } else {
689 index.insert(
690 sid.to_string(),
691 SessionSummary {
692 session_id: sid.to_string(),
693 created_at: file.created_at.clone(),
694 last_activity: now_iso(),
695 total_messages,
696 total_tokens,
697 title: fallback_title,
698 status: "active".to_string(),
699 },
700 );
701 }
702 self.save_index(&index)
703 }
704
705 pub fn history_chat(&self) -> Vec<ChatMessage> {
710 self.messages.iter().map(|m| m.to_chat()).collect()
711 }
712
713 pub fn ended_mid_tool(&self) -> bool {
715 self.messages
716 .last()
717 .map(|m| m.role == "tool")
718 .unwrap_or(false)
719 }
720}
721
722fn truncate_title(task: &str) -> String {
723 let t: String = task.chars().take(50).collect();
724 if task.chars().count() > 50 {
725 format!("{t}...")
726 } else {
727 t
728 }
729}
730
731fn safe_prefix_end(messages: &[SessionMessage], mut end: usize) -> usize {
734 end = end.min(messages.len());
735 loop {
736 let last = messages[..end].iter().enumerate().rev().find(|(_, m)| {
737 m.role == "assistant"
738 && m.tool_calls
739 .as_ref()
740 .map(|c| !c.is_empty())
741 .unwrap_or(false)
742 });
743 let Some((idx, message)) = last else {
744 break;
745 };
746 let ids: Vec<&str> = message
747 .tool_calls
748 .as_ref()
749 .map(|calls| calls.iter().map(|c| c.id.as_str()).collect())
750 .unwrap_or_default();
751 let all_present = ids.iter().all(|id| {
752 messages[..end]
753 .iter()
754 .any(|m| m.role == "tool" && m.tool_call_id.as_deref() == Some(*id))
755 });
756 if all_present {
757 break;
758 }
759 end = idx;
760 }
761 end
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 fn tmp_project() -> PathBuf {
769 let d = std::env::temp_dir().join(format!(
770 "aacode_sess_{}_{}",
771 std::process::id(),
772 uuid::Uuid::new_v4().simple()
773 ));
774 std::fs::create_dir_all(&d).unwrap();
775 d
776 }
777
778 #[test]
779 fn create_and_reload() {
780 let proj = tmp_project();
781 let mut sm = SessionManager::new(&proj);
782 let id = sm.create_session("do something big", None).unwrap();
783 assert!(id.starts_with("session_"));
784 assert_eq!(sm.messages.len(), 1);
785
786 let mut sm2 = SessionManager::new(&proj);
788 assert!(sm2.switch_session(&id).unwrap());
789 assert_eq!(sm2.messages[0].content, "do something big");
790 }
791
792 #[test]
793 fn append_and_persist() {
794 let proj = tmp_project();
795 let mut sm = SessionManager::new(&proj);
796 let id = sm.create_session("task", None).unwrap();
797 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("hi")))
798 .unwrap();
799 sm.flush().unwrap();
800 let read = sm.read_session_messages(&id);
801 assert_eq!(read.len(), 2);
802 assert_eq!(read[1].role, "assistant");
803 }
804
805 #[test]
806 fn tool_calls_persist() {
807 let proj = tmp_project();
808 let mut sm = SessionManager::new(&proj);
809 sm.create_session("t", None).unwrap();
810 let msg = ChatMessage::assistant_with_tools(
811 "",
812 vec![ToolCall {
813 id: "c1".into(),
814 name: "run_shell".into(),
815 arguments: "{}".into(),
816 }],
817 );
818 sm.add_message(SessionMessage::from_chat(&msg)).unwrap();
819 sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
820 "c1", "ok",
821 )))
822 .unwrap();
823 assert!(sm.ended_mid_tool());
824 let hist = sm.history_chat();
825 assert!(hist.iter().any(|m| m.tool_calls.is_some()));
826 }
827
828 #[test]
829 fn list_and_delete() {
830 let proj = tmp_project();
831 let mut sm = SessionManager::new(&proj);
832 let id1 = sm.create_session("one", None).unwrap();
833 let _id2 = sm.create_session("two", None).unwrap();
834 assert!(sm.list_sessions().len() >= 2);
835 assert!(sm.delete_session(&id1).unwrap());
836 assert!(!sm.list_sessions().iter().any(|s| s.session_id == id1));
837 }
838
839 #[test]
840 fn history_includes_system() {
841 let proj = tmp_project();
842 let mut sm = SessionManager::new(&proj);
843 sm.create_session("t", None).unwrap();
844 sm.add_message(SessionMessage::from_chat(&ChatMessage::system("sys")))
845 .unwrap();
846 assert!(sm.history_chat().iter().any(|m| m.role == "system"));
849 }
850
851 #[test]
852 fn scheduled_task_saves_to_existing_session() {
853 let proj = tmp_project();
854 let mut sm = SessionManager::new(&proj);
855
856 let id = sm.create_session("previous task", None).unwrap();
857 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
858 .unwrap();
859 sm.flush().unwrap();
860
861 let mut sm2 = SessionManager::new(&proj);
862 assert!(sm2.switch_session(&id).unwrap());
863 assert!(sm2.current_session_id.is_some());
864
865 let scheduled =
866 "[Scheduled: daily-check]\nThis is an automated scheduled run.\n\ncheck git status";
867
868 let already = sm2
869 .messages
870 .last()
871 .map(|m| m.role == "user" && m.content == scheduled)
872 .unwrap_or(false);
873 assert!(!already);
874
875 sm2.add_message(SessionMessage::from_chat(&ChatMessage::user(scheduled)))
876 .unwrap();
877 sm2.flush().unwrap();
878
879 let msgs = sm2.read_session_messages(&id);
880 assert_eq!(msgs.len(), 3);
881 assert_eq!(msgs[0].content, "previous task");
882 assert_eq!(msgs[1].role, "assistant");
883 assert_eq!(msgs[2].role, "user");
884 assert!(msgs[2].content.starts_with("[Scheduled: daily-check]"));
885 }
886
887 #[test]
888 fn dedup_skips_duplicate_user_message() {
889 let proj = tmp_project();
890 let mut sm = SessionManager::new(&proj);
891
892 let task = "[Scheduled: daily]\ncheck";
893 let id = sm.create_session(task, None).unwrap();
894
895 let mut sm2 = SessionManager::new(&proj);
896 sm2.switch_session(&id).unwrap();
897 let already = sm2
898 .messages
899 .last()
900 .map(|m| m.role == "user" && m.content == task)
901 .unwrap_or(false);
902 assert!(already);
903
904 let msgs = sm2.read_session_messages(&id);
905 assert_eq!(msgs.len(), 1);
906 }
907
908 #[test]
909 fn reasoning_content_counted_in_tokens() {
910 let msg = ChatMessage {
911 role: "assistant".into(),
912 content: "hello world".into(),
913 tool_calls: None,
914 tool_call_id: None,
915 reasoning_content: Some("I need to think about this carefully".into()),
916 };
917 let sm = SessionMessage::from_chat(&msg);
918 let content_only = estimate_tokens("hello world");
920 let with_reasoning = estimate_tokens("hello world")
921 + estimate_tokens("I need to think about this carefully");
922 assert!(sm.tokens > content_only);
923 assert_eq!(sm.tokens, with_reasoning);
924 }
925
926 #[test]
927 fn no_reasoning_content_tokens_unchanged() {
928 let msg = ChatMessage {
929 role: "assistant".into(),
930 content: "hello world".into(),
931 tool_calls: None,
932 tool_call_id: None,
933 reasoning_content: None,
934 };
935 let sm = SessionMessage::from_chat(&msg);
936 assert_eq!(sm.tokens, estimate_tokens("hello world"));
937 }
938
939 #[test]
940 fn tool_message_tokens_include_content_and_tool_call_id() {
941 let msg = ChatMessage {
944 role: "tool".into(),
945 content: "result data".into(),
946 tool_calls: None,
947 tool_call_id: Some("call_1".into()),
948 reasoning_content: None,
949 };
950 let sm = SessionMessage::from_chat(&msg);
951 assert_eq!(
952 sm.tokens,
953 estimate_tokens("result data") + estimate_tokens("call_1")
954 );
955 }
956
957 #[test]
958 fn compact_json_roundtrips() {
959 let proj = tmp_project();
960 let mut sm = SessionManager::new(&proj);
961 sm.create_session("compact test", None).unwrap();
962 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
963 "{\"key\": \"value\"}",
964 )))
965 .unwrap();
966 sm.flush().unwrap();
967
968 let id = sm.current_session_id.unwrap();
969 let mut sm2 = SessionManager::new(&proj);
970 assert!(sm2.switch_session(&id).unwrap());
971 assert_eq!(sm2.messages.len(), 2);
972 assert_eq!(sm2.messages[1].content, "{\"key\": \"value\"}");
973
974 let path = sm2.session_path(&id);
975 let raw = std::fs::read_to_string(&path).unwrap();
976 assert!(
977 !raw.contains("\n "),
978 "compact JSON should not have indentation"
979 );
980 let file: SessionFile = serde_json::from_str(&raw).unwrap();
981 assert_eq!(file.messages.len(), 2);
982 }
983
984 #[test]
985 fn persisted_dynamic_system_message_in_history() {
986 let proj = tmp_project();
987 let mut sm = SessionManager::new(&proj);
988 sm.create_session("task", None).unwrap();
989
990 let analysis = SessionMessage::from_chat(&ChatMessage::system(
991 "## Project Analysis\n2 .rs files, 1 .py file",
992 ));
993 sm.add_message(analysis).unwrap();
994 sm.flush().unwrap();
995
996 let history = sm.history_chat();
997 assert!(history
998 .iter()
999 .any(|m| m.role == "system" && m.content.contains("Project Analysis")));
1000
1001 let id = sm.current_session_id.unwrap();
1003 let mut sm2 = SessionManager::new(&proj);
1004 sm2.switch_session(&id).unwrap();
1005 let history2 = sm2.history_chat();
1006 assert!(history2
1007 .iter()
1008 .any(|m| m.role == "system" && m.content.contains("Project Analysis")));
1009 }
1010
1011 #[test]
1012 fn flush_cooldown_batches_messages() {
1013 let proj = tmp_project();
1014 let mut sm = SessionManager::new(&proj);
1015 let id = sm.create_session("task", None).unwrap();
1016
1017 for i in 0..5 {
1019 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
1020 &format!("msg{}", i),
1021 )))
1022 .unwrap();
1023 }
1024
1025 sm.flush().unwrap();
1027
1028 let msgs = sm.read_session_messages(&id);
1029 assert_eq!(msgs.len(), 6); assert_eq!(msgs[5].content, "msg4");
1031 }
1032
1033 #[test]
1034 fn flush_idempotent_when_clean() {
1035 let proj = tmp_project();
1036 let mut sm = SessionManager::new(&proj);
1037 sm.create_session("task", None).unwrap();
1038 assert!(sm.flush().is_ok());
1040 assert!(sm.dirty_count == 0);
1041 }
1042
1043 #[test]
1049 fn schema_conformance_fixture_roundtrips() {
1050 const FIXTURE: &str = include_str!("../../tests/fixtures/session_v1.json");
1051 let file: SessionFile = serde_json::from_str(FIXTURE).expect("fixture must parse");
1052
1053 assert_eq!(file.schema_version, 1, "schema_version must be pinned at 1");
1054 assert_eq!(file.session_id, "session_1700000000_abc12345");
1055 assert_eq!(file.created_at, "1700000000");
1056 assert_eq!(file.messages.len(), 3);
1057
1058 assert_eq!(file.messages[0].role, "user");
1060 assert_eq!(file.messages[0].content, "Create a hello.py file");
1061 assert!(file.messages[0].tool_calls.is_none());
1062 assert!(file.messages[0].tool_call_id.is_none());
1063 assert!(file.messages[0].reasoning_content.is_none());
1064
1065 assert_eq!(file.messages[1].role, "assistant");
1067 let tcs = file.messages[1]
1068 .tool_calls
1069 .as_ref()
1070 .expect("assistant tool_calls present");
1071 assert_eq!(tcs.len(), 1);
1072 assert_eq!(tcs[0].id, "call_1");
1073 assert_eq!(tcs[0].name, "run_shell");
1074 assert_eq!(tcs[0].arguments, r#"{"command":"echo hello"}"#);
1075 assert_eq!(
1076 file.messages[1].reasoning_content.as_deref(),
1077 Some("I should run a shell command")
1078 );
1079
1080 assert_eq!(file.messages[2].role, "tool");
1082 assert_eq!(file.messages[2].content, "hello");
1083 assert_eq!(file.messages[2].tool_call_id.as_deref(), Some("call_1"));
1084
1085 let reserialized = serde_json::to_string(&file).unwrap();
1087 let reparsed: SessionFile = serde_json::from_str(&reserialized).unwrap();
1088 assert_eq!(reparsed.schema_version, 1);
1089 assert_eq!(reparsed.messages.len(), 3);
1090 assert_eq!(
1091 reparsed.messages[1].tool_calls.as_ref().unwrap()[0].name,
1092 "run_shell"
1093 );
1094 }
1095
1096 #[test]
1100 fn legacy_segments_fixture_still_parses_and_is_dropped_on_write() {
1101 const FIXTURE: &str = include_str!("../../tests/fixtures/session_v2.json");
1102 let file: SessionFile = serde_json::from_str(FIXTURE).expect("legacy fixture must parse");
1103 assert_eq!(file.schema_version, 1);
1104 assert_eq!(file.messages.len(), 3);
1105 assert_eq!(
1106 file.messages[1].tool_calls.as_ref().unwrap()[0].name,
1107 "run_shell"
1108 );
1109 let raw = serde_json::to_string(&file).unwrap();
1111 assert!(
1112 !raw.contains("segments"),
1113 "segments must not be re-persisted: {raw}"
1114 );
1115 }
1116
1117 #[test]
1118 fn persisted_messages_never_contain_segments() {
1119 let proj = tmp_project();
1120 let mut sm = SessionManager::new(&proj);
1121 let id = sm.create_session("t", None).unwrap();
1122 sm.add_message(SessionMessage::from_chat(
1123 &ChatMessage::assistant_with_tools(
1124 "hi",
1125 vec![ToolCall {
1126 id: "c1".into(),
1127 name: "run_shell".into(),
1128 arguments: "{}".into(),
1129 }],
1130 ),
1131 ))
1132 .unwrap();
1133 sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
1134 "c1", "out",
1135 )))
1136 .unwrap();
1137 sm.flush().unwrap();
1138
1139 let raw = std::fs::read_to_string(sm.session_path(&id)).unwrap();
1140 assert!(!raw.contains("segments"), "no segments on disk: {raw}");
1141 }
1142
1143 #[test]
1144 fn now_iso_ms_has_millisecond_precision() {
1145 let s = now_iso_ms();
1146 let (secs, ms) = s.split_once('.').expect("must contain fractional part");
1147 assert!(secs.parse::<u64>().is_ok(), "integer seconds part: {s}");
1148 assert_eq!(ms.len(), 3, "millisecond part must be 3 digits: {s}");
1149 assert!(ms.parse::<u32>().is_ok());
1150 }
1151
1152 #[test]
1156 fn from_chat_tokens_include_tool_calls() {
1157 let plain = SessionMessage::from_chat(&ChatMessage::assistant("hello"));
1158 let with_calls = SessionMessage::from_chat(&ChatMessage::assistant_with_tools(
1159 "hello",
1160 vec![ToolCall {
1161 id: "c1".into(),
1162 name: "run_shell".into(),
1163 arguments: format!("{{\"path\":\"{}\"}}", "a".repeat(200)),
1164 }],
1165 ));
1166 assert!(
1167 with_calls.tokens > plain.tokens,
1168 "tool_calls tokens ({}) must exceed plain ({})",
1169 with_calls.tokens,
1170 plain.tokens
1171 );
1172 }
1173
1174 #[test]
1175 fn from_chat_tokens_include_tool_call_id() {
1176 let mut no_id = ChatMessage::tool_result("", "result".to_string());
1177 no_id.tool_call_id = None;
1178 let with_id = ChatMessage::tool_result("call_some_id_here", "result".to_string());
1179 assert!(
1180 SessionMessage::from_chat(&with_id).tokens > SessionMessage::from_chat(&no_id).tokens,
1181 "tool_call_id must add tokens"
1182 );
1183 }
1184
1185 #[test]
1186 fn from_chat_tokens_include_reasoning_content() {
1187 let mut base = ChatMessage::assistant("ok");
1188 let plain = SessionMessage::from_chat(&base);
1189 base.reasoning_content = Some("Let me think".repeat(50));
1190 let with_reasoning = SessionMessage::from_chat(&base);
1191 assert!(
1192 with_reasoning.tokens > plain.tokens,
1193 "reasoning_content must add tokens"
1194 );
1195 }
1196
1197 #[test]
1198 fn tool_call_only_assistant_has_nonzero_tokens() {
1199 let m = SessionMessage::from_chat(&ChatMessage::assistant_with_tools(
1202 "",
1203 vec![ToolCall {
1204 id: "c1".into(),
1205 name: "navigate".into(),
1206 arguments: "{\"url\":\"https://example.com\"}".into(),
1207 }],
1208 ));
1209 assert!(
1210 m.tokens > 0,
1211 "tool-call-only assistant must not be 0 tokens"
1212 );
1213 }
1214
1215 #[test]
1218 fn ensure_session_creates_then_touches() {
1219 let proj = tmp_project();
1220 let mut sm = SessionManager::new(&proj);
1221 sm.ensure_session("s1", "My Session").unwrap();
1222 assert!(
1223 sm.session_path("s1").exists(),
1224 "session file must be created"
1225 );
1226 assert_eq!(sm.load_index()["s1"].title, "My Session");
1227
1228 let first = sm.load_index()["s1"].last_activity.clone();
1230 std::thread::sleep(std::time::Duration::from_millis(1100));
1231 sm.ensure_session("s1", "").unwrap();
1232 let index2 = sm.load_index();
1233 assert!(
1234 index2["s1"].last_activity >= first,
1235 "last_activity must bump"
1236 );
1237 assert_eq!(
1238 index2["s1"].title, "My Session",
1239 "empty title must not clobber"
1240 );
1241 }
1242
1243 #[test]
1244 fn rename_session_sets_title() {
1245 let proj = tmp_project();
1246 let mut sm = SessionManager::new(&proj);
1247 sm.ensure_session("s1", "old").unwrap();
1248 sm.rename_session("s1", "new title").unwrap();
1249 assert_eq!(sm.load_index()["s1"].title, "new title");
1250 sm.rename_session("missing", "x").unwrap();
1252 }
1253
1254 #[test]
1255 fn touch_session_bumps_last_activity() {
1256 let proj = tmp_project();
1257 let mut sm = SessionManager::new(&proj);
1258 sm.ensure_session("s1", "").unwrap();
1259 let before = sm.load_index()["s1"].last_activity.clone();
1260 std::thread::sleep(std::time::Duration::from_millis(1100));
1261 sm.touch_session("s1").unwrap();
1262 assert!(sm.load_index()["s1"].last_activity >= before);
1263 }
1264
1265 #[test]
1266 fn append_session_messages_appends_and_updates_index() {
1267 let proj = tmp_project();
1268 let mut sm = SessionManager::new(&proj);
1269 sm.ensure_session("s1", "").unwrap();
1270 sm.append_session_messages(
1271 "s1",
1272 vec![
1273 SessionMessage::from_chat(&ChatMessage::user("hello")),
1274 SessionMessage::from_chat(&ChatMessage::assistant("hi")),
1275 ],
1276 )
1277 .unwrap();
1278 let msgs = sm.read_session_messages("s1");
1279 assert_eq!(msgs.len(), 2);
1280 assert_eq!(msgs[0].content, "hello");
1281 let index = sm.load_index();
1282 assert_eq!(index["s1"].total_messages, 2);
1283 assert_eq!(
1284 index["s1"].title, "hello",
1285 "title falls back to first user msg"
1286 );
1287 }
1288
1289 #[test]
1290 fn append_to_missing_session_creates_it() {
1291 let proj = tmp_project();
1292 let mut sm = SessionManager::new(&proj);
1293 sm.append_session_messages(
1294 "brand_new",
1295 vec![SessionMessage::from_chat(&ChatMessage::user("first"))],
1296 )
1297 .unwrap();
1298 assert!(sm.session_path("brand_new").exists());
1299 assert_eq!(sm.load_index()["brand_new"].total_messages, 1);
1300 assert_eq!(sm.load_index()["brand_new"].title, "first");
1301 }
1302
1303 #[test]
1304 fn valid_session_id_rejects_path_traversal() {
1305 assert!(valid_session_id("session_1700000000_abc12345"));
1306 assert!(valid_session_id("cron_daily_1"));
1307 assert!(valid_session_id("pending_x"));
1308 assert!(!valid_session_id(""));
1309 assert!(!valid_session_id("../etc"));
1310 assert!(!valid_session_id("a/b"));
1311 assert!(!valid_session_id("a\\b"));
1312 assert!(!valid_session_id(".."));
1313 }
1314
1315 #[test]
1316 fn old_file_without_schema_version_reads_fine() {
1317 let proj = tmp_project();
1318 let sm = SessionManager::new(&proj);
1319 let path = sm.session_path("s_old");
1320 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1321 let old = r#"{"session_id":"s_old","created_at":"1700000000","messages":[{"role":"user","content":"hi","timestamp":"1700000001","tokens":1}]}"#;
1323 std::fs::write(&path, old).unwrap();
1324
1325 let msgs = sm.read_session_messages("s_old");
1326 assert_eq!(msgs.len(), 1);
1327 assert_eq!(msgs[0].content, "hi");
1328
1329 let sessions = sm.list_sessions();
1331 assert!(sessions.iter().any(|s| s.session_id == "s_old"));
1332 }
1333
1334 #[test]
1335 fn context_checkpoint_roundtrips() {
1336 let proj = tmp_project();
1337 let mut sm = SessionManager::new(&proj);
1338 let sid = sm.create_session("t", None).unwrap();
1339 sm.set_context_checkpoint(Some(serde_json::json!({
1340 "cutoff_index": 3,
1341 "stub": "STUB",
1342 "session_id": &sid,
1343 "created_at": "now",
1344 "keep_tokens": 100
1345 })))
1346 .unwrap();
1347
1348 let mut sm2 = SessionManager::new(&proj);
1349 assert!(sm2.switch_session(&sid).unwrap());
1350 let cp = sm2.context_checkpoint().expect("checkpoint restored");
1351 assert_eq!(cp["cutoff_index"], 3);
1352 assert_eq!(cp["stub"], "STUB");
1353 }
1354
1355 #[test]
1356 fn export_jsonl_has_header_messages_footer() {
1357 let proj = tmp_project();
1358 let mut sm = SessionManager::new(&proj);
1359 let sid = sm.create_session("hello", None).unwrap();
1360 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("hi")))
1361 .unwrap();
1362 sm.flush().unwrap();
1363 let out = sm.export_jsonl(&sid).unwrap();
1364 let lines: Vec<serde_json::Value> = out
1365 .lines()
1366 .map(|l| serde_json::from_str(l).unwrap())
1367 .collect();
1368 assert_eq!(lines[0]["type"], "header");
1369 assert_eq!(lines[0]["format"], "aacode.session-export");
1370 assert_eq!(lines.last().unwrap()["type"], "footer");
1371 assert_eq!(lines.last().unwrap()["messages"], 2);
1372 assert!(lines
1373 .iter()
1374 .any(|l| l["type"] == "message" && l["content"] == "hi"));
1375 }
1376
1377 #[test]
1378 fn fork_moves_boundary_before_split_tool_group() {
1379 let proj = tmp_project();
1380 let mut sm = SessionManager::new(&proj);
1381 let sid = sm.create_session("src", None).unwrap();
1382 let asst = ChatMessage::assistant_with_tools(
1383 "",
1384 vec![ToolCall {
1385 id: "c1".into(),
1386 name: "run_shell".into(),
1387 arguments: "{}".into(),
1388 }],
1389 );
1390 sm.add_message(SessionMessage::from_chat(&asst)).unwrap();
1391 sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
1392 "c1", "ok",
1393 )))
1394 .unwrap();
1395 sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
1396 .unwrap();
1397 sm.flush().unwrap();
1398 let new_sid = sm.fork_session(&sid, Some(2)).unwrap();
1401 let sm2 = SessionManager::new(&proj);
1402 let msgs = sm2.read_session_messages(&new_sid);
1403 assert_eq!(
1404 msgs.len(),
1405 1,
1406 "boundary should move back before the split group"
1407 );
1408 assert_eq!(msgs[0].role, "user");
1409 assert_eq!(sm.messages.len(), 4);
1411 assert_eq!(sm.current_session_id.as_deref(), Some(sid.as_str()));
1412
1413 let kept = sm.fork_session(&sid, Some(3)).unwrap();
1415 let msgs = sm2.read_session_messages(&kept);
1416 assert_eq!(msgs.len(), 3);
1417 assert!(msgs.iter().any(|m| m.role == "tool"));
1418 }
1419}