Skip to main content

agent_core/core/
session.rs

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