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