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    /// Prompt-cache keep-warm spend (task #444), already INCLUDED in
21    /// `session_cost`; persisted so a resumed/reloaded session still labels
22    /// it. Absent (0) on sessions that never kept warm.
23    ///
24    /// SNAPSHOT-ONLY: never journaled. The v2 journal's `SessionMeta` is
25    /// `deny_unknown_fields`, so a new field there would make an older build
26    /// (0.10.1) treat every meta record as a torn tail and truncate the
27    /// session; the `.json` snapshot tolerates unknown fields. In journal
28    /// mode the label is therefore as of the last snapshot (slightly stale
29    /// until the next one); load clamps it to `session_cost`.
30    #[serde(default, skip_serializing_if = "is_zero_cost")]
31    pub keepwarm_cost: f64,
32    /// Keep-warm ping tokens, already INCLUDED in the token totals.
33    /// SNAPSHOT-ONLY, like `keepwarm_cost`; clamped on load to the totals
34    /// that survive a reload.
35    #[serde(default, skip_serializing_if = "KeepwarmTokens::is_zero")]
36    pub keepwarm_tokens: KeepwarmTokens,
37    /// Session prompt-cache read/write totals (the footer's `%↺` and
38    /// `/stats`). SNAPSHOT-ONLY, like `keepwarm_cost`: never journaled, so
39    /// in journal mode they are as of the last snapshot. Absent (0) on
40    /// sessions saved before they were persisted.
41    #[serde(default, skip_serializing_if = "is_zero_tokens")]
42    pub total_cache_read_tokens: u64,
43    #[serde(default, skip_serializing_if = "is_zero_tokens")]
44    pub total_cache_creation_tokens: u64,
45    /// Persisted listing hint: `api_messages.len()` at save time. Declared
46    /// BEFORE `api_messages` so it lands in the header that
47    /// `read_session_header` reads without touching the message array.
48    /// Not authoritative in memory — use `api_messages.len()`; refreshed by
49    /// `session_journal::save_session_in_dir`. Legacy files → 0.
50    #[serde(default)]
51    pub message_count: usize,
52    /// ID of the session this was compacted from (backward link).
53    /// Declared before `api_messages` so `read_session_header` sees it (F24).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub parent_session: Option<String>,
56    /// ID of the session created by compacting this one (forward link).
57    /// Declared before `api_messages` so `read_session_header` sees it (F24).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub compacted_into: Option<String>,
60    /// Session environment snapshot (names+values, secrets stripped).
61    /// Declared before `api_messages` so `read_session_header` sees it.
62    /// Belt-and-braces: values for secret keys are scrubbed on save even
63    /// if a future client forgets to strip.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub env: Option<Vec<(String, String)>>,
66    /// Names of env vars stripped as secrets (T5, names only, never values).
67    /// Declared before `api_messages` so `read_session_header` sees it.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub env_stripped: Vec<String>,
70    pub api_messages: Vec<SharedMessage>,
71    /// Legacy: the "ABORT CONTEXT" recap older Synaps saved for an
72    /// interrupted turn, to prepend to the next user message. Never written
73    /// now (an interrupted turn is real history plus a marker,
74    /// `agent_engine::engine::interrupt`) and never injected: a session that
75    /// still carries one is migrated when it is loaded (recap dropped,
76    /// marker appended).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub abort_context: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub prompt_provenance: Option<crate::prompt::PromptProvenance>,
81    /// Typed compaction summary provenance (spec §9.3). Present on sessions
82    /// produced by (or updated in place by) a compaction transition.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub compaction: Option<crate::compaction::CompactionRecord>,
85}
86
87/// Lightweight info for listing sessions without loading full message history
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct SessionInfo {
90    pub id: String,
91    pub title: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub name: Option<String>,
94    pub model: String,
95    pub created_at: DateTime<Utc>,
96    pub updated_at: DateTime<Utc>,
97    pub session_cost: f64,
98    pub message_count: usize,
99}
100
101/// Project canonical user content for display/title/compaction, never for inference.
102/// Only top-level text and attachment labels are visible; in particular, tool
103/// results are NOT user prompts and their nested content is never traversed.
104/// The original message (including attachment sources) is left untouched.
105pub fn user_content_for_display(content: &serde_json::Value) -> Option<String> {
106    if let Some(text) = content.as_str() {
107        return Some(text.to_string());
108    }
109    let parts: Vec<String> = content
110        .as_array()?
111        .iter()
112        .filter_map(|block| match block["type"].as_str() {
113            Some("text") => block["text"].as_str().map(str::to_string),
114            Some("image") => Some("[attached image]".to_string()),
115            Some("document") => {
116                // Canonical title is a filename, not document text. Accept the
117                // explicit filename spelling too, but never inspect source.
118                // Strip path prefixes and terminal/label control characters.
119                let name = block["filename"]
120                    .as_str()
121                    .or_else(|| block["title"].as_str())
122                    .unwrap_or("")
123                    .rsplit(['/', '\\'])
124                    .next()
125                    .unwrap_or("");
126                let name: String = name
127                    .chars()
128                    .filter(|c| {
129                        !c.is_control()
130                            && !matches!(c, '[' | ']' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
131                    })
132                    .take(120)
133                    .collect();
134                let name = name.trim();
135                Some(if name.is_empty() {
136                    "[attached document]".to_string()
137                } else {
138                    format!("[attached document: {name}]")
139                })
140            }
141            _ => None,
142        })
143        .filter(|part| !part.is_empty())
144        .collect();
145    (!parts.is_empty()).then(|| parts.join("\n"))
146}
147
148fn is_zero_cost(v: &f64) -> bool {
149    *v == 0.0
150}
151
152fn is_zero_tokens(v: &u64) -> bool {
153    *v == 0
154}
155
156/// Keep-warm token subtotal persisted in the snapshot (see
157/// [`Session::keepwarm_tokens`]).
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
159pub struct KeepwarmTokens {
160    #[serde(default)]
161    pub input: u64,
162    #[serde(default)]
163    pub output: u64,
164    #[serde(default)]
165    pub cache_read: u64,
166    #[serde(default)]
167    pub cache_creation: u64,
168}
169
170impl KeepwarmTokens {
171    pub fn is_zero(&self) -> bool {
172        *self == Self::default()
173    }
174}
175
176impl Session {
177    /// The keep-warm labels are a subset of the totals; a journal replay can
178    /// leave them (snapshot-only) ahead of totals that were reset since, so
179    /// clamp them back under. Never raises anything.
180    pub fn clamp_keepwarm_to_totals(&mut self) {
181        if !(self.keepwarm_cost.is_finite() && self.keepwarm_cost > 0.0) {
182            self.keepwarm_cost = 0.0;
183        }
184        self.keepwarm_cost = self.keepwarm_cost.min(self.session_cost.max(0.0));
185        self.keepwarm_tokens.input = self.keepwarm_tokens.input.min(self.total_input_tokens);
186        self.keepwarm_tokens.output = self.keepwarm_tokens.output.min(self.total_output_tokens);
187    }
188}
189
190impl Session {
191    pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
192        let now = Utc::now();
193        let id = format!(
194            "{}-{}",
195            now.format("%Y%m%d-%H%M%S"),
196            &uuid::Uuid::new_v4().to_string()[..4]
197        );
198        Session {
199            id,
200            title: String::new(),
201            name: None,
202            model: model.to_string(),
203            thinking_level: thinking_level.to_string(),
204            system_prompt: system_prompt.map(|s| s.to_string()),
205            created_at: now,
206            updated_at: now,
207            total_input_tokens: 0,
208            total_output_tokens: 0,
209            session_cost: 0.0,
210            keepwarm_cost: 0.0,
211            keepwarm_tokens: KeepwarmTokens::default(),
212            total_cache_read_tokens: 0,
213            total_cache_creation_tokens: 0,
214            message_count: 0,
215            api_messages: Vec::new(),
216            abort_context: None,
217            parent_session: None,
218            compacted_into: None,
219            env: None,
220            env_stripped: Vec::new(),
221            prompt_provenance: None,
222            compaction: None,
223        }
224    }
225
226    /// Create a successor session from a compaction transition (spec §9.3).
227    /// The summary enters context through the canonical sanitized rendering
228    /// ([`crate::compaction::compaction_context_messages`]); the parent's
229    /// system prompt stays TYPED metadata — the successor's `system_prompt`
230    /// field and the provenance record — never plain user text.
231    pub fn from_compaction_record(
232        parent: &Session,
233        summary_text: &str,
234        record: crate::compaction::CompactionRecord,
235    ) -> Self {
236        let now = Utc::now();
237        let id = format!(
238            "{}-{}",
239            now.format("%Y%m%d-%H%M%S"),
240            &uuid::Uuid::new_v4().to_string()[..4]
241        );
242        // Transfer session name from parent — the compacted session is the
243        // continuation, so the name should follow. Parent's name will be
244        // cleared when the caller saves it with compacted_into set.
245        let name = parent.name.clone();
246        Session {
247            id,
248            title: format!(
249                "↳ {}",
250                if parent.title.is_empty() {
251                    &parent.id
252                } else {
253                    &parent.title
254                }
255            ),
256            name,
257            model: parent.model.clone(),
258            thinking_level: parent.thinking_level.clone(),
259            system_prompt: parent.system_prompt.clone(),
260            created_at: now,
261            updated_at: now,
262            total_input_tokens: 0,
263            total_output_tokens: 0,
264            session_cost: 0.0,
265            keepwarm_cost: 0.0,
266            keepwarm_tokens: KeepwarmTokens::default(),
267            total_cache_read_tokens: 0,
268            total_cache_creation_tokens: 0,
269            message_count: 0,
270            api_messages: crate::compaction::compaction_context_messages(summary_text),
271            abort_context: None,
272            parent_session: Some(parent.id.clone()),
273            compacted_into: None,
274            env: parent.env.clone(),
275            env_stripped: parent.env_stripped.clone(),
276            prompt_provenance: None,
277            compaction: Some(record),
278        }
279    }
280
281    /// Set title from the first user message if not already set
282    pub fn auto_title(&mut self) {
283        if !self.title.is_empty() {
284            return;
285        }
286        for msg in &self.api_messages {
287            if msg["role"].as_str() == Some("user") {
288                if let Some(content) = user_content_for_display(&msg["content"]) {
289                    self.title = content.chars().take(80).collect();
290                    return;
291                }
292            }
293        }
294    }
295
296    /// Persist this session under the configured persistence mode
297    /// (`session_persistence` config key; default is the unchanged legacy
298    /// JSON path — see Task 35 / `crate::core::session_journal`).
299    pub async fn save(&self) -> std::io::Result<()> {
300        let dir = sessions_dir();
301        let mode = crate::config::load_config().session_persistence;
302        let session = self.clone(); // messages are Arc-shared — cheap clone
303        let guard = crate::core::session_save_order::acquire(&dir, &session.id).await;
304        tokio::task::spawn_blocking(move || {
305            let _guard = guard;
306            crate::core::session_journal::save_session_in_dir(&dir, &session, mode).map(|_| ())
307        })
308        .await
309        .map_err(std::io::Error::other)?
310    }
311
312    /// Publish a full context checkpoint and wait for durable persistence.
313    /// Captures the destination and configured mode BEFORE spawning blocking
314    /// work. Unlike `save`, journal mode always replaces the snapshot here.
315    ///
316    /// On success the snapshot file AND its containing directory have been
317    /// fsynced (also newly created directory ancestry). Snapshot publication
318    /// is the logical commit: errors from directory sync or journal cleanup
319    /// can occur AFTER it is visible, and do not imply rollback. Callers must
320    /// stop inference on ANY error and reload/retry rather than assume the old
321    /// head survived. Dropping this future does not cancel blocking I/O.
322    ///
323    /// Async saves to the same destination/session are ordered in-process,
324    /// including blocking writers whose awaiter was dropped. Callers still own
325    /// ordering of logical session mutations; this is not a multi-process
326    /// transaction. Durable saves fail closed on non-Unix platforms; fsync
327    /// guarantees still depend on the underlying filesystem/device.
328    pub async fn save_durable(&self) -> std::io::Result<()> {
329        let dir = sessions_dir();
330        let mode = crate::config::load_config().session_persistence;
331        self.save_durable_to(dir, mode).await
332    }
333
334    // Explicit inputs also let synthetic tempdir tests exercise the exact
335    // blocking dispatch without mutating process-wide config/environment.
336    async fn save_durable_to(
337        &self,
338        dir: PathBuf,
339        mode: crate::core::session_journal::SessionPersistence,
340    ) -> std::io::Result<()> {
341        let session = self.clone();
342        let guard = crate::core::session_save_order::acquire(&dir, &session.id).await;
343        tokio::task::spawn_blocking(move || {
344            let _guard = guard;
345            crate::core::session_journal::save_session_durable_in_dir(&dir, &session, mode)
346                .map(|_| ())
347        })
348        .await
349        .map_err(std::io::Error::other)?
350    }
351
352    /// Resolve an ambiguous head without racing an older detached writer.
353    /// Read and durable re-publication share the same save-order guard. A
354    /// successful read alone must not clear a failed directory-sync barrier.
355    pub async fn recover_durable(id: &str) -> std::io::Result<Self> {
356        let dir = sessions_dir();
357        let mode = crate::config::load_config().session_persistence;
358        let id = id.to_owned();
359        let guard = crate::core::session_save_order::acquire(&dir, &id).await;
360        tokio::task::spawn_blocking(move || {
361            let _guard = guard;
362            let session = Self::load_from_dir(&dir, &id)?;
363            crate::core::session_journal::save_session_durable_in_dir(&dir, &session, mode)?;
364            Ok(session)
365        })
366        .await
367        .map_err(std::io::Error::other)?
368    }
369
370    pub fn load(id: &str) -> std::io::Result<Self> {
371        Self::load_from_dir(&sessions_dir(), id)
372    }
373
374    /// Canonical backward-compatible host load for a caller-selected sessions
375    /// directory. Both legacy snapshots and journal-backed sessions resolve
376    /// through the same journal overlay implementation.
377    pub fn load_from_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Self> {
378        crate::core::session_journal::load_session_in_dir(dir, id)
379    }
380
381    pub fn info(&self) -> SessionInfo {
382        SessionInfo {
383            id: self.id.clone(),
384            title: self.title.clone(),
385            name: self.name.clone(),
386            model: self.model.clone(),
387            created_at: self.created_at,
388            updated_at: self.updated_at,
389            session_cost: self.session_cost,
390            message_count: self.api_messages.len(),
391        }
392    }
393
394    /// Assign a name to this session. Validates name, enforces uniqueness
395    /// across sessions, and rejects collisions with existing chain names.
396    /// Idempotent: re-applying the current name is a no-op.
397    pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
398        self.check_name(name).map_err(|e| match e {
399            NameError::Io(e) => e,
400            other => std::io::Error::other(other.to_string()),
401        })?;
402        self.name = Some(name.to_string());
403        Ok(())
404    }
405
406    /// [`Self::set_name`]'s checks without applying the name, as a typed
407    /// error (so a caller can tell "invalid" from "taken").
408    pub fn check_name(&self, name: &str) -> Result<(), NameError> {
409        validate_name(name).map_err(NameError::Invalid)?;
410        if self.name.as_deref() == Some(name) {
411            return Ok(());
412        }
413        for s in &list_sessions().map_err(NameError::Io)? {
414            if s.name.as_deref() == Some(name) && s.id != self.id {
415                return Err(NameError::Taken {
416                    name: name.to_string(),
417                    by: s.id.clone(),
418                });
419            }
420        }
421        if crate::core::chain::load_chain(name).is_ok() {
422            return Err(NameError::ChainConflict {
423                name: name.to_string(),
424            });
425        }
426        Ok(())
427    }
428
429    pub fn clear_name(&mut self) {
430        self.name = None;
431    }
432}
433
434/// Blocking body of [`Session::save`]: create the sessions dir (0700), then
435/// write `<id>.json` atomically via `private_fs` (temp file created with mode
436/// 0600 — never create-then-chmod — then renamed; symlink targets refused).
437#[cfg(test)]
438pub(crate) fn save_json_in_dir(
439    dir: &std::path::Path,
440    id: &str,
441    json: &[u8],
442) -> std::io::Result<()> {
443    // fix2: the SAME strict no-symlink root resolution and handle-relative
444    // atomic write as every journal operation (see `session_journal`).
445    crate::core::session_journal::write_json_snapshot(dir, id, json)
446}
447
448/// Find a session by full or partial ID match
449pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
450    let dir = sessions_dir();
451    if !dir.exists() {
452        return Err(std::io::Error::new(
453            std::io::ErrorKind::NotFound,
454            "no sessions directory",
455        ));
456    }
457
458    // fix2: strict handle-relative enumeration for both match phases.
459    let entries = crate::core::session_journal::session_dir_entries(&dir)?;
460
461    // Try exact match first
462    if entries
463        .iter()
464        .any(|e| e.name == format!("{partial_id}.json"))
465    {
466        return Session::load(partial_id);
467    }
468
469    // Partial match — find all that contain the partial ID
470    let mut matches: Vec<String> = Vec::new();
471    for entry in &entries {
472        if entry.name.ends_with(".json") {
473            let id = entry.name.trim_end_matches(".json");
474            if id.contains(partial_id) {
475                matches.push(id.to_string());
476            }
477        }
478    }
479
480    match matches.len() {
481        0 => Err(std::io::Error::new(
482            std::io::ErrorKind::NotFound,
483            format!("no session matching '{}'", partial_id),
484        )),
485        1 => Session::load(&matches[0]),
486        _ => Err(std::io::Error::other(format!(
487            "ambiguous: {} sessions match '{}'",
488            matches.len(),
489            partial_id
490        ))),
491    }
492}
493
494/// Load the most recently updated session
495pub fn latest_session() -> std::io::Result<Session> {
496    // Find the most-recently-modified session file by FILE mtime — without
497    // reading or JSON-parsing any of them. The previous impl called
498    // list_sessions(), which read + serde-tokenized EVERY session file to sort
499    // by the in-file `updated_at`; with hundreds of multi-MB sessions (221 files
500    // / 76MB here) that made `--continue` boot take ~11s. mtime is a free, exact
501    // proxy for "the session I was last in", and we then load ONLY that one.
502    let dir = sessions_dir();
503    if !dir.exists() {
504        return Err(std::io::Error::new(
505            std::io::ErrorKind::NotFound,
506            "no sessions found",
507        ));
508    }
509    // fix2: strict handle-relative enumeration — a symlinked ancestor
510    // refuses instead of being followed.
511    let entries = crate::core::session_journal::session_dir_entries(&dir)?;
512    let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
513    let mut newest: Option<(std::time::SystemTime, String)> = None;
514    for entry in &entries {
515        // A journal append IS a save of its session (Task 35): attribute the
516        // journal's mtime to the sibling snapshot so "latest" stays exact
517        // when snapshots lag behind appends.
518        let id = if let Some(id) = entry.name.strip_suffix(".json") {
519            id
520        } else if let Some(id) = entry.name.strip_suffix(".journal") {
521            if !names.contains(format!("{id}.json").as_str()) {
522                continue; // orphan journal — not loadable
523            }
524            id
525        } else {
526            continue;
527        };
528        if let Some(mtime) = entry.mtime {
529            if newest.as_ref().is_none_or(|(t, _)| mtime > *t) {
530                newest = Some((mtime, id.to_string()));
531            }
532        }
533    }
534    let id = newest
535        .map(|(_, id)| id)
536        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
537    Session::load(&id)
538}
539
540/// List all sessions, sorted by most recently updated.
541///
542/// Reads only the metadata HEADER of each session file (see
543/// [`read_session_header`]) — never the message history. Used by the session
544/// resolvers (by name/id). For the capped `/sessions` display prefer
545/// [`list_recent_sessions`], which reads only the N most-recent headers.
546pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
547    let dir = sessions_dir();
548    if !dir.exists() {
549        return Ok(Vec::new());
550    }
551    let mut sessions: Vec<SessionInfo> = Vec::new();
552    for entry in crate::core::session_journal::session_dir_entries(&dir)? {
553        if entry.name.ends_with(".json") {
554            if let Some(info) = parse_session_header(&dir, &entry.name) {
555                sessions.push(info);
556            }
557        }
558    }
559    sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
560    Ok(sessions)
561}
562
563/// The `limit` most-recently-modified sessions (by file mtime), most-recent
564/// first. Sorts the directory entries by mtime WITHOUT parsing, then reads only
565/// the top `limit` headers — so `/sessions` is O(limit) reads instead of
566/// O(#sessions). mtime is an exact proxy for `updated_at` (the file is rewritten
567/// on every save).
568pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
569    let dir = sessions_dir();
570    if !dir.exists() {
571        return Ok(Vec::new());
572    }
573    // fix2: strict handle-relative enumeration.
574    let entries = crate::core::session_journal::session_dir_entries(&dir)?;
575    let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
576    let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
577        std::collections::HashMap::new();
578    for entry in &entries {
579        // Journal mtimes attribute to their session snapshot (Task 35).
580        let id = if let Some(id) = entry.name.strip_suffix(".json") {
581            id
582        } else if let Some(id) = entry.name.strip_suffix(".journal") {
583            if !names.contains(format!("{id}.json").as_str()) {
584                continue;
585            }
586            id
587        } else {
588            continue;
589        };
590        if let Some(mtime) = entry.mtime {
591            let slot = by_snapshot
592                .entry(id.to_string())
593                .or_insert(std::time::SystemTime::UNIX_EPOCH);
594            if mtime > *slot {
595                *slot = mtime;
596            }
597        }
598    }
599    let mut files: Vec<(std::time::SystemTime, String)> =
600        by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
601    files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
602    files.truncate(limit);
603    let mut sessions: Vec<SessionInfo> = Vec::new();
604    for (_, id) in files {
605        if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
606            sessions.push(info);
607        }
608    }
609    Ok(sessions)
610}
611
612/// Read + parse just the metadata header of one session file into a
613/// [`SessionInfo`] (no message history). `message_count` comes from the
614/// persisted header field (written at save time); legacy files without it
615/// report 0.
616fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
617    #[derive(Deserialize)]
618    struct SessionMetadata {
619        #[serde(default)]
620        _journal_generation: Option<String>,
621        id: String,
622        #[serde(default)]
623        title: String,
624        #[serde(default)]
625        name: Option<String>,
626        model: String,
627        created_at: DateTime<Utc>,
628        updated_at: DateTime<Utc>,
629        #[serde(default)]
630        session_cost: f64,
631        #[serde(default)]
632        message_count: usize,
633    }
634    let header = read_session_header(dir, file_name)?;
635    let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
636    let mut info = SessionInfo {
637        id: meta.id,
638        title: meta.title,
639        name: meta.name,
640        model: meta.model,
641        created_at: meta.created_at,
642        updated_at: meta.updated_at,
643        session_cost: meta.session_cost,
644        message_count: meta.message_count,
645    };
646    // Journal freshness overlay (Task 35): when an opt-in journal exists,
647    // its bounded meta tail is newer than the (possibly lagging) snapshot
648    // header. Legacy sessions have no journal — zero extra I/O.
649    if let Some(tail) = crate::core::session_journal::journal_meta_tail_for_generation(
650        dir,
651        &info.id,
652        meta._journal_generation.as_deref(),
653    ) {
654        if tail.updated_at > info.updated_at {
655            info.updated_at = tail.updated_at;
656            info.session_cost = tail.session_cost;
657            if let Some(count) = tail.message_count {
658                info.message_count = count;
659            }
660        }
661    }
662    Some(info)
663}
664
665/// Read just the metadata header of a session file — everything BEFORE the
666/// (potentially multi-MB) `"api_messages"` array — and return it as a complete,
667/// parseable JSON object string. Reads in bounded chunks and STOPS as soon as
668/// the `api_messages` key is found, so it never reads or tokenizes the message
669/// history. Falls back to the whole file if the key isn't found (small/new
670/// sessions). This is what keeps `list_sessions()` O(#sessions) instead of
671/// O(total bytes on disk).
672fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
673    let file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
674    read_session_header_from_file(file)
675}
676
677/// Shared with generation checks; callers provide an already-confined handle.
678pub(crate) fn read_session_header_from_file(mut file: std::fs::File) -> Option<String> {
679    use std::io::Read;
680    const KEY: &[u8] = b"\"api_messages\"";
681    const MAX_HEADER: usize = 256 * 1024; // safety cap if the key is never found
682
683    // fix2: confined strict open — same root resolution as every other
684    // session read; a symlinked ancestor or artifact yields None, never
685    // foreign bytes.
686    let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
687    let mut chunk = [0u8; 16 * 1024];
688    let mut cut: Option<usize> = None;
689    while buf.len() <= MAX_HEADER {
690        let n = file.read(&mut chunk).ok()?;
691        if n == 0 {
692            break;
693        }
694        buf.extend_from_slice(&chunk[..n]);
695        if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
696            cut = Some(pos);
697            break;
698        }
699    }
700
701    let end = cut.unwrap_or(buf.len());
702    let trimmed = String::from_utf8_lossy(&buf[..end]);
703    let mut s = trimmed.trim_end().to_string();
704    if s.ends_with(',') {
705        s.pop();
706    }
707    if !s.ends_with('}') {
708        s.push('}');
709    }
710    Some(s)
711}
712
713fn sessions_dir() -> PathBuf {
714    crate::config::get_active_config_dir().join("sessions")
715}
716
717/// Remove a persisted session file (compaction-transition rollback) AND its
718/// journal, when one exists (Task 35) — a session and its journal live and
719/// die together. A missing file is not an error — rollback must be idempotent.
720pub fn delete_session_file(id: &str) -> std::io::Result<()> {
721    crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
722}
723
724/// Why a session name was not accepted ([`Session::check_name`]). The
725/// `Display` text is what `/saveas` has always printed.
726#[derive(Debug)]
727pub enum NameError {
728    /// Not `[a-z0-9-]{1,40}` (the [`validate_name`] message).
729    Invalid(String),
730    /// Another saved session already has this name.
731    Taken { name: String, by: String },
732    /// A chain already has this name.
733    ChainConflict { name: String },
734    /// The sessions directory could not be read.
735    Io(std::io::Error),
736}
737
738impl NameError {
739    /// Stable machine code: `invalid` | `taken` | `chain_conflict` | `io`.
740    pub fn code(&self) -> &'static str {
741        match self {
742            Self::Invalid(_) => "invalid",
743            Self::Taken { .. } => "taken",
744            Self::ChainConflict { .. } => "chain_conflict",
745            Self::Io(_) => "io",
746        }
747    }
748}
749
750impl std::fmt::Display for NameError {
751    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
752        match self {
753            Self::Invalid(m) => f.write_str(m),
754            Self::Taken { name, by } => write!(f, "name '{name}' already used by session {by}"),
755            Self::ChainConflict { name } => {
756                write!(f, "name '{name}' conflicts with an existing chain name")
757            }
758            Self::Io(e) => write!(f, "cannot read the sessions directory: {e}"),
759        }
760    }
761}
762
763impl std::error::Error for NameError {}
764
765/// Validate a session or chain name: [a-z0-9-]{1,40}.
766pub fn validate_name(name: &str) -> Result<(), String> {
767    if name.is_empty() {
768        return Err("name cannot be empty".into());
769    }
770    if name.len() > 40 {
771        return Err(format!("invalid name '{}': must be 40 chars or less", name));
772    }
773    if !name
774        .chars()
775        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
776    {
777        return Err(format!(
778            "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
779            name
780        ));
781    }
782    Ok(())
783}
784
785/// Find a session by its assigned name (not partial ID).
786/// Iterates session headers one file at a time and returns on first match,
787/// avoiding a full directory scan when the named session appears early.
788pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
789    let dir = sessions_dir();
790    for entry in crate::core::session_journal::session_dir_entries(&dir)? {
791        if !entry.name.ends_with(".json") {
792            continue;
793        }
794        if let Some(info) = parse_session_header(&dir, &entry.name) {
795            if info.name.as_deref() == Some(name) {
796                return Session::load(&info.id);
797            }
798        }
799    }
800    Err(std::io::Error::new(
801        std::io::ErrorKind::NotFound,
802        format!("no session named '{}'", name),
803    ))
804}
805
806/// Result of resolving a session that may have been compacted into a successor.
807#[derive(Debug)]
808pub struct ResolvedSession {
809    pub session: Session,
810    /// If the originally matched session had `compacted_into` set, this
811    /// contains a human-readable notice like
812    /// `"<old> was compacted into <new> — continuing there"`.
813    pub compaction_notice: Option<String>,
814}
815
816/// Maximum number of `compacted_into` hops before we bail (cycle guard).
817const MAX_COMPACTION_HOPS: usize = 32;
818
819/// Follow the `compacted_into` chain from `session` to its final successor.
820/// Returns the final session and a notice if any hops were taken.
821/// Errors on cycles, missing successors, or exceeding the hop limit.
822pub fn follow_compaction_chain(session: Session) -> std::io::Result<ResolvedSession> {
823    if session.compacted_into.is_none() {
824        return Ok(ResolvedSession {
825            session,
826            compaction_notice: None,
827        });
828    }
829
830    let original_id = session.id.clone();
831    let mut current = session;
832    let mut visited = std::collections::HashSet::new();
833    visited.insert(current.id.clone());
834
835    while let Some(ref successor_id) = current.compacted_into {
836        let succ_id = successor_id.clone();
837        if !visited.insert(succ_id.clone()) {
838            return Err(std::io::Error::new(
839                std::io::ErrorKind::InvalidData,
840                format!(
841                    "compaction cycle detected: session {} loops back to {}",
842                    current.id, succ_id
843                ),
844            ));
845        }
846        if visited.len() > MAX_COMPACTION_HOPS {
847            return Err(std::io::Error::new(
848                std::io::ErrorKind::InvalidData,
849                format!(
850                    "compaction chain too long (>{} hops) starting from {}",
851                    MAX_COMPACTION_HOPS, original_id
852                ),
853            ));
854        }
855        current = Session::load(&succ_id).map_err(|e| {
856            std::io::Error::new(
857                e.kind(),
858                format!(
859                    "session {} was compacted into {}, but the successor failed to load: {}",
860                    current.id, succ_id, e
861                ),
862            )
863        })?;
864    }
865
866    let notice = format!(
867        "{} was compacted into {} \u{2014} continuing there",
868        original_id, current.id
869    );
870    tracing::info!("{}", notice);
871    Ok(ResolvedSession {
872        session: current,
873        compaction_notice: Some(notice),
874    })
875}
876
877/// Read only the `compacted_into` field from a session's header on disk,
878/// without loading the full message history.
879pub fn read_compacted_into(id: &str) -> std::io::Result<Option<String>> {
880    read_compacted_into_in_dir(&sessions_dir(), id)
881}
882
883/// Like [`read_compacted_into`] but for a caller-selected sessions directory.
884pub fn read_compacted_into_in_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Option<String>> {
885    let file_name = format!("{}.json", id);
886    let header = read_session_header(dir, &file_name).ok_or_else(|| {
887        std::io::Error::new(
888            std::io::ErrorKind::NotFound,
889            format!("no session file for '{}'", id),
890        )
891    })?;
892    #[derive(serde::Deserialize)]
893    struct Hdr {
894        #[serde(default)]
895        compacted_into: Option<String>,
896    }
897    let hdr: Hdr = serde_json::from_str(&header).map_err(|e| {
898        std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
899    })?;
900    Ok(hdr.compacted_into)
901}
902
903/// Resolve a query string to a Session. Resolution order:
904/// 1. Chain name  2. Session name  3. Partial session ID
905pub fn resolve_session(query: &str) -> std::io::Result<Session> {
906    if let Ok(ptr) = crate::core::chain::load_chain(query) {
907        match Session::load(&ptr.head) {
908            Ok(s) => {
909                tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
910                return Ok(s);
911            }
912            Err(e) => {
913                return Err(std::io::Error::new(
914                    e.kind(),
915                    format!(
916                        "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
917                        query, ptr.head, e, query
918                    ),
919                ));
920            }
921        }
922    }
923    if let Ok(s) = find_session_by_name(query) {
924        tracing::info!("resolved '{}' via session name → {}", query, s.id);
925        return Ok(s);
926    }
927    find_session(query)
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use serde_json::json;
934
935    /// Minimal typed provenance for constructor tests.
936    fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
937        crate::compaction::CompactionRecord {
938            schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
939            source_session: parent.id.clone(),
940            source_message_count: parent.api_messages.len(),
941            source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
942            summary_provider: "anthropic".into(),
943            summary_model: "claude-sonnet-4-6".into(),
944            created_at: Utc::now(),
945            prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
946            included_classes: crate::compaction::ContentClass::ALL.to_vec(),
947            excluded_classes: Vec::new(),
948            redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
949            prior_system_prompt: parent.system_prompt.clone(),
950        }
951    }
952
953    #[cfg(unix)]
954    #[tokio::test]
955    async fn durable_dispatch_checkpoints_json_and_journal_without_global_config() {
956        use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
957        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
958            let tmp = tempfile::TempDir::new().unwrap();
959            let dir = tmp
960                .path()
961                .canonicalize()
962                .unwrap()
963                .join("profile/nested/sessions");
964            let mut session = Session::new("m", "medium", None);
965            session.api_messages.push(std::sync::Arc::new(
966                json!({"role":"user","content":"checkpoint"}),
967            ));
968            session.save_durable_to(dir.clone(), mode).await.unwrap();
969            let loaded = Session::load_from_dir(&dir, &session.id).unwrap();
970            assert_eq!(loaded.api_messages, session.api_messages);
971            // Force a later checkpoint after a journal-only append; both
972            // durable modes must contain the whole candidate in the snapshot.
973            session.api_messages.push(std::sync::Arc::new(
974                json!({"role":"assistant","content":"old tail"}),
975            ));
976            save_session_in_dir(&dir, &session, SessionPersistence::Journal).unwrap();
977            session.api_messages.truncate(1);
978            session.save_durable_to(dir.clone(), mode).await.unwrap();
979            assert_eq!(
980                Session::load_from_dir(&dir, &session.id)
981                    .unwrap()
982                    .api_messages,
983                session.api_messages
984            );
985            let snapshot: Session = serde_json::from_slice(
986                &std::fs::read(dir.join(format!("{}.json", session.id))).unwrap(),
987            )
988            .unwrap();
989            assert_eq!(snapshot.api_messages, session.api_messages);
990            assert_eq!(
991                dir.join(format!("{}.journal", session.id)).exists(),
992                mode == SessionPersistence::Journal
993            );
994        }
995    }
996
997    #[cfg(unix)]
998    #[tokio::test]
999    async fn durable_dispatch_returns_io_errors_and_refuses_symlinks() {
1000        use crate::core::session_journal::SessionPersistence;
1001        let tmp = tempfile::TempDir::new().unwrap();
1002        let root = tmp.path().canonicalize().unwrap();
1003        let victim = root.join("victim");
1004        std::fs::write(&victim, b"unchanged").unwrap();
1005        let dir = root.join("sessions");
1006        std::fs::create_dir(&dir).unwrap();
1007        let session = Session::new("m", "medium", None);
1008        std::os::unix::fs::symlink(&victim, dir.join(format!("{}.json", session.id))).unwrap();
1009        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1010            assert!(session.save_durable_to(dir.clone(), mode).await.is_err());
1011        }
1012        assert_eq!(std::fs::read(&victim).unwrap(), b"unchanged");
1013    }
1014
1015    #[cfg(unix)]
1016    #[tokio::test]
1017    async fn durable_dispatch_reports_post_commit_cleanup_error() {
1018        use crate::core::session_journal::SessionPersistence;
1019        let tmp = tempfile::TempDir::new().unwrap();
1020        let dir = tmp.path().canonicalize().unwrap();
1021        let mut session = Session::new("m", "medium", None);
1022        session
1023            .save_durable_to(dir.clone(), SessionPersistence::Journal)
1024            .await
1025            .unwrap();
1026        session.title = "published candidate".into();
1027        std::fs::create_dir(dir.join(format!("{}.journal.tmp", session.id))).unwrap();
1028        assert!(session
1029            .save_durable_to(dir.clone(), SessionPersistence::Journal)
1030            .await
1031            .is_err());
1032        // The receipt must be an error, even though rename already committed.
1033        assert_eq!(
1034            Session::load_from_dir(&dir, &session.id).unwrap().title,
1035            session.title
1036        );
1037    }
1038
1039    #[test]
1040    fn test_session_new() {
1041        let session = Session::new("gpt-4", "brief", Some("test prompt"));
1042
1043        // Check model and thinking_level are set correctly
1044        assert_eq!(session.model, "gpt-4");
1045        assert_eq!(session.thinking_level, "brief");
1046        assert_eq!(session.system_prompt, Some("test prompt".to_string()));
1047
1048        // Check ID is non-empty
1049        assert!(!session.id.is_empty());
1050
1051        // Check title starts empty
1052        assert_eq!(session.title, "");
1053
1054        // Check tokens are 0
1055        assert_eq!(session.total_input_tokens, 0);
1056        assert_eq!(session.total_output_tokens, 0);
1057
1058        // Check cost is 0
1059        assert_eq!(session.session_cost, 0.0);
1060
1061        // Check api_messages is empty
1062        assert!(session.api_messages.is_empty());
1063
1064        // Test without system prompt
1065        let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
1066        assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
1067        assert_eq!(session_no_prompt.thinking_level, "normal");
1068        assert_eq!(session_no_prompt.system_prompt, None);
1069    }
1070
1071    #[test]
1072    fn test_session_auto_title() {
1073        let mut session = Session::new("gpt-4", "brief", None);
1074
1075        // Add a user message
1076        session.api_messages.push(std::sync::Arc::new(json!({
1077            "role": "user",
1078            "content": "hello world"
1079        })));
1080
1081        // Call auto_title
1082        session.auto_title();
1083
1084        // Check title is set to message content
1085        assert_eq!(session.title, "hello world");
1086
1087        // Test it doesn't overwrite existing title
1088        session.title = "existing title".to_string();
1089        session.auto_title();
1090        assert_eq!(session.title, "existing title");
1091
1092        // Test with empty session (no messages)
1093        let mut empty_session = Session::new("gpt-4", "brief", None);
1094        empty_session.auto_title();
1095        assert_eq!(empty_session.title, "");
1096
1097        // Test with non-user message
1098        let mut session_no_user = Session::new("gpt-4", "brief", None);
1099        session_no_user
1100            .api_messages
1101            .push(std::sync::Arc::new(json!({
1102                "role": "assistant",
1103                "content": "response"
1104            })));
1105        session_no_user.auto_title();
1106        assert_eq!(session_no_user.title, "");
1107
1108        // Test with long content (should truncate to 80 chars)
1109        let mut session_long = Session::new("gpt-4", "brief", None);
1110        let long_content = "a".repeat(100);
1111        session_long.api_messages.push(std::sync::Arc::new(json!({
1112            "role": "user",
1113            "content": long_content
1114        })));
1115        session_long.auto_title();
1116        assert_eq!(session_long.title.len(), 80);
1117        assert_eq!(session_long.title, "a".repeat(80));
1118    }
1119
1120    #[test]
1121    fn attachment_projection_and_auto_title_never_read_sources_or_tool_results() {
1122        let content = json!([
1123            {"type": "text", "text": "Review"},
1124            {"type": "image", "source": {"type": "base64", "data": "IMAGE_SENTINEL"}},
1125            {"type": "document", "title": "a.txt",
1126             "source": {"type": "text", "data": "DOCUMENT_TEXT_SENTINEL"}},
1127            {"type": "document", "title": "b.pdf",
1128             "source": {"type": "base64", "data": "PDF_SENTINEL"}},
1129            {"type": "text", "text": "Then compare"},
1130            {"type": "tool_result", "tool_use_id": "t1", "content": [
1131                {"type": "text", "text": "TOOL_TEXT_SENTINEL"},
1132                {"type": "document", "title": "TOOL_TITLE_SENTINEL",
1133                 "source": {"type": "text", "data": "NESTED_DOCUMENT_SENTINEL"}}
1134            ]}
1135        ]);
1136        let original = content.clone();
1137        let projected = user_content_for_display(&content).unwrap();
1138        assert_eq!(projected, "Review\n[attached image]\n[attached document: a.txt]\n[attached document: b.pdf]\nThen compare");
1139        assert!(!projected.contains("SENTINEL"));
1140        assert_eq!(
1141            content, original,
1142            "projection must not mutate canonical data"
1143        );
1144
1145        let tool_only = json!([content[5].clone()]);
1146        assert!(user_content_for_display(&tool_only).is_none());
1147        let mut session = Session::new("m", "low", None);
1148        session.api_messages.push(std::sync::Arc::new(json!({
1149            "role": "user", "content": tool_only
1150        })));
1151        session.api_messages.push(std::sync::Arc::new(json!({
1152            "role": "user", "content": content
1153        })));
1154        session.auto_title();
1155        assert_eq!(
1156            session.title,
1157            projected.chars().take(80).collect::<String>()
1158        );
1159        assert!(!session.title.contains("SENTINEL"));
1160    }
1161
1162    #[test]
1163    fn attachment_only_titles_and_safe_filename_labels() {
1164        let content = json!([
1165            {"type": "document", "title": "/private/path/[report]\u{1b}\n.txt",
1166             "source": {"type": "text", "data": "DO_NOT_DISPLAY"}},
1167            {"type": "document", "filename": "C:\\private\\note.txt"},
1168            {"type": "document", "source": {"data": "NO_FILENAME"}}
1169        ]);
1170        assert_eq!(
1171            user_content_for_display(&content).unwrap(),
1172            "[attached document: report.txt]\n[attached document: note.txt]\n[attached document]"
1173        );
1174        for (block, title) in [
1175            (
1176                json!({"type": "image", "source": {"data": "IMAGE_SENTINEL"}}),
1177                "[attached image]",
1178            ),
1179            (
1180                json!({"type": "document", "title": "notes.txt",
1181                    "source": {"type": "text", "data": "TEXT_SENTINEL"}}),
1182                "[attached document: notes.txt]",
1183            ),
1184        ] {
1185            let mut session = Session::new("m", "low", None);
1186            session.api_messages.push(std::sync::Arc::new(json!({
1187                "role": "user", "content": [block]
1188            })));
1189            session.auto_title();
1190            assert_eq!(session.title, title);
1191        }
1192        assert!(user_content_for_display(&json!([])).is_none());
1193        assert!(
1194            user_content_for_display(&json!([{"type": "unknown", "text": "hidden"}])).is_none()
1195        );
1196        assert_eq!(
1197            user_content_for_display(&json!("plain text")).as_deref(),
1198            Some("plain text")
1199        );
1200    }
1201
1202    #[test]
1203    fn test_session_info() {
1204        let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
1205
1206        // Add some messages to test message count
1207        session.api_messages.push(std::sync::Arc::new(json!({
1208            "role": "user",
1209            "content": "test message"
1210        })));
1211        session.api_messages.push(std::sync::Arc::new(json!({
1212            "role": "assistant",
1213            "content": "test response"
1214        })));
1215
1216        session.title = "Test Title".to_string();
1217        session.session_cost = 0.05;
1218
1219        let info = session.info();
1220
1221        assert_eq!(info.id, session.id);
1222        assert_eq!(info.title, "Test Title");
1223        assert_eq!(info.model, "gpt-4");
1224        assert_eq!(info.created_at, session.created_at);
1225        assert_eq!(info.updated_at, session.updated_at);
1226        assert_eq!(info.session_cost, 0.05);
1227        assert_eq!(info.message_count, 2);
1228    }
1229
1230    #[test]
1231    fn test_session_info_struct() {
1232        let now = Utc::now();
1233
1234        let session_info = SessionInfo {
1235            id: "test-id".to_string(),
1236            title: "Test Title".to_string(),
1237            name: None,
1238            model: "gpt-4".to_string(),
1239            created_at: now,
1240            updated_at: now,
1241            session_cost: 1.23,
1242            message_count: 5,
1243        };
1244
1245        // Verify all fields are accessible
1246        assert_eq!(session_info.id, "test-id");
1247        assert_eq!(session_info.title, "Test Title");
1248        assert_eq!(session_info.model, "gpt-4");
1249        assert_eq!(session_info.created_at, now);
1250        assert_eq!(session_info.updated_at, now);
1251        assert_eq!(session_info.session_cost, 1.23);
1252        assert_eq!(session_info.message_count, 5);
1253    }
1254
1255    #[test]
1256    fn test_session_serialization_round_trip() {
1257        let mut session = Session::new(
1258            "gpt-4-turbo",
1259            "detailed",
1260            Some("You are a helpful assistant"),
1261        );
1262        session.title = "Test Session".to_string();
1263        session.api_messages.push(std::sync::Arc::new(
1264            json!({"role": "user", "content": "test"}),
1265        ));
1266        session.total_input_tokens = 100;
1267        session.total_output_tokens = 200;
1268        session.session_cost = 0.15;
1269
1270        // Serialize to JSON string
1271        let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
1272
1273        // Deserialize back from JSON string
1274        let deserialized: Session =
1275            serde_json::from_str(&json_str).expect("Failed to deserialize session");
1276
1277        // Verify all fields match
1278        assert_eq!(deserialized.id, session.id);
1279        assert_eq!(deserialized.title, session.title);
1280        assert_eq!(deserialized.model, session.model);
1281        assert_eq!(deserialized.thinking_level, session.thinking_level);
1282        assert_eq!(deserialized.system_prompt, session.system_prompt);
1283        assert_eq!(deserialized.created_at, session.created_at);
1284        assert_eq!(deserialized.updated_at, session.updated_at);
1285        assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
1286        assert_eq!(
1287            deserialized.total_output_tokens,
1288            session.total_output_tokens
1289        );
1290        assert_eq!(deserialized.session_cost, session.session_cost);
1291        assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
1292        assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
1293    }
1294
1295    #[test]
1296    fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
1297        let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
1298        let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
1299        let persisted: serde_json::Value =
1300            serde_json::from_str(&encoded).expect("inspect persisted session");
1301        assert_eq!(persisted["thinking_level"], "ultra");
1302        let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
1303        assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
1304        assert_eq!(restored.thinking_level, "ultra");
1305
1306        let compacted =
1307            Session::from_compaction_record(&restored, "summary", test_record(&restored));
1308        assert_eq!(compacted.model, restored.model);
1309        assert_eq!(compacted.thinking_level, "ultra");
1310        assert_eq!(
1311            compacted.parent_session.as_deref(),
1312            Some(restored.id.as_str())
1313        );
1314    }
1315
1316    #[test]
1317    fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
1318        let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
1319        let encoded = serde_json::to_string(&session).expect("serialize Max session");
1320        let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
1321        assert_eq!(restored.thinking_level, "max");
1322        assert_ne!(restored.thinking_level, "ultra");
1323        assert_ne!(restored.thinking_level, "xhigh");
1324    }
1325
1326    #[test]
1327    fn test_session_serialization_preserves_all_fields() {
1328        let mut session = Session::new(
1329            "claude-3-opus",
1330            "comprehensive",
1331            Some("Custom system prompt"),
1332        );
1333        session.title = "Complex Session".to_string();
1334
1335        // Add multiple messages
1336        session.api_messages.push(std::sync::Arc::new(
1337            json!({"role": "user", "content": "First message"}),
1338        ));
1339        session.api_messages.push(std::sync::Arc::new(
1340            json!({"role": "assistant", "content": "First response"}),
1341        ));
1342        session.api_messages.push(std::sync::Arc::new(
1343            json!({"role": "user", "content": "Second message"}),
1344        ));
1345
1346        // Set token counts and cost
1347        session.total_input_tokens = 1500;
1348        session.total_output_tokens = 2500;
1349        session.session_cost = 0.75;
1350
1351        // Serialize and deserialize
1352        let json_str = serde_json::to_string(&session).unwrap();
1353        let restored: Session = serde_json::from_str(&json_str).unwrap();
1354
1355        // Verify every field is preserved
1356        assert_eq!(restored.id, session.id);
1357        assert_eq!(restored.title, "Complex Session");
1358        assert_eq!(restored.model, "claude-3-opus");
1359        assert_eq!(restored.thinking_level, "comprehensive");
1360        assert_eq!(
1361            restored.system_prompt.as_ref().unwrap(),
1362            "Custom system prompt"
1363        );
1364        assert_eq!(restored.created_at, session.created_at);
1365        assert_eq!(restored.updated_at, session.updated_at);
1366        assert_eq!(restored.total_input_tokens, 1500);
1367        assert_eq!(restored.total_output_tokens, 2500);
1368        assert_eq!(restored.session_cost, 0.75);
1369        assert_eq!(restored.api_messages.len(), 3);
1370        assert_eq!(restored.api_messages[0]["role"], "user");
1371        assert_eq!(restored.api_messages[0]["content"], "First message");
1372        assert_eq!(restored.api_messages[1]["role"], "assistant");
1373        assert_eq!(restored.api_messages[2]["content"], "Second message");
1374    }
1375
1376    #[test]
1377    fn test_session_info_from_session_with_messages() {
1378        let mut session = Session::new("gpt-3.5-turbo", "normal", None);
1379
1380        // Add exactly 3 messages
1381        session.api_messages.push(std::sync::Arc::new(
1382            json!({"role": "user", "content": "message 1"}),
1383        ));
1384        session.api_messages.push(std::sync::Arc::new(
1385            json!({"role": "assistant", "content": "response 1"}),
1386        ));
1387        session.api_messages.push(std::sync::Arc::new(
1388            json!({"role": "user", "content": "message 2"}),
1389        ));
1390
1391        let info = session.info();
1392
1393        // Verify message count is exactly 3
1394        assert_eq!(info.message_count, 3);
1395        assert_eq!(info.id, session.id);
1396        assert_eq!(info.model, "gpt-3.5-turbo");
1397    }
1398
1399    #[test]
1400    fn test_session_auto_title_truncation() {
1401        let mut session = Session::new("gpt-4", "brief", None);
1402
1403        // Create a user message with exactly 200 characters
1404        let long_content = "a".repeat(200);
1405        session.api_messages.push(std::sync::Arc::new(json!({
1406            "role": "user",
1407            "content": long_content
1408        })));
1409
1410        session.auto_title();
1411
1412        // Verify title is exactly 80 characters
1413        assert_eq!(session.title.len(), 80);
1414        assert_eq!(session.title, "a".repeat(80));
1415    }
1416
1417    #[test]
1418    fn test_session_auto_title_skips_non_user_messages() {
1419        let mut session = Session::new("gpt-4", "brief", None);
1420
1421        // Push only an assistant message (no user messages)
1422        session.api_messages.push(std::sync::Arc::new(json!({
1423            "role": "assistant",
1424            "content": "This should be ignored for auto title"
1425        })));
1426
1427        session.auto_title();
1428
1429        // Verify title stays empty since there are no user messages
1430        assert_eq!(session.title, "");
1431
1432        // Test with system message too
1433        session.api_messages.push(std::sync::Arc::new(json!({
1434            "role": "system",
1435            "content": "System message should also be ignored"
1436        })));
1437
1438        session.auto_title();
1439        assert_eq!(session.title, "");
1440    }
1441
1442    #[test]
1443    fn test_session_new_generates_unique_ids() {
1444        let session1 = Session::new("gpt-4", "brief", None);
1445        let session2 = Session::new("gpt-4", "brief", None);
1446
1447        // Verify IDs are different
1448        assert_ne!(session1.id, session2.id);
1449        assert!(!session1.id.is_empty());
1450        assert!(!session2.id.is_empty());
1451    }
1452
1453    #[test]
1454    fn test_session_new_timestamps() {
1455        let before = Utc::now();
1456        let session = Session::new("gpt-4", "brief", None);
1457        let after = Utc::now();
1458
1459        // Verify created_at and updated_at are close to now (within 2 seconds)
1460        let created_diff = (session.created_at - before).num_seconds().abs();
1461        let updated_diff = (session.updated_at - before).num_seconds().abs();
1462
1463        assert!(
1464            created_diff <= 2,
1465            "created_at should be within 2 seconds of now"
1466        );
1467        assert!(
1468            updated_diff <= 2,
1469            "updated_at should be within 2 seconds of now"
1470        );
1471
1472        // Verify both timestamps are the same for new sessions
1473        assert_eq!(session.created_at, session.updated_at);
1474
1475        // Verify timestamps are not in the future
1476        assert!(session.created_at <= after);
1477        assert!(session.updated_at <= after);
1478    }
1479
1480    #[test]
1481    fn test_validate_name() {
1482        assert!(validate_name("work").is_ok());
1483        assert!(validate_name("my-project-2").is_ok());
1484        assert!(validate_name("a").is_ok());
1485        assert!(validate_name(&"a".repeat(40)).is_ok());
1486
1487        assert!(validate_name("").is_err());
1488        assert!(validate_name(&"a".repeat(41)).is_err());
1489        assert!(validate_name("UPPER").is_err());
1490        assert!(validate_name("has space").is_err());
1491        assert!(validate_name("under_score").is_err());
1492        assert!(validate_name("dots.bad").is_err());
1493
1494        let err = validate_name("Bad").unwrap_err();
1495        assert!(err.contains("Bad"));
1496        assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
1497    }
1498
1499    #[test]
1500    fn test_clear_name() {
1501        let mut s = Session::new("m", "brief", None);
1502        s.name = Some("foo".into());
1503        s.clear_name();
1504        assert_eq!(s.name, None);
1505    }
1506
1507    #[test]
1508    fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
1509        let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
1510        let json = serde_json::to_string(&original).unwrap();
1511        let restored: Session = serde_json::from_str(&json).unwrap();
1512        assert_eq!(restored.thinking_level, "ultracode");
1513        for other in ["ultra", "max", "xhigh"] {
1514            assert_ne!(restored.thinking_level, other);
1515        }
1516        let compacted =
1517            Session::from_compaction_record(&restored, "summary", test_record(&restored));
1518        let resumed: Session =
1519            serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
1520        assert_eq!(resumed.thinking_level, "ultracode");
1521        assert_eq!(
1522            resumed.parent_session.as_deref(),
1523            Some(original.id.as_str())
1524        );
1525    }
1526
1527    /// Private-mode tests (spec §5.4). Umask isolation: `#[serial(umask)]`
1528    /// serializes umask-mutating tests crate-wide; `UmaskGuard` restores the
1529    /// previous mask on drop (panic-safe). These exercise the blocking body
1530    /// of `Session::save` directly with a temp dir — no env mutation.
1531    #[cfg(unix)]
1532    mod private_modes {
1533        use super::*;
1534        use crate::core::private_fs::test_support::UmaskGuard;
1535        use serial_test::serial;
1536        use std::os::unix::fs::PermissionsExt;
1537
1538        fn mode_of(path: &std::path::Path) -> u32 {
1539            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1540        }
1541
1542        #[test]
1543        #[serial(umask)]
1544        fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
1545            let _umask = UmaskGuard::set(0);
1546            let tmp = tempfile::TempDir::new().unwrap();
1547            let dir = tmp.path().join("sessions");
1548            save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
1549            assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1550            assert_eq!(
1551                mode_of(&dir.join("sess-1.json")),
1552                0o600,
1553                "session file must be 0600"
1554            );
1555        }
1556
1557        #[test]
1558        fn save_refuses_symlink_target() {
1559            let tmp = tempfile::TempDir::new().unwrap();
1560            let dir = tmp.path().join("sessions");
1561            std::fs::create_dir_all(&dir).unwrap();
1562            let victim = tmp.path().join("victim.json");
1563            std::fs::write(&victim, "original").unwrap();
1564            std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
1565            let res = save_json_in_dir(&dir, "sess-1", b"{}");
1566            assert!(res.is_err(), "save onto a symlink must fail");
1567            assert_eq!(
1568                std::fs::read_to_string(&victim).unwrap(),
1569                "original",
1570                "no bytes may be written through the planted symlink"
1571            );
1572        }
1573    }
1574
1575    mod header_message_count {
1576        use super::*;
1577        use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
1578        use std::sync::Arc;
1579
1580        fn session_with(n: usize) -> Session {
1581            let mut s = Session::new("claude-sonnet-4-6", "medium", None);
1582            for i in 0..n {
1583                let role = if i % 2 == 0 { "user" } else { "assistant" };
1584                s.api_messages.push(Arc::new(
1585                    serde_json::json!({"role": role, "content": format!("m{i}")}),
1586                ));
1587            }
1588            s
1589        }
1590
1591        /// save → header reader round-trip: the count is persisted in the
1592        /// header (before `api_messages`), so the truncating reader sees it.
1593        #[test]
1594        fn json_save_persists_count_in_header() {
1595            let tmp = tempfile::TempDir::new().unwrap();
1596            let dir = tmp.path().join("sessions");
1597            let s = session_with(7);
1598            save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1599            let header = read_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1600            assert!(
1601                !header.contains("\"api_messages\""),
1602                "header must stop before the message array"
1603            );
1604            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1605            assert_eq!(info.message_count, 7);
1606        }
1607
1608        /// Journal mode: appended messages update the count via the meta tail.
1609        #[test]
1610        fn journal_append_updates_count() {
1611            let tmp = tempfile::TempDir::new().unwrap();
1612            let dir = tmp.path().join("sessions");
1613            let mut s = session_with(2);
1614            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1615            s.api_messages.push(Arc::new(
1616                serde_json::json!({"role": "user", "content": "more"}),
1617            ));
1618            s.updated_at = Utc::now();
1619            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1620            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1621            assert_eq!(info.message_count, 3);
1622        }
1623
1624        #[test]
1625        fn listing_does_not_overlay_stale_generation_metadata() {
1626            let tmp = tempfile::TempDir::new().unwrap();
1627            let dir = tmp.path().canonicalize().unwrap().join("sessions");
1628            let mut s = session_with(2);
1629            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1630            s.api_messages
1631                .push(Arc::new(serde_json::json!({"role":"user","content":"old"})));
1632            s.session_cost = 999.0;
1633            s.updated_at += chrono::Duration::hours(1);
1634            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1635            let journal_path = dir.join(format!("{}.journal", s.id));
1636            let stale = std::fs::read(&journal_path).unwrap();
1637            s.api_messages.truncate(1);
1638            s.session_cost = 1.0;
1639            s.updated_at -= chrono::Duration::hours(2);
1640            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1641            std::fs::write(journal_path, stale).unwrap();
1642            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1643            assert_eq!(info.message_count, 1);
1644            assert_eq!(info.session_cost, 1.0);
1645            assert_eq!(info.updated_at, s.updated_at);
1646        }
1647
1648        /// Legacy files without the field report 0, never fail to parse.
1649        #[test]
1650        fn legacy_header_defaults_to_zero() {
1651            let tmp = tempfile::TempDir::new().unwrap();
1652            let dir = tmp.path().join("sessions");
1653            let s = session_with(3);
1654            // Strip the field textually to keep declaration order (a
1655            // `to_value` round-trip would re-sort keys alphabetically).
1656            let json = serde_json::to_string(&s).unwrap();
1657            assert!(json.contains("\"message_count\":0,"));
1658            let legacy = json.replace("\"message_count\":0,", "");
1659            save_json_in_dir(&dir, &s.id, legacy.as_bytes()).unwrap();
1660            let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1661            assert_eq!(info.message_count, 0);
1662        }
1663    }
1664
1665    // ─── follow_compaction_chain tests ─────────────────────────────
1666
1667    use serial_test::serial;
1668
1669    fn save_test_session(dir: &std::path::Path, session: &Session) {
1670        crate::core::session_journal::save_session_in_dir(
1671            dir,
1672            session,
1673            crate::core::session_journal::SessionPersistence::default(),
1674        )
1675        .unwrap();
1676    }
1677
1678    /// Sets `SYNAPS_BASE_DIR` for one test and restores it on drop. `base_dir()`
1679    /// reads it BEFORE `HOME`, so a value left behind silently redirects every
1680    /// later config read/write in this test process — which made
1681    /// `config::tests::write_config_value_*` fail whenever a `follow_chain_*`
1682    /// test happened to run first (order-dependent CI flake).
1683    struct BaseDirGuard(Option<String>);
1684
1685    impl BaseDirGuard {
1686        fn set(path: &std::path::Path) -> Self {
1687            let old = std::env::var("SYNAPS_BASE_DIR").ok();
1688            std::env::set_var("SYNAPS_BASE_DIR", path);
1689            Self(old)
1690        }
1691    }
1692
1693    impl Drop for BaseDirGuard {
1694        fn drop(&mut self) {
1695            match self.0.take() {
1696                Some(value) => std::env::set_var("SYNAPS_BASE_DIR", value),
1697                None => std::env::remove_var("SYNAPS_BASE_DIR"),
1698            }
1699        }
1700    }
1701
1702    #[test]
1703    #[serial]
1704    fn follow_chain_no_compaction_is_identity() {
1705        let dir = tempfile::tempdir().unwrap();
1706        let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1707        std::fs::create_dir_all(dir.path().join(".synaps-cli/sessions")).unwrap();
1708        let s = Session::new("m", "brief", None);
1709        save_test_session(&dir.path().join(".synaps-cli/sessions"), &s);
1710        let resolved = follow_compaction_chain(s.clone()).unwrap();
1711        assert_eq!(resolved.session.id, s.id);
1712        assert!(resolved.compaction_notice.is_none());
1713    }
1714
1715    #[test]
1716    #[serial]
1717    fn follow_chain_of_three() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let sessions_dir = dir.path().join(".synaps-cli/sessions");
1720        let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1721        std::fs::create_dir_all(&sessions_dir).unwrap();
1722
1723        let mut a = Session::new("m", "brief", None);
1724        a.id = "aaa".into();
1725        let mut b = Session::new("m", "brief", None);
1726        b.id = "bbb".into();
1727        b.parent_session = Some("aaa".into());
1728        let mut c = Session::new("m", "brief", None);
1729        c.id = "ccc".into();
1730        c.parent_session = Some("bbb".into());
1731
1732        a.compacted_into = Some("bbb".into());
1733        b.compacted_into = Some("ccc".into());
1734        // c has no compacted_into — it's the end
1735
1736        save_test_session(&sessions_dir, &a);
1737        save_test_session(&sessions_dir, &b);
1738        save_test_session(&sessions_dir, &c);
1739
1740        let resolved = follow_compaction_chain(a).unwrap();
1741        assert_eq!(resolved.session.id, "ccc");
1742        let notice = resolved.compaction_notice.unwrap();
1743        assert!(notice.contains("aaa"), "notice mentions original: {notice}");
1744        assert!(notice.contains("ccc"), "notice mentions final: {notice}");
1745    }
1746
1747    #[test]
1748    #[serial]
1749    fn follow_chain_cycle_guard() {
1750        let dir = tempfile::tempdir().unwrap();
1751        let sessions_dir = dir.path().join(".synaps-cli/sessions");
1752        let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1753        std::fs::create_dir_all(&sessions_dir).unwrap();
1754
1755        let mut a = Session::new("m", "brief", None);
1756        a.id = "aaa".into();
1757        let mut b = Session::new("m", "brief", None);
1758        b.id = "bbb".into();
1759
1760        a.compacted_into = Some("bbb".into());
1761        b.compacted_into = Some("aaa".into()); // cycle!
1762
1763        save_test_session(&sessions_dir, &a);
1764        save_test_session(&sessions_dir, &b);
1765
1766        let err = follow_compaction_chain(a).unwrap_err();
1767        assert!(
1768            err.to_string().contains("cycle"),
1769            "error mentions cycle: {}",
1770            err
1771        );
1772    }
1773
1774    #[test]
1775    #[serial]
1776    fn follow_chain_missing_successor_error() {
1777        let dir = tempfile::tempdir().unwrap();
1778        let sessions_dir = dir.path().join(".synaps-cli/sessions");
1779        let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1780        std::fs::create_dir_all(&sessions_dir).unwrap();
1781
1782        let mut a = Session::new("m", "brief", None);
1783        a.id = "aaa".into();
1784        a.compacted_into = Some("nonexistent".into());
1785
1786        save_test_session(&sessions_dir, &a);
1787
1788        let err = follow_compaction_chain(a).unwrap_err();
1789        let msg = err.to_string();
1790        assert!(
1791            msg.contains("compacted into nonexistent") && msg.contains("failed to load"),
1792            "error names the missing successor: {msg}"
1793        );
1794    }
1795
1796}