Skip to main content

agent_core/core/
session.rs

1use crate::core::stream_types::SharedMessage;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Session {
8    pub id: String,
9    pub title: String,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub name: Option<String>,
12    pub model: String,
13    pub thinking_level: String,
14    pub system_prompt: Option<String>,
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17    pub total_input_tokens: u64,
18    pub total_output_tokens: u64,
19    pub session_cost: f64,
20    /// Persisted listing hint: `api_messages.len()` at save time. Declared
21    /// BEFORE `api_messages` so it lands in the header that
22    /// `read_session_header` reads without touching the message array.
23    /// Not authoritative in memory — use `api_messages.len()`; refreshed by
24    /// `session_journal::save_session_in_dir`. Legacy files → 0.
25    #[serde(default)]
26    pub message_count: usize,
27    pub api_messages: Vec<SharedMessage>,
28    /// Saved abort context — injected into the next user message on /continue
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub abort_context: Option<String>,
31    /// ID of the session this was compacted from (backward link)
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub parent_session: Option<String>,
34    /// ID of the session created by compacting this one (forward link)
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub compacted_into: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub prompt_provenance: Option<crate::prompt::PromptProvenance>,
39    /// Typed compaction summary provenance (spec §9.3). Present on sessions
40    /// produced by (or updated in place by) a compaction transition.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub compaction: Option<crate::compaction::CompactionRecord>,
43}
44
45/// Lightweight info for listing sessions without loading full message history
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SessionInfo {
48    pub id: String,
49    pub title: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub name: Option<String>,
52    pub model: String,
53    pub created_at: DateTime<Utc>,
54    pub updated_at: DateTime<Utc>,
55    pub session_cost: f64,
56    pub message_count: usize,
57}
58
59impl Session {
60    pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
61        let now = Utc::now();
62        let id = format!(
63            "{}-{}",
64            now.format("%Y%m%d-%H%M%S"),
65            &uuid::Uuid::new_v4().to_string()[..4]
66        );
67        Session {
68            id,
69            title: String::new(),
70            name: None,
71            model: model.to_string(),
72            thinking_level: thinking_level.to_string(),
73            system_prompt: system_prompt.map(|s| s.to_string()),
74            created_at: now,
75            updated_at: now,
76            total_input_tokens: 0,
77            total_output_tokens: 0,
78            session_cost: 0.0,
79            message_count: 0,
80            api_messages: Vec::new(),
81            abort_context: None,
82            parent_session: None,
83            compacted_into: None,
84            prompt_provenance: None,
85            compaction: None,
86        }
87    }
88
89    /// Create a successor session from a compaction transition (spec §9.3).
90    /// The summary enters context through the canonical sanitized rendering
91    /// ([`crate::compaction::compaction_context_messages`]); the parent's
92    /// system prompt stays TYPED metadata — the successor's `system_prompt`
93    /// field and the provenance record — never plain user text.
94    pub fn from_compaction_record(
95        parent: &Session,
96        summary_text: &str,
97        record: crate::compaction::CompactionRecord,
98    ) -> Self {
99        let now = Utc::now();
100        let id = format!(
101            "{}-{}",
102            now.format("%Y%m%d-%H%M%S"),
103            &uuid::Uuid::new_v4().to_string()[..4]
104        );
105        // Transfer session name from parent — the compacted session is the
106        // continuation, so the name should follow. Parent's name will be
107        // cleared when the caller saves it with compacted_into set.
108        let name = parent.name.clone();
109        Session {
110            id,
111            title: format!(
112                "↳ {}",
113                if parent.title.is_empty() {
114                    &parent.id
115                } else {
116                    &parent.title
117                }
118            ),
119            name,
120            model: parent.model.clone(),
121            thinking_level: parent.thinking_level.clone(),
122            system_prompt: parent.system_prompt.clone(),
123            created_at: now,
124            updated_at: now,
125            total_input_tokens: 0,
126            total_output_tokens: 0,
127            session_cost: 0.0,
128            message_count: 0,
129            api_messages: crate::compaction::compaction_context_messages(summary_text),
130            abort_context: None,
131            parent_session: Some(parent.id.clone()),
132            compacted_into: None,
133            prompt_provenance: None,
134            compaction: Some(record),
135        }
136    }
137
138    /// Set title from the first user message if not already set
139    pub fn auto_title(&mut self) {
140        if !self.title.is_empty() {
141            return;
142        }
143        for msg in &self.api_messages {
144            if msg["role"].as_str() == Some("user") {
145                if let Some(content) = msg["content"].as_str() {
146                    self.title = content.chars().take(80).collect();
147                    return;
148                }
149            }
150        }
151    }
152
153    /// Persist this session under the configured persistence mode
154    /// (`session_persistence` config key; default is the unchanged legacy
155    /// JSON path — see Task 35 / `crate::core::session_journal`).
156    pub async fn save(&self) -> std::io::Result<()> {
157        let dir = crate::config::resolve_write_path("sessions");
158        let mode = crate::config::load_config().session_persistence;
159        let session = self.clone(); // messages are Arc-shared — cheap clone
160        tokio::task::spawn_blocking(move || {
161            crate::core::session_journal::save_session_in_dir(&dir, &session, mode).map(|_| ())
162        })
163        .await
164        .map_err(std::io::Error::other)?
165    }
166
167    pub fn load(id: &str) -> std::io::Result<Self> {
168        Self::load_from_dir(&sessions_dir(), id)
169    }
170
171    /// Canonical backward-compatible host load for a caller-selected sessions
172    /// directory. Both legacy snapshots and journal-backed sessions resolve
173    /// through the same journal overlay implementation.
174    pub fn load_from_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Self> {
175        crate::core::session_journal::load_session_in_dir(dir, id)
176    }
177
178    pub fn info(&self) -> SessionInfo {
179        SessionInfo {
180            id: self.id.clone(),
181            title: self.title.clone(),
182            name: self.name.clone(),
183            model: self.model.clone(),
184            created_at: self.created_at,
185            updated_at: self.updated_at,
186            session_cost: self.session_cost,
187            message_count: self.api_messages.len(),
188        }
189    }
190
191    /// Assign a name to this session. Validates name, enforces uniqueness
192    /// across sessions, and rejects collisions with existing chain names.
193    /// Idempotent: re-applying the current name is a no-op.
194    pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
195        validate_name(name).map_err(std::io::Error::other)?;
196        if self.name.as_deref() == Some(name) {
197            return Ok(());
198        }
199        let sessions = list_sessions()?;
200        for s in &sessions {
201            if s.name.as_deref() == Some(name) && s.id != self.id {
202                return Err(std::io::Error::other(format!(
203                    "name '{}' already used by session {}",
204                    name, s.id
205                )));
206            }
207        }
208        if crate::core::chain::load_chain(name).is_ok() {
209            return Err(std::io::Error::other(format!(
210                "name '{}' conflicts with an existing chain name",
211                name
212            )));
213        }
214        self.name = Some(name.to_string());
215        Ok(())
216    }
217
218    pub fn clear_name(&mut self) {
219        self.name = None;
220    }
221}
222
223/// Blocking body of [`Session::save`]: create the sessions dir (0700), then
224/// write `<id>.json` atomically via `private_fs` (temp file created with mode
225/// 0600 — never create-then-chmod — then renamed; symlink targets refused).
226#[cfg(test)]
227pub(crate) fn save_json_in_dir(
228    dir: &std::path::Path,
229    id: &str,
230    json: &[u8],
231) -> std::io::Result<()> {
232    // fix2: the SAME strict no-symlink root resolution and handle-relative
233    // atomic write as every journal operation (see `session_journal`).
234    crate::core::session_journal::write_json_snapshot(dir, id, json)
235}
236
237/// Find a session by full or partial ID match
238pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
239    let dir = sessions_dir();
240    if !dir.exists() {
241        return Err(std::io::Error::new(
242            std::io::ErrorKind::NotFound,
243            "no sessions directory",
244        ));
245    }
246
247    // fix2: strict handle-relative enumeration for both match phases.
248    let entries = crate::core::session_journal::session_dir_entries(&dir)?;
249
250    // Try exact match first
251    if entries
252        .iter()
253        .any(|e| e.name == format!("{partial_id}.json"))
254    {
255        return Session::load(partial_id);
256    }
257
258    // Partial match — find all that contain the partial ID
259    let mut matches: Vec<String> = Vec::new();
260    for entry in &entries {
261        if entry.name.ends_with(".json") {
262            let id = entry.name.trim_end_matches(".json");
263            if id.contains(partial_id) {
264                matches.push(id.to_string());
265            }
266        }
267    }
268
269    match matches.len() {
270        0 => Err(std::io::Error::new(
271            std::io::ErrorKind::NotFound,
272            format!("no session matching '{}'", partial_id),
273        )),
274        1 => Session::load(&matches[0]),
275        _ => Err(std::io::Error::other(format!(
276            "ambiguous: {} sessions match '{}'",
277            matches.len(),
278            partial_id
279        ))),
280    }
281}
282
283/// Load the most recently updated session
284pub fn latest_session() -> std::io::Result<Session> {
285    // Find the most-recently-modified session file by FILE mtime — without
286    // reading or JSON-parsing any of them. The previous impl called
287    // list_sessions(), which read + serde-tokenized EVERY session file to sort
288    // by the in-file `updated_at`; with hundreds of multi-MB sessions (221 files
289    // / 76MB here) that made `--continue` boot take ~11s. mtime is a free, exact
290    // proxy for "the session I was last in", and we then load ONLY that one.
291    let dir = sessions_dir();
292    if !dir.exists() {
293        return Err(std::io::Error::new(
294            std::io::ErrorKind::NotFound,
295            "no sessions found",
296        ));
297    }
298    // fix2: strict handle-relative enumeration — a symlinked ancestor
299    // refuses instead of being followed.
300    let entries = crate::core::session_journal::session_dir_entries(&dir)?;
301    let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
302    let mut newest: Option<(std::time::SystemTime, String)> = None;
303    for entry in &entries {
304        // A journal append IS a save of its session (Task 35): attribute the
305        // journal's mtime to the sibling snapshot so "latest" stays exact
306        // when snapshots lag behind appends.
307        let id = if let Some(id) = entry.name.strip_suffix(".json") {
308            id
309        } else if let Some(id) = entry.name.strip_suffix(".journal") {
310            if !names.contains(format!("{id}.json").as_str()) {
311                continue; // orphan journal — not loadable
312            }
313            id
314        } else {
315            continue;
316        };
317        if let Some(mtime) = entry.mtime {
318            if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
319                newest = Some((mtime, id.to_string()));
320            }
321        }
322    }
323    let id = newest
324        .map(|(_, id)| id)
325        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
326    Session::load(&id)
327}
328
329/// List all sessions, sorted by most recently updated.
330///
331/// Reads only the metadata HEADER of each session file (see
332/// [`read_session_header`]) — never the message history. Used by the session
333/// resolvers (by name/id). For the capped `/sessions` display prefer
334/// [`list_recent_sessions`], which reads only the N most-recent headers.
335pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
336    let dir = sessions_dir();
337    if !dir.exists() {
338        return Ok(Vec::new());
339    }
340    let mut sessions: Vec<SessionInfo> = Vec::new();
341    for entry in crate::core::session_journal::session_dir_entries(&dir)? {
342        if entry.name.ends_with(".json") {
343            if let Some(info) = parse_session_header(&dir, &entry.name) {
344                sessions.push(info);
345            }
346        }
347    }
348    sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
349    Ok(sessions)
350}
351
352/// The `limit` most-recently-modified sessions (by file mtime), most-recent
353/// first. Sorts the directory entries by mtime WITHOUT parsing, then reads only
354/// the top `limit` headers — so `/sessions` is O(limit) reads instead of
355/// O(#sessions). mtime is an exact proxy for `updated_at` (the file is rewritten
356/// on every save).
357pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
358    let dir = sessions_dir();
359    if !dir.exists() {
360        return Ok(Vec::new());
361    }
362    // fix2: strict handle-relative enumeration.
363    let entries = crate::core::session_journal::session_dir_entries(&dir)?;
364    let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
365    let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
366        std::collections::HashMap::new();
367    for entry in &entries {
368        // Journal mtimes attribute to their session snapshot (Task 35).
369        let id = if let Some(id) = entry.name.strip_suffix(".json") {
370            id
371        } else if let Some(id) = entry.name.strip_suffix(".journal") {
372            if !names.contains(format!("{id}.json").as_str()) {
373                continue;
374            }
375            id
376        } else {
377            continue;
378        };
379        if let Some(mtime) = entry.mtime {
380            let slot = by_snapshot
381                .entry(id.to_string())
382                .or_insert(std::time::SystemTime::UNIX_EPOCH);
383            if mtime > *slot {
384                *slot = mtime;
385            }
386        }
387    }
388    let mut files: Vec<(std::time::SystemTime, String)> =
389        by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
390    files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
391    files.truncate(limit);
392    let mut sessions: Vec<SessionInfo> = Vec::new();
393    for (_, id) in files {
394        if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
395            sessions.push(info);
396        }
397    }
398    Ok(sessions)
399}
400
401/// Read + parse just the metadata header of one session file into a
402/// [`SessionInfo`] (no message history). `message_count` comes from the
403/// persisted header field (written at save time); legacy files without it
404/// report 0.
405fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
406    #[derive(Deserialize)]
407    struct SessionMetadata {
408        id: String,
409        #[serde(default)]
410        title: String,
411        #[serde(default)]
412        name: Option<String>,
413        model: String,
414        created_at: DateTime<Utc>,
415        updated_at: DateTime<Utc>,
416        #[serde(default)]
417        session_cost: f64,
418        #[serde(default)]
419        message_count: usize,
420    }
421    let header = read_session_header(dir, file_name)?;
422    let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
423    let mut info = SessionInfo {
424        id: meta.id,
425        title: meta.title,
426        name: meta.name,
427        model: meta.model,
428        created_at: meta.created_at,
429        updated_at: meta.updated_at,
430        session_cost: meta.session_cost,
431        message_count: meta.message_count,
432    };
433    // Journal freshness overlay (Task 35): when an opt-in journal exists,
434    // its bounded meta tail is newer than the (possibly lagging) snapshot
435    // header. Legacy sessions have no journal — zero extra I/O.
436    if let Some(tail) = crate::core::session_journal::journal_meta_tail(dir, &info.id) {
437        if tail.updated_at > info.updated_at {
438            info.updated_at = tail.updated_at;
439            info.session_cost = tail.session_cost;
440            if let Some(count) = tail.message_count {
441                info.message_count = count;
442            }
443        }
444    }
445    Some(info)
446}
447
448/// Read just the metadata header of a session file — everything BEFORE the
449/// (potentially multi-MB) `"api_messages"` array — and return it as a complete,
450/// parseable JSON object string. Reads in bounded chunks and STOPS as soon as
451/// the `api_messages` key is found, so it never reads or tokenizes the message
452/// history. Falls back to the whole file if the key isn't found (small/new
453/// sessions). This is what keeps `list_sessions()` O(#sessions) instead of
454/// O(total bytes on disk).
455fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
456    use std::io::Read;
457    const KEY: &[u8] = b"\"api_messages\"";
458    const MAX_HEADER: usize = 256 * 1024; // safety cap if the key is never found
459
460    // fix2: confined strict open — same root resolution as every other
461    // session read; a symlinked ancestor or artifact yields None, never
462    // foreign bytes.
463    let mut file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
464    let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
465    let mut chunk = [0u8; 16 * 1024];
466    let mut cut: Option<usize> = None;
467    while buf.len() <= MAX_HEADER {
468        let n = file.read(&mut chunk).ok()?;
469        if n == 0 {
470            break;
471        }
472        buf.extend_from_slice(&chunk[..n]);
473        if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
474            cut = Some(pos);
475            break;
476        }
477    }
478
479    let end = cut.unwrap_or(buf.len());
480    let trimmed = String::from_utf8_lossy(&buf[..end]);
481    let mut s = trimmed.trim_end().to_string();
482    if s.ends_with(',') {
483        s.pop();
484    }
485    if !s.ends_with('}') {
486        s.push('}');
487    }
488    Some(s)
489}
490
491fn sessions_dir() -> PathBuf {
492    crate::config::get_active_config_dir().join("sessions")
493}
494
495/// Remove a persisted session file (compaction-transition rollback) AND its
496/// journal, when one exists (Task 35) — a session and its journal live and
497/// die together. A missing file is not an error — rollback must be idempotent.
498pub fn delete_session_file(id: &str) -> std::io::Result<()> {
499    crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
500}
501
502/// Validate a session or chain name: [a-z0-9-]{1,40}.
503pub fn validate_name(name: &str) -> Result<(), String> {
504    if name.is_empty() {
505        return Err("name cannot be empty".into());
506    }
507    if name.len() > 40 {
508        return Err(format!("invalid name '{}': must be 40 chars or less", name));
509    }
510    if !name
511        .chars()
512        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
513    {
514        return Err(format!(
515            "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
516            name
517        ));
518    }
519    Ok(())
520}
521
522/// Find a session by its assigned name (not partial ID).
523/// Iterates session headers one file at a time and returns on first match,
524/// avoiding a full directory scan when the named session appears early.
525pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
526    let dir = sessions_dir();
527    for entry in crate::core::session_journal::session_dir_entries(&dir)? {
528        if !entry.name.ends_with(".json") {
529            continue;
530        }
531        if let Some(info) = parse_session_header(&dir, &entry.name) {
532            if info.name.as_deref() == Some(name) {
533                return Session::load(&info.id);
534            }
535        }
536    }
537    Err(std::io::Error::new(
538        std::io::ErrorKind::NotFound,
539        format!("no session named '{}'", name),
540    ))
541}
542
543/// Resolve a query string to a Session. Resolution order:
544/// 1. Chain name  2. Session name  3. Partial session ID
545pub fn resolve_session(query: &str) -> std::io::Result<Session> {
546    if let Ok(ptr) = crate::core::chain::load_chain(query) {
547        match Session::load(&ptr.head) {
548            Ok(s) => {
549                tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
550                return Ok(s);
551            }
552            Err(e) => {
553                return Err(std::io::Error::new(
554                    e.kind(),
555                    format!(
556                        "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
557                        query, ptr.head, e, query
558                    ),
559                ));
560            }
561        }
562    }
563    if let Ok(s) = find_session_by_name(query) {
564        tracing::info!("resolved '{}' via session name → {}", query, s.id);
565        return Ok(s);
566    }
567    find_session(query)
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use serde_json::json;
574
575    /// Minimal typed provenance for constructor tests.
576    fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
577        crate::compaction::CompactionRecord {
578            schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
579            source_session: parent.id.clone(),
580            source_message_count: parent.api_messages.len(),
581            source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
582            summary_provider: "anthropic".into(),
583            summary_model: "claude-sonnet-4-6".into(),
584            created_at: Utc::now(),
585            prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
586            included_classes: crate::compaction::ContentClass::ALL.to_vec(),
587            excluded_classes: Vec::new(),
588            redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
589            prior_system_prompt: parent.system_prompt.clone(),
590        }
591    }
592
593    #[test]
594    fn test_session_new() {
595        let session = Session::new("gpt-4", "brief", Some("test prompt"));
596
597        // Check model and thinking_level are set correctly
598        assert_eq!(session.model, "gpt-4");
599        assert_eq!(session.thinking_level, "brief");
600        assert_eq!(session.system_prompt, Some("test prompt".to_string()));
601
602        // Check ID is non-empty
603        assert!(!session.id.is_empty());
604
605        // Check title starts empty
606        assert_eq!(session.title, "");
607
608        // Check tokens are 0
609        assert_eq!(session.total_input_tokens, 0);
610        assert_eq!(session.total_output_tokens, 0);
611
612        // Check cost is 0
613        assert_eq!(session.session_cost, 0.0);
614
615        // Check api_messages is empty
616        assert!(session.api_messages.is_empty());
617
618        // Test without system prompt
619        let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
620        assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
621        assert_eq!(session_no_prompt.thinking_level, "normal");
622        assert_eq!(session_no_prompt.system_prompt, None);
623    }
624
625    #[test]
626    fn test_session_auto_title() {
627        let mut session = Session::new("gpt-4", "brief", None);
628
629        // Add a user message
630        session.api_messages.push(std::sync::Arc::new(json!({
631            "role": "user",
632            "content": "hello world"
633        })));
634
635        // Call auto_title
636        session.auto_title();
637
638        // Check title is set to message content
639        assert_eq!(session.title, "hello world");
640
641        // Test it doesn't overwrite existing title
642        session.title = "existing title".to_string();
643        session.auto_title();
644        assert_eq!(session.title, "existing title");
645
646        // Test with empty session (no messages)
647        let mut empty_session = Session::new("gpt-4", "brief", None);
648        empty_session.auto_title();
649        assert_eq!(empty_session.title, "");
650
651        // Test with non-user message
652        let mut session_no_user = Session::new("gpt-4", "brief", None);
653        session_no_user
654            .api_messages
655            .push(std::sync::Arc::new(json!({
656                "role": "assistant",
657                "content": "response"
658            })));
659        session_no_user.auto_title();
660        assert_eq!(session_no_user.title, "");
661
662        // Test with long content (should truncate to 80 chars)
663        let mut session_long = Session::new("gpt-4", "brief", None);
664        let long_content = "a".repeat(100);
665        session_long.api_messages.push(std::sync::Arc::new(json!({
666            "role": "user",
667            "content": long_content
668        })));
669        session_long.auto_title();
670        assert_eq!(session_long.title.len(), 80);
671        assert_eq!(session_long.title, "a".repeat(80));
672    }
673
674    #[test]
675    fn test_session_info() {
676        let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
677
678        // Add some messages to test message count
679        session.api_messages.push(std::sync::Arc::new(json!({
680            "role": "user",
681            "content": "test message"
682        })));
683        session.api_messages.push(std::sync::Arc::new(json!({
684            "role": "assistant",
685            "content": "test response"
686        })));
687
688        session.title = "Test Title".to_string();
689        session.session_cost = 0.05;
690
691        let info = session.info();
692
693        assert_eq!(info.id, session.id);
694        assert_eq!(info.title, "Test Title");
695        assert_eq!(info.model, "gpt-4");
696        assert_eq!(info.created_at, session.created_at);
697        assert_eq!(info.updated_at, session.updated_at);
698        assert_eq!(info.session_cost, 0.05);
699        assert_eq!(info.message_count, 2);
700    }
701
702    #[test]
703    fn test_session_info_struct() {
704        let now = Utc::now();
705
706        let session_info = SessionInfo {
707            id: "test-id".to_string(),
708            title: "Test Title".to_string(),
709            name: None,
710            model: "gpt-4".to_string(),
711            created_at: now,
712            updated_at: now,
713            session_cost: 1.23,
714            message_count: 5,
715        };
716
717        // Verify all fields are accessible
718        assert_eq!(session_info.id, "test-id");
719        assert_eq!(session_info.title, "Test Title");
720        assert_eq!(session_info.model, "gpt-4");
721        assert_eq!(session_info.created_at, now);
722        assert_eq!(session_info.updated_at, now);
723        assert_eq!(session_info.session_cost, 1.23);
724        assert_eq!(session_info.message_count, 5);
725    }
726
727    #[test]
728    fn test_session_serialization_round_trip() {
729        let mut session = Session::new(
730            "gpt-4-turbo",
731            "detailed",
732            Some("You are a helpful assistant"),
733        );
734        session.title = "Test Session".to_string();
735        session.api_messages.push(std::sync::Arc::new(
736            json!({"role": "user", "content": "test"}),
737        ));
738        session.total_input_tokens = 100;
739        session.total_output_tokens = 200;
740        session.session_cost = 0.15;
741
742        // Serialize to JSON string
743        let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
744
745        // Deserialize back from JSON string
746        let deserialized: Session =
747            serde_json::from_str(&json_str).expect("Failed to deserialize session");
748
749        // Verify all fields match
750        assert_eq!(deserialized.id, session.id);
751        assert_eq!(deserialized.title, session.title);
752        assert_eq!(deserialized.model, session.model);
753        assert_eq!(deserialized.thinking_level, session.thinking_level);
754        assert_eq!(deserialized.system_prompt, session.system_prompt);
755        assert_eq!(deserialized.created_at, session.created_at);
756        assert_eq!(deserialized.updated_at, session.updated_at);
757        assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
758        assert_eq!(
759            deserialized.total_output_tokens,
760            session.total_output_tokens
761        );
762        assert_eq!(deserialized.session_cost, session.session_cost);
763        assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
764        assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
765    }
766
767    #[test]
768    fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
769        let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
770        let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
771        let persisted: serde_json::Value =
772            serde_json::from_str(&encoded).expect("inspect persisted session");
773        assert_eq!(persisted["thinking_level"], "ultra");
774        let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
775        assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
776        assert_eq!(restored.thinking_level, "ultra");
777
778        let compacted =
779            Session::from_compaction_record(&restored, "summary", test_record(&restored));
780        assert_eq!(compacted.model, restored.model);
781        assert_eq!(compacted.thinking_level, "ultra");
782        assert_eq!(
783            compacted.parent_session.as_deref(),
784            Some(restored.id.as_str())
785        );
786    }
787
788    #[test]
789    fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
790        let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
791        let encoded = serde_json::to_string(&session).expect("serialize Max session");
792        let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
793        assert_eq!(restored.thinking_level, "max");
794        assert_ne!(restored.thinking_level, "ultra");
795        assert_ne!(restored.thinking_level, "xhigh");
796    }
797
798    #[test]
799    fn test_session_serialization_preserves_all_fields() {
800        let mut session = Session::new(
801            "claude-3-opus",
802            "comprehensive",
803            Some("Custom system prompt"),
804        );
805        session.title = "Complex Session".to_string();
806
807        // Add multiple messages
808        session.api_messages.push(std::sync::Arc::new(
809            json!({"role": "user", "content": "First message"}),
810        ));
811        session.api_messages.push(std::sync::Arc::new(
812            json!({"role": "assistant", "content": "First response"}),
813        ));
814        session.api_messages.push(std::sync::Arc::new(
815            json!({"role": "user", "content": "Second message"}),
816        ));
817
818        // Set token counts and cost
819        session.total_input_tokens = 1500;
820        session.total_output_tokens = 2500;
821        session.session_cost = 0.75;
822
823        // Serialize and deserialize
824        let json_str = serde_json::to_string(&session).unwrap();
825        let restored: Session = serde_json::from_str(&json_str).unwrap();
826
827        // Verify every field is preserved
828        assert_eq!(restored.id, session.id);
829        assert_eq!(restored.title, "Complex Session");
830        assert_eq!(restored.model, "claude-3-opus");
831        assert_eq!(restored.thinking_level, "comprehensive");
832        assert_eq!(
833            restored.system_prompt.as_ref().unwrap(),
834            "Custom system prompt"
835        );
836        assert_eq!(restored.created_at, session.created_at);
837        assert_eq!(restored.updated_at, session.updated_at);
838        assert_eq!(restored.total_input_tokens, 1500);
839        assert_eq!(restored.total_output_tokens, 2500);
840        assert_eq!(restored.session_cost, 0.75);
841        assert_eq!(restored.api_messages.len(), 3);
842        assert_eq!(restored.api_messages[0]["role"], "user");
843        assert_eq!(restored.api_messages[0]["content"], "First message");
844        assert_eq!(restored.api_messages[1]["role"], "assistant");
845        assert_eq!(restored.api_messages[2]["content"], "Second message");
846    }
847
848    #[test]
849    fn test_session_info_from_session_with_messages() {
850        let mut session = Session::new("gpt-3.5-turbo", "normal", None);
851
852        // Add exactly 3 messages
853        session.api_messages.push(std::sync::Arc::new(
854            json!({"role": "user", "content": "message 1"}),
855        ));
856        session.api_messages.push(std::sync::Arc::new(
857            json!({"role": "assistant", "content": "response 1"}),
858        ));
859        session.api_messages.push(std::sync::Arc::new(
860            json!({"role": "user", "content": "message 2"}),
861        ));
862
863        let info = session.info();
864
865        // Verify message count is exactly 3
866        assert_eq!(info.message_count, 3);
867        assert_eq!(info.id, session.id);
868        assert_eq!(info.model, "gpt-3.5-turbo");
869    }
870
871    #[test]
872    fn test_session_auto_title_truncation() {
873        let mut session = Session::new("gpt-4", "brief", None);
874
875        // Create a user message with exactly 200 characters
876        let long_content = "a".repeat(200);
877        session.api_messages.push(std::sync::Arc::new(json!({
878            "role": "user",
879            "content": long_content
880        })));
881
882        session.auto_title();
883
884        // Verify title is exactly 80 characters
885        assert_eq!(session.title.len(), 80);
886        assert_eq!(session.title, "a".repeat(80));
887    }
888
889    #[test]
890    fn test_session_auto_title_skips_non_user_messages() {
891        let mut session = Session::new("gpt-4", "brief", None);
892
893        // Push only an assistant message (no user messages)
894        session.api_messages.push(std::sync::Arc::new(json!({
895            "role": "assistant",
896            "content": "This should be ignored for auto title"
897        })));
898
899        session.auto_title();
900
901        // Verify title stays empty since there are no user messages
902        assert_eq!(session.title, "");
903
904        // Test with system message too
905        session.api_messages.push(std::sync::Arc::new(json!({
906            "role": "system",
907            "content": "System message should also be ignored"
908        })));
909
910        session.auto_title();
911        assert_eq!(session.title, "");
912    }
913
914    #[test]
915    fn test_session_new_generates_unique_ids() {
916        let session1 = Session::new("gpt-4", "brief", None);
917        let session2 = Session::new("gpt-4", "brief", None);
918
919        // Verify IDs are different
920        assert_ne!(session1.id, session2.id);
921        assert!(!session1.id.is_empty());
922        assert!(!session2.id.is_empty());
923    }
924
925    #[test]
926    fn test_session_new_timestamps() {
927        let before = Utc::now();
928        let session = Session::new("gpt-4", "brief", None);
929        let after = Utc::now();
930
931        // Verify created_at and updated_at are close to now (within 2 seconds)
932        let created_diff = (session.created_at - before).num_seconds().abs();
933        let updated_diff = (session.updated_at - before).num_seconds().abs();
934
935        assert!(
936            created_diff <= 2,
937            "created_at should be within 2 seconds of now"
938        );
939        assert!(
940            updated_diff <= 2,
941            "updated_at should be within 2 seconds of now"
942        );
943
944        // Verify both timestamps are the same for new sessions
945        assert_eq!(session.created_at, session.updated_at);
946
947        // Verify timestamps are not in the future
948        assert!(session.created_at <= after);
949        assert!(session.updated_at <= after);
950    }
951
952    #[test]
953    fn test_validate_name() {
954        assert!(validate_name("work").is_ok());
955        assert!(validate_name("my-project-2").is_ok());
956        assert!(validate_name("a").is_ok());
957        assert!(validate_name(&"a".repeat(40)).is_ok());
958
959        assert!(validate_name("").is_err());
960        assert!(validate_name(&"a".repeat(41)).is_err());
961        assert!(validate_name("UPPER").is_err());
962        assert!(validate_name("has space").is_err());
963        assert!(validate_name("under_score").is_err());
964        assert!(validate_name("dots.bad").is_err());
965
966        let err = validate_name("Bad").unwrap_err();
967        assert!(err.contains("Bad"));
968        assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
969    }
970
971    #[test]
972    fn test_clear_name() {
973        let mut s = Session::new("m", "brief", None);
974        s.name = Some("foo".into());
975        s.clear_name();
976        assert_eq!(s.name, None);
977    }
978
979    #[test]
980    fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
981        let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
982        let json = serde_json::to_string(&original).unwrap();
983        let restored: Session = serde_json::from_str(&json).unwrap();
984        assert_eq!(restored.thinking_level, "ultracode");
985        for other in ["ultra", "max", "xhigh"] {
986            assert_ne!(restored.thinking_level, other);
987        }
988        let compacted =
989            Session::from_compaction_record(&restored, "summary", test_record(&restored));
990        let resumed: Session =
991            serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
992        assert_eq!(resumed.thinking_level, "ultracode");
993        assert_eq!(
994            resumed.parent_session.as_deref(),
995            Some(original.id.as_str())
996        );
997    }
998
999    /// Private-mode tests (spec §5.4). Umask isolation: `#[serial(umask)]`
1000    /// serializes umask-mutating tests crate-wide; `UmaskGuard` restores the
1001    /// previous mask on drop (panic-safe). These exercise the blocking body
1002    /// of `Session::save` directly with a temp dir — no env mutation.
1003    #[cfg(unix)]
1004    mod private_modes {
1005        use super::*;
1006        use crate::core::private_fs::test_support::UmaskGuard;
1007        use serial_test::serial;
1008        use std::os::unix::fs::PermissionsExt;
1009
1010        fn mode_of(path: &std::path::Path) -> u32 {
1011            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1012        }
1013
1014        #[test]
1015        #[serial(umask)]
1016        fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
1017            let _umask = UmaskGuard::set(0);
1018            let tmp = tempfile::TempDir::new().unwrap();
1019            let dir = tmp.path().join("sessions");
1020            save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
1021            assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1022            assert_eq!(
1023                mode_of(&dir.join("sess-1.json")),
1024                0o600,
1025                "session file must be 0600"
1026            );
1027        }
1028
1029        #[test]
1030        fn save_refuses_symlink_target() {
1031            let tmp = tempfile::TempDir::new().unwrap();
1032            let dir = tmp.path().join("sessions");
1033            std::fs::create_dir_all(&dir).unwrap();
1034            let victim = tmp.path().join("victim.json");
1035            std::fs::write(&victim, "original").unwrap();
1036            std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
1037            let res = save_json_in_dir(&dir, "sess-1", b"{}");
1038            assert!(res.is_err(), "save onto a symlink must fail");
1039            assert_eq!(
1040                std::fs::read_to_string(&victim).unwrap(),
1041                "original",
1042                "no bytes may be written through the planted symlink"
1043            );
1044        }
1045    }
1046
1047    mod header_message_count {
1048        use super::*;
1049        use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
1050        use std::sync::Arc;
1051
1052        fn session_with(n: usize) -> Session {
1053            let mut s = Session::new("claude-sonnet-4-6", "medium", None);
1054            for i in 0..n {
1055                let role = if i % 2 == 0 { "user" } else { "assistant" };
1056                s.api_messages
1057                    .push(Arc::new(serde_json::json!({"role": role, "content": format!("m{i}")})));
1058            }
1059            s
1060        }
1061
1062        /// save → header reader round-trip: the count is persisted in the
1063        /// header (before `api_messages`), so the truncating reader sees it.
1064        #[test]
1065        fn json_save_persists_count_in_header() {
1066            let tmp = tempfile::TempDir::new().unwrap();
1067            let dir = tmp.path().join("sessions");
1068            let s = session_with(7);
1069            save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1070            let header = read_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1071            assert!(
1072                !header.contains("\"api_messages\""),
1073                "header must stop before the message array"
1074            );
1075            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1076            assert_eq!(info.message_count, 7);
1077        }
1078
1079        /// Journal mode: appended messages update the count via the meta tail.
1080        #[test]
1081        fn journal_append_updates_count() {
1082            let tmp = tempfile::TempDir::new().unwrap();
1083            let dir = tmp.path().join("sessions");
1084            let mut s = session_with(2);
1085            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1086            s.api_messages
1087                .push(Arc::new(serde_json::json!({"role": "user", "content": "more"})));
1088            s.updated_at = Utc::now();
1089            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1090            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1091            assert_eq!(info.message_count, 3);
1092        }
1093
1094        /// Legacy files without the field report 0, never fail to parse.
1095        #[test]
1096        fn legacy_header_defaults_to_zero() {
1097            let tmp = tempfile::TempDir::new().unwrap();
1098            let dir = tmp.path().join("sessions");
1099            let s = session_with(3);
1100            // Strip the field textually to keep declaration order (a
1101            // `to_value` round-trip would re-sort keys alphabetically).
1102            let json = serde_json::to_string(&s).unwrap();
1103            assert!(json.contains("\"message_count\":0,"));
1104            let legacy = json.replace("\"message_count\":0,", "");
1105            save_json_in_dir(&dir, &s.id, legacy.as_bytes()).unwrap();
1106            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1107            assert_eq!(info.message_count, 0);
1108        }
1109    }
1110}