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 std::path::{Path, PathBuf};
14use std::time::Instant;
15
16/// A persisted message (superset of ChatMessage with a timestamp + tokens).
17///
18/// NOTE: render segments (`thinking`/`thought`/`action`/`observation`) are **not**
19/// persisted. They are fully derivable from the message itself plus the
20/// immediately-following `role:"tool"` messages (`tool_calls` → action, tool
21/// message → observation), so storing them duplicated content and diverged from
22/// the Python `SessionMessage`. Hosts rebuild the collapse UI from the message
23/// array order (and the live `seg_content` event stream).
24///
25/// Legacy session files that still contain a `segments` field keep parsing —
26/// serde ignores unknown fields — but the field is never written again.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SessionMessage {
29    pub role: String,
30    #[serde(default)]
31    pub content: String,
32    #[serde(default)]
33    pub timestamp: String,
34    #[serde(default)]
35    pub tokens: usize,
36    #[serde(skip_serializing_if = "Option::is_none", default)]
37    pub tool_calls: Option<Vec<ToolCall>>,
38    #[serde(skip_serializing_if = "Option::is_none", default)]
39    pub tool_call_id: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none", default)]
41    pub reasoning_content: Option<String>,
42}
43
44impl SessionMessage {
45    pub fn from_chat(m: &ChatMessage) -> Self {
46        // Token estimate must match the runtime estimator
47        // (`agent::compact::estimate_messages_tokens`) and the Python
48        // `message_utils.estimate_tokens`: count content, reasoning_content,
49        // tool_calls (serialized) AND the tool_call_id. Omitting tool_calls
50        // made tool-call-only assistant messages report `tokens = 0`.
51        let mut tokens = estimate_tokens(&m.content);
52        if let Some(rc) = &m.reasoning_content {
53            tokens += estimate_tokens(rc);
54        }
55        if let Some(tcs) = &m.tool_calls {
56            if let Ok(json) = serde_json::to_string(tcs) {
57                tokens += estimate_tokens(&json);
58            }
59        }
60        if let Some(id) = &m.tool_call_id {
61            tokens += estimate_tokens(id);
62        }
63        SessionMessage {
64            role: m.role.clone(),
65            content: m.content.clone(),
66            timestamp: now_iso(),
67            tokens,
68            tool_calls: m.tool_calls.clone(),
69            tool_call_id: m.tool_call_id.clone(),
70            reasoning_content: m.reasoning_content.clone(),
71        }
72    }
73
74    pub fn to_chat(&self) -> ChatMessage {
75        ChatMessage {
76            role: self.role.clone(),
77            content: self.content.clone(),
78            tool_calls: self.tool_calls.clone(),
79            tool_call_id: self.tool_call_id.clone(),
80            reasoning_content: self.reasoning_content.clone(),
81        }
82    }
83}
84
85/// One entry in the sessions index.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SessionSummary {
88    pub session_id: String,
89    pub created_at: String,
90    pub last_activity: String,
91    #[serde(default)]
92    pub total_messages: usize,
93    #[serde(default)]
94    pub total_tokens: usize,
95    #[serde(default)]
96    pub title: String,
97    #[serde(default = "default_status")]
98    pub status: String,
99}
100
101fn default_status() -> String {
102    "active".to_string()
103}
104
105/// The on-disk session file shape.
106///
107/// `schema_version` is a forward-compatibility guard: bump it on any breaking
108/// change to this shape. iOS/Android parse the same file, so a shared
109/// conformance fixture (`tests/fixtures/session_v1.json`) locks the format
110/// across all three codebases.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112struct SessionFile {
113    #[serde(default = "default_schema_version")]
114    schema_version: u32,
115    session_id: String,
116    #[serde(default)]
117    created_at: String,
118    messages: Vec<SessionMessage>,
119}
120
121fn default_schema_version() -> u32 {
122    SCHEMA_VERSION
123}
124
125/// Current on-disk session schema version (see SESSION_FFI.md §6). Bump on any
126/// breaking change to the session file shape.
127pub const SCHEMA_VERSION: u32 = 1;
128
129/// Validate that a session id is a safe simple identifier (no path separators
130/// or `..`), so it can never escape the sessions directory.
131pub fn valid_session_id(sid: &str) -> bool {
132    !sid.is_empty()
133        && !sid.contains('/')
134        && !sid.contains('\\')
135        && !sid.contains("..")
136        && !sid.contains('\0')
137}
138
139/// Rough token estimate (4 chars ≈ 1 token). tiktoken is not ported.
140pub fn estimate_tokens(text: &str) -> usize {
141    text.chars().count() / 4
142}
143
144/// Current time as a compact ISO-ish string (no external chrono dependency).
145pub fn now_iso() -> String {
146    use std::time::{SystemTime, UNIX_EPOCH};
147    let secs = SystemTime::now()
148        .duration_since(UNIX_EPOCH)
149        .map(|d| d.as_secs())
150        .unwrap_or(0);
151    // Store epoch seconds as string; readable enough and monotonic for sorting.
152    secs.to_string()
153}
154
155/// Current time as an epoch-seconds string with millisecond precision
156/// (e.g. `"1700000000.123"`). Used for per-segment `created_at` so segments
157/// within the same message stay stably ordered and hosts can derive stable ids.
158pub fn now_iso_ms() -> String {
159    use std::time::{SystemTime, UNIX_EPOCH};
160    let d = SystemTime::now()
161        .duration_since(UNIX_EPOCH)
162        .unwrap_or_default();
163    format!("{}.{:03}", d.as_secs(), d.subsec_millis())
164}
165
166/// Atomic write: write to a temp file then rename over the target.
167fn atomic_write(path: &Path, contents: &str) -> Result<()> {
168    let parent = path
169        .parent()
170        .ok_or_else(|| AacodeError::Io("no parent".into()))?;
171    std::fs::create_dir_all(parent)?;
172    let tmp = parent.join(format!(".tmp_{}", uuid::Uuid::new_v4().simple()));
173    std::fs::write(&tmp, contents)?;
174    std::fs::rename(&tmp, path)?;
175    Ok(())
176}
177
178pub struct SessionManager {
179    sessions_dir: PathBuf,
180    pub current_session_id: Option<String>,
181    pub messages: Vec<SessionMessage>,
182    /// Flush cooldown: how many messages to batch before forcing a write.
183    flush_batch_size: usize,
184    /// Number of messages added since the last flush.
185    dirty_count: usize,
186    /// Last flush time (for interval-based debounce).
187    last_flush: Option<Instant>,
188    /// Minimum interval between flushes in seconds.
189    flush_interval_secs: f64,
190}
191
192impl SessionManager {
193    pub fn new(project_path: &Path) -> Self {
194        let sessions_dir = project_path.join(".aacode").join("sessions");
195        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
196        let _ = std::fs::create_dir_all(&sessions_dir);
197        SessionManager {
198            sessions_dir,
199            current_session_id: None,
200            messages: Vec::new(),
201            flush_batch_size: 10,
202            dirty_count: 0,
203            last_flush: None,
204            flush_interval_secs: 0.5,
205        }
206    }
207
208    fn index_path(&self) -> PathBuf {
209        self.sessions_dir.join("sessions_index.json")
210    }
211
212    fn session_path(&self, id: &str) -> PathBuf {
213        self.sessions_dir.join(format!("{id}.json"))
214    }
215
216    fn load_index(&self) -> std::collections::BTreeMap<String, SessionSummary> {
217        let path = self.index_path();
218        if let Ok(s) = std::fs::read_to_string(&path) {
219            if let Ok(m) = serde_json::from_str(&s) {
220                return m;
221            }
222        }
223        std::collections::BTreeMap::new()
224    }
225
226    fn save_index(&self, index: &std::collections::BTreeMap<String, SessionSummary>) -> Result<()> {
227        // Merge with disk to avoid clobbering concurrent writers.
228        let mut merged = self.load_index();
229        for (k, v) in index {
230            merged.insert(k.clone(), v.clone());
231        }
232        atomic_write(&self.index_path(), &serde_json::to_string(&merged)?)
233    }
234
235    /// Create a new session and make it current. Returns the session id.
236    pub fn create_session(&mut self, task: &str, title: Option<&str>) -> Result<String> {
237        let id = format!(
238            "session_{}_{}",
239            now_iso(),
240            &uuid::Uuid::new_v4().simple().to_string()[..8]
241        );
242        let title = title
243            .map(|t| t.to_string())
244            .unwrap_or_else(|| truncate_title(task));
245        let now = now_iso();
246        let summary = SessionSummary {
247            session_id: id.clone(),
248            created_at: now.clone(),
249            last_activity: now,
250            total_messages: 0,
251            total_tokens: 0,
252            title,
253            status: "active".to_string(),
254        };
255        self.current_session_id = Some(id.clone());
256        self.messages.clear();
257        if !task.trim().is_empty() {
258            self.messages.push(SessionMessage {
259                role: "user".to_string(),
260                content: task.to_string(),
261                timestamp: now_iso(),
262                tokens: estimate_tokens(task),
263                tool_calls: None,
264                tool_call_id: None,
265                reasoning_content: None,
266            });
267        }
268        let mut index = self.load_index();
269        let mut s = summary;
270        s.total_messages = self.messages.len();
271        s.total_tokens = self.total_tokens();
272        index.insert(id.clone(), s);
273        self.save_index(&index)?;
274        self.save_current()?;
275        self.dirty_count = 0;
276        self.last_flush = Some(Instant::now());
277        Ok(id)
278    }
279
280    /// Append a message to the current session. Writes are batched via
281    /// a cooldown (every N messages or every `flush_interval_secs`) to avoid
282    /// full-file serialize + disk write on every single message.
283    pub fn add_message(&mut self, msg: SessionMessage) -> Result<()> {
284        self.messages.push(msg);
285        self.dirty_count += 1;
286        self.maybe_flush()
287    }
288
289    /// Append several messages (batch) then persist once.
290    pub fn add_messages(&mut self, msgs: impl IntoIterator<Item = SessionMessage>) -> Result<()> {
291        for m in msgs {
292            self.messages.push(m);
293            self.dirty_count += 1;
294        }
295        self.maybe_flush()
296    }
297
298    /// Force a write of the current session to disk (messages + index).
299    pub fn flush(&mut self) -> Result<()> {
300        if self.dirty_count == 0 {
301            return Ok(());
302        }
303        self.save_current()?;
304        self.touch_index()?;
305        self.dirty_count = 0;
306        self.last_flush = Some(Instant::now());
307        Ok(())
308    }
309
310    fn maybe_flush(&mut self) -> Result<()> {
311        if self.dirty_count >= self.flush_batch_size {
312            return self.flush();
313        }
314        match self.last_flush {
315            None => self.flush(),
316            Some(t) if t.elapsed().as_secs_f64() >= self.flush_interval_secs => self.flush(),
317            _ => Ok(()),
318        }
319    }
320
321    fn total_tokens(&self) -> usize {
322        self.messages.iter().map(|m| m.tokens).sum()
323    }
324
325    fn touch_index(&self) -> Result<()> {
326        if let Some(id) = &self.current_session_id {
327            let mut index = self.load_index();
328            if let Some(s) = index.get_mut(id) {
329                s.last_activity = now_iso();
330                s.total_messages = self.messages.len();
331                s.total_tokens = self.total_tokens();
332                if s.title.trim().is_empty() {
333                    if let Some(first_user) = self.messages.iter().find(|m| m.role == "user") {
334                        s.title = truncate_title(&first_user.content);
335                    }
336                }
337            }
338            self.save_index(&index)?;
339        }
340        Ok(())
341    }
342
343    fn save_current(&self) -> Result<()> {
344        if let Some(id) = &self.current_session_id {
345            let created_at = self
346                .load_index()
347                .get(id)
348                .map(|s| s.created_at.clone())
349                .unwrap_or_else(now_iso);
350            let file = SessionFile {
351                schema_version: default_schema_version(),
352                session_id: id.clone(),
353                created_at,
354                messages: self.messages.clone(),
355            };
356            atomic_write(&self.session_path(id), &serde_json::to_string(&file)?)?;
357        }
358        Ok(())
359    }
360
361    /// Load an existing session as current.
362    pub fn switch_session(&mut self, id: &str) -> Result<bool> {
363        let path = self.session_path(id);
364        if !path.exists() {
365            return Ok(false);
366        }
367        let s = std::fs::read_to_string(&path)?;
368        let file: SessionFile = serde_json::from_str(&s)?;
369        self.current_session_id = Some(id.to_string());
370        self.messages = file.messages;
371        Ok(true)
372    }
373
374    /// List all sessions, newest activity first.
375    /// Falls back to scanning disk files when the index is empty.
376    pub fn list_sessions(&self) -> Vec<SessionSummary> {
377        let mut index = self.load_index();
378        if index.is_empty() {
379            if let Ok(entries) = std::fs::read_dir(&self.sessions_dir) {
380                for entry in entries.flatten() {
381                    let path = entry.path();
382                    if path.extension().and_then(|e| e.to_str()) != Some("json") {
383                        continue;
384                    }
385                    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
386                    if stem == "sessions_index" {
387                        continue;
388                    }
389                    if let Ok(s) = std::fs::read_to_string(&path) {
390                        if let Ok(file) = serde_json::from_str::<SessionFile>(&s) {
391                            let title = file
392                                .messages
393                                .first()
394                                .map(|m| truncate_title(&m.content))
395                                .unwrap_or_default();
396                            let summary = SessionSummary {
397                                session_id: file.session_id.clone(),
398                                created_at: file.created_at.clone(),
399                                last_activity: file
400                                    .messages
401                                    .last()
402                                    .map(|m| m.timestamp.clone())
403                                    .unwrap_or_default(),
404                                total_messages: file.messages.len(),
405                                total_tokens: file.messages.iter().map(|m| m.tokens).sum(),
406                                title,
407                                status: "active".to_string(),
408                            };
409                            index.entry(file.session_id).or_insert(summary);
410                        }
411                    }
412                }
413            }
414            // Self-heal: write rebuilt index back to disk.
415            if !index.is_empty() {
416                if let Ok(contents) = serde_json::to_string(&index) {
417                    let _ = atomic_write(&self.index_path(), &contents);
418                }
419            }
420        }
421        let mut v: Vec<SessionSummary> = index.into_values().collect();
422        v.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
423        v
424    }
425
426    /// Delete a session file + index entry.
427    pub fn delete_session(&mut self, id: &str) -> Result<bool> {
428        let mut index = self.load_index();
429        if index.remove(id).is_none() {
430            return Ok(false);
431        }
432        let _ = std::fs::remove_file(self.session_path(id));
433        // rewrite index without the removed key (save_index merges, so write raw)
434        atomic_write(&self.index_path(), &serde_json::to_string(&index)?)?;
435        if self.current_session_id.as_deref() == Some(id) {
436            self.current_session_id = None;
437            self.messages.clear();
438        }
439        Ok(true)
440    }
441
442    /// Read messages of a session file directly (without switching).
443    pub fn read_session_messages(&self, id: &str) -> Vec<SessionMessage> {
444        let path = self.session_path(id);
445        if let Ok(s) = std::fs::read_to_string(&path) {
446            if let Ok(file) = serde_json::from_str::<SessionFile>(&s) {
447                return file.messages;
448            }
449        }
450        Vec::new()
451    }
452
453    /// Ensure a session with the given id exists (idempotent create-or-touch).
454    /// If it exists, its `last_activity` is bumped (and a non-empty `title`
455    /// updates the stored title); otherwise an empty session file + index entry
456    /// are created.
457    pub fn ensure_session(&mut self, sid: &str, title: &str) -> Result<()> {
458        let mut index = self.load_index();
459        let now = now_iso();
460        if let Some(s) = index.get_mut(sid) {
461            s.last_activity = now;
462            if !title.trim().is_empty() {
463                s.title = title.to_string();
464            }
465            self.save_index(&index)?;
466            return Ok(());
467        }
468        let summary = SessionSummary {
469            session_id: sid.to_string(),
470            created_at: now.clone(),
471            last_activity: now.clone(),
472            total_messages: 0,
473            total_tokens: 0,
474            title: title.to_string(),
475            status: "active".to_string(),
476        };
477        index.insert(sid.to_string(), summary);
478        self.save_index(&index)?;
479        let file = SessionFile {
480            schema_version: SCHEMA_VERSION,
481            session_id: sid.to_string(),
482            created_at: now,
483            messages: Vec::new(),
484        };
485        atomic_write(&self.session_path(sid), &serde_json::to_string(&file)?)
486    }
487
488    /// Set an explicit title on an existing session (no-op if missing).
489    pub fn rename_session(&mut self, sid: &str, title: &str) -> Result<()> {
490        let mut index = self.load_index();
491        if let Some(s) = index.get_mut(sid) {
492            s.title = title.to_string();
493            self.save_index(&index)?;
494        }
495        Ok(())
496    }
497
498    /// Bump `last_activity` of an existing session (no-op if missing).
499    pub fn touch_session(&mut self, sid: &str) -> Result<()> {
500        let mut index = self.load_index();
501        if let Some(s) = index.get_mut(sid) {
502            s.last_activity = now_iso();
503            self.save_index(&index)?;
504        }
505        Ok(())
506    }
507
508    /// Append messages to a session file (creating the session if missing),
509    /// then refresh the index entry (counts + last_activity + title fallback).
510    pub fn append_session_messages(&mut self, sid: &str, msgs: Vec<SessionMessage>) -> Result<()> {
511        let path = self.session_path(sid);
512        let mut existing: Vec<SessionMessage> = if path.exists() {
513            std::fs::read_to_string(&path)
514                .ok()
515                .and_then(|s| serde_json::from_str::<SessionFile>(&s).ok())
516                .map(|f| f.messages)
517                .unwrap_or_default()
518        } else {
519            Vec::new()
520        };
521        existing.extend(msgs);
522
523        let total_messages = existing.len();
524        let total_tokens: usize = existing.iter().map(|m| m.tokens).sum();
525        let fallback_title = existing
526            .iter()
527            .find(|m| m.role == "user")
528            .map(|m| truncate_title(&m.content))
529            .unwrap_or_default();
530
531        let created_at = self
532            .load_index()
533            .get(sid)
534            .map(|s| s.created_at.clone())
535            .unwrap_or_else(now_iso);
536
537        let file = SessionFile {
538            schema_version: SCHEMA_VERSION,
539            session_id: sid.to_string(),
540            created_at,
541            messages: existing,
542        };
543        atomic_write(&path, &serde_json::to_string(&file)?)?;
544
545        let mut index = self.load_index();
546        if let Some(s) = index.get_mut(sid) {
547            s.total_messages = total_messages;
548            s.total_tokens = total_tokens;
549            s.last_activity = now_iso();
550            if s.title.trim().is_empty() && !fallback_title.is_empty() {
551                s.title = fallback_title.clone();
552            }
553        } else {
554            index.insert(
555                sid.to_string(),
556                SessionSummary {
557                    session_id: sid.to_string(),
558                    created_at: file.created_at.clone(),
559                    last_activity: now_iso(),
560                    total_messages,
561                    total_tokens,
562                    title: fallback_title,
563                    status: "active".to_string(),
564                },
565            );
566        }
567        self.save_index(&index)
568    }
569
570    /// Get the current session's messages as ChatMessages (for LLM history).
571    /// System messages are included so that dynamically-appended context
572    /// (project analysis, init instructions, compaction summaries, etc.) are
573    /// preserved in the prefix for provider KV-cache stability across calls.
574    pub fn history_chat(&self) -> Vec<ChatMessage> {
575        self.messages.iter().map(|m| m.to_chat()).collect()
576    }
577
578    /// Whether the last message is a tool result (interrupted mid-execution).
579    pub fn ended_mid_tool(&self) -> bool {
580        self.messages
581            .last()
582            .map(|m| m.role == "tool")
583            .unwrap_or(false)
584    }
585}
586
587fn truncate_title(task: &str) -> String {
588    let t: String = task.chars().take(50).collect();
589    if task.chars().count() > 50 {
590        format!("{t}...")
591    } else {
592        t
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    fn tmp_project() -> PathBuf {
601        let d = std::env::temp_dir().join(format!(
602            "aacode_sess_{}_{}",
603            std::process::id(),
604            uuid::Uuid::new_v4().simple()
605        ));
606        std::fs::create_dir_all(&d).unwrap();
607        d
608    }
609
610    #[test]
611    fn create_and_reload() {
612        let proj = tmp_project();
613        let mut sm = SessionManager::new(&proj);
614        let id = sm.create_session("do something big", None).unwrap();
615        assert!(id.starts_with("session_"));
616        assert_eq!(sm.messages.len(), 1);
617
618        // new manager, switch to it
619        let mut sm2 = SessionManager::new(&proj);
620        assert!(sm2.switch_session(&id).unwrap());
621        assert_eq!(sm2.messages[0].content, "do something big");
622    }
623
624    #[test]
625    fn append_and_persist() {
626        let proj = tmp_project();
627        let mut sm = SessionManager::new(&proj);
628        let id = sm.create_session("task", None).unwrap();
629        sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("hi")))
630            .unwrap();
631        sm.flush().unwrap();
632        let read = sm.read_session_messages(&id);
633        assert_eq!(read.len(), 2);
634        assert_eq!(read[1].role, "assistant");
635    }
636
637    #[test]
638    fn tool_calls_persist() {
639        let proj = tmp_project();
640        let mut sm = SessionManager::new(&proj);
641        sm.create_session("t", None).unwrap();
642        let msg = ChatMessage::assistant_with_tools(
643            "",
644            vec![ToolCall {
645                id: "c1".into(),
646                name: "run_shell".into(),
647                arguments: "{}".into(),
648            }],
649        );
650        sm.add_message(SessionMessage::from_chat(&msg)).unwrap();
651        sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
652            "c1", "ok",
653        )))
654        .unwrap();
655        assert!(sm.ended_mid_tool());
656        let hist = sm.history_chat();
657        assert!(hist.iter().any(|m| m.tool_calls.is_some()));
658    }
659
660    #[test]
661    fn list_and_delete() {
662        let proj = tmp_project();
663        let mut sm = SessionManager::new(&proj);
664        let id1 = sm.create_session("one", None).unwrap();
665        let _id2 = sm.create_session("two", None).unwrap();
666        assert!(sm.list_sessions().len() >= 2);
667        assert!(sm.delete_session(&id1).unwrap());
668        assert!(!sm.list_sessions().iter().any(|s| s.session_id == id1));
669    }
670
671    #[test]
672    fn history_includes_system() {
673        let proj = tmp_project();
674        let mut sm = SessionManager::new(&proj);
675        sm.create_session("t", None).unwrap();
676        sm.add_message(SessionMessage::from_chat(&ChatMessage::system("sys")))
677            .unwrap();
678        // After cache-stability fix: system messages are included in history
679        // so persisted dynamic context is preserved for prefix matching.
680        assert!(sm.history_chat().iter().any(|m| m.role == "system"));
681    }
682
683    #[test]
684    fn scheduled_task_saves_to_existing_session() {
685        let proj = tmp_project();
686        let mut sm = SessionManager::new(&proj);
687
688        let id = sm.create_session("previous task", None).unwrap();
689        sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
690            .unwrap();
691        sm.flush().unwrap();
692
693        let mut sm2 = SessionManager::new(&proj);
694        assert!(sm2.switch_session(&id).unwrap());
695        assert!(sm2.current_session_id.is_some());
696
697        let scheduled =
698            "[Scheduled: daily-check]\nThis is an automated scheduled run.\n\ncheck git status";
699
700        let already = sm2
701            .messages
702            .last()
703            .map(|m| m.role == "user" && m.content == scheduled)
704            .unwrap_or(false);
705        assert!(!already);
706
707        sm2.add_message(SessionMessage::from_chat(&ChatMessage::user(scheduled)))
708            .unwrap();
709        sm2.flush().unwrap();
710
711        let msgs = sm2.read_session_messages(&id);
712        assert_eq!(msgs.len(), 3);
713        assert_eq!(msgs[0].content, "previous task");
714        assert_eq!(msgs[1].role, "assistant");
715        assert_eq!(msgs[2].role, "user");
716        assert!(msgs[2].content.starts_with("[Scheduled: daily-check]"));
717    }
718
719    #[test]
720    fn dedup_skips_duplicate_user_message() {
721        let proj = tmp_project();
722        let mut sm = SessionManager::new(&proj);
723
724        let task = "[Scheduled: daily]\ncheck";
725        let id = sm.create_session(task, None).unwrap();
726
727        let mut sm2 = SessionManager::new(&proj);
728        sm2.switch_session(&id).unwrap();
729        let already = sm2
730            .messages
731            .last()
732            .map(|m| m.role == "user" && m.content == task)
733            .unwrap_or(false);
734        assert!(already);
735
736        let msgs = sm2.read_session_messages(&id);
737        assert_eq!(msgs.len(), 1);
738    }
739
740    #[test]
741    fn reasoning_content_counted_in_tokens() {
742        let msg = ChatMessage {
743            role: "assistant".into(),
744            content: "hello world".into(),
745            tool_calls: None,
746            tool_call_id: None,
747            reasoning_content: Some("I need to think about this carefully".into()),
748        };
749        let sm = SessionMessage::from_chat(&msg);
750        // Should count both content and reasoning_content tokens.
751        let content_only = estimate_tokens("hello world");
752        let with_reasoning = estimate_tokens("hello world")
753            + estimate_tokens("I need to think about this carefully");
754        assert!(sm.tokens > content_only);
755        assert_eq!(sm.tokens, with_reasoning);
756    }
757
758    #[test]
759    fn no_reasoning_content_tokens_unchanged() {
760        let msg = ChatMessage {
761            role: "assistant".into(),
762            content: "hello world".into(),
763            tool_calls: None,
764            tool_call_id: None,
765            reasoning_content: None,
766        };
767        let sm = SessionMessage::from_chat(&msg);
768        assert_eq!(sm.tokens, estimate_tokens("hello world"));
769    }
770
771    #[test]
772    fn tool_message_tokens_include_content_and_tool_call_id() {
773        // Tool messages have no reasoning_content; the estimate counts the
774        // content AND the tool_call_id (parity with the Python estimator).
775        let msg = ChatMessage {
776            role: "tool".into(),
777            content: "result data".into(),
778            tool_calls: None,
779            tool_call_id: Some("call_1".into()),
780            reasoning_content: None,
781        };
782        let sm = SessionMessage::from_chat(&msg);
783        assert_eq!(
784            sm.tokens,
785            estimate_tokens("result data") + estimate_tokens("call_1")
786        );
787    }
788
789    #[test]
790    fn compact_json_roundtrips() {
791        let proj = tmp_project();
792        let mut sm = SessionManager::new(&proj);
793        sm.create_session("compact test", None).unwrap();
794        sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
795            "{\"key\": \"value\"}",
796        )))
797        .unwrap();
798        sm.flush().unwrap();
799
800        let id = sm.current_session_id.unwrap();
801        let mut sm2 = SessionManager::new(&proj);
802        assert!(sm2.switch_session(&id).unwrap());
803        assert_eq!(sm2.messages.len(), 2);
804        assert_eq!(sm2.messages[1].content, "{\"key\": \"value\"}");
805
806        let path = sm2.session_path(&id);
807        let raw = std::fs::read_to_string(&path).unwrap();
808        assert!(
809            !raw.contains("\n  "),
810            "compact JSON should not have indentation"
811        );
812        let file: SessionFile = serde_json::from_str(&raw).unwrap();
813        assert_eq!(file.messages.len(), 2);
814    }
815
816    #[test]
817    fn persisted_dynamic_system_message_in_history() {
818        let proj = tmp_project();
819        let mut sm = SessionManager::new(&proj);
820        sm.create_session("task", None).unwrap();
821
822        let analysis = SessionMessage::from_chat(&ChatMessage::system(
823            "## Project Analysis\n2 .rs files, 1 .py file",
824        ));
825        sm.add_message(analysis).unwrap();
826        sm.flush().unwrap();
827
828        let history = sm.history_chat();
829        assert!(history
830            .iter()
831            .any(|m| m.role == "system" && m.content.contains("Project Analysis")));
832
833        // Reload from disk and verify system message is still in history.
834        let id = sm.current_session_id.unwrap();
835        let mut sm2 = SessionManager::new(&proj);
836        sm2.switch_session(&id).unwrap();
837        let history2 = sm2.history_chat();
838        assert!(history2
839            .iter()
840            .any(|m| m.role == "system" && m.content.contains("Project Analysis")));
841    }
842
843    #[test]
844    fn flush_cooldown_batches_messages() {
845        let proj = tmp_project();
846        let mut sm = SessionManager::new(&proj);
847        let id = sm.create_session("task", None).unwrap();
848
849        // Add messages below the batch threshold — should not write to disk.
850        for i in 0..5 {
851            sm.add_message(SessionMessage::from_chat(&ChatMessage::assistant(
852                &format!("msg{}", i),
853            )))
854            .unwrap();
855        }
856
857        // Force a flush and verify all messages are persisted.
858        sm.flush().unwrap();
859
860        let msgs = sm.read_session_messages(&id);
861        assert_eq!(msgs.len(), 6); // 1 from create + 5 added
862        assert_eq!(msgs[5].content, "msg4");
863    }
864
865    #[test]
866    fn flush_idempotent_when_clean() {
867        let proj = tmp_project();
868        let mut sm = SessionManager::new(&proj);
869        sm.create_session("task", None).unwrap();
870        // Calling flush on a clean session should be a no-op.
871        assert!(sm.flush().is_ok());
872        assert!(sm.dirty_count == 0);
873    }
874
875    // ── schema conformance (locks the on-disk format across hosts) ──
876
877    /// The canonical session-file fixture is shared with the iOS/Android hosts
878    /// (each has its own copy under their test trees). Any breaking change to
879    /// the on-disk shape must update this fixture and bump `schema_version`.
880    #[test]
881    fn schema_conformance_fixture_roundtrips() {
882        const FIXTURE: &str = include_str!("../../tests/fixtures/session_v1.json");
883        let file: SessionFile = serde_json::from_str(FIXTURE).expect("fixture must parse");
884
885        assert_eq!(file.schema_version, 1, "schema_version must be pinned at 1");
886        assert_eq!(file.session_id, "session_1700000000_abc12345");
887        assert_eq!(file.created_at, "1700000000");
888        assert_eq!(file.messages.len(), 3);
889
890        // user message: no tool fields.
891        assert_eq!(file.messages[0].role, "user");
892        assert_eq!(file.messages[0].content, "Create a hello.py file");
893        assert!(file.messages[0].tool_calls.is_none());
894        assert!(file.messages[0].tool_call_id.is_none());
895        assert!(file.messages[0].reasoning_content.is_none());
896
897        // assistant message: tool_calls + reasoning_content.
898        assert_eq!(file.messages[1].role, "assistant");
899        let tcs = file.messages[1]
900            .tool_calls
901            .as_ref()
902            .expect("assistant tool_calls present");
903        assert_eq!(tcs.len(), 1);
904        assert_eq!(tcs[0].id, "call_1");
905        assert_eq!(tcs[0].name, "run_shell");
906        assert_eq!(tcs[0].arguments, r#"{"command":"echo hello"}"#);
907        assert_eq!(
908            file.messages[1].reasoning_content.as_deref(),
909            Some("I should run a shell command")
910        );
911
912        // tool message: tool_call_id.
913        assert_eq!(file.messages[2].role, "tool");
914        assert_eq!(file.messages[2].content, "hello");
915        assert_eq!(file.messages[2].tool_call_id.as_deref(), Some("call_1"));
916
917        // Round-trip: re-serialize then re-parse must be stable.
918        let reserialized = serde_json::to_string(&file).unwrap();
919        let reparsed: SessionFile = serde_json::from_str(&reserialized).unwrap();
920        assert_eq!(reparsed.schema_version, 1);
921        assert_eq!(reparsed.messages.len(), 3);
922        assert_eq!(
923            reparsed.messages[1].tool_calls.as_ref().unwrap()[0].name,
924            "run_shell"
925        );
926    }
927
928    /// Legacy `session_v2.json` still carries a `segments` field from the old
929    /// schema; it must keep parsing (serde ignores unknown fields) and the field
930    /// must never be re-serialized.
931    #[test]
932    fn legacy_segments_fixture_still_parses_and_is_dropped_on_write() {
933        const FIXTURE: &str = include_str!("../../tests/fixtures/session_v2.json");
934        let file: SessionFile = serde_json::from_str(FIXTURE).expect("legacy fixture must parse");
935        assert_eq!(file.schema_version, 1);
936        assert_eq!(file.messages.len(), 3);
937        assert_eq!(
938            file.messages[1].tool_calls.as_ref().unwrap()[0].name,
939            "run_shell"
940        );
941        // `segments` is no longer a field → dropped on re-serialization.
942        let raw = serde_json::to_string(&file).unwrap();
943        assert!(
944            !raw.contains("segments"),
945            "segments must not be re-persisted: {raw}"
946        );
947    }
948
949    #[test]
950    fn persisted_messages_never_contain_segments() {
951        let proj = tmp_project();
952        let mut sm = SessionManager::new(&proj);
953        let id = sm.create_session("t", None).unwrap();
954        sm.add_message(SessionMessage::from_chat(
955            &ChatMessage::assistant_with_tools(
956                "hi",
957                vec![ToolCall {
958                    id: "c1".into(),
959                    name: "run_shell".into(),
960                    arguments: "{}".into(),
961                }],
962            ),
963        ))
964        .unwrap();
965        sm.add_message(SessionMessage::from_chat(&ChatMessage::tool_result(
966            "c1", "out",
967        )))
968        .unwrap();
969        sm.flush().unwrap();
970
971        let raw = std::fs::read_to_string(sm.session_path(&id)).unwrap();
972        assert!(!raw.contains("segments"), "no segments on disk: {raw}");
973    }
974
975    #[test]
976    fn now_iso_ms_has_millisecond_precision() {
977        let s = now_iso_ms();
978        let (secs, ms) = s.split_once('.').expect("must contain fractional part");
979        assert!(secs.parse::<u64>().is_ok(), "integer seconds part: {s}");
980        assert_eq!(ms.len(), 3, "millisecond part must be 3 digits: {s}");
981        assert!(ms.parse::<u32>().is_ok());
982    }
983
984    // ── from_chat token estimate parity (mirrors Python
985    //    `tests/test_context_compaction.py::TestEstimateTokensIntegration`) ──
986
987    #[test]
988    fn from_chat_tokens_include_tool_calls() {
989        let plain = SessionMessage::from_chat(&ChatMessage::assistant("hello"));
990        let with_calls = SessionMessage::from_chat(&ChatMessage::assistant_with_tools(
991            "hello",
992            vec![ToolCall {
993                id: "c1".into(),
994                name: "run_shell".into(),
995                arguments: format!("{{\"path\":\"{}\"}}", "a".repeat(200)),
996            }],
997        ));
998        assert!(
999            with_calls.tokens > plain.tokens,
1000            "tool_calls tokens ({}) must exceed plain ({})",
1001            with_calls.tokens,
1002            plain.tokens
1003        );
1004    }
1005
1006    #[test]
1007    fn from_chat_tokens_include_tool_call_id() {
1008        let mut no_id = ChatMessage::tool_result("", "result".to_string());
1009        no_id.tool_call_id = None;
1010        let with_id = ChatMessage::tool_result("call_some_id_here", "result".to_string());
1011        assert!(
1012            SessionMessage::from_chat(&with_id).tokens > SessionMessage::from_chat(&no_id).tokens,
1013            "tool_call_id must add tokens"
1014        );
1015    }
1016
1017    #[test]
1018    fn from_chat_tokens_include_reasoning_content() {
1019        let mut base = ChatMessage::assistant("ok");
1020        let plain = SessionMessage::from_chat(&base);
1021        base.reasoning_content = Some("Let me think".repeat(50));
1022        let with_reasoning = SessionMessage::from_chat(&base);
1023        assert!(
1024            with_reasoning.tokens > plain.tokens,
1025            "reasoning_content must add tokens"
1026        );
1027    }
1028
1029    #[test]
1030    fn tool_call_only_assistant_has_nonzero_tokens() {
1031        // Regression: the estimate used to ignore tool_calls, so an assistant
1032        // message carrying only tool calls reported `tokens = 0`.
1033        let m = SessionMessage::from_chat(&ChatMessage::assistant_with_tools(
1034            "",
1035            vec![ToolCall {
1036                id: "c1".into(),
1037                name: "navigate".into(),
1038                arguments: "{\"url\":\"https://example.com\"}".into(),
1039            }],
1040        ));
1041        assert!(
1042            m.tokens > 0,
1043            "tool-call-only assistant must not be 0 tokens"
1044        );
1045    }
1046
1047    // ── SESSION_FFI 存储方法 ──────────────────────────────────────────
1048
1049    #[test]
1050    fn ensure_session_creates_then_touches() {
1051        let proj = tmp_project();
1052        let mut sm = SessionManager::new(&proj);
1053        sm.ensure_session("s1", "My Session").unwrap();
1054        assert!(
1055            sm.session_path("s1").exists(),
1056            "session file must be created"
1057        );
1058        assert_eq!(sm.load_index()["s1"].title, "My Session");
1059
1060        // Idempotent re-ensure: bump last_activity, keep title on empty.
1061        let first = sm.load_index()["s1"].last_activity.clone();
1062        std::thread::sleep(std::time::Duration::from_millis(1100));
1063        sm.ensure_session("s1", "").unwrap();
1064        let index2 = sm.load_index();
1065        assert!(
1066            index2["s1"].last_activity >= first,
1067            "last_activity must bump"
1068        );
1069        assert_eq!(
1070            index2["s1"].title, "My Session",
1071            "empty title must not clobber"
1072        );
1073    }
1074
1075    #[test]
1076    fn rename_session_sets_title() {
1077        let proj = tmp_project();
1078        let mut sm = SessionManager::new(&proj);
1079        sm.ensure_session("s1", "old").unwrap();
1080        sm.rename_session("s1", "new title").unwrap();
1081        assert_eq!(sm.load_index()["s1"].title, "new title");
1082        // Rename on a missing session is a no-op, not an error.
1083        sm.rename_session("missing", "x").unwrap();
1084    }
1085
1086    #[test]
1087    fn touch_session_bumps_last_activity() {
1088        let proj = tmp_project();
1089        let mut sm = SessionManager::new(&proj);
1090        sm.ensure_session("s1", "").unwrap();
1091        let before = sm.load_index()["s1"].last_activity.clone();
1092        std::thread::sleep(std::time::Duration::from_millis(1100));
1093        sm.touch_session("s1").unwrap();
1094        assert!(sm.load_index()["s1"].last_activity >= before);
1095    }
1096
1097    #[test]
1098    fn append_session_messages_appends_and_updates_index() {
1099        let proj = tmp_project();
1100        let mut sm = SessionManager::new(&proj);
1101        sm.ensure_session("s1", "").unwrap();
1102        sm.append_session_messages(
1103            "s1",
1104            vec![
1105                SessionMessage::from_chat(&ChatMessage::user("hello")),
1106                SessionMessage::from_chat(&ChatMessage::assistant("hi")),
1107            ],
1108        )
1109        .unwrap();
1110        let msgs = sm.read_session_messages("s1");
1111        assert_eq!(msgs.len(), 2);
1112        assert_eq!(msgs[0].content, "hello");
1113        let index = sm.load_index();
1114        assert_eq!(index["s1"].total_messages, 2);
1115        assert_eq!(
1116            index["s1"].title, "hello",
1117            "title falls back to first user msg"
1118        );
1119    }
1120
1121    #[test]
1122    fn append_to_missing_session_creates_it() {
1123        let proj = tmp_project();
1124        let mut sm = SessionManager::new(&proj);
1125        sm.append_session_messages(
1126            "brand_new",
1127            vec![SessionMessage::from_chat(&ChatMessage::user("first"))],
1128        )
1129        .unwrap();
1130        assert!(sm.session_path("brand_new").exists());
1131        assert_eq!(sm.load_index()["brand_new"].total_messages, 1);
1132        assert_eq!(sm.load_index()["brand_new"].title, "first");
1133    }
1134
1135    #[test]
1136    fn valid_session_id_rejects_path_traversal() {
1137        assert!(valid_session_id("session_1700000000_abc12345"));
1138        assert!(valid_session_id("cron_daily_1"));
1139        assert!(valid_session_id("pending_x"));
1140        assert!(!valid_session_id(""));
1141        assert!(!valid_session_id("../etc"));
1142        assert!(!valid_session_id("a/b"));
1143        assert!(!valid_session_id("a\\b"));
1144        assert!(!valid_session_id(".."));
1145    }
1146
1147    #[test]
1148    fn old_file_without_schema_version_reads_fine() {
1149        let proj = tmp_project();
1150        let sm = SessionManager::new(&proj);
1151        let path = sm.session_path("s_old");
1152        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1153        // Old-format file: no `schema_version` field.
1154        let old = r#"{"session_id":"s_old","created_at":"1700000000","messages":[{"role":"user","content":"hi","timestamp":"1700000001","tokens":1}]}"#;
1155        std::fs::write(&path, old).unwrap();
1156
1157        let msgs = sm.read_session_messages("s_old");
1158        assert_eq!(msgs.len(), 1);
1159        assert_eq!(msgs[0].content, "hi");
1160
1161        // list_sessions fallback must rebuild the index from the old file.
1162        let sessions = sm.list_sessions();
1163        assert!(sessions.iter().any(|s| s.session_id == "s_old"));
1164    }
1165}