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