Skip to main content

recursive/session/
mod.rs

1//! Session files for production pause/resume.
2//!
3//! A `SessionFile` captures enough agent state to resume a run that
4//! terminated abnormally (budget exceeded, stuck, transcript limit).
5//! It stores the goal, model, provider, a hash of the tool registry,
6//! the steps consumed so far, and the full transcript.
7//!
8//! Sessions are written alongside transcripts when `--session-out` is
9//! set and the finish reason is non-success. They live under
10//! `<workspace>/.recursive/sessions/` by convention.
11//!
12//! Split into sub-modules during the Goal 221 module refactor:
13//! - `serialize` — `TranscriptEntry`, `entry_to_message`
14//! - `lifecycle` — `SessionLock`, `truncate_transcript_to_turn`
15//! - `orphan`   — `OrphanToolCall`
16//! - `reader`   — `SessionReader`
17//! - `writer`   — `SessionWriter`, `SessionPersistenceSink`
18
19pub mod lifecycle;
20pub mod orphan;
21pub mod reader;
22pub mod serialize;
23pub mod writer;
24
25use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27
28use crate::llm::ToolSpec;
29use crate::message::Message;
30
31// Re-exports from sub-modules (keep the 8 `pub use` in lib.rs working)
32pub use lifecycle::{truncate_transcript_to_turn, SessionLock, SessionLockBusy, TruncateStats};
33pub use orphan::OrphanToolCall;
34pub use reader::SessionReader;
35pub use serialize::{entry_to_message, LoadedEntry, TranscriptEntry};
36pub use writer::{SessionPersistenceSink, SessionWriter};
37
38/// Current schema version for session files.
39/// Increment when the format changes in a breaking way.
40const SESSION_SCHEMA_VERSION: u32 = 1;
41
42/// Maximum `schema_version` this build of the binary can interpret.
43/// `SessionReader::load_meta` rejects anything strictly greater
44/// than this. Bump this constant (and the helper below) when
45/// making a non-backward-compatible change to `SessionMeta`.
46pub(crate) const SUPPORTED_SESSION_SCHEMA_VERSION: u32 = 1;
47
48// ---------------------------------------------------------------------------
49// SessionStatus
50// ---------------------------------------------------------------------------
51
52/// Status of a session. Persisted as a lowercase string.
53///
54/// Backward compatibility has THREE complementary mechanisms, all
55/// required for this enum to load files written by every prior build:
56///
57/// 1. **`#[serde(alias = ...)]` on individual variants** — accepts
58///    legacy on-disk strings that map to the same semantic state.
59///    For example, `Completed` accepts both `"completed"` (current)
60///    and `"success"` (the string the pre-Goal-276 writer emitted).
61///    Without these aliases, old session files would silently load
62///    as `Active` (the `other` catch-all) instead of `Completed`.
63///
64/// 2. **`#[serde(other)]` on the last variant (`Active`)** — catches
65///    any *unknown* string the build does not recognise (e.g.
66///    `"stalled"` written by a future version, or a typo). It MUST
67///    stay last; serde requires the catch-all at the end.
68///
69/// 3. **`#[serde(default)]` on the field in `SessionMeta`** (see
70///    below) — handles a *missing* `status` field on disk. Without
71///    it, a `.meta.json` written before the field existed would
72///    fail to load.
73///
74/// Aliases handle **known** legacy values and preserve semantics;
75/// `#[serde(other)]` handles **unknown** values and degrades safely
76/// to `Active`; the field-level `#[serde(default)]` handles a
77/// **missing** field. They are distinct and not interchangeable.
78///
79/// New variants added here must:
80/// - Provide aliases for any prior string literals mapping to the
81///   same semantic state.
82/// - Keep `Active` as the LAST variant (the `#[serde(other)]` arm
83///   must remain terminal — moving it earlier is a serde error).
84#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum SessionStatus {
87    /// Session finished its goal successfully.
88    /// Legacy alias: `"success"` (written by `cli/resume.rs` and
89    /// `main.rs` before Goal 276).
90    #[serde(alias = "success")]
91    Completed,
92    /// Session ended because of an unrecoverable error
93    /// (provider crash, panic, transcript-limit hard stop, etc.).
94    /// Legacy alias: `"incomplete"` (written by the pre-Goal-276
95    /// finish_status mapping for non-`NoMoreToolCalls` outcomes).
96    #[serde(alias = "incomplete")]
97    Crashed,
98    /// Session was cancelled by a shutdown signal (SIGINT/SIGTERM).
99    /// Legacy alias: `"cancelled"` (the string `FinishReason::Cancelled`
100    /// emits via its `Display` impl; that string was used in
101    /// `cli/session.rs` for the `interrupt` path).
102    #[serde(alias = "cancelled")]
103    Interrupted,
104    /// Session is parked for later resume (e.g. via
105    /// `recursive pause <id>`).
106    Paused,
107    /// Session is the live, currently-running state. Also acts as
108    /// the catch-all (`#[serde(other)]`) for unknown on-disk
109    /// statuses — see the type-level doc comment above.
110    ///
111    /// MUST remain the last variant. Adding a new variant after
112    /// `Active` is a compile error; reorder first.
113    ///
114    /// `#[default]` makes this the `Default::default()` value,
115    /// which `#[serde(default)]` on the field in `SessionMeta`
116    /// relies on when a `.meta.json` file omits the `status`
117    /// key.  The `#[serde(other)]` arm above is a separate
118    /// catch-all for *unknown* string values — see the
119    /// type-level doc comment.
120    #[serde(other)]
121    #[default]
122    Active,
123}
124
125impl std::fmt::Display for SessionStatus {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        // The lowercase string MUST match what serde produces via
128        // `#[serde(rename_all = "lowercase")]`. The test
129        // `session_status_display_matches_serde` guards against
130        // accidental drift between this hand-written impl and the
131        // derived serialization.
132        match self {
133            Self::Completed => write!(f, "completed"),
134            Self::Crashed => write!(f, "crashed"),
135            Self::Interrupted => write!(f, "interrupted"),
136            Self::Paused => write!(f, "paused"),
137            Self::Active => write!(f, "active"),
138        }
139    }
140}
141
142impl SessionStatus {
143    /// Map a [`FinishReason`] to the canonical `SessionStatus` written
144    /// when the writer is finalised.
145    ///
146    /// The mapping is EXHAUSTIVE: there is no wildcard arm, so
147    /// adding a new variant to `FinishReason` becomes a compile error
148    /// in this function rather than silently falling back to
149    /// `Crashed`. The compiler is the safety net.
150    pub fn for_finish(reason: &crate::agent::FinishReason) -> Self {
151        use crate::agent::FinishReason;
152        match reason {
153            FinishReason::NoMoreToolCalls => Self::Completed,
154            FinishReason::BudgetExceeded
155            | FinishReason::ProviderStop(_)
156            | FinishReason::Stuck { .. }
157            | FinishReason::TranscriptLimit { .. }
158            | FinishReason::PermissionDenialLimit
159            | FinishReason::WallClockExceeded { .. } => Self::Crashed,
160            FinishReason::Cancelled => Self::Interrupted,
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// SessionFile (legacy JSON session format)
167// ---------------------------------------------------------------------------
168
169/// A saved session that can be resumed.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SessionFile {
172    pub schema_version: u32,
173    /// The original goal string.
174    pub goal: String,
175    /// Model name (e.g. "gpt-4o-mini").
176    pub model: String,
177    /// Provider identifier (e.g. "openai").
178    pub provider: String,
179    /// BLAKE3 hash of the tool registry specs at the time of save.
180    /// Used to detect tool changes that would make the session invalid.
181    pub tool_registry_hash: String,
182    /// Number of steps already consumed.
183    pub steps_consumed: usize,
184    /// The full transcript so far (system prompt + user goal + all exchanges).
185    pub transcript: Vec<Message>,
186    /// Resolved provider preset id (e.g. "deepseek") at session creation
187    /// time. `None` when the user did not configure a preset — kept
188    /// optional for back-compat with pre-preset-config session files.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub preset: Option<String>,
191}
192
193impl SessionFile {
194    /// Create a new session file from the agent's current state.
195    pub fn new(
196        goal: String,
197        model: String,
198        provider: String,
199        tool_specs: &[ToolSpec],
200        steps_consumed: usize,
201        transcript: Vec<Message>,
202    ) -> Self {
203        let tool_registry_hash = hash_tool_specs(tool_specs);
204        Self {
205            schema_version: SESSION_SCHEMA_VERSION,
206            goal,
207            model,
208            provider,
209            tool_registry_hash,
210            steps_consumed,
211            transcript,
212            preset: None,
213        }
214    }
215
216    /// Write the session to a JSON file at `path`.
217    pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
218        let json = serde_json::to_string_pretty(self)
219            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
220        if let Some(parent) = path.parent() {
221            std::fs::create_dir_all(parent)?;
222        }
223        crate::atomic::atomic_write(path, json.as_bytes())
224    }
225
226    /// Read a session from a JSON file at `path`.
227    pub fn read_from(path: &Path) -> std::io::Result<Self> {
228        let bytes = std::fs::read(path)?;
229        serde_json::from_slice(&bytes)
230            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
231    }
232
233    /// Validate that the tool registry hash matches the current tool specs.
234    /// Returns `Ok(())` if they match, or an error describing the mismatch.
235    pub fn validate_tool_registry(&self, current_specs: &[ToolSpec]) -> Result<(), String> {
236        let current_hash = hash_tool_specs(current_specs);
237        if self.tool_registry_hash == current_hash {
238            Ok(())
239        } else {
240            Err(format!(
241                "tool registry hash mismatch: session has '{}', current is '{}'. \
242                 Tools have changed since the session was saved; cannot resume.",
243                self.tool_registry_hash, current_hash
244            ))
245        }
246    }
247
248    /// Return the transcript messages.
249    pub fn messages(&self) -> &[Message] {
250        &self.transcript
251    }
252
253    /// Consume self and return the transcript.
254    pub fn into_transcript(self) -> Vec<Message> {
255        self.transcript
256    }
257}
258
259// ---------------------------------------------------------------------------
260// Tool spec hashing
261// ---------------------------------------------------------------------------
262
263/// Compute a BLAKE3 hash of the tool registry specs.
264///
265/// The hash is computed over a deterministic JSON representation of the
266/// specs, sorted by tool name. This ensures that the same set of tools
267/// always produces the same hash, regardless of registration order.
268///
269/// Used by both [`SessionFile`] (legacy `.json` resume) and the
270/// JSONL session meta's `tool_registry_hash` field (g151) so that
271/// `recursive resume` can refuse to load a session whose tool
272/// inventory has drifted.
273pub fn hash_tool_specs(specs: &[ToolSpec]) -> String {
274    use std::collections::BTreeMap;
275
276    let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
277    for spec in specs {
278        let value = serde_json::json!({
279            "description": spec.description,
280            "parameters": spec.parameters,
281        });
282        map.insert(spec.name.clone(), value);
283    }
284    // BTreeMap<String, serde_json::Value> is always serializable; the error
285    // branch is unreachable in practice. If it is somehow reached, log it and
286    // return a sentinel that callers can detect and handle explicitly rather
287    // than silently comparing equal to every other hash.
288    let canonical = serde_json::to_string(&map).unwrap_or_else(|e| {
289        tracing::error!(error = %e, "hash_tool_specs: unreachable serialization failure — drift detection may be compromised");
290        String::new()
291    });
292    let hash = blake3::hash(canonical.as_bytes());
293    hash.to_hex().to_string()
294}
295
296// ---------------------------------------------------------------------------
297// Session path utilities
298// ---------------------------------------------------------------------------
299
300/// Default session output path for a given workspace.
301/// Returns `~/.recursive/workspaces/<ws-hash>/sessions/<timestamp>-<goal-prefix>.json`.
302pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
303    let ts = filesystem_safe_timestamp();
304    // Sanitise the goal prefix for use in a filename
305    let prefix: String = goal
306        .chars()
307        .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
308        .take(40)
309        .collect();
310    let prefix = if prefix.is_empty() {
311        "unnamed".to_string()
312    } else {
313        prefix
314    };
315    let dir = crate::paths::user_sessions_dir(workspace)
316        .unwrap_or_else(|_| workspace.join(".recursive").join("sessions"));
317    dir.join(format!("{}-{}.json", ts, prefix))
318}
319
320/// List all session files in a workspace's session directory.
321pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
322    let dir = match crate::paths::user_sessions_dir(workspace) {
323        Ok(d) => d,
324        Err(_) => workspace.join(".recursive").join("sessions"),
325    };
326    if !dir.is_dir() {
327        return Ok(Vec::new());
328    }
329    let mut sessions = Vec::new();
330    for entry in std::fs::read_dir(&dir)? {
331        let entry = entry?;
332        let path = entry.path();
333        if path.extension().and_then(|e| e.to_str()) == Some("json") {
334            sessions.push(path);
335        }
336    }
337    sessions.sort();
338    Ok(sessions)
339}
340
341// ---------------------------------------------------------------------------
342// Timestamp utilities
343// ---------------------------------------------------------------------------
344
345/// RFC3339 timestamp safe for use in path components on all platforms.
346/// Colons in the time portion are replaced with hyphens (Windows forbids `:`).
347pub(crate) fn filesystem_safe_timestamp() -> String {
348    chrono_lite_now().replace(':', "-")
349}
350
351/// RFC3339 UTC timestamp using `chrono`. Format:
352/// "YYYY-MM-DDTHH:MM:SSZ".
353pub(crate) fn chrono_lite_now() -> String {
354    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
355}
356
357pub(crate) fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
358    let z = z + 719_468;
359    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
360    let doe = (z - era * 146_097) as u64;
361    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
362    let y = yoe as i64 + era * 400;
363    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
364    let mp = (5 * doy + 2) / 153;
365    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
366    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
367    let y = if m <= 2 { y + 1 } else { y };
368    (y, m, d)
369}
370
371// ---------------------------------------------------------------------------
372// Workspace slug
373// ---------------------------------------------------------------------------
374
375/// Convert an absolute workspace path into a filesystem-safe slug.
376///
377/// - Replaces `/` with `-`
378/// - Strips leading `-` (from the root `/`)
379/// - Truncates to 80 characters
380pub(crate) fn workspace_slug(workspace: &Path) -> String {
381    let abs = if workspace.is_absolute() {
382        workspace.to_path_buf()
383    } else {
384        std::env::current_dir().unwrap_or_default().join(workspace)
385    };
386
387    // Only keep characters that are valid in a session_id:
388    // ASCII alphanumeric, '-', '_', '.'.  Everything else (path
389    // separators, Windows drive colons, 8.3 tildes like RUNNER~1,
390    // spaces, non-ASCII) becomes '-'.
391    let s: String = abs
392        .to_string_lossy()
393        .chars()
394        .map(|c| {
395            if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
396                c
397            } else {
398                '-'
399            }
400        })
401        .collect();
402    // Strip leading dashes (from root slash / drive letter)
403    let s = s.trim_start_matches('-').to_string();
404    // Truncate to 80 chars (safe for multibyte)
405    if s.len() > 80 {
406        crate::truncate_str(&s, 80).to_string()
407    } else {
408        s
409    }
410}
411
412// ---------------------------------------------------------------------------
413// UsageMeta — per-message token usage
414// ---------------------------------------------------------------------------
415
416/// Token usage for one or more LLM API calls, as reported by the provider.
417///
418/// Used both per-message (`TranscriptEntry.usage`) and as a cumulative
419/// session total stored in `SessionMeta.cost` (g156).
420#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
421pub struct UsageMeta {
422    pub input_tokens: u32,
423    pub output_tokens: u32,
424    /// Prompt-cache creation tokens (Anthropic / OpenAI).
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub cache_creation_tokens: Option<u32>,
427    /// Prompt-cache read tokens (Anthropic / OpenAI).
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub cache_read_tokens: Option<u32>,
430    /// Reasoning/thinking tokens (DeepSeek R1, o1, etc.).
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub reasoning_tokens: Option<u32>,
433}
434
435impl UsageMeta {
436    /// Accumulate another `UsageMeta` into self.
437    pub fn accumulate(&mut self, other: &UsageMeta) {
438        self.input_tokens += other.input_tokens;
439        self.output_tokens += other.output_tokens;
440        self.cache_creation_tokens = Some(
441            self.cache_creation_tokens.unwrap_or(0) + other.cache_creation_tokens.unwrap_or(0),
442        );
443        self.cache_read_tokens =
444            Some(self.cache_read_tokens.unwrap_or(0) + other.cache_read_tokens.unwrap_or(0));
445        self.reasoning_tokens =
446            Some(self.reasoning_tokens.unwrap_or(0) + other.reasoning_tokens.unwrap_or(0));
447    }
448
449    /// Convert from `crate::llm::TokenUsage` (g156 integration point).
450    pub fn from_token_usage(tu: &crate::llm::TokenUsage) -> Self {
451        UsageMeta {
452            input_tokens: tu.prompt_tokens,
453            output_tokens: tu.completion_tokens,
454            cache_creation_tokens: if tu.cache_miss_tokens > 0 {
455                Some(tu.cache_miss_tokens)
456            } else {
457                None
458            },
459            cache_read_tokens: if tu.cache_hit_tokens > 0 {
460                Some(tu.cache_hit_tokens)
461            } else {
462                None
463            },
464            // Goal 273: pass through reasoning tokens so they can be
465            // summed into SessionCost and priced (treated as output
466            // by `ModelPricing::cost_usd`).
467            reasoning_tokens: Some(tu.reasoning_tokens),
468        }
469    }
470
471    /// Returns true if both `input_tokens` and `output_tokens` are zero.
472    pub fn is_zero(&self) -> bool {
473        self.input_tokens == 0 && self.output_tokens == 0
474    }
475}
476
477// ---------------------------------------------------------------------------
478// SessionCost — cumulative token cost for a session
479// ---------------------------------------------------------------------------
480
481/// Cumulative token cost for a session, stored in `.meta.json`.
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
483pub struct SessionCost {
484    pub total_input_tokens: u64,
485    pub total_output_tokens: u64,
486    #[serde(default)]
487    pub total_cache_creation_tokens: u64,
488    #[serde(default)]
489    pub total_cache_read_tokens: u64,
490    /// Goal 273: reasoning / thinking tokens summed across the
491    /// session. Priced at the model's output rate (see
492    /// `ModelPricing::cost_usd`).
493    #[serde(default)]
494    pub total_reasoning_tokens: u64,
495}
496
497impl SessionCost {
498    pub fn accumulate(&mut self, usage: &UsageMeta) {
499        self.total_input_tokens += usage.input_tokens as u64;
500        self.total_output_tokens += usage.output_tokens as u64;
501        self.total_cache_creation_tokens += usage.cache_creation_tokens.unwrap_or(0) as u64;
502        self.total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0) as u64;
503        self.total_reasoning_tokens += usage.reasoning_tokens.unwrap_or(0) as u64;
504    }
505}
506
507// ---------------------------------------------------------------------------
508// SessionMeta
509// ---------------------------------------------------------------------------
510
511/// `schema_version` written into a `SessionMeta` whose on-disk
512/// representation predates the field. Pre-Goal-269 session files
513/// have no `schema_version`; defaulting to 1 (the current value)
514/// keeps them loadable.
515fn default_schema_version() -> u32 {
516    1
517}
518
519/// Metadata for a JSONL session.
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct SessionMeta {
522    /// Schema version of the persisted `SessionMeta`. Bumped
523    /// whenever a non-backward-compatible field is added (e.g.
524    /// dropping a `#[serde(default)]` attribute). The on-disk
525    /// format is read by `SessionReader::load_meta`, which checks
526    /// this field and refuses to load a session with a
527    /// `schema_version` it does not understand.
528    ///
529    /// Defaults to `1` for pre-Goal-269 session files (the field
530    /// was introduced in Goal 269, 2026-06-11).
531    #[serde(default = "default_schema_version")]
532    pub schema_version: u32,
533    pub session_id: String,
534    pub goal: String,
535    pub model: String,
536    pub provider: String,
537    pub created_at: String,
538    pub updated_at: String,
539    pub message_count: u64,
540    /// Lifecycle state of the session. Defaults to `Active` for
541    /// `.meta.json` files written before Goal 276 (no `status`
542    /// key) and degrades to `Active` for unknown string values
543    /// (caught by `SessionStatus::Active`'s `#[serde(other)]` arm).
544    #[serde(default)]
545    pub status: SessionStatus,
546    /// BLAKE3 hash of the tool registry specs at session creation
547    /// time. Used by `recursive resume` (g151) to refuse loading a
548    /// session whose tool inventory has drifted. `None` for
549    /// pre-g151 sessions; resume tolerates the absence with a
550    /// warning rather than an abort.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub tool_registry_hash: Option<String>,
553    /// First user message in this session, truncated to 200 chars (g157).
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub first_prompt: Option<String>,
556    /// Most recent user message, truncated to 200 chars (g157).
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub last_prompt: Option<String>,
559    /// Cumulative token usage for this session (g156).
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub cost: Option<SessionCost>,
562    /// Resolved provider preset id (e.g. "deepseek") at session creation
563    /// time. `None` for pre-preset-config sessions or when the user did
564    /// not configure a preset. Optional + skipped on serialize so old
565    /// session files round-trip cleanly.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub preset: Option<String>,
568    /// Optional human-readable display name for this session, set via `--name`.
569    /// Shown in the /resume picker and `sessions list` output.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub name: Option<String>,
572}
573
574// ---------------------------------------------------------------------------
575// ExportedTranscript
576// ---------------------------------------------------------------------------
577
578/// A portable exported transcript for sharing and analysis.
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ExportedTranscript {
581    pub version: u32,
582    pub session_id: String,
583    pub model: String,
584    pub goal: String,
585    pub created_at: String,
586    /// Status at the moment of export. Mirrors `SessionMeta::status`
587    /// — the same enum is used in both structs to keep the
588    /// wire shape consistent for external SDK consumers.
589    pub status: SessionStatus,
590    /// Every persisted message in on-disk order, including those that
591    /// were folded into a compaction summary (older `messages.len()` of
592    /// these were superseded by the corresponding [`Self::compact_boundaries`]
593    /// entry at runtime). Consumers that only want the post-compaction
594    /// context should filter messages before the last boundary's index.
595    pub messages: Vec<TranscriptEntry>,
596    /// Cross-turn compaction boundaries, in order. Each records how many
597    /// older messages were folded into the summary that follows it.
598    /// Empty for sessions that never compacted.
599    #[serde(default)]
600    pub compact_boundaries: Vec<ExportedCompactBoundary>,
601    /// Count of messages in `messages` (excludes boundary markers).
602    pub message_count: u64,
603}
604
605/// A compaction boundary exported alongside [`ExportedTranscript::messages`].
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct ExportedCompactBoundary {
608    /// Index into `ExportedTranscript::messages` at the position where the
609    /// boundary sits (the summary message immediately follows).
610    pub message_index: usize,
611    pub turn: Option<u32>,
612    /// How many older messages were folded into the summary.
613    pub removed: usize,
614    pub timestamp: String,
615}
616
617impl ExportedTranscript {
618    /// Build an ExportedTranscript from a session directory.
619    pub fn from_session_dir(session_dir: &Path) -> std::io::Result<Self> {
620        let meta = SessionReader::load_meta(session_dir)?;
621        let loaded = SessionReader::load_full_history(session_dir)?;
622        let mut messages: Vec<TranscriptEntry> = Vec::new();
623        let mut compact_boundaries: Vec<ExportedCompactBoundary> = Vec::new();
624        for entry in loaded {
625            match entry {
626                LoadedEntry::Message(m) => messages.push(*m),
627                LoadedEntry::CompactBoundary { turn, removed } => {
628                    // The boundary marker sits between messages; record the
629                    // index of the message that would follow it.
630                    let timestamp = messages
631                        .last()
632                        .map(|m| m.timestamp.clone())
633                        .unwrap_or_default();
634                    compact_boundaries.push(ExportedCompactBoundary {
635                        message_index: messages.len(),
636                        turn,
637                        removed,
638                        timestamp,
639                    });
640                }
641            }
642        }
643        let message_count = messages.len() as u64;
644        Ok(Self {
645            version: 1,
646            session_id: meta.session_id,
647            model: meta.model,
648            goal: meta.goal,
649            created_at: meta.created_at,
650            status: meta.status,
651            messages,
652            compact_boundaries,
653            message_count,
654        })
655    }
656}
657
658// ---------------------------------------------------------------------------
659// Tests for mod.rs types
660// ---------------------------------------------------------------------------
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::message::{Message, Role};
666
667    #[test]
668    fn session_round_trip() {
669        let goal = "fix the bug".to_string();
670        let model = "gpt-4o-mini".to_string();
671        let provider = "openai".to_string();
672        let tool_specs = vec![
673            ToolSpec {
674                name: "Read".into(),
675                description: "Read a file".into(),
676                parameters: serde_json::json!({"type":"object"}),
677            },
678            ToolSpec {
679                name: "Write".into(),
680                description: "Write a file".into(),
681                parameters: serde_json::json!({"type":"object"}),
682            },
683        ];
684        let transcript = vec![
685            Message::system("You are a helpful assistant.".to_string()),
686            Message::user("fix the bug".to_string()),
687            Message::assistant("Let me look at the code.".to_string()),
688        ];
689
690        let session = SessionFile::new(
691            goal.clone(),
692            model.clone(),
693            provider.clone(),
694            &tool_specs,
695            2,
696            transcript.clone(),
697        );
698
699        let tmp = tempfile::NamedTempFile::new().unwrap();
700        session.write_to(tmp.path()).unwrap();
701
702        let restored = SessionFile::read_from(tmp.path()).unwrap();
703        assert_eq!(restored.schema_version, SESSION_SCHEMA_VERSION);
704        assert_eq!(restored.goal, goal);
705        assert_eq!(restored.model, model);
706        assert_eq!(restored.provider, provider);
707        assert_eq!(restored.steps_consumed, 2);
708        assert_eq!(restored.transcript.len(), 3);
709        assert_eq!(
710            restored.transcript[0].content,
711            "You are a helpful assistant."
712        );
713        assert_eq!(restored.transcript[1].content, "fix the bug");
714        assert_eq!(restored.transcript[2].content, "Let me look at the code.");
715    }
716
717    #[test]
718    fn resume_validates_tool_registry_hash() {
719        let tool_specs = vec![ToolSpec {
720            name: "Read".into(),
721            description: "Read a file".into(),
722            parameters: serde_json::json!({"type":"object"}),
723        }];
724        let session = SessionFile::new(
725            "test".into(),
726            "model".into(),
727            "provider".into(),
728            &tool_specs,
729            0,
730            vec![],
731        );
732
733        // Same specs should validate
734        assert!(session.validate_tool_registry(&tool_specs).is_ok());
735
736        // Different specs should fail
737        let different_specs = vec![ToolSpec {
738            name: "Write".into(),
739            description: "Write a file".into(),
740            parameters: serde_json::json!({"type":"object"}),
741        }];
742        assert!(session.validate_tool_registry(&different_specs).is_err());
743    }
744
745    #[test]
746    #[cfg_attr(target_os = "windows", ignore)]
747    fn session_list_finds_files_in_workspace() {
748        let tmp = crate::test_util::IsolatedWorkspace::new();
749        let ws = tmp.path();
750
751        // No sessions dir yet
752        let sessions = list_sessions(ws).unwrap();
753        assert!(sessions.is_empty());
754
755        // Create a session file
756        let session = SessionFile::new(
757            "test".into(),
758            "model".into(),
759            "provider".into(),
760            &[],
761            0,
762            vec![],
763        );
764        let path = default_session_path(ws, "test");
765        session.write_to(&path).unwrap();
766
767        let sessions = list_sessions(ws).unwrap();
768        assert_eq!(sessions.len(), 1);
769        assert_eq!(
770            sessions[0].extension().and_then(|e| e.to_str()),
771            Some("json")
772        );
773    }
774
775    #[test]
776    fn resume_continues_from_seeded_transcript() {
777        let transcript = vec![
778            Message::system("sys".to_string()),
779            Message::user("original goal".to_string()),
780            Message::assistant("partial work".to_string()),
781        ];
782        let session = SessionFile::new(
783            "original goal".into(),
784            "model".into(),
785            "provider".into(),
786            &[],
787            1,
788            transcript.clone(),
789        );
790
791        // The transcript should be preserved exactly
792        assert_eq!(session.messages().len(), 3);
793        assert_eq!(session.messages()[0].content, "sys");
794        assert_eq!(session.messages()[1].content, "original goal");
795        assert_eq!(session.messages()[2].content, "partial work");
796
797        // into_transcript should give back the messages
798        let restored = session.into_transcript();
799        assert_eq!(restored.len(), 3);
800    }
801
802    #[test]
803    fn round_trip_with_tool_calls() {
804        use crate::llm::ToolCall;
805
806        let tool_calls = vec![
807            ToolCall {
808                id: "call_001".to_string(),
809                name: "Read".to_string(),
810                arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
811            },
812            ToolCall {
813                id: "call_002".to_string(),
814                name: "Write".to_string(),
815                arguments: serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"}),
816            },
817        ];
818
819        let transcript = vec![
820            Message::system("You are an agent.".to_string()),
821            Message::user("refactor the code".to_string()),
822            Message::assistant_with_tool_calls(
823                "I'll read the file first.".to_string(),
824                tool_calls.clone(),
825            ),
826            Message::tool_result("call_001", "fn main() { println!(\"hello\"); }"),
827        ];
828
829        let session = SessionFile::new(
830            "refactor".into(),
831            "gpt-4o".into(),
832            "openai".into(),
833            &[],
834            3,
835            transcript,
836        );
837
838        let tmp = tempfile::NamedTempFile::new().unwrap();
839        session.write_to(tmp.path()).unwrap();
840
841        let restored = SessionFile::read_from(tmp.path()).unwrap();
842        assert_eq!(restored.transcript.len(), 4);
843
844        // Verify the assistant message with tool_calls is preserved
845        let assistant_msg = &restored.transcript[2];
846        assert_eq!(assistant_msg.role, Role::Assistant);
847        assert_eq!(assistant_msg.content, "I'll read the file first.");
848        assert_eq!(assistant_msg.tool_calls.len(), 2);
849        assert_eq!(assistant_msg.tool_calls[0].id, "call_001");
850        assert_eq!(assistant_msg.tool_calls[0].name, "Read");
851        assert_eq!(
852            assistant_msg.tool_calls[0].arguments,
853            serde_json::json!({"path": "/tmp/foo.rs"})
854        );
855        assert_eq!(assistant_msg.tool_calls[1].id, "call_002");
856        assert_eq!(assistant_msg.tool_calls[1].name, "Write");
857        assert_eq!(
858            assistant_msg.tool_calls[1].arguments,
859            serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"})
860        );
861
862        // Verify the tool result message
863        let tool_msg = &restored.transcript[3];
864        assert_eq!(tool_msg.role, Role::Tool);
865        assert_eq!(tool_msg.tool_call_id, Some("call_001".to_string()));
866        assert_eq!(tool_msg.content, "fn main() { println!(\"hello\"); }");
867    }
868
869    #[test]
870    fn read_from_nonexistent_file() {
871        let tmp = crate::test_util::IsolatedWorkspace::new();
872        let bogus_path = tmp.path().join("does_not_exist.json");
873
874        let result = SessionFile::read_from(&bogus_path);
875        assert!(result.is_err());
876        let err = result.unwrap_err();
877        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
878    }
879
880    #[test]
881    fn read_from_corrupt_json() {
882        let tmp = tempfile::NamedTempFile::new().unwrap();
883        std::fs::write(tmp.path(), "this is not valid json {{{garbage").unwrap();
884
885        let result = SessionFile::read_from(tmp.path());
886        assert!(result.is_err());
887        let err = result.unwrap_err();
888        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
889    }
890
891    #[test]
892    fn validate_tool_registry_mismatch() {
893        let original_specs = vec![
894            ToolSpec {
895                name: "Read".into(),
896                description: "Read a file".into(),
897                parameters: serde_json::json!({"type":"object"}),
898            },
899            ToolSpec {
900                name: "Write".into(),
901                description: "Write a file".into(),
902                parameters: serde_json::json!({"type":"object"}),
903            },
904        ];
905
906        let session = SessionFile::new(
907            "test".into(),
908            "model".into(),
909            "provider".into(),
910            &original_specs,
911            0,
912            vec![],
913        );
914
915        // Validate against a completely different set of tools
916        let different_specs = vec![ToolSpec {
917            name: "execute_command".into(),
918            description: "Run a shell command".into(),
919            parameters: serde_json::json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
920        }];
921
922        let result = session.validate_tool_registry(&different_specs);
923        assert!(result.is_err());
924        let err_msg = result.unwrap_err();
925        assert!(
926            err_msg.contains("mismatch"),
927            "Expected error to contain 'mismatch', got: {err_msg}"
928        );
929    }
930
931    #[test]
932    fn default_session_path_sanitizes_special_chars() {
933        let tmp = crate::test_util::IsolatedWorkspace::new();
934        let ws = tmp.path();
935
936        // Goal with spaces, slashes, unicode, and other special chars
937        let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
938        let path = default_session_path(ws, goal);
939
940        // Extract just the filename (without the .json extension)
941        let filename = path.file_stem().unwrap().to_str().unwrap();
942
943        // The filename format is "{timestamp}-{sanitized_goal}".
944        // The timestamp is filesystem-safe (colons replaced with hyphens).
945        // We verify the goal-derived suffix: strip the timestamp prefix
946        // (everything up to and including the "Z-" separator).
947        let goal_suffix = filename
948            .find("Z-")
949            .map(|i| &filename[i + 2..])
950            .expect("filename should contain Z- separator between timestamp and goal");
951
952        // The goal suffix should contain only alphanumeric (unicode-aware), underscore, or dash
953        for ch in goal_suffix.chars() {
954            assert!(
955                ch.is_alphanumeric() || ch == '_' || ch == '-',
956                "Unexpected character '{}' (U+{:04X}) in goal suffix: {}",
957                ch,
958                ch as u32,
959                goal_suffix
960            );
961        }
962
963        // Spaces, slashes, #, —, ™ should all be stripped
964        assert!(!goal_suffix.contains(' '));
965        assert!(!goal_suffix.contains('/'));
966        assert!(!goal_suffix.contains('#'));
967        assert!(!goal_suffix.contains('™'));
968        assert!(!goal_suffix.contains('—'));
969
970        // The path should still end inside a sessions/ directory
971        // (now lives under the user data dir, not the workspace).
972        let parent = path.parent().expect("session path has parent");
973        assert_eq!(
974            parent.file_name().and_then(|n| n.to_str()),
975            Some("sessions"),
976            "expected sessions dir as parent, got {}",
977            path.display()
978        );
979        // And should have .json extension
980        assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
981    }
982
983    /// Kills: `replace || with && at 306:41` and `replace || with && at 306:54`.
984    ///
985    /// The filter `c.is_alphanumeric() || *c == '_' || *c == '-'` must preserve
986    /// ALL three classes of characters.
987    ///
988    /// - Mutant at 306:41 (`&&` instead of first `||`): reduces to `*c == '-'` only,
989    ///   dropping alphanumeric AND underscore characters.  A goal with only letters
990    ///   (no `-`) would produce an empty prefix → "unnamed".
991    ///
992    /// - Mutant at 306:54 (`&&` instead of second `||`): reduces to `c.is_alphanumeric()` only,
993    ///   dropping underscore.  A goal with `_` would lose it from the prefix.
994    ///
995    /// Using "my_feature" exercises both: the alpha chars kill 306:41 and the `_` kills 306:54.
996    #[test]
997    fn default_session_path_preserves_underscore_and_alphanumeric() {
998        let tmp = crate::test_util::IsolatedWorkspace::new();
999        let ws = tmp.path();
1000        let path = default_session_path(ws, "my_feature");
1001        let filename = path.file_stem().unwrap().to_str().unwrap();
1002        let goal_suffix = filename
1003            .find("Z-")
1004            .map(|i| &filename[i + 2..])
1005            .expect("filename should contain Z- separator");
1006        assert_eq!(
1007            goal_suffix, "my_feature",
1008            "goal prefix must preserve both alphanumeric chars and underscores; got: {goal_suffix}"
1009        );
1010    }
1011
1012    #[test]
1013    fn default_session_path_empty_goal_uses_unnamed() {
1014        // Kills: `delete !` on `if prefix.is_empty()` in default_session_path.
1015        // A goal whose characters are ALL filtered out (e.g. only special symbols)
1016        // must fall back to "unnamed" as the filename prefix.
1017        // With the `!` deleted, the then-branch would fire when prefix is non-empty
1018        // and the else-branch when it IS empty → wrong filename.
1019        let tmp = crate::test_util::IsolatedWorkspace::new();
1020        let ws = tmp.path();
1021
1022        // All chars will be filtered: none is alphanumeric, '_', or '-'.
1023        let goal = "!@#$%^&*()";
1024        let path = default_session_path(ws, goal);
1025        let filename = path.file_stem().unwrap().to_str().unwrap();
1026
1027        // The format is "{timestamp}-unnamed"; extract the suffix after "Z-".
1028        let goal_suffix = filename
1029            .find("Z-")
1030            .map(|i| &filename[i + 2..])
1031            .expect("filename should contain Z- separator");
1032        assert_eq!(
1033            goal_suffix, "unnamed",
1034            "empty-goal filter must produce 'unnamed'"
1035        );
1036    }
1037
1038    #[test]
1039    #[cfg_attr(target_os = "windows", ignore)]
1040    fn list_sessions_ignores_non_json_files() {
1041        // Kills: `replace == with !=` on the `Some("json")` comparison in list_sessions.
1042        // When non-.json files are present in the sessions dir, they must NOT appear.
1043        // With `!=`, json files would be excluded and non-json files included.
1044        use std::fs;
1045
1046        let tmp = crate::test_util::IsolatedWorkspace::new();
1047        let ws = tmp.path();
1048
1049        // Create a session to establish the sessions directory
1050        let session = SessionFile::new("test".into(), "m".into(), "p".into(), &[], 0, vec![]);
1051        let json_path = default_session_path(ws, "test");
1052        session.write_to(&json_path).unwrap();
1053
1054        // Add a non-json file in the same directory
1055        let sessions_dir = json_path.parent().unwrap();
1056        fs::write(sessions_dir.join("README.txt"), b"not a session").unwrap();
1057        fs::write(sessions_dir.join("tmp.bak"), b"backup").unwrap();
1058
1059        let sessions = list_sessions(ws).unwrap();
1060
1061        // Must contain exactly the one .json file
1062        assert_eq!(
1063            sessions.len(),
1064            1,
1065            "non-json files must be excluded; found: {sessions:?}"
1066        );
1067        assert_eq!(
1068            sessions[0].extension().and_then(|e| e.to_str()),
1069            Some("json"),
1070        );
1071    }
1072
1073    #[test]
1074    fn filesystem_safe_timestamp_has_no_colons() {
1075        let ts = filesystem_safe_timestamp();
1076        assert!(!ts.contains(':'));
1077        assert!(ts.ends_with('Z'));
1078    }
1079
1080    #[test]
1081    fn workspace_slug_matches_expected() {
1082        // Absolute path
1083        let p = Path::new("/home/user/projects/my-app");
1084        let slug = workspace_slug(p);
1085        assert!(!slug.starts_with('-'));
1086        assert!(slug.contains("home-user-projects-my-app"));
1087        assert!(slug.len() <= 80);
1088
1089        // Relative path
1090        let p = Path::new(".");
1091        let slug = workspace_slug(p);
1092        assert!(!slug.is_empty());
1093        assert!(slug.len() <= 80);
1094    }
1095
1096    /// Kills: `replace || with && at 394:54`.
1097    ///
1098    /// The filter `c.is_ascii_alphanumeric() || c == '_' || c == '.'` must
1099    /// preserve all three character classes.  Mutant at 394:54 replaces the
1100    /// second `||` with `&&`, collapsing the condition to `c.is_ascii_alphanumeric()`
1101    /// only — both `_` and `.` get replaced with `-`.
1102    #[test]
1103    fn workspace_slug_preserves_underscore_and_dot() {
1104        let p = Path::new("/home/user_name/project.ver");
1105        let slug = workspace_slug(p);
1106        assert!(
1107            slug.contains('_'),
1108            "workspace_slug must preserve underscore; got: {slug}"
1109        );
1110        assert!(
1111            slug.contains('.'),
1112            "workspace_slug must preserve dot; got: {slug}"
1113        );
1114    }
1115
1116    #[test]
1117    fn workspace_slug_truncates_to_80_chars() {
1118        // Kills: `replace > with >=` on `if s.len() > 80` in workspace_slug.
1119        // With `>=`, a slug of exactly 80 chars would be truncated to 80 (no change) which
1120        // is fine for >= case, but a slug of exactly 81 must be truncated to 80.
1121        // Simpler: use a path that produces a slug > 80 chars and verify it's capped.
1122        let long_component = "a".repeat(100);
1123        let p = std::path::PathBuf::from(format!("/home/user/{long_component}"));
1124        let slug = workspace_slug(&p);
1125        assert!(
1126            slug.len() <= 80,
1127            "slug must be capped at 80 chars; got length {}",
1128            slug.len()
1129        );
1130        // Also verify slug starts without a leading dash
1131        assert!(!slug.starts_with('-'), "slug must not start with dash");
1132    }
1133
1134    // ── chrono_lite_now / epoch_day_to_ymd ──────────────────────────────────
1135
1136    #[test]
1137    fn epoch_day_to_ymd_unix_epoch() {
1138        // Day 0 = 1970-01-01
1139        assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
1140    }
1141
1142    #[test]
1143    fn epoch_day_to_ymd_known_dates() {
1144        // 2024-01-01 = day 19723 since epoch
1145        assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
1146        // 2000-02-29 (leap day) = day 11016
1147        assert_eq!(epoch_day_to_ymd(11016), (2000, 2, 29));
1148        // 2100-03-01 (2100 is NOT a leap year, so 2100-02-29 doesn't exist)
1149        // 2100-01-01 = day 47482
1150        assert_eq!(epoch_day_to_ymd(47482), (2100, 1, 1));
1151    }
1152
1153    /// Kills: `replace + with -` and `replace + with *` at line 360:33,
1154    ///        `replace - with /` at line 360:47.
1155    ///
1156    /// Unix day 47541 is 2100-03-01.  At this day `doe = 36524` exactly
1157    /// (100 * 365.24 days into era 5), which makes `doe/36524 = 1`.
1158    /// When the `+doe/36524` correction is removed, negated, or replaced
1159    /// by a multiply, `yoe` drops to 99 and the month formula produces
1160    /// the non-existent date 2100-02-29 instead of the correct 2100-03-01.
1161    #[test]
1162    fn epoch_day_to_ymd_century_correction_at_doe_36524() {
1163        // 2100-03-01: first day after the "no leap year in 2100" boundary.
1164        assert_eq!(epoch_day_to_ymd(47541), (2100, 3, 1));
1165    }
1166
1167    /// Kills: `replace - with +` and `replace - with /` at line 358:40.
1168    ///
1169    /// Unix day -719528 is year 0, January 1 (proleptic Gregorian; year 0 = 1 BC).
1170    /// The adjusted epoch value is negative, triggering the `else { z - 146_096 }`
1171    /// branch in the era calculation.  Both mutations compute era = 0 instead of -1,
1172    /// which causes a u64 overflow in `doe` and produces a completely wrong date.
1173    #[test]
1174    fn epoch_day_to_ymd_negative_epoch_day_pre_ce() {
1175        // Year 0 Jan 1 = Unix day -719528 (proleptic Gregorian year 0 = 1 BC).
1176        assert_eq!(epoch_day_to_ymd(-719528), (0, 1, 1));
1177    }
1178
1179    #[test]
1180    fn chrono_lite_now_format() {
1181        let ts = chrono_lite_now();
1182        // Must match YYYY-MM-DDTHH:MM:SSZ
1183        assert_eq!(ts.len(), 20, "unexpected length: {ts}");
1184        assert_eq!(&ts[4..5], "-", "missing first dash: {ts}");
1185        assert_eq!(&ts[7..8], "-", "missing second dash: {ts}");
1186        assert_eq!(&ts[10..11], "T", "missing T separator: {ts}");
1187        assert_eq!(&ts[13..14], ":", "missing first colon: {ts}");
1188        assert_eq!(&ts[16..17], ":", "missing second colon: {ts}");
1189        assert_eq!(&ts[19..20], "Z", "missing Z suffix: {ts}");
1190        // All digit fields must parse as numbers
1191        let year: u32 = ts[0..4].parse().expect("year");
1192        let month: u32 = ts[5..7].parse().expect("month");
1193        let day: u32 = ts[8..10].parse().expect("day");
1194        let hour: u32 = ts[11..13].parse().expect("hour");
1195        let min: u32 = ts[14..16].parse().expect("minute");
1196        let sec: u32 = ts[17..19].parse().expect("second");
1197        assert!(year >= 2024, "year looks wrong: {year}");
1198        assert!((1..=12).contains(&month), "month out of range: {month}");
1199        assert!((1..=31).contains(&day), "day out of range: {day}");
1200        assert!(hour < 24, "hour out of range: {hour}");
1201        assert!(min < 60, "minute out of range: {min}");
1202        assert!(sec < 60, "second out of range: {sec}");
1203    }
1204
1205    // ── Goal 276: SessionStatus enum + backward-compatibility surface ─────
1206
1207    #[test]
1208    fn session_status_serializes_lowercase() {
1209        // The lowercase string is part of the wire contract — external
1210        // SDK consumers parse `status: "completed"` as a known state.
1211        // A drift here would silently break their pipelines.
1212        for (variant, expected) in [
1213            (SessionStatus::Active, "active"),
1214            (SessionStatus::Completed, "completed"),
1215            (SessionStatus::Crashed, "crashed"),
1216            (SessionStatus::Interrupted, "interrupted"),
1217            (SessionStatus::Paused, "paused"),
1218        ] {
1219            let json = serde_json::to_string(&variant).unwrap();
1220            assert_eq!(json, format!("\"{expected}\""), "mismatch for {variant:?}");
1221        }
1222    }
1223
1224    #[test]
1225    fn session_status_display_matches_serde() {
1226        // The hand-written Display impl is what CLI output and format
1227        // strings see. The derived serde impl is what the on-disk
1228        // JSON sees. They must agree — if a future contributor adds
1229        // a variant or renames one, this test catches the drift.
1230        for variant in [
1231            SessionStatus::Active,
1232            SessionStatus::Completed,
1233            SessionStatus::Crashed,
1234            SessionStatus::Interrupted,
1235            SessionStatus::Paused,
1236        ] {
1237            let from_display = format!("{variant}");
1238            let from_serde = serde_json::to_string(&variant)
1239                .unwrap()
1240                .trim_matches('"')
1241                .to_string();
1242            assert_eq!(
1243                from_display, from_serde,
1244                "Display and serde disagree for {variant:?}"
1245            );
1246        }
1247    }
1248
1249    #[test]
1250    fn session_status_legacy_aliases_round_trip() {
1251        // Pre-Goal-276 writer emitted `"success"` for completed runs and
1252        // `"incomplete"` for everything else; the migrate path also
1253        // accepts `"cancelled"`. The aliases must read back as the
1254        // correct modern variant — silent loss of "success" -> "active"
1255        // would be a corruption bug visible only after a real
1256        // production write.
1257        let cases = [
1258            ("success", SessionStatus::Completed),
1259            ("incomplete", SessionStatus::Crashed),
1260            ("cancelled", SessionStatus::Interrupted),
1261            ("completed", SessionStatus::Completed),
1262            ("crashed", SessionStatus::Crashed),
1263            ("interrupted", SessionStatus::Interrupted),
1264            ("paused", SessionStatus::Paused),
1265        ];
1266        for (legacy_str, expected) in cases {
1267            let parsed: SessionStatus = serde_json::from_str(&format!("\"{legacy_str}\""))
1268                .unwrap_or_else(|e| panic!("legacy alias {legacy_str:?} did not deserialize: {e}"));
1269            assert_eq!(parsed, expected, "wrong mapping for {legacy_str:?}");
1270        }
1271    }
1272
1273    #[test]
1274    fn session_meta_unknown_status_deserializes_to_active() {
1275        // Old on-disk `.meta.json` files (or files written by a future
1276        // build) may contain a `status` value this build doesn't
1277        // recognise — e.g. `"stalled"` from a future hang-detection
1278        // feature. The `#[serde(other)]` catch-all on `Active` must
1279        // convert that to `Active` instead of refusing to load the
1280        // session.
1281        let json = serde_json::json!({
1282            "session_id": "future",
1283            "goal": "future session",
1284            "model": "gpt-4o-mini",
1285            "provider": "openai",
1286            "created_at": "2099-01-01T00:00:00Z",
1287            "updated_at": "2099-01-01T00:00:00Z",
1288            "message_count": 0,
1289            "status": "stalled"
1290        });
1291        let restored: SessionMeta = serde_json::from_value(json)
1292            .expect("unknown status string must not break SessionMeta deserialization");
1293        assert_eq!(
1294            restored.status,
1295            SessionStatus::Active,
1296            "unknown status should map to Active via #[serde(other)]"
1297        );
1298    }
1299
1300    #[test]
1301    fn session_meta_missing_status_field_defaults_to_active() {
1302        // The `#[serde(default)]` on the `status` field covers the case
1303        // where the key is absent (e.g. a `.meta.json` written before
1304        // the field existed). This is distinct from the
1305        // unknown-string case above (which `#[serde(other)]` handles).
1306        let json = serde_json::json!({
1307            "session_id": "missing-status",
1308            "goal": "no status key",
1309            "model": "gpt-4o-mini",
1310            "provider": "openai",
1311            "created_at": "2099-01-01T00:00:00Z",
1312            "updated_at": "2099-01-01T00:00:00Z",
1313            "message_count": 0
1314            // NB: no `status` key
1315        });
1316        let restored: SessionMeta = serde_json::from_value(json)
1317            .expect("missing status field must not break SessionMeta deserialization");
1318        assert_eq!(
1319            restored.status,
1320            SessionStatus::Active,
1321            "missing status key should default to Active via #[serde(default)]"
1322        );
1323    }
1324
1325    #[test]
1326    fn session_meta_status_active_round_trip() {
1327        // Build a SessionMeta with the new status enum, serialize, then
1328        // deserialize, and confirm the variant is preserved.
1329        let meta = SessionMeta {
1330            status: SessionStatus::Active,
1331            ..meta_for_test(SUPPORTED_SESSION_SCHEMA_VERSION)
1332        };
1333        let json = serde_json::to_string(&meta).expect("serialize SessionMeta");
1334        let restored: SessionMeta = serde_json::from_str(&json).expect("deserialize SessionMeta");
1335        assert_eq!(restored.status, SessionStatus::Active);
1336    }
1337
1338    #[test]
1339    fn for_finish_maps_exhaustively() {
1340        // Every `FinishReason` variant must map to a `SessionStatus`.
1341        // If a new `FinishReason` is added without updating
1342        // `SessionStatus::for_finish`, the compiler will surface the
1343        // non-exhaustive match (no `_` arm) — this test just
1344        // double-checks the currently-known mapping is correct.
1345        use crate::agent::FinishReason;
1346        assert_eq!(
1347            SessionStatus::for_finish(&FinishReason::NoMoreToolCalls),
1348            SessionStatus::Completed
1349        );
1350        assert_eq!(
1351            SessionStatus::for_finish(&FinishReason::BudgetExceeded),
1352            SessionStatus::Crashed
1353        );
1354        assert_eq!(
1355            SessionStatus::for_finish(&FinishReason::ProviderStop("rate_limited".into())),
1356            SessionStatus::Crashed
1357        );
1358        assert_eq!(
1359            SessionStatus::for_finish(&FinishReason::Stuck {
1360                repeated_call: "Read".into(),
1361                repeats: 5
1362            }),
1363            SessionStatus::Crashed
1364        );
1365        assert_eq!(
1366            SessionStatus::for_finish(&FinishReason::TranscriptLimit {
1367                chars: 100_000,
1368                limit: 80_000
1369            }),
1370            SessionStatus::Crashed
1371        );
1372        assert_eq!(
1373            SessionStatus::for_finish(&FinishReason::Cancelled),
1374            SessionStatus::Interrupted
1375        );
1376        assert_eq!(
1377            SessionStatus::for_finish(&FinishReason::PermissionDenialLimit),
1378            SessionStatus::Crashed
1379        );
1380    }
1381
1382    // -----------------------------------------------------------------------
1383    // SessionMeta schema_version tests (Goal 269)
1384    // -----------------------------------------------------------------------
1385
1386    /// Build a `SessionMeta` with the minimum required fields populated
1387    /// for tests that care only about the schema-version behaviour.
1388    /// `schema_version` is intentionally set via the helper, not
1389    /// hard-coded, so changing the supported version only requires
1390    /// updating `SUPPORTED_SESSION_SCHEMA_VERSION` in one place.
1391    fn meta_for_test(schema_version: u32) -> SessionMeta {
1392        SessionMeta {
1393            schema_version,
1394            session_id: "20260611T120000Z-test".into(),
1395            goal: "schema version test".into(),
1396            model: "gpt-4o-mini".into(),
1397            provider: "openai".into(),
1398            created_at: "2026-06-11T12:00:00Z".into(),
1399            updated_at: "2026-06-11T12:00:00Z".into(),
1400            message_count: 0,
1401            status: SessionStatus::Active,
1402            tool_registry_hash: None,
1403            first_prompt: None,
1404            last_prompt: None,
1405            cost: None,
1406            preset: None,
1407            name: None,
1408        }
1409    }
1410
1411    #[test]
1412    fn test_session_meta_round_trip() {
1413        // Goal 269 — the `schema_version` field is preserved across
1414        // a serialize/deserialize round-trip.
1415        let original = meta_for_test(SUPPORTED_SESSION_SCHEMA_VERSION);
1416        let json = serde_json::to_string(&original).expect("serialize SessionMeta");
1417        let restored: SessionMeta = serde_json::from_str(&json).expect("deserialize SessionMeta");
1418        assert_eq!(restored.schema_version, SUPPORTED_SESSION_SCHEMA_VERSION);
1419        assert_eq!(restored.schema_version, 1);
1420        assert_eq!(restored.session_id, original.session_id);
1421        assert_eq!(restored.goal, original.goal);
1422    }
1423
1424    #[test]
1425    fn test_session_meta_default_schema_version() {
1426        // Goal 269 — pre-Goal-269 `.meta.json` files omit
1427        // `schema_version`. The `#[serde(default)]` attribute must
1428        // fill in the current value so they remain loadable.
1429        let legacy_json = serde_json::json!({
1430            "session_id": "20260610T120000Z-legacy",
1431            "goal": "legacy session",
1432            "model": "gpt-4o-mini",
1433            "provider": "openai",
1434            "created_at": "2026-06-10T12:00:00Z",
1435            "updated_at": "2026-06-10T12:00:00Z",
1436            "message_count": 0,
1437            "status": "completed"
1438        });
1439        // The key assertion: no `schema_version` key, but deserialization
1440        // still succeeds and the field is populated by the helper.
1441        assert!(
1442            legacy_json.get("schema_version").is_none(),
1443            "test fixture must omit schema_version to exercise the default"
1444        );
1445        let restored: SessionMeta =
1446            serde_json::from_value(legacy_json).expect("deserialize legacy SessionMeta");
1447        assert_eq!(restored.schema_version, default_schema_version());
1448        assert_eq!(restored.schema_version, 1);
1449    }
1450
1451    #[test]
1452    fn test_load_rejects_future_schema_version() {
1453        // Goal 269 — `SessionReader::load_meta` must refuse to load a
1454        // session whose `schema_version` is newer than this build
1455        // supports. The error message must surface both the found
1456        // and the supported values, so the user can tell which build
1457        // produced the session.
1458        let tmp = tempfile::tempdir().expect("tempdir");
1459        let session_dir = tmp.path();
1460        std::fs::create_dir_all(session_dir).expect("mkdir session dir");
1461        let future = serde_json::json!({
1462            "schema_version": 999,
1463            "session_id": "2099-01-01T00:00:00Z-future",
1464            "goal": "future session",
1465            "model": "gpt-4o-mini",
1466            "provider": "openai",
1467            "created_at": "2099-01-01T00:00:00Z",
1468            "updated_at": "2099-01-01T00:00:00Z",
1469            "message_count": 0,
1470            "status": "active"
1471        });
1472        let meta_path = session_dir.join(".meta.json");
1473        std::fs::write(
1474            &meta_path,
1475            serde_json::to_string_pretty(&future).expect("serialize future meta"),
1476        )
1477        .expect("write future .meta.json");
1478
1479        let err = SessionReader::load_meta(session_dir)
1480            .expect_err("load_meta must reject future schema_version");
1481        let msg = err.to_string();
1482        // The `crate::error::Error::SchemaTooNew` display format we
1483        // registered must reach the caller, even after the conversion
1484        // to `std::io::Error::InvalidData`.
1485        assert!(
1486            msg.contains("schema_version=999"),
1487            "error must include found version; got: {msg}"
1488        );
1489        assert!(
1490            msg.contains("supported up to 1"),
1491            "error must include supported version; got: {msg}"
1492        );
1493    }
1494
1495    /// Goal 273: SessionCost tracks reasoning tokens across accumulate calls.
1496    #[test]
1497    fn session_cost_tracks_reasoning_tokens() {
1498        let mut cost = SessionCost::default();
1499        let usage_a = UsageMeta {
1500            reasoning_tokens: Some(1000),
1501            ..Default::default()
1502        };
1503        let usage_b = UsageMeta {
1504            reasoning_tokens: Some(500),
1505            ..Default::default()
1506        };
1507        cost.accumulate(&usage_a);
1508        cost.accumulate(&usage_b);
1509        assert_eq!(cost.total_reasoning_tokens, 1500);
1510    }
1511
1512    // -----------------------------------------------------------------------
1513    // UsageMeta arithmetic tests
1514    // -----------------------------------------------------------------------
1515
1516    #[test]
1517    fn usage_meta_accumulate_adds_tokens() {
1518        let mut a = UsageMeta {
1519            input_tokens: 10,
1520            output_tokens: 20,
1521            cache_creation_tokens: Some(5),
1522            cache_read_tokens: Some(3),
1523            reasoning_tokens: Some(7),
1524        };
1525        let b = UsageMeta {
1526            input_tokens: 1,
1527            output_tokens: 2,
1528            cache_creation_tokens: Some(3),
1529            cache_read_tokens: Some(4),
1530            reasoning_tokens: Some(5),
1531        };
1532        a.accumulate(&b);
1533        assert_eq!(a.input_tokens, 11, "input_tokens must add");
1534        assert_eq!(a.output_tokens, 22, "output_tokens must add");
1535        assert_eq!(a.cache_creation_tokens, Some(8), "cache_creation must add");
1536        assert_eq!(a.cache_read_tokens, Some(7), "cache_read must add");
1537        assert_eq!(a.reasoning_tokens, Some(12), "reasoning must add");
1538    }
1539
1540    #[test]
1541    fn usage_meta_accumulate_with_none_treats_as_zero() {
1542        let mut a = UsageMeta {
1543            input_tokens: 5,
1544            output_tokens: 10,
1545            cache_creation_tokens: None,
1546            cache_read_tokens: None,
1547            reasoning_tokens: None,
1548        };
1549        let b = UsageMeta {
1550            input_tokens: 3,
1551            output_tokens: 4,
1552            cache_creation_tokens: Some(2),
1553            cache_read_tokens: Some(1),
1554            reasoning_tokens: Some(6),
1555        };
1556        a.accumulate(&b);
1557        assert_eq!(a.input_tokens, 8);
1558        assert_eq!(a.output_tokens, 14);
1559        assert_eq!(a.cache_creation_tokens, Some(2), "None + Some(2) = Some(2)");
1560        assert_eq!(a.cache_read_tokens, Some(1), "None + Some(1) = Some(1)");
1561        assert_eq!(a.reasoning_tokens, Some(6), "None + Some(6) = Some(6)");
1562    }
1563
1564    #[test]
1565    fn usage_meta_from_token_usage_positive_values() {
1566        use crate::llm::TokenUsage;
1567        let tu = TokenUsage {
1568            prompt_tokens: 100,
1569            completion_tokens: 50,
1570            total_tokens: 150,
1571            cache_miss_tokens: 30,
1572            cache_hit_tokens: 20,
1573            reasoning_tokens: 10,
1574        };
1575        let meta = UsageMeta::from_token_usage(&tu);
1576        assert_eq!(meta.input_tokens, 100);
1577        assert_eq!(meta.output_tokens, 50);
1578        assert_eq!(meta.cache_creation_tokens, Some(30), "positive miss → Some");
1579        assert_eq!(meta.cache_read_tokens, Some(20), "positive hit → Some");
1580        assert_eq!(meta.reasoning_tokens, Some(10));
1581    }
1582
1583    #[test]
1584    fn usage_meta_from_token_usage_zero_cache_is_none() {
1585        use crate::llm::TokenUsage;
1586        let tu = TokenUsage {
1587            prompt_tokens: 5,
1588            completion_tokens: 5,
1589            total_tokens: 10,
1590            cache_miss_tokens: 0,
1591            cache_hit_tokens: 0,
1592            reasoning_tokens: 0,
1593        };
1594        let meta = UsageMeta::from_token_usage(&tu);
1595        assert_eq!(
1596            meta.cache_creation_tokens, None,
1597            "zero cache_miss must produce None"
1598        );
1599        assert_eq!(
1600            meta.cache_read_tokens, None,
1601            "zero cache_hit must produce None"
1602        );
1603    }
1604
1605    #[test]
1606    fn usage_meta_is_zero_both_zero() {
1607        let meta = UsageMeta {
1608            input_tokens: 0,
1609            output_tokens: 0,
1610            ..Default::default()
1611        };
1612        assert!(meta.is_zero(), "both zero → is_zero returns true");
1613    }
1614
1615    #[test]
1616    fn usage_meta_is_zero_input_nonzero() {
1617        let meta = UsageMeta {
1618            input_tokens: 1,
1619            output_tokens: 0,
1620            ..Default::default()
1621        };
1622        assert!(!meta.is_zero(), "nonzero input → is_zero returns false");
1623    }
1624
1625    #[test]
1626    fn usage_meta_is_zero_output_nonzero() {
1627        let meta = UsageMeta {
1628            input_tokens: 0,
1629            output_tokens: 1,
1630            ..Default::default()
1631        };
1632        assert!(!meta.is_zero(), "nonzero output → is_zero returns false");
1633    }
1634
1635    // -----------------------------------------------------------------------
1636    // SessionCost accumulate arithmetic tests
1637    // -----------------------------------------------------------------------
1638
1639    #[test]
1640    fn session_cost_accumulate_all_fields() {
1641        let mut cost = SessionCost::default();
1642        let usage = UsageMeta {
1643            input_tokens: 100,
1644            output_tokens: 50,
1645            cache_creation_tokens: Some(20),
1646            cache_read_tokens: Some(10),
1647            reasoning_tokens: Some(5),
1648        };
1649        cost.accumulate(&usage);
1650        assert_eq!(cost.total_input_tokens, 100, "input must accumulate");
1651        assert_eq!(cost.total_output_tokens, 50, "output must accumulate");
1652        assert_eq!(
1653            cost.total_cache_creation_tokens, 20,
1654            "cache creation must accumulate"
1655        );
1656        assert_eq!(
1657            cost.total_cache_read_tokens, 10,
1658            "cache read must accumulate"
1659        );
1660        assert_eq!(cost.total_reasoning_tokens, 5, "reasoning must accumulate");
1661    }
1662
1663    #[test]
1664    fn session_cost_accumulate_twice_sums_correctly() {
1665        let mut cost = SessionCost::default();
1666        let u1 = UsageMeta {
1667            input_tokens: 100,
1668            output_tokens: 50,
1669            cache_creation_tokens: None,
1670            cache_read_tokens: None,
1671            reasoning_tokens: None,
1672        };
1673        let u2 = UsageMeta {
1674            input_tokens: 200,
1675            output_tokens: 75,
1676            cache_creation_tokens: None,
1677            cache_read_tokens: None,
1678            reasoning_tokens: None,
1679        };
1680        cost.accumulate(&u1);
1681        cost.accumulate(&u2);
1682        assert_eq!(cost.total_input_tokens, 300, "300 = 100 + 200");
1683        assert_eq!(cost.total_output_tokens, 125, "125 = 50 + 75");
1684    }
1685
1686    // -----------------------------------------------------------------------
1687    // hash_tool_specs determinism test
1688    // -----------------------------------------------------------------------
1689
1690    #[test]
1691    fn hash_tool_specs_is_deterministic() {
1692        let specs = vec![
1693            ToolSpec {
1694                name: "Read".into(),
1695                description: "read a file".into(),
1696                parameters: serde_json::json!({"type": "object"}),
1697            },
1698            ToolSpec {
1699                name: "Write".into(),
1700                description: "write a file".into(),
1701                parameters: serde_json::json!({"type": "object"}),
1702            },
1703        ];
1704        let h1 = hash_tool_specs(&specs);
1705        let h2 = hash_tool_specs(&specs);
1706        assert_eq!(h1, h2, "same inputs must produce same hash");
1707        assert!(!h1.is_empty(), "hash must be non-empty");
1708    }
1709
1710    #[test]
1711    fn hash_tool_specs_differs_for_different_specs() {
1712        let specs_a = vec![ToolSpec {
1713            name: "Read".into(),
1714            description: "read".into(),
1715            parameters: serde_json::json!({}),
1716        }];
1717        let specs_b = vec![ToolSpec {
1718            name: "Write".into(),
1719            description: "write".into(),
1720            parameters: serde_json::json!({}),
1721        }];
1722        assert_ne!(
1723            hash_tool_specs(&specs_a),
1724            hash_tool_specs(&specs_b),
1725            "different specs must produce different hashes"
1726        );
1727    }
1728
1729    #[test]
1730    fn default_schema_version_is_one() {
1731        assert_eq!(
1732            default_schema_version(),
1733            1,
1734            "default schema version must be 1"
1735        );
1736    }
1737
1738    // Goal: session export must include the full history (messages folded
1739    // by compaction included) plus the compaction boundaries, so consumers
1740    // see the whole conversation rather than just the post-compaction tail.
1741    #[test]
1742    fn export_includes_full_history_and_compact_boundaries() {
1743        let tmp = crate::test_util::IsolatedWorkspace::new();
1744        let ws = tmp.path();
1745        let mut w = SessionWriter::create(ws, "export test", "model", "openai").unwrap();
1746        let session_dir = w.session_dir().to_path_buf();
1747
1748        w.append(&Message::user("folded turn".to_string()), None, None)
1749            .unwrap();
1750        w.append(&Message::assistant("folded reply".to_string()), None, None)
1751            .unwrap();
1752        // Boundary: 2 messages folded.
1753        w.write_compact_boundary(1, 2, None).unwrap();
1754        w.append(&Message::system("summary".to_string()), None, None)
1755            .unwrap();
1756        w.append(&Message::user("post turn".to_string()), None, None)
1757            .unwrap();
1758        w.finish(SessionStatus::Completed).unwrap();
1759
1760        let exported = ExportedTranscript::from_session_dir(&session_dir).unwrap();
1761        // All 4 real messages are present (compaction did not strip them).
1762        assert_eq!(exported.messages.len(), 4);
1763        assert_eq!(exported.message_count, 4);
1764        // One boundary recorded, sitting at the index of the summary that
1765        // follows it (after the 2 folded messages).
1766        assert_eq!(exported.compact_boundaries.len(), 1);
1767        let b = &exported.compact_boundaries[0];
1768        assert_eq!(b.removed, 2);
1769        assert_eq!(b.turn, Some(1));
1770        assert_eq!(b.message_index, 2, "boundary precedes the summary message");
1771    }
1772}