返回 CodeWhale
session_manager.rs
根目录 / crates / tui / src / session_manager.rs
1 //! Session management for resuming conversations.
2 //!
3 //! This module provides functionality for:
4 //! - Saving sessions to disk
5 //! - Listing previous sessions
6 //! - Resuming sessions by ID
7 //! - Managing session lifecycle
8
9 use crate::artifacts::ArtifactRecord;
10 use crate::config::ApiProvider;
11 use crate::model_routing::AutoRouteReceipt;
12 use crate::models::{ContentBlock, Message, SystemPrompt};
13 use crate::tools::plan::PlanSnapshot;
14 use crate::tools::todo::TodoListSnapshot;
15 use crate::tui::file_mention::ContextReference;
16 use crate::utils::write_atomic;
17 use crate::work_graph::ReasoningEffortTier;
18 use chrono::{DateTime, Utc};
19 use serde::{Deserialize, Serialize};
20 use std::collections::{BTreeMap, BTreeSet};
21 use std::fs;
22 use std::io;
23 use std::path::{Component, Path, PathBuf};
24 use uuid::Uuid;
25
26 /// Maximum number of sessions to retain
27 const MAX_SESSIONS: usize = 50;
28 /// Maximum session title length, in `char`s. Matches the bound the session
29 /// picker's rename prompt has always enforced.
30 pub const MAX_SESSION_TITLE_CHARS: usize = 100;
31 const WORK_GRAPH_IMPORT_ARCHIVE_DIR: &str = ".work-graph-import-archive";
32 const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
33 const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
34
35 const fn default_session_schema_version() -> u32 {
36 CURRENT_SESSION_SCHEMA_VERSION
37 }
38
39 const fn default_queue_schema_version() -> u32 {
40 CURRENT_QUEUE_SCHEMA_VERSION
41 }
42
43 fn normalize_managed_dir(path: PathBuf) -> std::io::Result<PathBuf> {
44 if path.as_os_str().is_empty() {
45 return Err(std::io::Error::new(
46 std::io::ErrorKind::InvalidInput,
47 "managed directory path cannot be empty",
48 ));
49 }
50 if path.components().any(|component| {
51 matches!(
52 component,
53 Component::ParentDir | Component::Prefix(_) | Component::RootDir
54 )
55 }) && path.is_relative()
56 {
57 return Err(std::io::Error::new(
58 std::io::ErrorKind::InvalidInput,
59 "managed directory path cannot contain traversal components",
60 ));
61 }
62 if path.is_absolute() {
63 return Ok(path);
64 }
65 std::env::current_dir().map(|cwd| cwd.join(path))
66 }
67
68 /// Persisted queued message for offline/degraded mode.
69 #[derive(Debug, Clone, Serialize, Deserialize)]
70 pub struct QueuedSessionMessage {
71 pub display: String,
72 #[serde(default)]
73 pub skill_instruction: Option<String>,
74 #[serde(default)]
75 pub skill_provenance: Option<crate::plugins::types::PluginAuthority>,
76 }
77
78 /// Persisted queue state for recovery after restart/crash.
79 #[derive(Debug, Clone, Serialize, Deserialize)]
80 pub struct OfflineQueueState {
81 #[serde(default = "default_queue_schema_version")]
82 pub schema_version: u32,
83 /// Session ID this queue belongs to. Queue is only restored when
84 /// resuming the same session to prevent stale messages leaking into new chats.
85 #[serde(default)]
86 pub session_id: Option<String>,
87 #[serde(default)]
88 pub messages: Vec<QueuedSessionMessage>,
89 #[serde(default)]
90 pub draft: Option<QueuedSessionMessage>,
91 }
92
93 impl Default for OfflineQueueState {
94 fn default() -> Self {
95 Self {
96 schema_version: CURRENT_QUEUE_SCHEMA_VERSION,
97 session_id: None,
98 messages: Vec::new(),
99 draft: None,
100 }
101 }
102 }
103
104 /// Durable context-reference metadata attached to a user message.
105 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106 pub struct SessionContextReference {
107 pub message_index: usize,
108 pub reference: ContextReference,
109 }
110
111 /// Session metadata stored with each saved session
112 #[derive(Debug, Clone, Serialize, Deserialize)]
113 pub struct SessionMetadata {
114 /// Unique session identifier
115 pub id: String,
116 /// Human-readable title (derived from first message)
117 pub title: String,
118 /// When the session was created
119 pub created_at: DateTime<Utc>,
120 /// When the session was last updated
121 pub updated_at: DateTime<Utc>,
122 /// Number of messages in the session
123 pub message_count: usize,
124 /// Total tokens used
125 pub total_tokens: u64,
126 /// Model used for the session
127 pub model: String,
128 /// Provider used for the session model. Defaults for legacy saved sessions.
129 #[serde(default = "default_model_provider")]
130 pub model_provider: String,
131 /// Exact configured provider key. This is separate from `model_provider`
132 /// so old consumers can keep treating that field as the built-in provider
133 /// kind (`custom` for every named custom route).
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub model_provider_id: Option<String>,
136 /// Workspace directory
137 pub workspace: PathBuf,
138 /// Optional mode label (agent/plan/etc.)
139 #[serde(default)]
140 pub mode: Option<String>,
141 /// Accumulated cost data for persisted billing and high-water mark.
142 #[serde(default)]
143 pub cost: SessionCostSnapshot,
144 /// Source session id when this session was created with `deepseek fork`.
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub parent_session_id: Option<String>,
147 /// Source message count at fork time. This is intentionally coarse:
148 /// current saved sessions are linear JSON files, not per-entry trees.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub forked_from_message_count: Option<usize>,
151 /// Cumulative turn duration in seconds (sum of completed turn elapsed
152 /// times). Persisted so the footer "worked" chip survives restarts
153 /// (#2038).
154 #[serde(default)]
155 pub cumulative_turn_secs: u64,
156 /// Durable archive flag (#2934 / #4397). Archived sessions stay on disk
157 /// and stay loadable; they are hidden from the default browse surfaces
158 /// and are never chosen by auto-resume.
159 ///
160 /// This mirrors `ThreadRecord::archived` in [`crate::runtime_threads`] so
161 /// the TUI session surfaces and the Runtime API/web dashboard project the
162 /// same lifecycle field instead of two divergent notions of "put away".
163 /// Additive and `skip_serializing_if`-guarded: sessions written before
164 /// v0.9.2 load as `archived = false` and round-trip byte-identically
165 /// until the flag is actually set.
166 #[serde(default, skip_serializing_if = "is_not_archived")]
167 pub archived: bool,
168 }
169
170 fn is_not_archived(archived: &bool) -> bool {
171 !*archived
172 }
173
174 /// Sessions currently owned by an in-process interactive surface (the TUI).
175 ///
176 /// A saved session is a file, and a running TUI holds the authoritative copy
177 /// in memory: it autosaves the whole document from `App` state. That makes an
178 /// out-of-band write to the *same* session unsafe — the next autosave would
179 /// silently revert it. Rather than let that happen quietly, the owner claims
180 /// the id here and any external writer is refused.
181 ///
182 /// A static registry rather than a field on `RuntimeApiState` because the
183 /// embedded Runtime API runs inside the TUI process; a standalone
184 /// `codewhale web` has an empty registry and is therefore never blocked, which
185 /// is exactly right — there is no TUI holding anything.
186 static LIVE_SESSIONS: std::sync::OnceLock<std::sync::RwLock<std::collections::HashSet<String>>> =
187 std::sync::OnceLock::new();
188
189 fn live_sessions() -> &'static std::sync::RwLock<std::collections::HashSet<String>> {
190 LIVE_SESSIONS.get_or_init(Default::default)
191 }
192
193 /// Who is asking to mutate a saved session.
194 ///
195 /// This is an authority distinction, not a convenience one: the owner may
196 /// write because it will update its in-memory copy in the same step; anyone
197 /// else may not, because it cannot.
198 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
199 pub enum SessionMutator {
200 /// The in-process surface that currently owns the session (the TUI). It
201 /// is responsible for updating its cached metadata atomically with the
202 /// write — see `App::apply_session_mutation`.
203 Owner,
204 /// Any other writer: the Runtime API, the web dashboard, a second
205 /// process. Refused while the session is claimed.
206 External,
207 }
208
209 /// Set the claimed session to exactly `session_id` (or nothing).
210 ///
211 /// The TUI owns at most one session at a time, so switching sessions must
212 /// release the previous claim in the same step — otherwise a `/new` would
213 /// leave the old id permanently locked against the dashboard.
214 pub fn set_live_session(session_id: Option<&str>) {
215 if let Ok(mut live) = live_sessions().write() {
216 live.clear();
217 if let Some(id) = session_id.map(str::trim).filter(|id| !id.is_empty()) {
218 live.insert(id.to_string());
219 }
220 }
221 }
222
223 /// Is this session currently owned by an in-process interactive surface?
224 #[must_use]
225 pub fn is_live_session(session_id: &str) -> bool {
226 live_sessions()
227 .read()
228 .is_ok_and(|live| live.contains(session_id))
229 }
230
231 /// The error an external writer gets when the session is live.
232 ///
233 /// `ResourceBusy` so callers can map it to a typed conflict rather than
234 /// pattern-matching on a message.
235 fn live_session_conflict(session_id: &str) -> std::io::Error {
236 std::io::Error::new(
237 std::io::ErrorKind::ResourceBusy,
238 format!(
239 "session '{session_id}' is open in an interactive Codewhale session; \
240 change it there instead — an external write would be reverted by its next autosave"
241 ),
242 )
243 }
244
245 /// File-name stem of the sidecar mapping session ids to the session
246 /// instance (process boot) that created their persisted record. Lives in
247 /// the sessions directory next to the `<id>.json` records it describes.
248 const SESSION_BOOT_OWNERS_STEM: &str = "session_boot_owners";
249
250 static SESSION_BOOT_ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
251
252 /// Identity of this running session instance (one per process boot).
253 ///
254 /// Mirrors the `SubAgentManager` boot id from #405: persisted records are
255 /// stamped with the instance that created them, so a later Codewhale
256 /// instance in the same workspace can tell restored rows from its own live
257 /// work (#4416).
258 #[must_use]
259 pub fn current_session_boot_id() -> &'static str {
260 SESSION_BOOT_ID.get_or_init(|| format!("boot_{}", &Uuid::new_v4().to_string()[..12]))
261 }
262
263 /// Which archive states a session listing includes.
264 ///
265 /// Deliberately the same three-way shape as
266 /// [`crate::runtime_threads::ThreadListFilter`] so `/v1/sessions` and
267 /// `/v1/threads` answer the same `include_archived` / `archived_only` query
268 /// pair with the same semantics.
269 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270 pub enum SessionListFilter {
271 /// Only `archived = false` sessions. The browse default.
272 #[default]
273 ActiveOnly,
274 /// Active and archived sessions, newest first.
275 IncludeArchived,
276 /// Only `archived = true` sessions.
277 ArchivedOnly,
278 }
279
280 impl SessionListFilter {
281 /// Resolve the `include_archived` / `archived_only` query pair the same
282 /// way the threads routes do.
283 #[must_use]
284 pub fn from_query(include_archived: Option<bool>, archived_only: Option<bool>) -> Self {
285 if archived_only.unwrap_or(false) {
286 Self::ArchivedOnly
287 } else if include_archived.unwrap_or(false) {
288 Self::IncludeArchived
289 } else {
290 Self::ActiveOnly
291 }
292 }
293
294 #[must_use]
295 pub fn admits(self, archived: bool) -> bool {
296 match self {
297 Self::ActiveOnly => !archived,
298 Self::IncludeArchived => true,
299 Self::ArchivedOnly => archived,
300 }
301 }
302 }
303
304 fn default_model_provider() -> String {
305 "deepseek".to_string()
306 }
307
308 impl SessionMetadata {
309 pub(crate) fn set_model_provider_route(&mut self, kind: &str, identity: Option<&str>) {
310 self.model_provider = kind.to_string();
311 self.model_provider_id = identity.map(str::to_string);
312 }
313 }
314
315 /// Cost and high-water-mark fields persisted with each session.
316 ///
317 /// The coverage fields below are persisted **alongside** the money so a restored
318 /// session can still say what its total covers. Without them a reload produced a
319 /// dollar figure with no completeness information, which then rendered as "0 of 0
320 /// turns priced" — a fabricated claim of a complete total. Sessions written
321 /// before these fields existed deserialize them from `Default`, which is
322 /// indistinguishable from that same false reading, so the load path detects the
323 /// legacy shape explicitly (see [`Self::coverage_is_legacy_unknown`]) rather than
324 /// trusting the defaults (#4318).
325 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
326 pub struct SessionCostSnapshot {
327 /// Accumulated parent-turn session cost in USD.
328 #[serde(default)]
329 pub session_cost_usd: f64,
330 /// Accumulated parent-turn session cost in CNY.
331 #[serde(default)]
332 pub session_cost_cny: f64,
333 /// Accumulated sub-agent/background LLM cost in USD.
334 #[serde(default)]
335 pub subagent_cost_usd: f64,
336 /// Accumulated sub-agent/background LLM cost in CNY.
337 #[serde(default)]
338 pub subagent_cost_cny: f64,
339 /// Max-ever displayed session+subagent cost in USD (preserves #244
340 /// monotonic guarantee across session restarts).
341 #[serde(default)]
342 pub displayed_cost_high_water_usd: f64,
343 /// Max-ever displayed session+subagent cost in CNY.
344 #[serde(default)]
345 pub displayed_cost_high_water_cny: f64,
346 /// Turns whose route was money-metered and produced an authoritative price.
347 /// These are exactly the turns the persisted totals contain.
348 #[serde(default)]
349 pub priced_turns: u32,
350 /// Money-metered (or unknown-basis) turns that produced no authoritative
351 /// price, so their spend is missing from the persisted totals.
352 #[serde(default)]
353 pub unpriced_turns: u32,
354 /// CNY-specific coverage. USD-only routes are unpriced in CNY rather than
355 /// silently contributing a fabricated zero.
356 #[serde(default)]
357 pub cny_priced_turns: u32,
358 #[serde(default)]
359 pub cny_unpriced_turns: u32,
360 /// Stable reason labels for the unpriced turns.
361 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
362 pub unpriced_reasons: BTreeSet<String>,
363 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
364 pub cny_unpriced_reasons: BTreeSet<String>,
365 /// Token classes used on some route that carry no published price.
366 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
367 pub unpriced_classes: BTreeSet<String>,
368 /// Provenance labels of the pricing rows the totals were built from.
369 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
370 pub pricing_provenances: BTreeSet<String>,
371 /// Live-pricing downgrade receipts recorded while building the totals.
372 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
373 pub live_pricing_defects: BTreeSet<String>,
374 /// Live rows that failed validation and had no usable bundled fallback.
375 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
376 pub live_pricing_unusable_defects: BTreeSet<String>,
377 /// Redacted per-route receipts: provider, configured identity, wire model,
378 /// billing surface, endpoint fingerprint, billing mode, currency. Never a URL, a
379 /// credential, or a filesystem path.
380 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
381 pub route_receipts: BTreeSet<String>,
382 /// Written by builds that track coverage, so a reader can tell "this session
383 /// genuinely had zero money-metered turns" apart from "this session predates
384 /// coverage tracking". Absent on legacy rows.
385 #[serde(default)]
386 pub coverage_recorded: bool,
387 }
388
389 impl SessionCostSnapshot {
390 /// Session + subagent spend as **one** dual-currency accumulator.
391 ///
392 /// The persisted USD and CNY columns are projections of per-turn
393 /// [`crate::pricing::CostEstimate`]s that were accumulated jointly; every
394 /// display total is derived from this single fold so the two currencies
395 /// cannot be re-summed by separate code paths that then drift (#4939).
396 /// CNY is *not* an FX multiple of USD: a turn carries CNY only when its
397 /// route published an authoritative CNY row (provider-published
398 /// dual-currency pricing, e.g. DeepSeek's CNY table), and a USD-only turn
399 /// contributes exactly zero CNY while `cny_unpriced_turns` records the gap.
400 #[must_use]
401 pub fn total_estimate(&self) -> crate::pricing::CostEstimate {
402 crate::pricing::CostEstimate {
403 usd: self.session_cost_usd,
404 cny: self.session_cost_cny,
405 }
406 .saturating_add(crate::pricing::CostEstimate {
407 usd: self.subagent_cost_usd,
408 cny: self.subagent_cost_cny,
409 })
410 }
411
412 /// Session + subagent cost in USD.
413 pub fn total_usd(&self) -> f64 {
414 self.total_estimate()
415 .amount(crate::pricing::CostCurrency::Usd)
416 }
417
418 /// Session + subagent cost in CNY.
419 pub fn total_cny(&self) -> f64 {
420 self.total_estimate()
421 .amount(crate::pricing::CostCurrency::Cny)
422 }
423
424 /// Whether this snapshot's coverage state must be shown as unknown.
425 ///
426 /// True when the snapshot has no coverage evidence — the signature of a
427 /// session written before coverage was persisted. Reporting any such
428 /// session as "0 of 0 priced" would claim completeness without evidence,
429 /// including when the saved amount is zero.
430 #[must_use]
431 pub fn coverage_is_legacy_unknown(&self) -> bool {
432 !self.coverage_recorded
433 }
434 }
435
436 impl SessionMetadata {
437 /// Copy cost fields from another metadata (used when forking a session).
438 #[allow(dead_code)]
439 pub fn copy_cost_from(&mut self, other: &SessionMetadata) {
440 self.cost = other.cost.clone();
441 }
442
443 /// Record additive lineage metadata for a forked saved session.
444 pub fn mark_forked_from(&mut self, parent: &SessionMetadata) {
445 self.parent_session_id = Some(parent.id.clone());
446 self.forked_from_message_count = Some(parent.message_count);
447 }
448 }
449
450 /// Durable Work-panel state. Optional on [`SavedSession`] so every session
451 /// written before v0.8.68 remains loadable without migration.
452 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
453 pub struct SessionWorkState {
454 /// Authoritative Work Graph. Optional so pre-Work-Graph sessions and old
455 /// binaries continue to exchange fully populated Plan/To-do views.
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub graph: Option<crate::work_graph::WorkGraphSnapshot>,
458 #[serde(default, skip_serializing_if = "TodoListSnapshot::is_empty")]
459 pub todos: TodoListSnapshot,
460 #[serde(default, skip_serializing_if = "PlanSnapshot::is_empty")]
461 pub plan: PlanSnapshot,
462 }
463
464 impl SessionWorkState {
465 #[must_use]
466 pub fn is_empty(&self) -> bool {
467 self.graph
468 .as_ref()
469 .is_none_or(crate::work_graph::WorkGraphSnapshot::is_empty)
470 && self.todos.is_empty()
471 && self.plan.is_empty()
472 }
473 }
474
475 /// Latest concrete Auto route and the decision receipt that produced it.
476 ///
477 /// This is additive, optional session metadata: sessions written before
478 /// v0.9.1 deserialize with no receipt and keep their legacy restore behavior.
479 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
480 pub(crate) struct SavedAutoRouteReceipt {
481 pub(crate) provider: ApiProvider,
482 pub(crate) provider_identity: String,
483 pub(crate) model: String,
484 pub(crate) receipt: AutoRouteReceipt,
485 /// Canonical effective reasoning receipt for the selected route, including
486 /// routes where a concrete tier cannot be proven. Optional so older
487 /// sessions remain loadable.
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub(crate) effective_reasoning_effort: Option<ReasoningEffortTier>,
490 }
491
492 /// A saved session containing full conversation history
493 #[derive(Debug, Clone, Serialize, Deserialize)]
494 pub struct SavedSession {
495 /// Schema version for migration compatibility
496 #[serde(default = "default_session_schema_version")]
497 pub schema_version: u32,
498 /// Session metadata
499 pub metadata: SessionMetadata,
500 /// Conversation messages
501 pub messages: Vec<Message>,
502 /// System prompt if any
503 pub system_prompt: Option<String>,
504 /// Compact linked context references for user-visible `@path` and
505 /// `/attach` mentions. Optional for backward-compatible session loads.
506 #[serde(default, skip_serializing_if = "Vec::is_empty")]
507 pub context_references: Vec<SessionContextReference>,
508 /// Metadata registry of large outputs produced during this session.
509 /// Artifact contents are stored in the session-owned artifact directory.
510 #[serde(default, skip_serializing_if = "Vec::is_empty")]
511 pub artifacts: Vec<ArtifactRecord>,
512 /// To-do and plan state shown in the Work sidebar.
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub work_state: Option<SessionWorkState>,
515 /// Most recent accepted/completed Auto decision, when the saved model mode
516 /// is `auto`. Optional for backward-compatible session loads.
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub(crate) last_auto_route: Option<SavedAutoRouteReceipt>,
519 }
520
521 /// Manager for session persistence operations
522 #[derive(Debug)]
523 pub struct SessionManager {
524 /// Directory where sessions are stored
525 sessions_dir: PathBuf,
526 }
527
528 /// Origin of a crash-recovery checkpoint file.
529 #[derive(Debug, Clone, PartialEq, Eq)]
530 pub enum CheckpointSource {
531 /// Per-session checkpoint file `checkpoints/<session_id>.json`.
532 Session(String),
533 /// Legacy single-slot checkpoint file `checkpoints/latest.json`.
534 Legacy,
535 }
536
537 /// A crash-recovery checkpoint file discovered on disk (metadata only —
538 /// callers load the session content separately).
539 #[derive(Debug, Clone)]
540 pub struct CheckpointRef {
541 pub source: CheckpointSource,
542 pub path: PathBuf,
543 pub modified: std::time::SystemTime,
544 }
545
546 /// File names in `checkpoints/` that are never per-session checkpoints.
547 const LEGACY_CHECKPOINT_FILE: &str = "latest.json";
548 const OFFLINE_QUEUE_FILE: &str = "offline_queue.json";
549
550 impl SessionManager {
551 fn validated_session_id<'a>(&self, id: &'a str) -> std::io::Result<&'a str> {
552 let trimmed = id.trim();
553 if trimmed.is_empty() {
554 return Err(std::io::Error::new(
555 std::io::ErrorKind::InvalidInput,
556 "Session id cannot be empty",
557 ));
558 }
559 if !trimmed
560 .chars()
561 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
562 {
563 return Err(std::io::Error::new(
564 std::io::ErrorKind::InvalidInput,
565 format!("Invalid session id '{id}'"),
566 ));
567 }
568 if trimmed == SESSION_BOOT_OWNERS_STEM {
569 return Err(std::io::Error::new(
570 std::io::ErrorKind::InvalidInput,
571 format!("Session id '{trimmed}' collides with a reserved sessions file"),
572 ));
573 }
574 Ok(trimmed)
575 }
576
577 fn validated_session_path(&self, id: &str) -> std::io::Result<PathBuf> {
578 let trimmed = self.validated_session_id(id)?;
579 Ok(self.sessions_dir.join(format!("{trimmed}.json")))
580 }
581
582 fn checkpoints_dir(&self) -> PathBuf {
583 self.sessions_dir.join("checkpoints")
584 }
585
586 fn validated_checkpoint_path(&self, session_id: &str) -> std::io::Result<PathBuf> {
587 let trimmed = self.validated_session_id(session_id)?;
588 // Reserved file names inside `checkpoints/` must never collide with a
589 // per-session checkpoint file.
590 if format!("{trimmed}.json") == LEGACY_CHECKPOINT_FILE
591 || format!("{trimmed}.json") == OFFLINE_QUEUE_FILE
592 {
593 return Err(std::io::Error::new(
594 std::io::ErrorKind::InvalidInput,
595 format!("Session id '{trimmed}' collides with a reserved checkpoint file"),
596 ));
597 }
598 Ok(self.checkpoints_dir().join(format!("{trimmed}.json")))
599 }
600
601 /// Create a new `SessionManager` with the specified sessions directory
602 pub fn new(sessions_dir: PathBuf) -> std::io::Result<Self> {
603 let sessions_dir = normalize_managed_dir(sessions_dir)?;
604 // Ensure the sessions directory exists
605 fs::create_dir_all(&sessions_dir)?;
606 Ok(Self { sessions_dir })
607 }
608
609 /// Create a `SessionManager` using the default location.
610 pub fn default_location() -> std::io::Result<Self> {
611 Self::new(default_sessions_dir()?)
612 }
613
614 /// Return the resolved sessions directory path.
615 pub fn sessions_dir(&self) -> &Path {
616 &self.sessions_dir
617 }
618
619 /// Save a session to disk using atomic write (temp file + fsync + rename).
620 pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
621 let path = self.validated_session_path(&session.metadata.id)?;
622 let already_persisted = path.exists()
623 || self
624 .validated_checkpoint_path(&session.metadata.id)
625 .is_ok_and(|checkpoint| checkpoint.exists());
626
627 self.archive_before_first_graph_write(session, &path)?;
628
629 let content = serde_json::to_string_pretty(&session)
630 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
631
632 // Atomic write via write_atomic (NamedTempFile + fsync + persist)
633 write_atomic(&path, content.as_bytes())?;
634 self.stamp_session_boot_owner_for_new_record(&session.metadata.id, already_persisted);
635
636 // Clean up old sessions if we have too many
637 self.cleanup_old_sessions()?;
638
639 Ok(path)
640 }
641
642 /// Save a crash-recovery checkpoint for in-flight turns.
643 ///
644 /// Checkpoints are keyed per session (`checkpoints/<session_id>.json`) so
645 /// concurrent sessions never overwrite each other's crash-recovery state.
646 pub fn save_checkpoint(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
647 let path = self.validated_checkpoint_path(&session.metadata.id)?;
648 let session_path = self.validated_session_path(&session.metadata.id)?;
649 self.archive_before_first_graph_write(session, &session_path)?;
650 fs::create_dir_all(self.checkpoints_dir())?;
651 let already_persisted = path.exists() || session_path.exists();
652 let content = serde_json::to_string_pretty(&session)
653 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
654 write_atomic(&path, content.as_bytes())?;
655 self.stamp_session_boot_owner_for_new_record(&session.metadata.id, already_persisted);
656 Ok(path)
657 }
658
659 fn session_boot_owners_path(&self) -> PathBuf {
660 self.sessions_dir
661 .join(format!("{SESSION_BOOT_OWNERS_STEM}.json"))
662 }
663
664 fn load_session_boot_owners(&self) -> BTreeMap<String, String> {
665 fs::read_to_string(self.session_boot_owners_path())
666 .ok()
667 .and_then(|content| serde_json::from_str(&content).ok())
668 .unwrap_or_default()
669 }
670
671 /// Does any durable record (session file or crash checkpoint) exist for
672 /// this session id?
673 fn session_record_exists(&self, session_id: &str) -> bool {
674 self.validated_session_path(session_id)
675 .is_ok_and(|path| path.exists())
676 || self
677 .validated_checkpoint_path(session_id)
678 .is_ok_and(|path| path.exists())
679 }
680
681 /// Record which session instance owns `session_id`'s persisted record.
682 ///
683 /// Entries whose durable record no longer exists are pruned on the same
684 /// write, so the sidecar cannot grow without bound.
685 pub(crate) fn record_session_boot_owner(
686 &self,
687 session_id: &str,
688 boot_id: &str,
689 ) -> std::io::Result<()> {
690 let id = self.validated_session_id(session_id)?.to_string();
691 let mut owners = self.load_session_boot_owners();
692 owners.retain(|owned, _| owned == &id || self.session_record_exists(owned));
693 owners.insert(id, boot_id.to_string());
694 let content = serde_json::to_string_pretty(&owners)
695 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
696 write_atomic(&self.session_boot_owners_path(), content.as_bytes())
697 }
698
699 /// The session-instance boot id stamped on this session's persisted
700 /// record, when one was recorded.
701 #[must_use]
702 pub fn session_boot_owner(&self, session_id: &str) -> Option<String> {
703 let id = self.validated_session_id(session_id).ok()?;
704 self.load_session_boot_owners().get(id).cloned()
705 }
706
707 /// Was this session's persisted record created by a different session
708 /// instance (an earlier or sibling Codewhale process)?
709 ///
710 /// Mirrors `SubAgentManager::is_from_prior_session` (#405): a durable
711 /// record with no stamped owner predates the marker and is classified as
712 /// prior-instance work, while an id with no durable record at all is
713 /// this instance's own not-yet-persisted session.
714 #[must_use]
715 pub fn session_from_prior_instance(&self, session_id: &str) -> bool {
716 match self.session_boot_owner(session_id) {
717 Some(owner) => owner != current_session_boot_id(),
718 None => self.session_record_exists(session_id),
719 }
720 }
721
722 /// Stamp this instance as creator when a save writes the first durable
723 /// record for `session_id`. A record that already existed keeps its
724 /// original owner: re-serializing another instance's work (crash
725 /// recovery, external mutation) must not re-badge it as ours.
726 fn stamp_session_boot_owner_for_new_record(&self, session_id: &str, already_persisted: bool) {
727 if already_persisted || self.session_boot_owner(session_id).is_some() {
728 return;
729 }
730 if let Err(error) = self.record_session_boot_owner(session_id, current_session_boot_id()) {
731 tracing::warn!(session_id, %error, "could not stamp session boot owner");
732 }
733 }
734
735 fn clear_session_boot_owner(&self, session_id: &str) {
736 let Ok(id) = self.validated_session_id(session_id) else {
737 return;
738 };
739 let mut owners = self.load_session_boot_owners();
740 if owners.remove(id).is_none() {
741 return;
742 }
743 if let Ok(content) = serde_json::to_string_pretty(&owners) {
744 let _ = write_atomic(&self.session_boot_owners_path(), content.as_bytes());
745 }
746 }
747
748 /// Preserve the exact pre-import session once, before the first graph-
749 /// bearing session or checkpoint write can replace it.
750 fn archive_before_first_graph_write(
751 &self,
752 session: &SavedSession,
753 source: &Path,
754 ) -> std::io::Result<()> {
755 let writes_graph = session
756 .work_state
757 .as_ref()
758 .and_then(|state| state.graph.as_ref())
759 .is_some_and(|graph| !graph.is_empty());
760 if !writes_graph || !source.exists() {
761 return Ok(());
762 }
763 let bytes = fs::read(source)?;
764 let already_graph_backed = serde_json::from_slice::<SavedSession>(&bytes)
765 .ok()
766 .and_then(|saved| saved.work_state)
767 .and_then(|state| state.graph)
768 .is_some_and(|graph| !graph.is_empty());
769 if already_graph_backed {
770 return Ok(());
771 }
772 let archive_dir = self.sessions_dir.join(WORK_GRAPH_IMPORT_ARCHIVE_DIR);
773 fs::create_dir_all(&archive_dir)?;
774 let archive =
775 archive_dir.join(source.file_name().ok_or_else(|| {
776 io::Error::new(io::ErrorKind::InvalidInput, "invalid session path")
777 })?);
778 if !archive.exists() {
779 write_atomic(&archive, &bytes)?;
780 }
781 Ok(())
782 }
783
784 fn read_checkpoint_file(&self, path: &Path) -> std::io::Result<Option<SavedSession>> {
785 if !path.exists() {
786 return Ok(None);
787 }
788 let content = fs::read_to_string(path)?;
789 let mut session: SavedSession = serde_json::from_str(&content)
790 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
791 if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
792 return Err(std::io::Error::new(
793 std::io::ErrorKind::InvalidData,
794 format!(
795 "Checkpoint schema v{} is newer than supported v{}",
796 session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
797 ),
798 ));
799 }
800 session.system_prompt = strip_legacy_truncation_note(session.system_prompt);
801 Ok(Some(session))
802 }
803
804 /// Load a specific session's crash-recovery checkpoint if present.
805 pub fn load_session_checkpoint(
806 &self,
807 session_id: &str,
808 ) -> std::io::Result<Option<SavedSession>> {
809 let path = self.validated_checkpoint_path(session_id)?;
810 self.read_checkpoint_file(&path)
811 }
812
813 /// Load the legacy single-slot checkpoint (`checkpoints/latest.json`) if
814 /// present. Compatibility read only — this release no longer writes it.
815 pub fn load_legacy_checkpoint(&self) -> std::io::Result<Option<SavedSession>> {
816 let path = self.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
817 self.read_checkpoint_file(&path)
818 }
819
820 /// Clear one session's crash-recovery checkpoint. Scoped: this can never
821 /// remove another session's checkpoint file or the legacy slot.
822 pub fn clear_session_checkpoint(&self, session_id: &str) -> std::io::Result<()> {
823 let path = self.validated_checkpoint_path(session_id)?;
824 if path.exists() {
825 fs::remove_file(path)?;
826 }
827 Ok(())
828 }
829
830 /// Remove the legacy single-slot checkpoint file.
831 pub fn clear_legacy_checkpoint(&self) -> std::io::Result<()> {
832 let path = self.checkpoints_dir().join(LEGACY_CHECKPOINT_FILE);
833 if path.exists() {
834 fs::remove_file(path)?;
835 }
836 Ok(())
837 }
838
839 /// Enumerate all crash-recovery checkpoint files (per-session files plus
840 /// the legacy single slot), sorted most recently modified first. Only
841 /// file metadata is read here; callers load content per candidate.
842 pub fn list_checkpoints(&self) -> std::io::Result<Vec<CheckpointRef>> {
843 let dir = self.checkpoints_dir();
844 let mut refs = Vec::new();
845 let entries = match fs::read_dir(&dir) {
846 Ok(entries) => entries,
847 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(refs),
848 Err(err) => return Err(err),
849 };
850 for entry in entries {
851 let entry = entry?;
852 let path = entry.path();
853 if !path.is_file() || path.extension().is_none_or(|ext| ext != "json") {
854 continue;
855 }
856 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
857 continue;
858 };
859 let source = if name == LEGACY_CHECKPOINT_FILE {
860 CheckpointSource::Legacy
861 } else if name == OFFLINE_QUEUE_FILE {
862 continue;
863 } else {
864 let session_id = name.trim_end_matches(".json").to_string();
865 if self.validated_checkpoint_path(&session_id).is_err() {
866 continue;
867 }
868 CheckpointSource::Session(session_id)
869 };
870 let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else {
871 continue;
872 };
873 refs.push(CheckpointRef {
874 source,
875 path,
876 modified,
877 });
878 }
879 refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
880 Ok(refs)
881 }
882
883 /// Migrate a session recovered from the legacy single-slot checkpoint to
884 /// a per-session checkpoint file. Never overwrites an existing
885 /// per-session file and leaves the legacy file in place (older binaries
886 /// still read it; the legacy writer is already gone). Returns whether a
887 /// file was written.
888 pub fn write_session_checkpoint_if_absent(
889 &self,
890 session: &SavedSession,
891 ) -> std::io::Result<bool> {
892 let path = self.validated_checkpoint_path(&session.metadata.id)?;
893 if path.exists() {
894 return Ok(false);
895 }
896 self.save_checkpoint(session)?;
897 Ok(true)
898 }
899
900 /// Save offline queue state (queued + draft messages).
901 pub fn save_offline_queue_state(
902 &self,
903 state: &OfflineQueueState,
904 session_id: Option<&str>,
905 ) -> std::io::Result<PathBuf> {
906 let checkpoints = self.sessions_dir.join("checkpoints");
907 fs::create_dir_all(&checkpoints)?;
908 let path = checkpoints.join("offline_queue.json");
909 let mut state_with_id = state.clone();
910 state_with_id.session_id = session_id.map(|s| s.to_string());
911 let content = serde_json::to_string_pretty(&state_with_id)
912 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
913 write_atomic(&path, content.as_bytes())?;
914 Ok(path)
915 }
916
917 /// Load offline queue state if present.
918 pub fn load_offline_queue_state(&self) -> std::io::Result<Option<OfflineQueueState>> {
919 let path = self
920 .sessions_dir
921 .join("checkpoints")
922 .join("offline_queue.json");
923 if !path.exists() {
924 return Ok(None);
925 }
926 let content = fs::read_to_string(&path)?;
927 let state: OfflineQueueState = serde_json::from_str(&content)
928 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
929 if state.schema_version > CURRENT_QUEUE_SCHEMA_VERSION {
930 return Err(std::io::Error::new(
931 std::io::ErrorKind::InvalidData,
932 format!(
933 "Offline queue schema v{} is newer than supported v{}",
934 state.schema_version, CURRENT_QUEUE_SCHEMA_VERSION
935 ),
936 ));
937 }
938 Ok(Some(state))
939 }
940
941 /// Remove persisted offline queue state.
942 pub fn clear_offline_queue_state(&self) -> std::io::Result<()> {
943 let path = self
944 .sessions_dir
945 .join("checkpoints")
946 .join("offline_queue.json");
947 if path.exists() {
948 fs::remove_file(path)?;
949 }
950 Ok(())
951 }
952
953 /// Load a session by ID
954 pub fn load_session(&self, id: &str) -> std::io::Result<SavedSession> {
955 let path = self.validated_session_path(id)?;
956
957 let content = fs::read_to_string(&path)?;
958 let mut session: SavedSession = serde_json::from_str(&content)
959 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
960 if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
961 return Err(std::io::Error::new(
962 std::io::ErrorKind::InvalidData,
963 format!(
964 "Session schema v{} is newer than supported v{}",
965 session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
966 ),
967 ));
968 }
969
970 session.system_prompt = strip_legacy_truncation_note(session.system_prompt);
971
972 let repair = crate::tool_history_repair::repair_tool_call_pairs(&mut session.messages);
973 if !repair.is_empty() {
974 session.metadata.message_count = session.messages.len();
975 tracing::warn!(
976 session_id = %session.metadata.id,
977 repaired_call_ids = ?repair.repaired_call_ids,
978 duplicate_result_ids = ?repair.duplicate_result_ids,
979 orphan_result_ids = ?repair.orphan_result_ids,
980 "repaired persisted tool call/result history"
981 );
982 }
983
984 Ok(session)
985 }
986
987 /// Load a session by partial ID prefix
988 pub fn load_session_by_prefix(&self, prefix: &str) -> std::io::Result<SavedSession> {
989 let sessions = self.list_sessions()?;
990
991 let matches: Vec<_> = sessions
992 .into_iter()
993 .filter(|s| s.id.starts_with(prefix))
994 .collect();
995
996 match matches.len() {
997 0 => Err(std::io::Error::new(
998 std::io::ErrorKind::NotFound,
999 format!("No session found with prefix: {prefix}"),
1000 )),
1001 1 => self.load_session(&matches[0].id),
1002 _ => Err(std::io::Error::new(
1003 std::io::ErrorKind::InvalidInput,
1004 format!(
1005 "Ambiguous prefix '{}' matches {} sessions",
1006 prefix,
1007 matches.len()
1008 ),
1009 )),
1010 }
1011 }
1012
1013 /// List all saved sessions, sorted by most recently updated
1014 pub fn list_sessions(&self) -> std::io::Result<Vec<SessionMetadata>> {
1015 let mut sessions = Vec::new();
1016
1017 for entry in fs::read_dir(&self.sessions_dir)? {
1018 let entry = entry?;
1019 let path = entry.path();
1020
1021 if path.extension().is_some_and(|ext| ext == "json")
1022 && let Ok(session) = Self::load_session_metadata(&path)
1023 {
1024 sessions.push(session);
1025 }
1026 }
1027
1028 // Sort by updated_at descending (most recent first)
1029 sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
1030
1031 Ok(sessions)
1032 }
1033
1034 /// Set the durable archive flag on a saved session and return the
1035 /// resulting metadata.
1036 ///
1037 /// This is the single writer for the flag: the picker, the `/sessions`
1038 /// command, and `PATCH /v1/sessions/{id}` all route through it so the TUI
1039 /// and the web dashboard cannot drift into two archive notions. A no-op
1040 /// call (already in the requested state) still returns the metadata and
1041 /// does not rewrite the file.
1042 pub fn set_session_archived(
1043 &self,
1044 id: &str,
1045 archived: bool,
1046 mutator: SessionMutator,
1047 ) -> std::io::Result<SessionMetadata> {
1048 if mutator == SessionMutator::External && is_live_session(id) {
1049 return Err(live_session_conflict(id));
1050 }
1051 let mut session = self.load_session(id)?;
1052 if session.metadata.archived == archived {
1053 return Ok(session.metadata);
1054 }
1055 session.metadata.archived = archived;
1056 self.save_session(&session)?;
1057 Ok(session.metadata)
1058 }
1059
1060 /// Re-read the durable lifecycle fields for `metadata` from disk.
1061 ///
1062 /// This is the autosave-survival guard. A TUI autosave rebuilds the whole
1063 /// session document from in-memory `App` state; any lifecycle field it
1064 /// carries from a stale cache would silently revert a rename or archive
1065 /// that landed in between — including one applied by the picker earlier in
1066 /// the same event loop, or by `/rename` while a snapshot was already
1067 /// queued.
1068 ///
1069 /// So rather than trusting any cache, the writer re-reads the persisted
1070 /// values immediately before writing. `title`, `archived`, `created_at`,
1071 /// and fork lineage are *lifecycle* state owned by the file, not
1072 /// conversation state owned by the running turn. Reading them back costs
1073 /// one bounded metadata-prefix read.
1074 ///
1075 /// Returns `true` when an existing record was found and merged. A missing
1076 /// record is not an error: the first save of a new session has nothing to
1077 /// merge from.
1078 pub fn merge_persisted_lifecycle(&self, metadata: &mut SessionMetadata) -> bool {
1079 let Ok(path) = self.validated_session_path(&metadata.id) else {
1080 return false;
1081 };
1082 let Ok(persisted) = Self::load_session_metadata(&path) else {
1083 return false;
1084 };
1085 metadata.title = persisted.title;
1086 metadata.archived = persisted.archived;
1087 metadata.created_at = persisted.created_at;
1088 metadata.parent_session_id = persisted.parent_session_id;
1089 metadata.forked_from_message_count = persisted.forked_from_message_count;
1090 true
1091 }
1092
1093 /// Rename a saved session and return the resulting metadata.
1094 ///
1095 /// Titles are trimmed and bounded to [`MAX_SESSION_TITLE_CHARS`]
1096 /// characters (counted in `char`s, not bytes, so a CJK or emoji title is
1097 /// not truncated mid-scalar). Created-at and fork lineage are untouched.
1098 pub fn rename_session(
1099 &self,
1100 id: &str,
1101 title: &str,
1102 mutator: SessionMutator,
1103 ) -> std::io::Result<SessionMetadata> {
1104 let title = normalize_session_title(title)?;
1105 if mutator == SessionMutator::External && is_live_session(id) {
1106 return Err(live_session_conflict(id));
1107 }
1108 let mut session = self.load_session(id)?;
1109 if session.metadata.title == title {
1110 return Ok(session.metadata);
1111 }
1112 session.metadata.title = title;
1113 self.save_session(&session)?;
1114 Ok(session.metadata)
1115 }
1116
1117 /// Load only the metadata from a session file.
1118 ///
1119 /// Optimization for #337: previously this called
1120 /// `serde_json::from_reader` which forces serde to scan every token in
1121 /// the file just to validate JSON structure — including the
1122 /// (potentially many MB of) `messages` and `tool_log` arrays we're
1123 /// going to discard. For a user with hundreds of long sessions, a
1124 /// single `list_sessions()` call could chew through tens of MB of
1125 /// JSON per startup.
1126 ///
1127 /// We now read at most 64 KB up front and string-extract the
1128 /// top-level `metadata` object, which is invariably tiny (~500 B)
1129 /// and appears before any large `messages`/`tool_log` payload. We
1130 /// fall back to a full-file read only if the prefix doesn't yield a
1131 /// parseable metadata block (e.g. an oddly-formatted legacy file).
1132 fn load_session_metadata(path: &Path) -> std::io::Result<SessionMetadata> {
1133 use std::io::Read;
1134
1135 const PREFIX_BYTES: usize = 64 * 1024;
1136 let mut file = fs::File::open(path)?;
1137 let mut buf = Vec::with_capacity(PREFIX_BYTES);
1138 file.by_ref()
1139 .take(PREFIX_BYTES as u64)
1140 .read_to_end(&mut buf)?;
1141
1142 if let Some(metadata) = extract_top_level_metadata(&buf) {
1143 return Ok(metadata);
1144 }
1145
1146 // Metadata wasn't extractable from the prefix (truncated mid-block,
1147 // unusual key ordering, etc.). Read the rest and try again with the
1148 // full buffer before giving up.
1149 let mut rest = Vec::new();
1150 file.read_to_end(&mut rest)?;
1151 buf.extend_from_slice(&rest);
1152 extract_top_level_metadata(&buf).ok_or_else(|| {
1153 std::io::Error::new(
1154 std::io::ErrorKind::InvalidData,
1155 "session file missing parseable `metadata` block",
1156 )
1157 })
1158 }
1159
1160 /// Delete a session by ID
1161 pub fn delete_session(&self, id: &str) -> std::io::Result<()> {
1162 let path = self.validated_session_path(id)?;
1163 fs::remove_file(path)?;
1164 self.clear_session_boot_owner(id);
1165 let session_dir = self.sessions_dir.join(id.trim());
1166 if session_dir.exists() {
1167 fs::remove_dir_all(session_dir)?;
1168 }
1169 Ok(())
1170 }
1171
1172 /// Clean up old sessions to stay within `MAX_SESSIONS` limit.
1173 pub fn cleanup_old_sessions(&self) -> std::io::Result<()> {
1174 self.cleanup_old_sessions_keeping(None)
1175 }
1176
1177 /// As [`Self::cleanup_old_sessions`], but never deletes `keep` — the
1178 /// session being resumed at boot. Without this, a background cleanup that
1179 /// races session restore can prune the just-resumed session when 50+
1180 /// newer records exist (its `updated_at` is not bumped until first save).
1181 pub fn cleanup_old_sessions_keeping(&self, keep: Option<&str>) -> std::io::Result<()> {
1182 let sessions = self.list_sessions()?;
1183
1184 if sessions.len() > MAX_SESSIONS {
1185 for session in sessions.iter().skip(MAX_SESSIONS) {
1186 if keep.is_some_and(|id| id == session.id) {
1187 continue;
1188 }
1189 let _ = self.delete_session(&session.id);
1190 }
1191 }
1192
1193 Ok(())
1194 }
1195
1196 /// Remove session files whose `updated_at` is older than `max_age`
1197 /// from the persisted-sessions directory. Returns the number of
1198 /// records pruned. Building block for #406's phase-2 auto-archive
1199 /// on boot; today the user-facing entry point is the
1200 /// `/sessions prune <days>` slash command.
1201 ///
1202 /// Crash-recovery safety: skips the per-session checkpoint files
1203 /// (`checkpoints/<session_id>.json`), the legacy single-slot
1204 /// checkpoint (`checkpoints/latest.json`), and any file under `checkpoints/`
1205 /// — those are owned by the checkpoint subsystem and live with
1206 /// stricter durability rules. Only top-level `<session_id>.json`
1207 /// files are candidates.
1208 ///
1209 /// `max_age` is checked against the metadata's `updated_at`
1210 /// timestamp embedded in the JSON, not the filesystem mtime — the
1211 /// user may have rsynced their `~/.deepseek` between machines and
1212 /// fs mtimes can lie.
1213 pub fn prune_sessions_older_than(
1214 &self,
1215 max_age: std::time::Duration,
1216 ) -> std::io::Result<usize> {
1217 self.prune_sessions_older_than_keeping(max_age, None)
1218 }
1219
1220 /// As [`Self::prune_sessions_older_than`], but never deletes `keep` — the
1221 /// active session. A just-resumed session's `updated_at` is stale until
1222 /// its first post-resume save, so an age prune could otherwise delete the
1223 /// live session out from under the TUI.
1224 pub fn prune_sessions_older_than_keeping(
1225 &self,
1226 max_age: std::time::Duration,
1227 keep: Option<&str>,
1228 ) -> std::io::Result<usize> {
1229 let cutoff = Utc::now()
1230 - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::days(365 * 10));
1231 let sessions = self.list_sessions()?;
1232 let mut pruned = 0usize;
1233 for session in sessions {
1234 if keep.is_some_and(|id| id == session.id) {
1235 continue;
1236 }
1237 if session.updated_at < cutoff {
1238 if let Err(err) = self.delete_session(&session.id) {
1239 tracing::warn!(
1240 target: "session",
1241 session = session.id,
1242 ?err,
1243 "session prune skipped a record",
1244 );
1245 continue;
1246 }
1247 pruned += 1;
1248 }
1249 }
1250 Ok(pruned)
1251 }
1252
1253 /// Get the most recent session scoped to the current workspace.
1254 ///
1255 /// Archived sessions are skipped: archiving is the user saying "not this
1256 /// one", and `--continue` / auto-resume must honour that rather than
1257 /// dragging a put-away session back.
1258 pub fn get_latest_session_for_workspace(
1259 &self,
1260 workspace: &Path,
1261 ) -> std::io::Result<Option<SessionMetadata>> {
1262 let sessions = self.list_sessions()?;
1263 Ok(sessions.into_iter().find(|session| {
1264 !session.archived
1265 && workspace_scope_matches(&session.workspace, workspace)
1266 && !is_empty_auto_created_session(session)
1267 }))
1268 }
1269
1270 /// Search sessions by title
1271 pub fn search_sessions(&self, query: &str) -> std::io::Result<Vec<SessionMetadata>> {
1272 let query_lower = query.to_lowercase();
1273 let sessions = self.list_sessions()?;
1274
1275 Ok(sessions
1276 .into_iter()
1277 .filter(|s| s.title.to_lowercase().contains(&query_lower))
1278 .collect())
1279 }
1280 }
1281
1282 /// Trim and bound a user-supplied session title.
1283 ///
1284 /// Returns `InvalidInput` for an empty title or one longer than
1285 /// [`MAX_SESSION_TITLE_CHARS`] so every rename surface (picker, `/rename`,
1286 /// `PATCH /v1/sessions/{id}`) rejects the same inputs with the same reason.
1287 pub fn normalize_session_title(title: &str) -> std::io::Result<String> {
1288 let trimmed = title.trim();
1289 if trimmed.is_empty() {
1290 return Err(std::io::Error::new(
1291 std::io::ErrorKind::InvalidInput,
1292 "Session title cannot be empty",
1293 ));
1294 }
1295 if trimmed.chars().count() > MAX_SESSION_TITLE_CHARS {
1296 return Err(std::io::Error::new(
1297 std::io::ErrorKind::InvalidInput,
1298 format!("Session title cannot exceed {MAX_SESSION_TITLE_CHARS} characters"),
1299 ));
1300 }
1301 Ok(trimmed.to_string())
1302 }
1303
1304 pub(crate) fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> bool {
1305 if paths_equivalent(saved_workspace, current_workspace) {
1306 return true;
1307 }
1308
1309 match (
1310 find_git_root(saved_workspace),
1311 find_git_root(current_workspace),
1312 ) {
1313 (Some(saved_root), Some(current_root)) => paths_equivalent(&saved_root, &current_root),
1314 _ => false,
1315 }
1316 }
1317
1318 fn is_empty_auto_created_session(session: &SessionMetadata) -> bool {
1319 session.message_count == 0 && session.title.trim().eq_ignore_ascii_case("New Session")
1320 }
1321
1322 fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
1323 let lhs_canonical = fs::canonicalize(lhs).ok();
1324 let rhs_canonical = fs::canonicalize(rhs).ok();
1325 match (lhs_canonical, rhs_canonical) {
1326 (Some(lhs), Some(rhs)) => lhs == rhs,
1327 _ => lhs == rhs,
1328 }
1329 }
1330
1331 fn find_git_root(path: &Path) -> Option<PathBuf> {
1332 let mut current = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1333 loop {
1334 let git_entry = current.join(".git");
1335 if git_entry.exists() {
1336 return is_git_metadata_entry(&git_entry).then_some(current);
1337 }
1338 match current.parent() {
1339 Some(parent) if parent != current => current = parent.to_path_buf(),
1340 _ => return None,
1341 }
1342 }
1343 }
1344
1345 fn is_git_metadata_entry(path: &Path) -> bool {
1346 if path.is_dir() {
1347 return path.join("HEAD").is_file();
1348 }
1349
1350 fs::read_to_string(path)
1351 .map(|content| content.trim_start().starts_with("gitdir:"))
1352 .unwrap_or(false)
1353 }
1354
1355 /// Resolve the default session directory path.
1356 ///
1357 /// v0.8.44: prefers `~/.codewhale/sessions`, falls back to
1358 /// `~/.deepseek/sessions` for existing installs. Uses the write-path resolver
1359 /// so the first access relocates any legacy `~/.deepseek/sessions` into
1360 /// `~/.codewhale/sessions` when the primary directory is missing (#3240).
1361 /// If an older build already created an empty primary sessions directory, copy
1362 /// missing legacy entries into it without overwriting newer CodeWhale data.
1363 pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
1364 let dir = codewhale_config::ensure_state_dir("sessions")
1365 .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()))?;
1366 match merge_missing_legacy_session_entries(&dir) {
1367 Ok(0) => {}
1368 Ok(count) => {
1369 tracing::info!(
1370 target: "session::migration",
1371 "Copied {count} missing legacy session entries into {}",
1372 dir.display()
1373 );
1374 }
1375 Err(err) => {
1376 tracing::warn!(
1377 target: "session::migration",
1378 "Could not copy legacy sessions into {}: {err}",
1379 dir.display()
1380 );
1381 }
1382 }
1383 Ok(dir)
1384 }
1385
1386 fn merge_missing_legacy_session_entries(primary: &Path) -> io::Result<usize> {
1387 if codewhale_paths::codewhale_home_is_explicit() {
1388 return Ok(0);
1389 }
1390
1391 let legacy = codewhale_config::legacy_deepseek_home()
1392 .map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?
1393 .join("sessions");
1394 if !legacy.is_dir() || paths_equivalent(primary, &legacy) {
1395 return Ok(0);
1396 }
1397
1398 copy_missing_dir_entries(&legacy, primary)
1399 }
1400
1401 fn copy_missing_dir_entries(src: &Path, dst: &Path) -> io::Result<usize> {
1402 fs::create_dir_all(dst)?;
1403 let mut copied = 0;
1404 for entry in fs::read_dir(src)? {
1405 let entry = entry?;
1406 let source = entry.path();
1407 let target = dst.join(entry.file_name());
1408
1409 let file_type = entry.file_type()?;
1410 if file_type.is_dir() {
1411 if entry.file_name() == std::ffi::OsStr::new("checkpoints") || target.exists() {
1412 continue;
1413 }
1414 copied += copy_missing_dir_entries(&source, &target)?;
1415 } else if file_type.is_file() {
1416 copied += usize::from(copy_file_create_new(&source, &target)?);
1417 }
1418 }
1419 Ok(copied)
1420 }
1421
1422 fn copy_file_create_new(src: &Path, dst: &Path) -> io::Result<bool> {
1423 let mut source = fs::File::open(src)?;
1424 let mut target = match fs::OpenOptions::new()
1425 .write(true)
1426 .create_new(true)
1427 .open(dst)
1428 {
1429 Ok(file) => file,
1430 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
1431 Err(err) => return Err(err),
1432 };
1433 if let Err(err) = io::copy(&mut source, &mut target) {
1434 let _ = fs::remove_file(dst);
1435 return Err(err);
1436 }
1437 Ok(true)
1438 }
1439
1440 /// Prune snapshots older than `max_age` for `workspace`.
1441 ///
1442 /// Always non-fatal. Returns silently — callers don't need the count
1443 /// (the underlying repo logs at WARN if anything blew up).
1444 pub fn prune_workspace_snapshots(workspace: &Path, max_age: std::time::Duration) {
1445 match crate::snapshot::prune_older_than(workspace, max_age) {
1446 Ok(0) => {}
1447 Ok(n) => {
1448 tracing::debug!(target: "snapshot", "boot prune removed {n} snapshot(s)");
1449 }
1450 Err(e) => {
1451 tracing::warn!(target: "snapshot", "boot prune failed: {e}");
1452 }
1453 }
1454 }
1455
1456 /// Create a new `SavedSession` from conversation state
1457 pub fn create_saved_session(
1458 messages: &[Message],
1459 model: &str,
1460 workspace: &Path,
1461 total_tokens: u64,
1462 system_prompt: Option<&SystemPrompt>,
1463 ) -> SavedSession {
1464 create_saved_session_with_mode(
1465 messages,
1466 model,
1467 workspace,
1468 total_tokens,
1469 system_prompt,
1470 None,
1471 )
1472 }
1473
1474 /// Create a new `SavedSession` from conversation state with optional mode label
1475 pub fn create_saved_session_with_mode(
1476 messages: &[Message],
1477 model: &str,
1478 workspace: &Path,
1479 total_tokens: u64,
1480 system_prompt: Option<&SystemPrompt>,
1481 mode: Option<&str>,
1482 ) -> SavedSession {
1483 create_saved_session_with_id_and_mode(
1484 Uuid::new_v4().to_string(),
1485 messages,
1486 model,
1487 workspace,
1488 total_tokens,
1489 system_prompt,
1490 mode,
1491 )
1492 }
1493
1494 /// Create a new `SavedSession` using a caller-owned session id.
1495 pub fn create_saved_session_with_id_and_mode(
1496 id: String,
1497 messages: &[Message],
1498 model: &str,
1499 workspace: &Path,
1500 total_tokens: u64,
1501 system_prompt: Option<&SystemPrompt>,
1502 mode: Option<&str>,
1503 ) -> SavedSession {
1504 let now = Utc::now();
1505
1506 // Generate title from first user message
1507 let title = messages
1508 .iter()
1509 .find(|m| m.role == "user")
1510 .and_then(|m| {
1511 m.content.iter().find_map(|block| match block {
1512 ContentBlock::Text { text, .. } => {
1513 let prompt = extract_user_prompt(text);
1514 if prompt.is_empty() {
1515 None
1516 } else {
1517 Some(truncate_title(prompt, 50))
1518 }
1519 }
1520 _ => None,
1521 })
1522 })
1523 .unwrap_or_else(|| "New Session".to_string());
1524
1525 SavedSession {
1526 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
1527 metadata: SessionMetadata {
1528 id,
1529 title,
1530 created_at: now,
1531 updated_at: now,
1532 message_count: messages.len(),
1533 total_tokens,
1534 model: model.to_string(),
1535 model_provider: default_model_provider(),
1536 model_provider_id: None,
1537 workspace: workspace.to_path_buf(),
1538 mode: mode.map(str::to_string),
1539 cost: SessionCostSnapshot::default(),
1540 parent_session_id: None,
1541 forked_from_message_count: None,
1542 cumulative_turn_secs: 0,
1543 archived: false,
1544 },
1545 messages: messages.to_vec(),
1546 system_prompt: system_prompt_to_string(system_prompt),
1547 context_references: Vec::new(),
1548 artifacts: Vec::new(),
1549 work_state: None,
1550 last_auto_route: None,
1551 }
1552 }
1553
1554 /// Update an existing session with new messages
1555 pub fn update_session(
1556 mut session: SavedSession,
1557 messages: &[Message],
1558 total_tokens: u64,
1559 system_prompt: Option<&SystemPrompt>,
1560 ) -> SavedSession {
1561 session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
1562 session.messages.clear();
1563 session.messages.extend_from_slice(messages);
1564 session.metadata.updated_at = Utc::now();
1565 session.metadata.message_count = messages.len();
1566 session.metadata.total_tokens = total_tokens;
1567 session.system_prompt = system_prompt_to_string(system_prompt);
1568 session
1569 }
1570
1571 /// Strip a stale `[Session note]` block that was written by the old
1572 /// 500-message cap. Only removes notes that contain the specific
1573 /// "older messages were dropped" phrase — ordinary user-added
1574 /// `[Session note]` prompts are left untouched.
1575 fn strip_legacy_truncation_note(system_prompt: Option<String>) -> Option<String> {
1576 let sp = system_prompt?;
1577 let Some(trimmed) = sp.strip_prefix("[Session note]\n") else {
1578 return Some(sp);
1579 };
1580 // Only strip if this is the known cap_messages note.
1581 if !trimmed.contains("older messages were dropped") {
1582 return Some(sp);
1583 }
1584 // The note block ends with "\n\n---\n\n" (7 chars) followed by the real prompt.
1585 trimmed
1586 .find("\n\n---\n\n")
1587 .map(|pos| trimmed[pos + 7..].to_string())
1588 }
1589
1590 /// String-scan a JSON byte buffer for the top-level `"metadata":{...}`
1591 /// block and return it parsed. Returns `None` if no balanced metadata
1592 /// object is present in the buffer.
1593 ///
1594 /// Supports the optimisation in `SessionManager::load_session_metadata`
1595 /// (#337). The scanner is brace-balanced and string-aware so a `{` or
1596 /// `}` appearing inside a string literal doesn't perturb the depth
1597 /// count.
1598 fn extract_top_level_metadata(buf: &[u8]) -> Option<SessionMetadata> {
1599 let s = std::str::from_utf8(buf).ok()?;
1600 let bytes = s.as_bytes();
1601
1602 // Find the FIRST `"metadata"` key that appears outside of any string
1603 // literal. Walking with brace/string awareness costs almost nothing
1604 // and avoids matching `metadata` inside an earlier message body.
1605 let key_pat = b"\"metadata\"";
1606 let mut idx = 0usize;
1607 let mut in_string = false;
1608 let mut escape = false;
1609 let key_offset = loop {
1610 if idx >= bytes.len() {
1611 return None;
1612 }
1613 let c = bytes[idx];
1614 if escape {
1615 escape = false;
1616 idx += 1;
1617 continue;
1618 }
1619 if c == b'\\' {
1620 escape = true;
1621 idx += 1;
1622 continue;
1623 }
1624 if c == b'"' {
1625 // If we're already in a string, this closes it; otherwise it
1626 // opens one. But before flipping we check for the key match
1627 // when we're entering a string at exactly this position.
1628 if !in_string && bytes[idx..].starts_with(key_pat) {
1629 break idx;
1630 }
1631 in_string = !in_string;
1632 idx += 1;
1633 continue;
1634 }
1635 idx += 1;
1636 };
1637
1638 // Position past the key.
1639 let after_key = key_offset + key_pat.len();
1640 // Find the colon that separates key from value (skip whitespace).
1641 let mut after_colon = after_key;
1642 while after_colon < bytes.len() && (bytes[after_colon] as char).is_whitespace() {
1643 after_colon += 1;
1644 }
1645 if after_colon >= bytes.len() || bytes[after_colon] != b':' {
1646 return None;
1647 }
1648 after_colon += 1;
1649 while after_colon < bytes.len() && (bytes[after_colon] as char).is_whitespace() {
1650 after_colon += 1;
1651 }
1652 if after_colon >= bytes.len() || bytes[after_colon] != b'{' {
1653 return None;
1654 }
1655
1656 // Walk the object, balancing braces.
1657 let mut depth = 0i32;
1658 let mut in_string = false;
1659 let mut escape = false;
1660 let mut end = None;
1661 for (i, &c) in bytes[after_colon..].iter().enumerate() {
1662 let abs = after_colon + i;
1663 if escape {
1664 escape = false;
1665 continue;
1666 }
1667 if c == b'\\' {
1668 escape = true;
1669 continue;
1670 }
1671 if c == b'"' {
1672 in_string = !in_string;
1673 continue;
1674 }
1675 if in_string {
1676 continue;
1677 }
1678 match c {
1679 b'{' => depth += 1,
1680 b'}' => {
1681 depth -= 1;
1682 if depth == 0 {
1683 end = Some(abs + 1);
1684 break;
1685 }
1686 }
1687 _ => {}
1688 }
1689 }
1690 let end = end?;
1691 serde_json::from_str::<SessionMetadata>(&s[after_colon..end]).ok()
1692 }
1693
1694 fn system_prompt_to_string(system_prompt: Option<&SystemPrompt>) -> Option<String> {
1695 match system_prompt {
1696 Some(SystemPrompt::Text(text)) => Some(text.clone()),
1697 Some(SystemPrompt::Blocks(blocks)) => Some(
1698 blocks
1699 .iter()
1700 .map(|b| b.text.clone())
1701 .collect::<Vec<_>>()
1702 .join("\n\n---\n\n"),
1703 ),
1704 None => None,
1705 }
1706 }
1707
1708 /// Truncate a session ID to 8 characters for compact display.
1709 /// Returns a `&str` borrowing from the input — no allocation.
1710 pub fn truncate_id(id: &str) -> &str {
1711 id.get(..8).unwrap_or(id)
1712 }
1713
1714 /// Strip a leading `<turn_meta>...</turn_meta>` block from saved user text.
1715 ///
1716 /// Older sessions can have turn metadata prefixed to the first user message.
1717 /// The session picker and generated session titles should show the user's
1718 /// prompt, not the cache/debug envelope.
1719 pub(crate) fn extract_user_prompt(raw: &str) -> &str {
1720 let trimmed = raw.trim_start();
1721 let Some(after_open) = trimmed.strip_prefix("<turn_meta>") else {
1722 return trimmed;
1723 };
1724 if let Some(close_pos) = after_open.find("</turn_meta>") {
1725 return after_open[close_pos + "</turn_meta>".len()..].trim_start();
1726 }
1727 after_open.trim_start()
1728 }
1729
1730 /// Clean a stored title for display, falling back to a neutral label.
1731 pub(crate) fn extract_title(raw: &str) -> &str {
1732 let title = extract_user_prompt(raw);
1733 if title.is_empty() { "Session" } else { title }
1734 }
1735
1736 /// Strip common inline thinking/reasoning XML sections from saved assistant
1737 /// text before it is shown in session previews.
1738 pub(crate) fn strip_thinking_tags(text: &str) -> String {
1739 if !text.contains("<think") && !text.contains("<thinking") && !text.contains("<reasoning") {
1740 return text.to_string();
1741 }
1742
1743 let tags = ["think", "thinking", "reasoning"];
1744 let mut result = text.to_string();
1745 for tag in tags {
1746 let open = format!("<{tag}>");
1747 let close = format!("</{tag}>");
1748 while let Some(start) = result.find(&open) {
1749 let Some(end) = result[start..].find(&close) else {
1750 break;
1751 };
1752 let end_abs = start + end + close.len();
1753 result.replace_range(start..end_abs, "");
1754 }
1755 }
1756 result
1757 }
1758
1759 /// Truncate a string to create a title (character-safe for UTF-8)
1760 fn truncate_title(s: &str, max_len: usize) -> String {
1761 let s = s.trim();
1762 let first_line = s.lines().next().unwrap_or(s);
1763
1764 let char_count = first_line.chars().count();
1765 if char_count <= max_len {
1766 first_line.to_string()
1767 } else {
1768 let truncated: String = first_line.chars().take(max_len - 3).collect();
1769 format!("{truncated}...")
1770 }
1771 }
1772
1773 /// Format a session for display in a picker
1774 pub fn format_session_line(meta: &SessionMetadata) -> String {
1775 let age = format_age(&meta.updated_at);
1776 let updated = format_session_updated_at(&meta.updated_at, &age);
1777 let truncated_title = truncate_title(extract_title(&meta.title), 40);
1778 let fork_label = if meta.parent_session_id.is_some() {
1779 " | fork"
1780 } else {
1781 ""
1782 };
1783
1784 format!(
1785 "{} | {} | {} msgs{} | {}",
1786 truncate_id(&meta.id),
1787 truncated_title,
1788 meta.message_count,
1789 fork_label,
1790 updated
1791 )
1792 }
1793
1794 pub(crate) fn format_session_updated_at(dt: &DateTime<Utc>, age: &str) -> String {
1795 format!("{} ({age})", dt.format("%Y-%m-%d %H:%M UTC"))
1796 }
1797
1798 /// Format a datetime as relative age
1799 fn format_age(dt: &DateTime<Utc>) -> String {
1800 let now = Utc::now();
1801 let duration = now.signed_duration_since(*dt);
1802
1803 if duration.num_minutes() < 1 {
1804 "just now".to_string()
1805 } else if duration.num_hours() < 1 {
1806 format!("{}m ago", duration.num_minutes())
1807 } else if duration.num_days() < 1 {
1808 format!("{}h ago", duration.num_hours())
1809 } else if duration.num_weeks() < 1 {
1810 format!("{}d ago", duration.num_days())
1811 } else {
1812 format!("{}w ago", duration.num_weeks())
1813 }
1814 }
1815
1816 // === Unit Tests ===
1817
1818 #[cfg(test)]
1819 mod tests {
1820 use super::*;
1821 use crate::models::ContentBlock;
1822 use crate::tools::plan::StepStatus;
1823 use crate::tui::history::{HistoryCell, ToolCell, history_cells_from_message};
1824 use std::fs;
1825 use tempfile::tempdir;
1826
1827 fn make_test_message(role: &str, text: &str) -> Message {
1828 Message {
1829 role: role.to_string(),
1830 content: vec![ContentBlock::Text {
1831 text: text.to_string(),
1832 cache_control: None,
1833 }],
1834 }
1835 }
1836
1837 /// Coverage state round-trips with the money it qualifies, and a session
1838 /// written before coverage existed is detected as *unknown* rather than being
1839 /// read as a complete total covering zero turns (#4318).
1840 #[test]
1841 fn cost_snapshot_round_trips_coverage_and_detects_legacy_unknown() {
1842 // A pre-coverage row: real money, no coverage fields at all.
1843 let legacy: SessionCostSnapshot = serde_json::from_value(serde_json::json!({
1844 "session_cost_usd": 1.25,
1845 "session_cost_cny": 0.0,
1846 "subagent_cost_usd": 0.0,
1847 "subagent_cost_cny": 0.0,
1848 "displayed_cost_high_water_usd": 1.25,
1849 "displayed_cost_high_water_cny": 0.0
1850 }))
1851 .expect("legacy cost snapshot stays readable");
1852 assert_eq!(legacy.priced_turns, 0);
1853 assert_eq!(legacy.unpriced_turns, 0);
1854 assert!(!legacy.coverage_recorded);
1855 assert!(
1856 legacy.coverage_is_legacy_unknown(),
1857 "a non-zero total with no coverage evidence must not read as complete"
1858 );
1859
1860 // An all-zero pre-coverage session is still unknown: zero may mean no
1861 // turns, all unpriced turns, or exact zero usage. Absence of evidence is
1862 // never rewritten into a complete 0/0 claim.
1863 let empty = SessionCostSnapshot::default();
1864 assert!(empty.coverage_is_legacy_unknown());
1865
1866 // A coverage-aware writer that recorded zero money-metered turns is also
1867 // not unknown — it positively knows the answer is zero.
1868 let recorded_zero = SessionCostSnapshot {
1869 session_cost_usd: 1.25,
1870 coverage_recorded: true,
1871 ..SessionCostSnapshot::default()
1872 };
1873 assert!(!recorded_zero.coverage_is_legacy_unknown());
1874
1875 // Full round-trip of every coverage field.
1876 let full = SessionCostSnapshot {
1877 session_cost_usd: 2.5,
1878 session_cost_cny: 3.0,
1879 subagent_cost_usd: 0.5,
1880 subagent_cost_cny: 0.25,
1881 displayed_cost_high_water_usd: 3.0,
1882 displayed_cost_high_water_cny: 3.25,
1883 priced_turns: 7,
1884 unpriced_turns: 2,
1885 cny_priced_turns: 1,
1886 cny_unpriced_turns: 8,
1887 unpriced_reasons: ["missing_class_price".to_string()].into(),
1888 cny_unpriced_reasons: ["currency_not_published".to_string()].into(),
1889 unpriced_classes: ["cache_write".to_string()].into(),
1890 pricing_provenances: ["models_dev_bundled".to_string()].into(),
1891 live_pricing_defects: ["live_pricing_stale".to_string()].into(),
1892 live_pricing_unusable_defects: ["live_pricing_scope_mismatch".to_string()].into(),
1893 route_receipts: ["provider=anthropic identity=- model=claude-haiku-4-5 \
1894 surface=first-party-payg endpoint_fp=abc123 currency=usd"
1895 .to_string()]
1896 .into(),
1897 coverage_recorded: true,
1898 };
1899 let json = serde_json::to_string(&full).expect("serialize");
1900 let back: SessionCostSnapshot = serde_json::from_str(&json).expect("round-trip");
1901 assert_eq!(back.priced_turns, 7);
1902 assert_eq!(back.unpriced_turns, 2);
1903 assert_eq!(back.cny_priced_turns, 1);
1904 assert_eq!(back.cny_unpriced_turns, 8);
1905 assert_eq!(back.unpriced_reasons, full.unpriced_reasons);
1906 assert_eq!(back.cny_unpriced_reasons, full.cny_unpriced_reasons);
1907 assert_eq!(back.unpriced_classes, full.unpriced_classes);
1908 assert_eq!(back.pricing_provenances, full.pricing_provenances);
1909 assert_eq!(back.live_pricing_defects, full.live_pricing_defects);
1910 assert_eq!(
1911 back.live_pricing_unusable_defects,
1912 full.live_pricing_unusable_defects
1913 );
1914 assert_eq!(back.route_receipts, full.route_receipts);
1915 assert!(back.coverage_recorded);
1916 assert!(!back.coverage_is_legacy_unknown());
1917
1918 // The persisted receipts carry no endpoint URL or credential.
1919 let lower = json.to_lowercase();
1920 for needle in ["http", "api_key", "authorization", "bearer", "sk-"] {
1921 assert!(!lower.contains(needle), "{needle} leaked into {json}");
1922 }
1923 }
1924
1925 /// The USD and CNY totals a snapshot reports are projections of one
1926 /// dual-currency accumulation, never two independent sums that could
1927 /// disagree (#4939).
1928 ///
1929 /// For any turn sequence — dual-priced, USD-only, CNY-only, or garbage
1930 /// estimates — folding the turns jointly and projecting each currency must
1931 /// equal accumulating that currency on its own. This is the invariant that
1932 /// makes the persisted per-currency columns safe: they are written from the
1933 /// same joint fold, so a code path can no longer update one and forget the
1934 /// other. CNY is derived from provider-published CNY rows, not from an FX
1935 /// multiple of USD, so a USD-only turn must contribute exactly zero CNY.
1936 #[test]
1937 fn cost_snapshot_currency_totals_are_projections_of_one_accumulator() {
1938 use crate::pricing::CostEstimate;
1939
1940 let turn_sequences: &[&[CostEstimate]] = &[
1941 // Dual-priced turns (DeepSeek-style routes with a published CNY row).
1942 &[
1943 CostEstimate {
1944 usd: 0.01,
1945 cny: 0.07,
1946 },
1947 CostEstimate {
1948 usd: 0.02,
1949 cny: 0.14,
1950 },
1951 ],
1952 // USD-only turns: CNY unpublished, so the CNY projection stays zero.
1953 &[
1954 CostEstimate {
1955 usd: 0.25,
1956 cny: 0.0,
1957 },
1958 CostEstimate { usd: 1.5, cny: 0.0 },
1959 ],
1960 // Mixed: one currency priced per turn, alternating.
1961 &[
1962 CostEstimate { usd: 0.5, cny: 0.0 },
1963 CostEstimate { usd: 0.0, cny: 3.5 },
1964 CostEstimate {
1965 usd: 0.125,
1966 cny: 0.875,
1967 },
1968 ],
1969 // Hostile values: sanitization must apply identically per currency.
1970 &[
1971 CostEstimate {
1972 usd: f64::NAN,
1973 cny: 0.25,
1974 },
1975 CostEstimate {
1976 usd: 0.75,
1977 cny: -1.0,
1978 },
1979 CostEstimate {
1980 usd: f64::INFINITY,
1981 cny: 0.25,
1982 },
1983 ],
1984 ];
1985
1986 for turns in turn_sequences {
1987 // Joint fold: how the app accumulates (one accumulator, both
1988 // currencies advance together through the same saturating_add).
1989 let joint = turns.iter().fold(CostEstimate::default(), |acc, turn| {
1990 acc.saturating_add(*turn)
1991 });
1992
1993 // Independent per-currency folds: what a drifted parallel
1994 // accumulator would compute if it only saw one currency.
1995 let usd_alone = turns.iter().fold(CostEstimate::default(), |acc, turn| {
1996 acc.saturating_add(CostEstimate {
1997 usd: turn.usd,
1998 cny: 0.0,
1999 })
2000 });
2001 let cny_alone = turns.iter().fold(CostEstimate::default(), |acc, turn| {
2002 acc.saturating_add(CostEstimate {
2003 usd: 0.0,
2004 cny: turn.cny,
2005 })
2006 });
2007
2008 let snapshot = SessionCostSnapshot {
2009 session_cost_usd: joint.usd,
2010 session_cost_cny: joint.cny,
2011 ..SessionCostSnapshot::default()
2012 };
2013 assert_eq!(
2014 snapshot.total_usd(),
2015 usd_alone.usd,
2016 "USD projection drifted from independent accumulation for {turns:?}"
2017 );
2018 assert_eq!(
2019 snapshot.total_cny(),
2020 cny_alone.cny,
2021 "CNY projection drifted from independent accumulation for {turns:?}"
2022 );
2023 assert_eq!(snapshot.total_estimate().usd, snapshot.total_usd());
2024 assert_eq!(snapshot.total_estimate().cny, snapshot.total_cny());
2025 }
2026
2027 // A USD-only session projects zero CNY — no fabricated FX conversion —
2028 // and the subagent column joins the same fold.
2029 let usd_only = SessionCostSnapshot {
2030 session_cost_usd: 2.5,
2031 subagent_cost_usd: 0.5,
2032 ..SessionCostSnapshot::default()
2033 };
2034 assert_eq!(usd_only.total_usd(), 3.0);
2035 assert_eq!(usd_only.total_cny(), 0.0);
2036 }
2037
2038 fn write_session_record(
2039 manager: &SessionManager,
2040 id: &str,
2041 workspace: &Path,
2042 updated_at: DateTime<Utc>,
2043 ) {
2044 let session = SavedSession {
2045 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
2046 messages: vec![make_test_message("user", "hi")],
2047 metadata: SessionMetadata {
2048 id: id.to_string(),
2049 title: format!("session-{id}"),
2050 created_at: updated_at,
2051 updated_at,
2052 message_count: 1,
2053 total_tokens: 0,
2054 model: "deepseek-v4-flash".to_string(),
2055 model_provider: "deepseek".to_string(),
2056 model_provider_id: None,
2057 workspace: workspace.to_path_buf(),
2058 mode: None,
2059 cost: SessionCostSnapshot::default(),
2060 parent_session_id: None,
2061 forked_from_message_count: None,
2062 cumulative_turn_secs: 0,
2063 archived: false,
2064 },
2065 system_prompt: None,
2066 context_references: Vec::new(),
2067 artifacts: Vec::new(),
2068 work_state: None,
2069 last_auto_route: None,
2070 };
2071 manager.save_session(&session).expect("save");
2072 }
2073
2074 fn write_empty_session_record(
2075 manager: &SessionManager,
2076 id: &str,
2077 workspace: &Path,
2078 updated_at: DateTime<Utc>,
2079 ) {
2080 let session = SavedSession {
2081 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
2082 messages: Vec::new(),
2083 metadata: SessionMetadata {
2084 id: id.to_string(),
2085 title: "New Session".to_string(),
2086 created_at: updated_at,
2087 updated_at,
2088 message_count: 0,
2089 total_tokens: 0,
2090 model: "deepseek-v4-pro".to_string(),
2091 model_provider: "deepseek".to_string(),
2092 model_provider_id: None,
2093 workspace: workspace.to_path_buf(),
2094 mode: Some("yolo".to_string()),
2095 cost: SessionCostSnapshot::default(),
2096 parent_session_id: None,
2097 forked_from_message_count: None,
2098 cumulative_turn_secs: 0,
2099 archived: false,
2100 },
2101 system_prompt: None,
2102 context_references: Vec::new(),
2103 artifacts: Vec::new(),
2104 work_state: None,
2105 last_auto_route: None,
2106 };
2107 manager.save_session(&session).expect("save empty");
2108 }
2109
2110 #[test]
2111 fn session_boot_owner_stamps_only_the_creating_instance() {
2112 let tmp = tempdir().expect("tempdir");
2113 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
2114 let workspace = tmp.path().join("ws");
2115
2116 // A record this instance creates is stamped with this boot id and is
2117 // therefore not prior-instance work.
2118 write_session_record(&manager, "mine", &workspace, Utc::now());
2119 assert_eq!(
2120 manager.session_boot_owner("mine").as_deref(),
2121 Some(current_session_boot_id())
2122 );
2123 assert!(!manager.session_from_prior_instance("mine"));
2124
2125 // An id with no durable record at all is this instance's own
2126 // not-yet-persisted session.
2127 assert!(!manager.session_from_prior_instance("unsaved"));
2128
2129 // A record stamped by another boot id stays owned by that instance,
2130 // even after this instance re-serializes it (crash recovery must not
2131 // re-badge restored work as ours).
2132 manager
2133 .record_session_boot_owner("theirs", "boot_other_instance")
2134 .expect("stamp");
2135 write_session_record(&manager, "theirs", &workspace, Utc::now());
2136 assert_eq!(
2137 manager.session_boot_owner("theirs").as_deref(),
2138 Some("boot_other_instance")
2139 );
2140 assert!(manager.session_from_prior_instance("theirs"));
2141
2142 // A legacy record with no marker is classified as prior-instance
2143 // work, and a later re-save keeps it unclaimed.
2144 write_session_record(&manager, "legacy", &workspace, Utc::now());
2145 manager.clear_session_boot_owner("legacy");
2146 assert!(manager.session_from_prior_instance("legacy"));
2147 write_session_record(&manager, "legacy", &workspace, Utc::now());
2148 assert!(manager.session_from_prior_instance("legacy"));
2149
2150 // Deleting the record drops its marker.
2151 manager.delete_session("theirs").expect("delete");
2152 assert_eq!(manager.session_boot_owner("theirs"), None);
2153 }
2154
2155 #[test]
2156 fn session_boot_owner_sidecar_never_lists_as_a_session() {
2157 let tmp = tempdir().expect("tempdir");
2158 let manager = SessionManager::new(tmp.path().to_path_buf()).expect("manager");
2159 write_session_record(&manager, "real", &tmp.path().join("ws"), Utc::now());
2160 assert!(manager.session_boot_owners_path().exists());
2161 let listed = manager.list_sessions().expect("list");
2162 assert_eq!(listed.len(), 1);
2163 assert_eq!(listed[0].id, "real");
2164 // The reserved stem cannot be claimed as a session id either.
2165 assert!(manager.load_session("session_boot_owners").is_err());
2166 }
2167
2168 #[test]
2169 fn test_session_manager_new() {
2170 let tmp = tempdir().expect("tempdir");
2171 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2172 assert!(tmp.path().join("sessions").exists());
2173 let _ = manager;
2174 }
2175
2176 #[test]
2177 fn test_save_and_load_session() {
2178 let tmp = tempdir().expect("tempdir");
2179 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2180
2181 let messages = vec![
2182 make_test_message("user", "Hello!"),
2183 make_test_message("assistant", "Hi there!"),
2184 ];
2185
2186 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2187 let session_id = session.metadata.id.clone();
2188
2189 manager.save_session(&session).expect("save");
2190
2191 let loaded = manager.load_session(&session_id).expect("load");
2192 assert_eq!(loaded.metadata.id, session_id);
2193 assert_eq!(loaded.messages.len(), 2);
2194 }
2195
2196 /// #4681: reopening a session must not surface `<turn_meta>` machine
2197 /// blocks in the transcript. Covers the current trailing shape and the
2198 /// legacy leading shape (sessions saved before the turn-meta tail move),
2199 /// while the loaded API history keeps both envelopes intact for replay.
2200 #[test]
2201 fn rehydrated_turn_meta_blocks_never_render_in_history_cells() {
2202 let tmp = tempdir().expect("tempdir");
2203 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2204
2205 let turn_meta = "<turn_meta>\nCurrent local date: 2026-08-01\n</turn_meta>";
2206 let trailing_shape = Message {
2207 role: "user".to_string(),
2208 content: vec![
2209 ContentBlock::Text {
2210 text: "Fix the flaky test".to_string(),
2211 cache_control: None,
2212 },
2213 ContentBlock::Text {
2214 text: turn_meta.to_string(),
2215 cache_control: None,
2216 },
2217 ],
2218 };
2219 let legacy_leading_shape = Message {
2220 role: "user".to_string(),
2221 content: vec![
2222 ContentBlock::Text {
2223 text: turn_meta.to_string(),
2224 cache_control: None,
2225 },
2226 ContentBlock::Text {
2227 text: "Now add the docs".to_string(),
2228 cache_control: None,
2229 },
2230 ],
2231 };
2232 let messages = vec![
2233 trailing_shape,
2234 make_test_message("assistant", "Done."),
2235 legacy_leading_shape,
2236 ];
2237 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2238 let session_id = session.metadata.id.clone();
2239 manager.save_session(&session).expect("save");
2240
2241 let loaded = manager.load_session(&session_id).expect("load");
2242
2243 // Display path: no rendered cell may carry turn_meta markup.
2244 let rendered: Vec<HistoryCell> = loaded
2245 .messages
2246 .iter()
2247 .flat_map(history_cells_from_message)
2248 .collect();
2249 let user_texts: Vec<&str> = rendered
2250 .iter()
2251 .filter_map(|cell| match cell {
2252 HistoryCell::User { content } => Some(content.as_str()),
2253 _ => None,
2254 })
2255 .collect();
2256 assert_eq!(user_texts, vec!["Fix the flaky test", "Now add the docs"]);
2257 assert!(
2258 !user_texts.iter().any(|text| text.contains("<turn_meta")),
2259 "rendered cells must not contain turn_meta markup: {user_texts:?}"
2260 );
2261
2262 // Model-facing replay: the persisted envelopes survive the round trip.
2263 let replayed_envelopes = loaded
2264 .messages
2265 .iter()
2266 .flat_map(|message| &message.content)
2267 .filter(|block| {
2268 matches!(block, ContentBlock::Text { text, .. } if text.contains("<turn_meta>"))
2269 })
2270 .count();
2271 assert_eq!(replayed_envelopes, 2);
2272 }
2273
2274 #[test]
2275 fn load_session_repairs_dangling_tool_call_with_visible_receipt() {
2276 let tmp = tempdir().expect("tempdir");
2277 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2278 let messages = vec![Message {
2279 role: "assistant".to_string(),
2280 content: vec![ContentBlock::ToolUse {
2281 id: "call-crashed".to_string(),
2282 name: "read_file".to_string(),
2283 input: serde_json::json!({"path": "README.md"}),
2284 caller: None,
2285 }],
2286 }];
2287 let session = create_saved_session(&messages, "test-model", tmp.path(), 0, None);
2288 let session_id = session.metadata.id.clone();
2289 manager.save_session(&session).expect("save");
2290
2291 let loaded = manager.load_session(&session_id).expect("load");
2292
2293 assert_eq!(loaded.metadata.message_count, loaded.messages.len());
2294 assert!(loaded.messages.iter().any(|message| {
2295 message.content.iter().any(|block| {
2296 matches!(
2297 block,
2298 ContentBlock::ToolResult {
2299 tool_use_id,
2300 content,
2301 is_error: Some(true),
2302 ..
2303 } if tool_use_id == "call-crashed" && content.contains("crashed_and_repaired")
2304 )
2305 })
2306 }));
2307 assert!(loaded.messages.iter().any(|message| {
2308 (message.role == "assistant"
2309 || message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE)
2310 && message.content.iter().any(|block| {
2311 matches!(
2312 block,
2313 ContentBlock::Text { text, .. }
2314 if text.contains("[tool_history_repair]")
2315 )
2316 })
2317 }));
2318 }
2319
2320 #[test]
2321 fn save_and_load_session_preserves_rich_update_plan_tool_payload() {
2322 let tmp = tempdir().expect("tempdir");
2323 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2324 let messages = vec![
2325 make_test_message("user", "plan this carefully"),
2326 Message {
2327 role: "assistant".to_string(),
2328 content: vec![ContentBlock::ToolUse {
2329 id: "plan-1".to_string(),
2330 name: "update_plan".to_string(),
2331 input: serde_json::json!({
2332 "objective": "Make Plan mode reviewable",
2333 "sources_used": ["gh issue view 2691"],
2334 "critical_files": ["crates/tui/src/tools/plan.rs"],
2335 "constraints": ["Preserve legacy update_plan payloads"],
2336 "verification_plan": "Run focused plan tests",
2337 "handoff_packet": "Next agent should inspect replay",
2338 "plan": [
2339 { "step": "render replay card", "status": "completed" }
2340 ]
2341 }),
2342 caller: None,
2343 }],
2344 },
2345 Message {
2346 role: "user".to_string(),
2347 content: vec![ContentBlock::ToolResult {
2348 tool_use_id: "plan-1".to_string(),
2349 content: "Plan updated".to_string(),
2350 is_error: None,
2351 content_blocks: None,
2352 }],
2353 },
2354 ];
2355 let session = create_saved_session(&messages, "deepseek-v4-flash", tmp.path(), 42, None);
2356 let session_id = session.metadata.id.clone();
2357
2358 manager.save_session(&session).expect("save");
2359 let loaded = manager.load_session(&session_id).expect("load");
2360
2361 assert_eq!(loaded.messages.len(), 3);
2362 let cells = history_cells_from_message(&loaded.messages[1]);
2363 let Some(HistoryCell::Tool(ToolCell::PlanUpdate(cell))) = cells.first() else {
2364 panic!("expected loaded update_plan to replay as a PlanUpdate cell");
2365 };
2366 assert_eq!(
2367 cell.snapshot.objective.as_deref(),
2368 Some("Make Plan mode reviewable")
2369 );
2370 assert_eq!(
2371 cell.snapshot.critical_files,
2372 vec!["crates/tui/src/tools/plan.rs"]
2373 );
2374 assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed);
2375 }
2376
2377 #[test]
2378 fn save_session_preserves_large_tool_outputs_for_cache_fidelity() {
2379 let tmp = tempdir().expect("tempdir");
2380 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2381 let raw = "RAW_SESSION_SENTINEL\n".repeat(2_000);
2382 let messages = vec![
2383 Message {
2384 role: "assistant".to_string(),
2385 content: vec![ContentBlock::ToolUse {
2386 id: "call-big".to_string(),
2387 name: "exec_shell".to_string(),
2388 input: serde_json::json!({"command": "cargo test -p codewhale-tui"}),
2389 caller: None,
2390 }],
2391 },
2392 Message {
2393 role: "user".to_string(),
2394 content: vec![ContentBlock::ToolResult {
2395 tool_use_id: "call-big".to_string(),
2396 content: raw.clone(),
2397 is_error: None,
2398 content_blocks: None,
2399 }],
2400 },
2401 ];
2402 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2403 session.artifacts.push(crate::artifacts::ArtifactRecord {
2404 id: "art_call-big".to_string(),
2405 kind: crate::artifacts::ArtifactKind::ToolOutput,
2406 session_id: session.metadata.id.clone(),
2407 tool_call_id: "call-big".to_string(),
2408 tool_name: "exec_shell".to_string(),
2409 created_at: Utc::now(),
2410 byte_size: raw.len() as u64,
2411 preview: "checking crate ... error[E0425]".to_string(),
2412 storage_path: PathBuf::from("artifacts/art_call-big.txt"),
2413 });
2414
2415 let path = manager.save_session(&session).expect("save");
2416 let persisted_json = fs::read_to_string(path).expect("read persisted session");
2417 // Raw output is preserved in-session so resume can hit the LLM cache.
2418 assert!(persisted_json.contains("RAW_SESSION_SENTINEL"));
2419
2420 let loaded = manager.load_session(&session.metadata.id).expect("load");
2421 let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else {
2422 panic!("expected loaded tool result");
2423 };
2424 // Loaded session retains the original output for cache fidelity.
2425 assert!(content.contains("RAW_SESSION_SENTINEL"));
2426 assert!(!content.contains("[TOOL_OUTPUT_RECEIPT]"));
2427 }
2428
2429 #[test]
2430 fn load_session_preserves_legacy_large_tool_outputs_for_cache_fidelity() {
2431 let tmp = tempdir().expect("tempdir");
2432 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2433 let raw = "RAW_LEGACY_RESUME_SENTINEL\n".repeat(2_000);
2434 let messages = vec![
2435 Message {
2436 role: "assistant".to_string(),
2437 content: vec![ContentBlock::ToolUse {
2438 id: "call-legacy".to_string(),
2439 name: "exec_shell".to_string(),
2440 input: serde_json::json!({"command": "cargo check"}),
2441 caller: None,
2442 }],
2443 },
2444 Message {
2445 role: "user".to_string(),
2446 content: vec![ContentBlock::ToolResult {
2447 tool_use_id: "call-legacy".to_string(),
2448 content: raw.clone(),
2449 is_error: None,
2450 content_blocks: None,
2451 }],
2452 },
2453 ];
2454 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2455 session.artifacts.push(crate::artifacts::ArtifactRecord {
2456 id: "art_call-legacy".to_string(),
2457 kind: crate::artifacts::ArtifactKind::ToolOutput,
2458 session_id: session.metadata.id.clone(),
2459 tool_call_id: "call-legacy".to_string(),
2460 tool_name: "exec_shell".to_string(),
2461 created_at: Utc::now(),
2462 byte_size: raw.len() as u64,
2463 preview: "cargo check output".to_string(),
2464 storage_path: PathBuf::from("artifacts/art_call-legacy.txt"),
2465 });
2466 let path = manager
2467 .validated_session_path(&session.metadata.id)
2468 .expect("path");
2469 fs::write(
2470 &path,
2471 serde_json::to_string_pretty(&session).expect("serialize legacy session"),
2472 )
2473 .expect("write legacy raw session");
2474 assert!(
2475 fs::read_to_string(&path)
2476 .expect("read legacy raw")
2477 .contains("RAW_LEGACY_RESUME_SENTINEL")
2478 );
2479
2480 let loaded = manager.load_session(&session.metadata.id).expect("load");
2481 let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else {
2482 panic!("expected loaded tool result");
2483 };
2484 // Loaded session preserves original output so resume can hit the LLM cache.
2485 assert!(content.contains("RAW_LEGACY_RESUME_SENTINEL"));
2486 assert!(!content.contains("[TOOL_OUTPUT_RECEIPT]"));
2487 }
2488
2489 #[test]
2490 fn test_list_sessions() {
2491 let tmp = tempdir().expect("tempdir");
2492 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2493
2494 // Create a few sessions
2495 for i in 0..3 {
2496 let messages = vec![make_test_message("user", &format!("Session {i}"))];
2497 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2498 manager.save_session(&session).expect("save");
2499 }
2500
2501 let sessions = manager.list_sessions().expect("list");
2502 assert_eq!(sessions.len(), 3);
2503 }
2504
2505 #[test]
2506 fn default_manager_copies_legacy_sessions_when_primary_already_exists() {
2507 let _lock = crate::test_support::lock_test_env();
2508 let tmp = tempdir().expect("tempdir");
2509 let home = tmp.path().join("home");
2510 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2511 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
2512
2513 let primary_sessions = home.join(".codewhale").join("sessions");
2514 let legacy_sessions = home.join(".deepseek").join("sessions");
2515 fs::create_dir_all(&primary_sessions).expect("primary sessions");
2516 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
2517 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
2518 fs::write(
2519 legacy_sessions.join("checkpoints").join("latest.json"),
2520 "{}",
2521 )
2522 .expect("legacy checkpoint");
2523
2524 let mut legacy_session = create_saved_session(
2525 &[make_test_message("user", "find my old session")],
2526 "test-model",
2527 tmp.path(),
2528 100,
2529 None,
2530 );
2531 legacy_session.metadata.id = "legacy-visible".to_string();
2532 legacy_session.metadata.title = "session from legacy home".to_string();
2533 fs::write(
2534 legacy_sessions.join("legacy-visible.json"),
2535 serde_json::to_string_pretty(&legacy_session).expect("serialize legacy session"),
2536 )
2537 .expect("write legacy session");
2538
2539 let manager = SessionManager::default_location().expect("default manager");
2540 assert_eq!(manager.sessions_dir(), primary_sessions.as_path());
2541 assert!(primary_sessions.join("legacy-visible.json").exists());
2542 assert!(!primary_sessions.join("checkpoints").exists());
2543 assert!(legacy_sessions.join("legacy-visible.json").exists());
2544
2545 let sessions = manager.list_sessions().expect("list");
2546 assert_eq!(sessions.len(), 1);
2547 assert_eq!(sessions[0].id, "legacy-visible");
2548 }
2549
2550 #[test]
2551 fn legacy_session_copy_never_overwrites_primary_session() {
2552 let _lock = crate::test_support::lock_test_env();
2553 let tmp = tempdir().expect("tempdir");
2554 let home = tmp.path().join("home");
2555 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2556 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
2557
2558 let primary_sessions = home.join(".codewhale").join("sessions");
2559 let legacy_sessions = home.join(".deepseek").join("sessions");
2560 fs::create_dir_all(&primary_sessions).expect("primary sessions");
2561 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
2562
2563 let primary_path = primary_sessions.join("same-id.json");
2564 fs::write(&primary_path, "primary data wins").expect("write primary session");
2565 fs::write(
2566 legacy_sessions.join("same-id.json"),
2567 "legacy data must not overwrite",
2568 )
2569 .expect("write legacy session");
2570
2571 let dir = default_sessions_dir().expect("default session dir");
2572 assert_eq!(dir, primary_sessions);
2573 assert_eq!(
2574 fs::read_to_string(primary_path).expect("read primary session"),
2575 "primary data wins"
2576 );
2577 }
2578
2579 #[test]
2580 fn explicit_codewhale_home_disables_legacy_session_copy() {
2581 let _lock = crate::test_support::lock_test_env();
2582 let tmp = tempdir().expect("tempdir");
2583 let home = tmp.path().join("home");
2584 let explicit_home = tmp.path().join("explicit-codewhale");
2585 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2586 let _codewhale_home =
2587 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
2588
2589 let legacy_sessions = home.join(".deepseek").join("sessions");
2590 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
2591 fs::write(legacy_sessions.join("legacy-visible.json"), "{}").expect("write legacy session");
2592
2593 let dir = default_sessions_dir().expect("default session dir");
2594 assert_eq!(dir, explicit_home.join("sessions"));
2595 assert!(!dir.join("legacy-visible.json").exists());
2596 }
2597
2598 #[cfg(unix)]
2599 #[test]
2600 fn non_unicode_codewhale_home_is_still_an_explicit_session_boundary() {
2601 use std::os::unix::ffi::OsStringExt;
2602
2603 let _lock = crate::test_support::lock_test_env();
2604 let tmp = tempdir().expect("tempdir");
2605 let home = tmp.path().join("home");
2606 let explicit_home = tmp.path().join(std::ffi::OsString::from_vec(
2607 b"codewhale-\xff-home".to_vec(),
2608 ));
2609 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
2610 let _codewhale_home =
2611 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &explicit_home);
2612
2613 let legacy_sessions = home.join(".deepseek").join("sessions");
2614 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
2615 fs::write(legacy_sessions.join("ambient.json"), "ambient").expect("ambient legacy session");
2616 let safe_primary = tmp.path().join("safe-primary");
2617 fs::create_dir_all(&safe_primary).expect("safe primary");
2618
2619 assert_eq!(
2620 merge_missing_legacy_session_entries(&safe_primary).expect("merge decision"),
2621 0
2622 );
2623 assert!(!safe_primary.join("ambient.json").exists());
2624 }
2625
2626 #[test]
2627 fn latest_session_for_workspace_ignores_newer_other_directory() {
2628 let tmp = tempdir().expect("tempdir");
2629 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2630 let workspace_a = tmp.path().join("aa").join("aaa");
2631 let workspace_b = tmp.path().join("bb").join("bbb");
2632 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
2633 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
2634 fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git boundary");
2635
2636 write_session_record(
2637 &manager,
2638 "current-workspace",
2639 &workspace_a,
2640 Utc::now() - chrono::Duration::minutes(10),
2641 );
2642 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
2643
2644 let global = manager
2645 .list_sessions()
2646 .expect("list")
2647 .into_iter()
2648 .next()
2649 .expect("global latest");
2650 assert_eq!(global.id, "other-workspace");
2651
2652 let scoped = manager
2653 .get_latest_session_for_workspace(&workspace_a)
2654 .expect("latest for workspace")
2655 .expect("scoped latest");
2656 assert_eq!(scoped.id, "current-workspace");
2657 }
2658
2659 #[test]
2660 fn latest_session_for_workspace_ignores_invalid_parent_git_marker() {
2661 let tmp = tempdir().expect("tempdir");
2662 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2663 let workspace_a = tmp.path().join("aa").join("aaa");
2664 let workspace_b = tmp.path().join("bb").join("bbb");
2665 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
2666 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
2667 fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git marker");
2668
2669 write_session_record(
2670 &manager,
2671 "current-workspace",
2672 &workspace_a,
2673 Utc::now() - chrono::Duration::minutes(10),
2674 );
2675 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
2676
2677 let scoped = manager
2678 .get_latest_session_for_workspace(&workspace_a)
2679 .expect("latest for workspace")
2680 .expect("scoped latest");
2681 assert_eq!(scoped.id, "current-workspace");
2682 }
2683
2684 #[test]
2685 fn latest_session_for_workspace_matches_same_git_repository() {
2686 let tmp = tempdir().expect("tempdir");
2687 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2688 let repo = tmp.path().join("repo");
2689 let repo_app = repo.join("apps").join("client");
2690 let repo_crate = repo.join("crates").join("server");
2691 let other_repo = tmp.path().join("other").join("project");
2692 fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
2693 fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
2694 fs::create_dir_all(&repo_app).expect("mkdir repo app");
2695 fs::create_dir_all(&repo_crate).expect("mkdir repo crate");
2696 fs::create_dir_all(&other_repo).expect("mkdir other repo");
2697
2698 write_session_record(
2699 &manager,
2700 "same-repo",
2701 &repo_app,
2702 Utc::now() - chrono::Duration::minutes(5),
2703 );
2704 write_session_record(&manager, "other-repo", &other_repo, Utc::now());
2705
2706 let scoped = manager
2707 .get_latest_session_for_workspace(&repo_crate)
2708 .expect("latest for workspace")
2709 .expect("same repo latest");
2710 assert_eq!(scoped.id, "same-repo");
2711 }
2712
2713 #[test]
2714 fn latest_session_for_workspace_skips_empty_auto_created_session() {
2715 let tmp = tempdir().expect("tempdir");
2716 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2717 let workspace = tmp.path().join("repo");
2718 fs::create_dir_all(&workspace).expect("mkdir workspace");
2719
2720 write_session_record(
2721 &manager,
2722 "interrupted-user-turn",
2723 &workspace,
2724 Utc::now() - chrono::Duration::minutes(5),
2725 );
2726 write_empty_session_record(&manager, "empty-auto-shell", &workspace, Utc::now());
2727
2728 let global = manager
2729 .list_sessions()
2730 .expect("list")
2731 .into_iter()
2732 .next()
2733 .expect("global latest");
2734 assert_eq!(global.id, "empty-auto-shell");
2735
2736 let scoped = manager
2737 .get_latest_session_for_workspace(&workspace)
2738 .expect("latest for workspace")
2739 .expect("scoped latest");
2740 assert_eq!(scoped.id, "interrupted-user-turn");
2741 }
2742
2743 #[test]
2744 fn test_load_by_prefix() {
2745 let tmp = tempdir().expect("tempdir");
2746 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2747
2748 let messages = vec![make_test_message("user", "Test session")];
2749 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2750 let prefix = truncate_id(&session.metadata.id).to_string();
2751 manager.save_session(&session).expect("save");
2752
2753 let loaded = manager.load_session_by_prefix(&prefix).expect("load");
2754 assert_eq!(loaded.messages.len(), 1);
2755 }
2756
2757 #[test]
2758 fn test_delete_session() {
2759 let tmp = tempdir().expect("tempdir");
2760 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2761
2762 let messages = vec![make_test_message("user", "To be deleted")];
2763 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2764 let session_id = session.metadata.id.clone();
2765
2766 manager.save_session(&session).expect("save");
2767 assert!(manager.load_session(&session_id).is_ok());
2768
2769 manager.delete_session(&session_id).expect("delete");
2770 assert!(manager.load_session(&session_id).is_err());
2771 }
2772
2773 #[test]
2774 fn delete_session_removes_artifact_directory() {
2775 let tmp = tempdir().expect("tempdir");
2776 let sessions_dir = tmp.path().join("sessions");
2777 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
2778
2779 let session = create_saved_session(
2780 &[make_test_message("user", "artifact session")],
2781 "test-model",
2782 tmp.path(),
2783 100,
2784 None,
2785 );
2786 let session_id = session.metadata.id.clone();
2787 let artifact_dir = sessions_dir.join(&session_id).join("artifacts");
2788 fs::create_dir_all(&artifact_dir).expect("artifact dir");
2789 fs::write(artifact_dir.join("art_call.txt"), "raw output").expect("artifact file");
2790
2791 manager.save_session(&session).expect("save");
2792 manager.delete_session(&session_id).expect("delete");
2793
2794 assert!(!sessions_dir.join(format!("{session_id}.json")).exists());
2795 assert!(!sessions_dir.join(&session_id).exists());
2796 }
2797
2798 #[test]
2799 fn test_session_id_rejects_invalid_characters() {
2800 let tmp = tempdir().expect("tempdir");
2801 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2802
2803 let err = manager
2804 .load_session("../outside")
2805 .expect_err("invalid id should fail");
2806 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
2807
2808 let err = manager
2809 .delete_session("sess bad")
2810 .expect_err("invalid id should fail");
2811 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
2812 }
2813
2814 #[test]
2815 fn test_session_manager_rejects_relative_traversal_dir() {
2816 let err = SessionManager::new(PathBuf::from("../sessions"))
2817 .expect_err("relative traversal directory should fail");
2818 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
2819 }
2820
2821 #[test]
2822 fn test_truncate_title() {
2823 assert_eq!(truncate_title("Short", 50), "Short");
2824 assert_eq!(
2825 truncate_title("This is a very long title that should be truncated", 20),
2826 "This is a very lo..."
2827 );
2828 assert_eq!(truncate_title("Line 1\nLine 2", 50), "Line 1");
2829 }
2830
2831 #[test]
2832 fn extract_user_prompt_strips_turn_meta_prefix() {
2833 assert_eq!(
2834 extract_user_prompt("<turn_meta>{\"cache\":\"x\"}</turn_meta>\nReal prompt"),
2835 "Real prompt"
2836 );
2837 assert_eq!(extract_user_prompt(" Real prompt"), "Real prompt");
2838 assert_eq!(
2839 extract_user_prompt("<turn_meta>{\"unterminated\":true}\nReal prompt"),
2840 "{\"unterminated\":true}\nReal prompt"
2841 );
2842 }
2843
2844 #[test]
2845 fn create_saved_session_uses_prompt_after_turn_meta_for_title() {
2846 let tmp = tempdir().expect("tempdir");
2847 let messages = vec![make_test_message(
2848 "user",
2849 "<turn_meta>{\"cache\":\"x\"}</turn_meta>\nFix the session picker history pane",
2850 )];
2851 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
2852 assert_eq!(
2853 session.metadata.title,
2854 "Fix the session picker history pane"
2855 );
2856 }
2857
2858 #[test]
2859 fn strip_thinking_tags_removes_common_inline_blocks() {
2860 let text = "Before <think>private</think> middle <reasoning>hidden</reasoning> after";
2861 let cleaned = strip_thinking_tags(text);
2862 assert_eq!(cleaned, "Before middle after");
2863 assert_eq!(strip_thinking_tags("plain answer"), "plain answer");
2864 }
2865
2866 #[test]
2867 fn test_format_age() {
2868 let now = Utc::now();
2869 assert_eq!(format_age(&now), "just now");
2870
2871 let hour_ago = now - chrono::Duration::hours(2);
2872 assert_eq!(format_age(&hour_ago), "2h ago");
2873
2874 let day_ago = now - chrono::Duration::days(3);
2875 assert_eq!(format_age(&day_ago), "3d ago");
2876 }
2877
2878 #[test]
2879 fn format_session_line_includes_absolute_updated_timestamp() {
2880 let mut session = create_saved_session(
2881 &[make_test_message("user", "Find Friday work")],
2882 "test-model",
2883 Path::new("/tmp/project"),
2884 100,
2885 None,
2886 );
2887 session.metadata.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z")
2888 .expect("timestamp")
2889 .with_timezone(&Utc);
2890
2891 let line = format_session_line(&session.metadata);
2892
2893 assert!(
2894 line.contains("2026-06-01 12:34 UTC"),
2895 "session list should include an absolute timestamp, got {line:?}"
2896 );
2897 }
2898
2899 #[test]
2900 fn test_update_session() {
2901 let tmp = tempdir().expect("tempdir");
2902
2903 let messages = vec![make_test_message("user", "Hello")];
2904 let session = create_saved_session(&messages, "test-model", tmp.path(), 50, None);
2905
2906 let new_messages = vec![
2907 make_test_message("user", "Hello"),
2908 make_test_message("assistant", "Hi!"),
2909 ];
2910
2911 let updated = update_session(session, &new_messages, 100, None);
2912 assert_eq!(updated.messages.len(), 2);
2913 assert_eq!(updated.metadata.total_tokens, 100);
2914 }
2915
2916 #[test]
2917 fn save_load_round_trip_preserves_all_messages_for_cache_fidelity() {
2918 let tmp = tempdir().expect("tempdir");
2919 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2920 // Covers the old 500-message cap boundary and well beyond.
2921 for count in [0, 1, 500, 501, 600, 1000] {
2922 let original: Vec<_> = (0..count)
2923 .map(|i| {
2924 make_test_message(
2925 if i % 2 == 0 { "user" } else { "assistant" },
2926 &format!("round-trip message {i}"),
2927 )
2928 })
2929 .collect();
2930
2931 let session = create_saved_session(&original, "test-model", tmp.path(), 0, None);
2932 manager.save_session(&session).expect("save");
2933 let loaded = manager.load_session(&session.metadata.id).expect("load");
2934
2935 assert_eq!(
2936 loaded.messages.len(),
2937 count,
2938 "count preserved for count={count}"
2939 );
2940 assert_eq!(
2941 loaded.messages, original,
2942 "every message byte-identical after round-trip for count={count}"
2943 );
2944 }
2945 }
2946
2947 #[test]
2948 fn test_checkpoint_round_trip_and_clear() {
2949 let tmp = tempdir().expect("tempdir");
2950 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
2951 let messages = vec![make_test_message("user", "checkpoint me")];
2952 let mut session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
2953 session.work_state = Some(SessionWorkState {
2954 todos: crate::tools::todo::TodoListSnapshot {
2955 items: vec![crate::tools::todo::TodoItem {
2956 id: 1,
2957 content: "verify checkpoint durability".to_string(),
2958 status: crate::tools::todo::TodoStatus::InProgress,
2959 }],
2960 completion_pct: 0,
2961 in_progress_id: Some(1),
2962 },
2963 ..SessionWorkState::default()
2964 });
2965
2966 let path = manager.save_checkpoint(&session).expect("save checkpoint");
2967 assert_eq!(
2968 path.file_name().and_then(|n| n.to_str()),
2969 Some(format!("{}.json", session.metadata.id).as_str()),
2970 "checkpoint file must be keyed by session id"
2971 );
2972 let loaded = manager
2973 .load_session_checkpoint(&session.metadata.id)
2974 .expect("load checkpoint")
2975 .expect("checkpoint exists");
2976 assert_eq!(loaded.metadata.id, session.metadata.id);
2977 assert_eq!(loaded.messages, session.messages);
2978 assert_eq!(
2979 loaded.work_state, session.work_state,
2980 "work state must survive the checkpoint round trip"
2981 );
2982
2983 manager
2984 .clear_session_checkpoint(&session.metadata.id)
2985 .expect("clear checkpoint");
2986 assert!(
2987 manager
2988 .load_session_checkpoint(&session.metadata.id)
2989 .expect("load checkpoint")
2990 .is_none()
2991 );
2992 }
2993
2994 #[test]
2995 fn graph_backed_work_state_remains_readable_by_legacy_shape() {
2996 #[derive(serde::Deserialize)]
2997 struct LegacyWorkState {
2998 #[serde(default)]
2999 todos: crate::tools::todo::TodoListSnapshot,
3000 #[serde(default)]
3001 plan: crate::tools::plan::PlanSnapshot,
3002 }
3003
3004 let fixture = include_bytes!("../tests/fixtures/work_graph_session_v1_reader.json");
3005 let current: SavedSession = serde_json::from_slice(fixture).expect("current reader");
3006 let state = current.work_state.expect("fixture Work state");
3007 let legacy: LegacyWorkState = serde_json::from_value(
3008 serde_json::from_slice::<serde_json::Value>(fixture)
3009 .expect("fixture JSON")["work_state"]
3010 .clone(),
3011 )
3012 .expect("v1 reader ignores graph");
3013 assert_eq!(legacy.todos, state.todos);
3014 assert_eq!(legacy.plan, state.plan);
3015 let graph = state.graph.expect("fixture graph");
3016 crate::work_graph::validate(&graph).expect("valid fixture graph");
3017 assert_eq!(crate::work_graph::project_todos(&graph), state.todos);
3018 assert_eq!(crate::work_graph::project_plan(&graph), state.plan);
3019 }
3020
3021 #[test]
3022 fn first_graph_write_archives_exact_legacy_session_once() {
3023 let tmp = tempdir().expect("tempdir");
3024 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3025 let mut session = create_saved_session(
3026 &[make_test_message("user", "archive before import")],
3027 "test-model",
3028 tmp.path(),
3029 0,
3030 None,
3031 );
3032 let plan = crate::tools::plan::PlanSnapshot {
3033 items: vec![crate::tools::plan::PlanItemArg {
3034 step: "Import".to_string(),
3035 status: crate::tools::plan::StepStatus::Pending,
3036 }],
3037 ..crate::tools::plan::PlanSnapshot::default()
3038 };
3039 let todos = crate::tools::todo::TodoListSnapshot::default();
3040 session.work_state = Some(SessionWorkState {
3041 graph: None,
3042 todos: todos.clone(),
3043 plan: plan.clone(),
3044 });
3045 let path = manager.save_session(&session).expect("save legacy session");
3046 let legacy_bytes = fs::read(&path).expect("read legacy bytes");
3047
3048 let graph = crate::work_graph::import_legacy(&session.metadata.id, &plan, &todos)
3049 .expect("import graph");
3050 session.work_state = Some(SessionWorkState {
3051 graph: Some(graph),
3052 todos,
3053 plan,
3054 });
3055 manager.save_session(&session).expect("first graph write");
3056 let archive = manager
3057 .sessions_dir
3058 .join(WORK_GRAPH_IMPORT_ARCHIVE_DIR)
3059 .join(path.file_name().expect("session filename"));
3060 assert_eq!(fs::read(&archive).expect("archive exists"), legacy_bytes);
3061
3062 session.metadata.title = "later graph write".to_string();
3063 manager.save_session(&session).expect("second graph write");
3064 assert_eq!(
3065 fs::read(&archive).expect("archive still exists"),
3066 legacy_bytes,
3067 "later graph writes must not replace the pre-import receipt"
3068 );
3069 }
3070
3071 #[test]
3072 fn checkpoints_are_independent_per_session() {
3073 let tmp = tempdir().expect("tempdir");
3074 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3075 let first = create_saved_session(
3076 &[make_test_message("user", "session one")],
3077 "test-model",
3078 tmp.path(),
3079 0,
3080 None,
3081 );
3082 let second = create_saved_session(
3083 &[make_test_message("user", "session two")],
3084 "test-model",
3085 tmp.path(),
3086 0,
3087 None,
3088 );
3089
3090 manager.save_checkpoint(&first).expect("save first");
3091 manager.save_checkpoint(&second).expect("save second");
3092 manager
3093 .clear_session_checkpoint(&first.metadata.id)
3094 .expect("clear first");
3095
3096 assert!(
3097 manager
3098 .load_session_checkpoint(&first.metadata.id)
3099 .expect("load first")
3100 .is_none(),
3101 "clearing one session must remove only that session's file"
3102 );
3103 let survivor = manager
3104 .load_session_checkpoint(&second.metadata.id)
3105 .expect("load second")
3106 .expect("second checkpoint survives");
3107 assert_eq!(survivor.metadata.id, second.metadata.id);
3108 }
3109
3110 #[test]
3111 fn list_checkpoints_includes_legacy_slot_and_skips_offline_queue() {
3112 let tmp = tempdir().expect("tempdir");
3113 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3114 let session = create_saved_session(
3115 &[make_test_message("user", "list me")],
3116 "test-model",
3117 tmp.path(),
3118 0,
3119 None,
3120 );
3121 manager.save_checkpoint(&session).expect("save checkpoint");
3122 let checkpoints = tmp.path().join("sessions").join("checkpoints");
3123 fs::write(checkpoints.join("latest.json"), "{}").expect("write legacy slot");
3124 fs::write(checkpoints.join("offline_queue.json"), "{}").expect("write offline queue");
3125
3126 let refs = manager.list_checkpoints().expect("list checkpoints");
3127 assert_eq!(refs.len(), 2, "offline queue must not be a candidate");
3128 assert!(
3129 refs.iter()
3130 .any(|r| r.source == CheckpointSource::Session(session.metadata.id.clone()))
3131 );
3132 assert!(refs.iter().any(|r| r.source == CheckpointSource::Legacy));
3133 }
3134
3135 #[test]
3136 fn legacy_migration_never_overwrites_existing_per_session_checkpoint() {
3137 let tmp = tempdir().expect("tempdir");
3138 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3139 let mut session = create_saved_session(
3140 &[make_test_message("user", "original")],
3141 "test-model",
3142 tmp.path(),
3143 0,
3144 None,
3145 );
3146 manager.save_checkpoint(&session).expect("save checkpoint");
3147
3148 session.messages = vec![make_test_message("user", "stale legacy copy")];
3149 let written = manager
3150 .write_session_checkpoint_if_absent(&session)
3151 .expect("migration attempt");
3152 assert!(!written, "migration must not overwrite an existing file");
3153 let loaded = manager
3154 .load_session_checkpoint(&session.metadata.id)
3155 .expect("load")
3156 .expect("checkpoint exists");
3157 assert_eq!(
3158 loaded.messages,
3159 vec![make_test_message("user", "original")],
3160 "existing per-session checkpoint content must be preserved"
3161 );
3162 }
3163
3164 #[test]
3165 fn workspace_scope_matches_subdirectories_in_same_git_checkout() {
3166 let tmp = tempdir().expect("tempdir");
3167 let repo = tmp.path().join("repo");
3168 let nested = repo.join("crates").join("tui");
3169 fs::create_dir_all(&nested).expect("mkdir nested");
3170 fs::write(repo.join(".git"), "gitdir: .git/worktrees/repo").expect("write git marker");
3171
3172 assert!(workspace_scope_matches(&repo, &nested));
3173 }
3174
3175 #[test]
3176 fn workspace_scope_rejects_sibling_git_checkouts() {
3177 let tmp = tempdir().expect("tempdir");
3178 let first = tmp.path().join("repo-a");
3179 let second = tmp.path().join("repo-b");
3180 fs::create_dir_all(&first).expect("mkdir first");
3181 fs::create_dir_all(&second).expect("mkdir second");
3182 fs::write(first.join(".git"), "gitdir: .git/worktrees/a").expect("write first marker");
3183 fs::write(second.join(".git"), "gitdir: .git/worktrees/b").expect("write second marker");
3184
3185 assert!(!workspace_scope_matches(&first, &second));
3186 }
3187
3188 #[test]
3189 fn test_offline_queue_round_trip_and_clear() {
3190 let tmp = tempdir().expect("tempdir");
3191 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3192
3193 let state = OfflineQueueState {
3194 messages: vec![QueuedSessionMessage {
3195 display: "queued message".to_string(),
3196 skill_instruction: Some("Use skill".to_string()),
3197 skill_provenance: None,
3198 }],
3199 draft: Some(QueuedSessionMessage {
3200 display: "draft message".to_string(),
3201 skill_instruction: None,
3202 skill_provenance: None,
3203 }),
3204 ..OfflineQueueState::default()
3205 };
3206
3207 manager
3208 .save_offline_queue_state(&state, Some("test-session"))
3209 .expect("save queue state");
3210 let loaded = manager
3211 .load_offline_queue_state()
3212 .expect("load queue state")
3213 .expect("queue state exists");
3214 assert_eq!(loaded.messages.len(), 1);
3215 assert_eq!(loaded.messages[0].display, "queued message");
3216 assert!(loaded.draft.is_some());
3217
3218 manager
3219 .clear_offline_queue_state()
3220 .expect("clear queue state");
3221 assert!(
3222 manager
3223 .load_offline_queue_state()
3224 .expect("load queue state")
3225 .is_none()
3226 );
3227 }
3228
3229 #[test]
3230 fn test_offline_queue_stamps_session_id_on_save() {
3231 // #487: save_offline_queue_state must stamp the supplied
3232 // session id so the load path's mismatch check has something
3233 // to compare against. A queue persisted without a session id
3234 // is the legacy unscoped form which the load path treats as
3235 // stale-risky and refuses to restore.
3236 let tmp = tempdir().expect("tempdir");
3237 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3238
3239 let state = OfflineQueueState {
3240 messages: vec![QueuedSessionMessage {
3241 display: "first parked".to_string(),
3242 skill_instruction: None,
3243 skill_provenance: None,
3244 }],
3245 ..OfflineQueueState::default()
3246 };
3247
3248 manager
3249 .save_offline_queue_state(&state, Some("session-A"))
3250 .expect("save with session id");
3251 let loaded = manager
3252 .load_offline_queue_state()
3253 .expect("ok")
3254 .expect("present");
3255 assert_eq!(loaded.session_id.as_deref(), Some("session-A"));
3256
3257 // Re-saving with a different session id replaces the stamp.
3258 manager
3259 .save_offline_queue_state(&state, Some("session-B"))
3260 .expect("re-save");
3261 let reloaded = manager
3262 .load_offline_queue_state()
3263 .expect("ok")
3264 .expect("present");
3265 assert_eq!(reloaded.session_id.as_deref(), Some("session-B"));
3266
3267 // Saving without a session id explicitly (None) clears the
3268 // stamp — UI's load path treats that as legacy-unscoped and
3269 // fails closed.
3270 manager
3271 .save_offline_queue_state(&state, None)
3272 .expect("save without session id");
3273 let unscoped = manager
3274 .load_offline_queue_state()
3275 .expect("ok")
3276 .expect("present");
3277 assert!(
3278 unscoped.session_id.is_none(),
3279 "save with None must persist a missing session_id"
3280 );
3281 }
3282
3283 #[test]
3284 fn test_session_context_references_round_trip() {
3285 let tmp = tempdir().expect("tempdir");
3286 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3287 let mut session = create_saved_session(
3288 &[make_test_message("user", "read @src/main.rs")],
3289 "deepseek-v4-pro",
3290 tmp.path(),
3291 0,
3292 None,
3293 );
3294 session.context_references.push(SessionContextReference {
3295 message_index: 0,
3296 reference: ContextReference {
3297 kind: crate::tui::file_mention::ContextReferenceKind::File,
3298 source: crate::tui::file_mention::ContextReferenceSource::AtMention,
3299 badge: "file".to_string(),
3300 label: "src/main.rs".to_string(),
3301 target: tmp.path().join("src/main.rs").display().to_string(),
3302 included: true,
3303 expanded: true,
3304 detail: Some("included".to_string()),
3305 },
3306 });
3307
3308 let path = manager.save_session(&session).expect("save session");
3309 let loaded = manager
3310 .load_session(&session.metadata.id)
3311 .expect("load session");
3312 assert!(path.exists());
3313 assert_eq!(loaded.context_references, session.context_references);
3314 }
3315
3316 #[test]
3317 fn test_checkpoint_rejects_newer_schema() {
3318 let tmp = tempdir().expect("tempdir");
3319 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3320 let checkpoints = tmp.path().join("sessions").join("checkpoints");
3321 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
3322 let path = checkpoints.join("latest.json");
3323 fs::write(
3324 &path,
3325 r#"{
3326 "schema_version": 999,
3327 "metadata": {
3328 "id": "sid",
3329 "title": "bad",
3330 "created_at": "2026-01-01T00:00:00Z",
3331 "updated_at": "2026-01-01T00:00:00Z",
3332 "message_count": 0,
3333 "total_tokens": 0,
3334 "model": "m",
3335 "workspace": "/tmp",
3336 "mode": null
3337 },
3338 "messages": [],
3339 "system_prompt": null
3340 }"#,
3341 )
3342 .expect("write checkpoint");
3343
3344 let err = manager
3345 .load_legacy_checkpoint()
3346 .expect_err("should reject schema");
3347 assert!(err.to_string().contains("newer than supported"));
3348
3349 // The same guard applies to per-session checkpoint files.
3350 fs::rename(&path, checkpoints.join("sid.json")).expect("rename to per-session file");
3351 let err = manager
3352 .load_session_checkpoint("sid")
3353 .expect_err("should reject schema");
3354 assert!(err.to_string().contains("newer than supported"));
3355 }
3356
3357 #[test]
3358 fn test_load_session_rejects_newer_schema() {
3359 let tmp = tempdir().expect("tempdir");
3360 let sessions_dir = tmp.path().join("sessions");
3361 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
3362
3363 let id = "future-session";
3364 let path = sessions_dir.join(format!("{id}.json"));
3365 fs::write(
3366 &path,
3367 r#"{
3368 "schema_version": 999,
3369 "metadata": {
3370 "id": "future-session",
3371 "title": "future",
3372 "created_at": "2026-01-01T00:00:00Z",
3373 "updated_at": "2026-01-01T00:00:00Z",
3374 "message_count": 0,
3375 "total_tokens": 0,
3376 "model": "m",
3377 "workspace": "/tmp",
3378 "mode": null
3379 },
3380 "messages": [],
3381 "system_prompt": null
3382 }"#,
3383 )
3384 .expect("write session");
3385
3386 let err = manager.load_session(id).expect_err("should reject schema");
3387 assert!(
3388 err.to_string().contains("newer than supported"),
3389 "unexpected error: {err}"
3390 );
3391 }
3392
3393 /// Regression for #337: metadata extraction skips the (potentially
3394 /// huge) `messages` array — it must succeed even when the messages
3395 /// array is megabytes long, and it must NOT confuse a `"metadata"`
3396 /// substring inside a message body for the real top-level key.
3397 #[test]
3398 fn extract_top_level_metadata_skips_huge_messages_array() {
3399 // Build a session JSON with a large `messages` payload that
3400 // contains the literal string `"metadata"` in a user message —
3401 // a naive `find("\"metadata\"")` would mis-target this.
3402 let big_text = format!(
3403 r#"this message references "metadata" inside it, repeated:{}"#,
3404 "x".repeat(20_000)
3405 );
3406 let json = format!(
3407 r#"{{
3408 "schema_version": 1,
3409 "metadata": {{
3410 "id": "abc-123",
3411 "title": "Real Session",
3412 "created_at": "2026-01-01T00:00:00Z",
3413 "updated_at": "2026-01-02T00:00:00Z",
3414 "message_count": 12,
3415 "total_tokens": 4096,
3416 "model": "deepseek-v4-flash",
3417 "workspace": "/tmp"
3418 }},
3419 "messages": [
3420 {{ "role": "user", "content": [ {{ "Text": {{ "text": {big_text:?} }} }} ] }}
3421 ]
3422 }}"#
3423 );
3424
3425 let extracted =
3426 extract_top_level_metadata(json.as_bytes()).expect("metadata extractable from prefix");
3427 assert_eq!(extracted.id, "abc-123");
3428 assert_eq!(extracted.title, "Real Session");
3429 assert_eq!(extracted.message_count, 12);
3430 assert_eq!(extracted.total_tokens, 4096);
3431 }
3432
3433 #[test]
3434 fn extract_top_level_metadata_handles_braces_inside_strings() {
3435 // A title containing `{` and `}` inside the metadata block must
3436 // not throw off the brace counter.
3437 let json = r#"{
3438 "metadata": {
3439 "id": "x",
3440 "title": "weird { title } with braces",
3441 "created_at": "2026-01-01T00:00:00Z",
3442 "updated_at": "2026-01-01T00:00:00Z",
3443 "message_count": 0,
3444 "total_tokens": 0,
3445 "model": "m",
3446 "workspace": "/tmp"
3447 },
3448 "messages": []
3449 }"#;
3450 let extracted = extract_top_level_metadata(json.as_bytes())
3451 .expect("brace-in-string survives the scanner");
3452 assert_eq!(extracted.title, "weird { title } with braces");
3453 }
3454
3455 #[test]
3456 fn saved_session_deserializes_without_artifacts_as_empty_registry() {
3457 let json = r#"{
3458 "schema_version": 1,
3459 "metadata": {
3460 "id": "legacy-session",
3461 "title": "legacy",
3462 "created_at": "2026-05-08T00:00:00Z",
3463 "updated_at": "2026-05-08T00:00:00Z",
3464 "message_count": 0,
3465 "total_tokens": 0,
3466 "model": "deepseek-v4-pro",
3467 "workspace": "/tmp"
3468 },
3469 "messages": [],
3470 "system_prompt": null
3471 }"#;
3472
3473 let session: SavedSession = serde_json::from_str(json).expect("legacy session loads");
3474 assert!(session.artifacts.is_empty());
3475 assert!(session.last_auto_route.is_none());
3476 assert!(session.metadata.parent_session_id.is_none());
3477 assert!(session.metadata.forked_from_message_count.is_none());
3478 }
3479
3480 #[test]
3481 fn fork_lineage_metadata_round_trips_and_formats() {
3482 let tmp = tempdir().expect("tempdir");
3483 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3484 let parent = create_saved_session(
3485 &[
3486 make_test_message("user", "try approach A"),
3487 make_test_message("assistant", "A looks viable"),
3488 ],
3489 "deepseek-v4-pro",
3490 Path::new("/tmp"),
3491 42,
3492 None,
3493 );
3494 let mut forked = create_saved_session(
3495 &parent.messages,
3496 &parent.metadata.model,
3497 &parent.metadata.workspace,
3498 parent.metadata.total_tokens,
3499 None,
3500 );
3501 forked.metadata.mark_forked_from(&parent.metadata);
3502
3503 manager.save_session(&forked).expect("save fork");
3504 let loaded = manager
3505 .load_session(&forked.metadata.id)
3506 .expect("load fork");
3507
3508 assert_eq!(
3509 loaded.metadata.parent_session_id.as_deref(),
3510 Some(parent.metadata.id.as_str())
3511 );
3512 assert_eq!(loaded.metadata.forked_from_message_count, Some(2));
3513 let line = format_session_line(&loaded.metadata);
3514 assert!(line.contains("fork"));
3515 assert!(!line.contains(parent.metadata.id.as_str()));
3516 }
3517
3518 #[test]
3519 fn save_and_load_session_preserves_artifact_metadata() {
3520 let tmp = tempdir().expect("tempdir");
3521 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3522 let mut session = create_saved_session(
3523 &[make_test_message("user", "run tests")],
3524 "deepseek-v4-pro",
3525 Path::new("/tmp"),
3526 0,
3527 None,
3528 );
3529 session.artifacts.push(crate::artifacts::ArtifactRecord {
3530 id: "art_call_big".to_string(),
3531 kind: crate::artifacts::ArtifactKind::ToolOutput,
3532 session_id: session.metadata.id.clone(),
3533 tool_call_id: "call-big".to_string(),
3534 tool_name: "exec_shell".to_string(),
3535 created_at: Utc::now(),
3536 byte_size: 512_000,
3537 preview: "cargo test output".to_string(),
3538 storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"),
3539 });
3540
3541 manager.save_session(&session).expect("save");
3542 let loaded = manager.load_session(&session.metadata.id).expect("load");
3543
3544 assert_eq!(loaded.artifacts, session.artifacts);
3545 }
3546
3547 // ---- #406 prune_sessions_older_than ----
3548 //
3549 // The helper is a building block for the auto-archive design: it
3550 // removes session files older than a threshold while leaving fresh
3551 // ones (and the checkpoint directory) alone. Tests cover the empty
3552 // case, the all-fresh case, the all-stale case, and the mixed case.
3553
3554 fn write_session_with_updated_at(
3555 manager: &SessionManager,
3556 id: &str,
3557 updated_at: DateTime<Utc>,
3558 ) {
3559 // Build a minimal SavedSession by hand so the test isn't tied
3560 // to whatever the helper functions emit; we just need a
3561 // metadata block whose `updated_at` matches the requested
3562 // value.
3563 write_session_record(manager, id, Path::new("/tmp"), updated_at);
3564 }
3565
3566 #[test]
3567 fn prune_sessions_older_than_returns_zero_for_empty_dir() {
3568 let tmp = tempdir().expect("tempdir");
3569 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3570 let pruned = manager
3571 .prune_sessions_older_than(std::time::Duration::from_secs(3600))
3572 .expect("prune");
3573 assert_eq!(pruned, 0);
3574 }
3575
3576 #[test]
3577 fn prune_sessions_older_than_keeps_fresh_records() {
3578 let tmp = tempdir().expect("tempdir");
3579 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3580 // All updated within the last hour.
3581 write_session_with_updated_at(
3582 &manager,
3583 "fresh-1",
3584 Utc::now() - chrono::Duration::minutes(30),
3585 );
3586 write_session_with_updated_at(
3587 &manager,
3588 "fresh-2",
3589 Utc::now() - chrono::Duration::minutes(5),
3590 );
3591 let pruned = manager
3592 .prune_sessions_older_than(std::time::Duration::from_secs(3600))
3593 .expect("prune");
3594 assert_eq!(pruned, 0);
3595 // Both files still on disk.
3596 assert_eq!(manager.list_sessions().expect("list").len(), 2);
3597 }
3598
3599 #[test]
3600 fn prune_sessions_older_than_removes_stale_records() {
3601 let tmp = tempdir().expect("tempdir");
3602 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3603 // Two stale records ≥7 days old.
3604 write_session_with_updated_at(&manager, "stale-1", Utc::now() - chrono::Duration::days(8));
3605 write_session_with_updated_at(&manager, "stale-2", Utc::now() - chrono::Duration::days(30));
3606 let pruned = manager
3607 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
3608 .expect("prune");
3609 assert_eq!(pruned, 2);
3610 assert_eq!(manager.list_sessions().expect("list").len(), 0);
3611 }
3612
3613 #[test]
3614 fn prune_sessions_older_than_only_removes_stale_records_in_mixed_dir() {
3615 let tmp = tempdir().expect("tempdir");
3616 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
3617 write_session_with_updated_at(&manager, "fresh", Utc::now() - chrono::Duration::hours(1));
3618 write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
3619 let pruned = manager
3620 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
3621 .expect("prune");
3622 assert_eq!(pruned, 1);
3623 let remaining = manager.list_sessions().expect("list");
3624 assert_eq!(remaining.len(), 1);
3625 assert_eq!(remaining[0].id, "fresh");
3626 }
3627
3628 #[test]
3629 fn prune_sessions_older_than_skips_checkpoint_directory() {
3630 // The checkpoint subsystem owns `<sessions>/checkpoints/` —
3631 // prune must not walk into it. The list_sessions iterator
3632 // already filters to top-level `*.json` files (skipping
3633 // sub-directories), so this test pins that behaviour.
3634 let tmp = tempdir().expect("tempdir");
3635 let sessions_dir = tmp.path().join("sessions");
3636 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
3637 let checkpoint_dir = sessions_dir.join("checkpoints");
3638 fs::create_dir_all(&checkpoint_dir).expect("mkdir checkpoints");
3639 // Drop a stale-looking JSON inside the checkpoint dir; prune
3640 // should leave it alone.
3641 let checkpoint_file = checkpoint_dir.join("latest.json");
3642 fs::write(&checkpoint_file, "{}").expect("write checkpoint");
3643
3644 write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
3645 let pruned = manager
3646 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
3647 .expect("prune");
3648 assert_eq!(pruned, 1, "the top-level stale session should be removed");
3649 assert!(
3650 checkpoint_file.exists(),
3651 "checkpoint file should be untouched"
3652 );
3653 }
3654
3655 #[test]
3656 fn test_load_offline_queue_rejects_newer_schema() {
3657 let tmp = tempdir().expect("tempdir");
3658 let sessions_dir = tmp.path().join("sessions");
3659 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
3660 let checkpoints = sessions_dir.join("checkpoints");
3661 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
3662 let path = checkpoints.join("offline_queue.json");
3663 fs::write(
3664 &path,
3665 r#"{
3666 "schema_version": 999,
3667 "messages": [],
3668 "draft": null
3669 }"#,
3670 )
3671 .expect("write queue");
3672
3673 let err = manager
3674 .load_offline_queue_state()
3675 .expect_err("should reject schema");
3676 assert!(
3677 err.to_string().contains("newer than supported"),
3678 "unexpected error: {err}"
3679 );
3680 }
3681 }
3682
3682 lines RUST