Skip to main content

aacode_rs/session/
mod.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Session persistence: `.aacode/sessions/<id>.json` + `sessions_index.json`.
5//!
6//! Ported from Python `utils/session_manager.py`. Uses atomic writes
7//! (temp file + rename). Messages carry structured tool_calls / tool_call_id /
8//! reasoning_content, matching the LLM `ChatMessage` shape.
9
10use 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/// A persisted message (superset of ChatMessage with a timestamp + tokens).
18///
19/// NOTE: render segments (`thinking`/`thought`/`action`/`observation`) are **not**
20/// persisted. They are fully derivable from the message itself plus the
21/// immediately-following `role:"tool"` messages (`tool_calls` → action, tool
22/// message → observation), so storing them duplicated content and diverged from
23/// the Python `SessionMessage`. Hosts rebuild the collapse UI from the message
24/// array order (and the live `seg_content` event stream).
25///
26/// Legacy session files that still contain a `segments` field keep parsing —
27/// serde ignores unknown fields — but the field is never written again.
28#[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        // Token estimate must match the runtime estimator
48        // (`agent::compact::estimate_messages_tokens`) and the Python
49        // `message_utils.estimate_tokens`: count content, reasoning_content,
50        // tool_calls (serialized) AND the tool_call_id. Omitting tool_calls
51        // made tool-call-only assistant messages report `tokens = 0`.
52        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/// One entry in the sessions index.
87#[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/// The on-disk session file shape.
107///
108/// `schema_version` is a forward-compatibility guard: bump it on any breaking
109/// change to this shape. iOS/Android parse the same file, so a shared
110/// conformance fixture (`tests/fixtures/session_v1.json`) locks the format
111/// across all three codebases.
112#[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    /// Retrieval cutoff for the model view. The stored messages stay complete;
121    /// this only records where the model view starts. `None` = no cutoff.
122    #[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
130/// Current on-disk session schema version (see SESSION_FFI.md §6). Bump on any
131/// breaking change to the session file shape.
132pub const SCHEMA_VERSION: u32 = 1;
133
134/// Validate that a session id is a safe simple identifier (no path separators
135/// or `..`), so it can never escape the sessions directory.
136pub 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
144/// Rough token estimate (4 chars ≈ 1 token). tiktoken is not ported.
145pub fn estimate_tokens(text: &str) -> usize {
146    text.chars().count() / 4
147}
148
149/// Current time as a compact ISO-ish string (no external chrono dependency).
150pub 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    // Store epoch seconds as string; readable enough and monotonic for sorting.
157    secs.to_string()
158}
159
160/// Current time as an epoch-seconds string with millisecond precision
161/// (e.g. `"1700000000.123"`). Used for per-segment `created_at` so segments
162/// within the same message stay stably ordered and hosts can derive stable ids.
163pub 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
171/// Atomic write: write to a temp file then rename over the target.
172fn 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    /// Retrieval cutoff for the model view (optional; persisted per session).
188    pub context_checkpoint: Option<Value>,
189    /// Flush cooldown: how many messages to batch before forcing a write.
190    flush_batch_size: usize,
191    /// Number of messages added since the last flush.
192    dirty_count: usize,
193    /// Last flush time (for interval-based debounce).
194    last_flush: Option<Instant>,
195    /// Minimum interval between flushes in seconds.
196    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        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
203        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    /// Project root this session store belongs to (`<project>/.aacode/sessions`).
221    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        // Merge with disk to avoid clobbering concurrent writers.
245        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    /// Create a new session and make it current. Returns the session id.
253    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    /// Append a message to the current session. Writes are batched via
299    /// a cooldown (every N messages or every `flush_interval_secs`) to avoid
300    /// full-file serialize + disk write on every single message.
301    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    /// Append several messages (batch) then persist once.
308    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    /// Force a write of the current session to disk (messages + index).
317    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    /// Load an existing session as current.
381    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    /// Persisted retrieval checkpoint (model-view cutoff only).
395    pub fn context_checkpoint(&self) -> Option<Value> {
396        self.context_checkpoint.clone()
397    }
398
399    /// Persist a retrieval checkpoint. Messages themselves are never modified.
400    pub fn set_context_checkpoint(&mut self, checkpoint: Option<Value>) -> Result<()> {
401        self.context_checkpoint = checkpoint;
402        self.save_current()
403    }
404
405    /// Export one session as JSONL (user-triggered, read-only).
406    ///
407    /// Format: a `header` line, one `message` line per persisted message, and a
408    /// `footer` line. Stable IDs/fields; no execution replay. Tool output text
409    /// is exported verbatim — callers are responsible for not exporting
410    /// sessions that contain secrets.
411    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    /// Fork a session from a message prefix into a new session.
446    ///
447    /// The prefix is copied by value; the source session is untouched and the
448    /// new session is **not** made current. A boundary that would split an
449    /// `assistant(tool_calls)` from its `tool` results is moved back to before
450    /// the assistant so tool pairing is always intact. The retrieval checkpoint
451    /// is reset (it is recomputed safely for the new session).
452    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    /// List all sessions, newest activity first.
508    /// Falls back to scanning disk files when the index is empty.
509    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            // Self-heal: write rebuilt index back to disk.
548            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    /// Delete a session file + index entry.
560    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        // rewrite index without the removed key (save_index merges, so write raw)
567        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    /// Read messages of a session file directly (without switching).
576    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    /// Ensure a session with the given id exists (idempotent create-or-touch).
587    /// If it exists, its `last_activity` is bumped (and a non-empty `title`
588    /// updates the stored title); otherwise an empty session file + index entry
589    /// are created.
590    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    /// Set an explicit title on an existing session (no-op if missing).
623    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    /// Bump `last_activity` of an existing session (no-op if missing).
633    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    /// Append messages to a session file (creating the session if missing),
643    /// then refresh the index entry (counts + last_activity + title fallback).
644    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    /// Get the current session's messages as ChatMessages (for LLM history).
706    /// System messages are included so that dynamically-appended context
707    /// (project analysis, init instructions, compaction summaries, etc.) are
708    /// preserved in the prefix for provider KV-cache stability across calls.
709    pub fn history_chat(&self) -> Vec<ChatMessage> {
710        self.messages.iter().map(|m| m.to_chat()).collect()
711    }
712
713    /// Whether the last message is a tool result (interrupted mid-execution).
714    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
731/// Move a fork boundary back so an `assistant(tool_calls)` block is never cut
732/// from its `tool` results (protocol integrity).
733fn 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        // new manager, switch to it
787        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        // After cache-stability fix: system messages are included in history
847        // so persisted dynamic context is preserved for prefix matching.
848        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        // Should count both content and reasoning_content tokens.
919        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        // Tool messages have no reasoning_content; the estimate counts the
942        // content AND the tool_call_id (parity with the Python estimator).
943        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        // Reload from disk and verify system message is still in history.
1002        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        // Add messages below the batch threshold — should not write to disk.
1018        for i in 0..5 {
1019            sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
1020                &format!("msg{}", i),
1021            )))
1022            .unwrap();
1023        }
1024
1025        // Force a flush and verify all messages are persisted.
1026        sm.flush().unwrap();
1027
1028        let msgs = sm.read_session_messages(&id);
1029        assert_eq!(msgs.len(), 6); // 1 from create + 5 added
1030        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        // Calling flush on a clean session should be a no-op.
1039        assert!(sm.flush().is_ok());
1040        assert!(sm.dirty_count == 0);
1041    }
1042
1043    // ── schema conformance (locks the on-disk format across hosts) ──
1044
1045    /// The canonical session-file fixture is shared with the iOS/Android hosts
1046    /// (each has its own copy under their test trees). Any breaking change to
1047    /// the on-disk shape must update this fixture and bump `schema_version`.
1048    #[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        // user message: no tool fields.
1059        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        // assistant message: tool_calls + reasoning_content.
1066        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        // tool message: tool_call_id.
1081        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        // Round-trip: re-serialize then re-parse must be stable.
1086        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    /// Legacy `session_v2.json` still carries a `segments` field from the old
1097    /// schema; it must keep parsing (serde ignores unknown fields) and the field
1098    /// must never be re-serialized.
1099    #[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        // `segments` is no longer a field → dropped on re-serialization.
1110        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    // ── from_chat token estimate parity (mirrors Python
1153    //    `tests/test_context_compaction.py::TestEstimateTokensIntegration`) ──
1154
1155    #[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        // Regression: the estimate used to ignore tool_calls, so an assistant
1200        // message carrying only tool calls reported `tokens = 0`.
1201        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    // ── SESSION_FFI 存储方法 ──────────────────────────────────────────
1216
1217    #[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        // Idempotent re-ensure: bump last_activity, keep title on empty.
1229        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        // Rename on a missing session is a no-op, not an error.
1251        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        // Old-format file: no `schema_version` field.
1322        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        // list_sessions fallback must rebuild the index from the old file.
1330        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        // messages: 0 user, 1 assistant(tool_calls), 2 tool, 3 assistant
1399        // A fork at 2 would keep the assistant but drop its tool result.
1400        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        // Source untouched / not made current.
1410        assert_eq!(sm.messages.len(), 4);
1411        assert_eq!(sm.current_session_id.as_deref(), Some(sid.as_str()));
1412
1413        // A complete boundary keeps the whole group.
1414        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}