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