Skip to main content

recursive/
session.rs

1//! Session files for production pause/resume.
2//!
3//! A `SessionFile` captures enough agent state to resume a run that
4//! terminated abnormally (budget exceeded, stuck, transcript limit).
5//! It stores the goal, model, provider, a hash of the tool registry,
6//! the steps consumed so far, and the full transcript.
7//!
8//! Sessions are written alongside transcripts when `--session-out` is
9//! set and the finish reason is non-success. They live under
10//! `<workspace>/.recursive/sessions/` by convention.
11
12use serde::{Deserialize, Serialize};
13use std::io::{BufRead, BufWriter, Write};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use uuid::Uuid;
17
18use crate::event::{AgentEvent, EventSink};
19use crate::llm::ToolSpec;
20use crate::message::Message;
21
22/// Current schema version for session files.
23/// Increment when the format changes in a breaking way.
24const SESSION_SCHEMA_VERSION: u32 = 1;
25
26/// A saved session that can be resumed.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SessionFile {
29    pub schema_version: u32,
30    /// The original goal string.
31    pub goal: String,
32    /// Model name (e.g. "gpt-4o-mini").
33    pub model: String,
34    /// Provider identifier (e.g. "openai").
35    pub provider: String,
36    /// BLAKE3 hash of the tool registry specs at the time of save.
37    /// Used to detect tool changes that would make the session invalid.
38    pub tool_registry_hash: String,
39    /// Number of steps already consumed.
40    pub steps_consumed: usize,
41    /// The full transcript so far (system prompt + user goal + all exchanges).
42    pub transcript: Vec<Message>,
43    /// Resolved provider preset id (e.g. "deepseek") at session creation
44    /// time. `None` when the user did not configure a preset — kept
45    /// optional for back-compat with pre-preset-config session files.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub preset: Option<String>,
48}
49
50impl SessionFile {
51    /// Create a new session file from the agent's current state.
52    pub fn new(
53        goal: String,
54        model: String,
55        provider: String,
56        tool_specs: &[ToolSpec],
57        steps_consumed: usize,
58        transcript: Vec<Message>,
59    ) -> Self {
60        let tool_registry_hash = hash_tool_specs(tool_specs);
61        Self {
62            schema_version: SESSION_SCHEMA_VERSION,
63            goal,
64            model,
65            provider,
66            tool_registry_hash,
67            steps_consumed,
68            transcript,
69            preset: None,
70        }
71    }
72
73    /// Write the session to a JSON file at `path`.
74    pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
75        let json = serde_json::to_string_pretty(self)
76            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
77        if let Some(parent) = path.parent() {
78            std::fs::create_dir_all(parent)?;
79        }
80        std::fs::write(path, json)
81    }
82
83    /// Read a session from a JSON file at `path`.
84    pub fn read_from(path: &Path) -> std::io::Result<Self> {
85        let bytes = std::fs::read(path)?;
86        serde_json::from_slice(&bytes)
87            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
88    }
89
90    /// Validate that the tool registry hash matches the current tool specs.
91    /// Returns `Ok(())` if they match, or an error describing the mismatch.
92    pub fn validate_tool_registry(&self, current_specs: &[ToolSpec]) -> Result<(), String> {
93        let current_hash = hash_tool_specs(current_specs);
94        if self.tool_registry_hash == current_hash {
95            Ok(())
96        } else {
97            Err(format!(
98                "tool registry hash mismatch: session has '{}', current is '{}'. \
99                 Tools have changed since the session was saved; cannot resume.",
100                self.tool_registry_hash, current_hash
101            ))
102        }
103    }
104
105    /// Return the transcript messages.
106    pub fn messages(&self) -> &[Message] {
107        &self.transcript
108    }
109
110    /// Consume self and return the transcript.
111    pub fn into_transcript(self) -> Vec<Message> {
112        self.transcript
113    }
114}
115
116/// Compute a BLAKE3 hash of the tool registry specs.
117///
118/// The hash is computed over a deterministic JSON representation of the
119/// specs, sorted by tool name. This ensures that the same set of tools
120/// always produces the same hash, regardless of registration order.
121///
122/// Used by both [`SessionFile`] (legacy `.json` resume) and the
123/// JSONL session meta's `tool_registry_hash` field (g151) so that
124/// `recursive resume` can refuse to load a session whose tool
125/// inventory has drifted.
126pub fn hash_tool_specs(specs: &[ToolSpec]) -> String {
127    use std::collections::BTreeMap;
128
129    let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
130    for spec in specs {
131        let value = serde_json::json!({
132            "description": spec.description,
133            "parameters": spec.parameters,
134        });
135        map.insert(spec.name.clone(), value);
136    }
137    let canonical = serde_json::to_string(&map).unwrap_or_default();
138    let hash = blake3::hash(canonical.as_bytes());
139    hash.to_hex().to_string()
140}
141
142/// Default session output path for a given workspace.
143/// Returns `~/.recursive/workspaces/<ws-hash>/sessions/<timestamp>-<goal-prefix>.json`.
144pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
145    let ts = filesystem_safe_timestamp();
146    // Sanitise the goal prefix for use in a filename
147    let prefix: String = goal
148        .chars()
149        .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
150        .take(40)
151        .collect();
152    let prefix = if prefix.is_empty() {
153        "unnamed".to_string()
154    } else {
155        prefix
156    };
157    let dir = crate::paths::user_sessions_dir(workspace)
158        .unwrap_or_else(|_| workspace.join(".recursive").join("sessions"));
159    dir.join(format!("{}-{}.json", ts, prefix))
160}
161
162/// List all session files in a workspace's session directory.
163pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
164    let dir = match crate::paths::user_sessions_dir(workspace) {
165        Ok(d) => d,
166        Err(_) => workspace.join(".recursive").join("sessions"),
167    };
168    if !dir.is_dir() {
169        return Ok(Vec::new());
170    }
171    let mut sessions = Vec::new();
172    for entry in std::fs::read_dir(&dir)? {
173        let entry = entry?;
174        let path = entry.path();
175        if path.extension().and_then(|e| e.to_str()) == Some("json") {
176            sessions.push(path);
177        }
178    }
179    sessions.sort();
180    Ok(sessions)
181}
182
183/// RFC3339 timestamp safe for use in path components on all platforms.
184/// Colons in the time portion are replaced with hyphens (Windows forbids `:`).
185fn filesystem_safe_timestamp() -> String {
186    chrono_lite_now().replace(':', "-")
187}
188
189// Tiny RFC3339-ish timestamp without pulling in `chrono`. Format:
190// "YYYY-MM-DDTHH:MM:SSZ" using UTC.
191fn chrono_lite_now() -> String {
192    use std::time::{SystemTime, UNIX_EPOCH};
193    let secs = SystemTime::now()
194        .duration_since(UNIX_EPOCH)
195        .map(|d| d.as_secs())
196        .unwrap_or(0);
197    let day = secs / 86_400;
198    let sec_of_day = secs % 86_400;
199    let (h, m, s) = (sec_of_day / 3600, (sec_of_day / 60) % 60, sec_of_day % 60);
200    let (y, mo, d) = epoch_day_to_ymd(day as i64);
201    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
202}
203
204fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
205    let z = z + 719_468;
206    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
207    let doe = (z - era * 146_097) as u64;
208    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
209    let y = yoe as i64 + era * 400;
210    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
211    let mp = (5 * doy + 2) / 153;
212    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
213    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
214    let y = if m <= 2 { y + 1 } else { y };
215    (y, m, d)
216}
217
218// ---------------------------------------------------------------------------
219// JSONL session persistence (Goal 107)
220// ---------------------------------------------------------------------------
221
222/// Token usage for one or more LLM API calls, as reported by the provider.
223///
224/// Used both per-message (`TranscriptEntry.usage`) and as a cumulative
225/// session total stored in `SessionMeta.cost` (g156).
226#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
227pub struct UsageMeta {
228    pub input_tokens: u32,
229    pub output_tokens: u32,
230    /// Prompt-cache creation tokens (Anthropic / OpenAI).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub cache_creation_tokens: Option<u32>,
233    /// Prompt-cache read tokens (Anthropic / OpenAI).
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub cache_read_tokens: Option<u32>,
236    /// Reasoning/thinking tokens (DeepSeek R1, o1, etc.).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub reasoning_tokens: Option<u32>,
239}
240
241impl UsageMeta {
242    /// Accumulate another `UsageMeta` into self.
243    pub fn accumulate(&mut self, other: &UsageMeta) {
244        self.input_tokens += other.input_tokens;
245        self.output_tokens += other.output_tokens;
246        self.cache_creation_tokens = Some(
247            self.cache_creation_tokens.unwrap_or(0) + other.cache_creation_tokens.unwrap_or(0),
248        );
249        self.cache_read_tokens =
250            Some(self.cache_read_tokens.unwrap_or(0) + other.cache_read_tokens.unwrap_or(0));
251        self.reasoning_tokens =
252            Some(self.reasoning_tokens.unwrap_or(0) + other.reasoning_tokens.unwrap_or(0));
253    }
254
255    /// Convert from `crate::llm::TokenUsage` (g156 integration point).
256    pub fn from_token_usage(tu: &crate::llm::TokenUsage) -> Self {
257        UsageMeta {
258            input_tokens: tu.prompt_tokens,
259            output_tokens: tu.completion_tokens,
260            cache_creation_tokens: if tu.cache_miss_tokens > 0 {
261                Some(tu.cache_miss_tokens)
262            } else {
263                None
264            },
265            cache_read_tokens: if tu.cache_hit_tokens > 0 {
266                Some(tu.cache_hit_tokens)
267            } else {
268                None
269            },
270            reasoning_tokens: None,
271        }
272    }
273
274    /// Returns true if both `input_tokens` and `output_tokens` are zero.
275    pub fn is_zero(&self) -> bool {
276        self.input_tokens == 0 && self.output_tokens == 0
277    }
278}
279
280/// Cumulative token cost for a session, stored in `.meta.json`.
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct SessionCost {
283    pub total_input_tokens: u64,
284    pub total_output_tokens: u64,
285    #[serde(default)]
286    pub total_cache_creation_tokens: u64,
287    #[serde(default)]
288    pub total_cache_read_tokens: u64,
289}
290
291impl SessionCost {
292    pub fn accumulate(&mut self, usage: &UsageMeta) {
293        self.total_input_tokens += usage.input_tokens as u64;
294        self.total_output_tokens += usage.output_tokens as u64;
295        self.total_cache_creation_tokens += usage.cache_creation_tokens.unwrap_or(0) as u64;
296        self.total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0) as u64;
297    }
298}
299
300/// Metadata for a JSONL session.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SessionMeta {
303    pub session_id: String,
304    pub goal: String,
305    pub model: String,
306    pub provider: String,
307    pub created_at: String,
308    pub updated_at: String,
309    pub message_count: u64,
310    pub status: String,
311    /// BLAKE3 hash of the tool registry specs at session creation
312    /// time. Used by `recursive resume` (g151) to refuse loading a
313    /// session whose tool inventory has drifted. `None` for
314    /// pre-g151 sessions; resume tolerates the absence with a
315    /// warning rather than an abort.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub tool_registry_hash: Option<String>,
318    /// First user message in this session, truncated to 200 chars (g157).
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub first_prompt: Option<String>,
321    /// Most recent user message, truncated to 200 chars (g157).
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub last_prompt: Option<String>,
324    /// Cumulative token usage for this session (g156).
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub cost: Option<SessionCost>,
327    /// Resolved provider preset id (e.g. "deepseek") at session creation
328    /// time. `None` for pre-preset-config sessions or when the user did
329    /// not configure a preset. Optional + skipped on serialize so old
330    /// session files round-trip cleanly.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub preset: Option<String>,
333    /// Optional human-readable display name for this session, set via `--name`.
334    /// Shown in the /resume picker and `sessions list` output.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub name: Option<String>,
337}
338
339/// A portable exported transcript for sharing and analysis.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ExportedTranscript {
342    pub version: u32,
343    pub session_id: String,
344    pub model: String,
345    pub goal: String,
346    pub created_at: String,
347    pub status: String,
348    pub messages: Vec<TranscriptEntry>,
349    pub message_count: u64,
350}
351
352impl ExportedTranscript {
353    /// Build an  from a session directory.
354    pub fn from_session_dir(session_dir: &Path) -> std::io::Result<Self> {
355        let meta = SessionReader::load_meta(session_dir)?;
356        let entries = SessionReader::load_transcript(session_dir)?;
357        Ok(Self {
358            version: 1,
359            session_id: meta.session_id,
360            model: meta.model,
361            goal: meta.goal,
362            created_at: meta.created_at,
363            status: meta.status,
364            messages: entries.clone(),
365            message_count: entries.len() as u64,
366        })
367    }
368}
369
370/// A single JSONL line representing one message in the transcript.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct TranscriptEntry {
373    /// Stable UUID v4 for this message (g155). Empty string for pre-g155 entries.
374    #[serde(default)]
375    pub uuid: String,
376    /// UUID of the parent message in the conversation chain (g155).
377    /// `None` for the root message (first message in a session or chain branch).
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub parent_uuid: Option<String>,
380    /// For tool-result messages, the UUID of the assistant message that issued
381    /// the tool call (g155). Enables correct attribution in multi-agent trees.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub source_tool_assistant_uuid: Option<String>,
384    pub id: String,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub parent_id: Option<String>,
387    pub role: String,
388    pub content: String,
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub tool_calls: Vec<crate::llm::ToolCall>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub tool_call_id: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub reasoning_content: Option<String>,
395    /// Token usage for this message (non-None for assistant messages, g156).
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub usage: Option<UsageMeta>,
398    pub timestamp: String,
399    /// Goal-153: per-call audit metadata. Only populated on `role: "tool"`
400    /// messages (i.e. tool results). Never sent to providers — see
401    /// [`entry_to_message`] which drops this field.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub audit: Option<crate::tools::AuditMeta>,
404}
405
406/// Goal-153: describes a tool call that was dispatched but never completed
407/// (no matching `tool` result message in the transcript).
408#[derive(Debug, Clone)]
409pub struct OrphanToolCall {
410    /// `id` field of the assistant `TranscriptEntry` that issued the call.
411    pub assistant_msg_id: String,
412    /// The `id` of the tool call itself (matches `tool_call_id` on the
413    /// expected — but missing — tool result message).
414    pub tool_call_id: String,
415    /// The name of the tool that was called.
416    pub tool_name: String,
417    /// BLAKE3 of canonical JSON of the call arguments (for drift detection).
418    pub args_hash: String,
419    /// Side-effect class, determined from the current registry (valid because
420    /// `recursive resume` validates the registry hash before calling this).
421    pub side_effect_at_call: crate::tools::ToolSideEffect,
422}
423
424/// A compact-boundary system entry written to the JSONL when cross-turn
425/// compaction fires (g157). Parsed by `SessionReader::load_transcript`
426/// to skip pre-boundary messages on resume.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428struct CompactBoundaryEntry {
429    #[serde(rename = "type")]
430    entry_type: String,
431    subtype: String,
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    turn: Option<u32>,
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    compacted_count: Option<usize>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    summary_uuid: Option<String>,
438    timestamp: String,
439}
440
441/// Convert a persisted [`TranscriptEntry`] into a runtime [`Message`].
442///
443/// Drops persistence-only fields (`id`, `parent_id`, `timestamp`,
444/// and — once g153 lands — `audit`). The result is what
445/// `run_resumed` expects as its `seed` argument and what provider
446/// adapters serialise onto the wire.
447///
448/// This is the **isolation point** between the persisted shape
449/// (`TranscriptEntry`) and the LLM wire shape (`Message`):
450/// provider adapters never see persistence-only fields. An unknown
451/// `role` string maps to `Role::User` defensively so a corrupted
452/// transcript can't panic the resume handler — in practice this
453/// never fires because the writer only ever emits the four known
454/// roles.
455pub fn entry_to_message(entry: TranscriptEntry) -> Message {
456    use crate::message::Role;
457    let role = match entry.role.as_str() {
458        "system" => Role::System,
459        "user" => Role::User,
460        "assistant" => Role::Assistant,
461        "tool" => Role::Tool,
462        _ => Role::User,
463    };
464    Message {
465        role,
466        content: entry.content,
467        tool_calls: entry.tool_calls,
468        tool_call_id: entry.tool_call_id,
469        reasoning_content: entry.reasoning_content,
470    }
471}
472
473/// Writer for appending messages to a JSONL session file.
474///
475/// Opens (or creates) a `.jsonl` file in append mode and writes one
476/// JSON object per line. A companion `.meta.json` file tracks session
477/// metadata and is updated on `finish()`.
478pub struct SessionWriter {
479    session_id: String,
480    session_dir: PathBuf,
481    writer: BufWriter<std::fs::File>,
482    message_count: u64,
483    /// UUID of the last-appended message; used as `parent_uuid` for the
484    /// next message in the chain (g155).
485    last_uuid: Option<String>,
486    /// Accumulated token usage across all appended messages (g156).
487    cumulative_usage: UsageMeta,
488    /// First user prompt, truncated to 200 chars (g157).
489    first_prompt: Option<String>,
490    /// Most recent user prompt, truncated to 200 chars (g157).
491    last_prompt: Option<String>,
492    /// Held for the lifetime of the writer so a second
493    /// `SessionWriter::open_existing` (or `create`) on the same
494    /// `session_dir` is refused. Cleaned up on `Drop`.
495    _lock: Option<SessionLock>,
496    /// Optional human-readable display name for this session.
497    name: Option<String>,
498}
499
500impl SessionWriter {
501    /// Create a new session in the given workspace directory.
502    ///
503    /// The session directory is `<workspace>/.recursive/sessions/<workspace-slug>/<session-id>/`.
504    /// The `.jsonl` file and `.meta.json` are placed inside that directory.
505    ///
506    /// Equivalent to `create_with_tools(workspace, goal, model, provider, &[])`;
507    /// no `tool_registry_hash` is stamped in the meta. Prefer
508    /// `create_with_tools` whenever the caller has a `ToolRegistry`,
509    /// so that `recursive resume` (g151) can detect tool drift.
510    pub fn create(
511        workspace: &Path,
512        goal: &str,
513        model: &str,
514        provider: &str,
515    ) -> std::io::Result<Self> {
516        Self::create_with_tools(workspace, goal, model, provider, &[], None)
517    }
518
519    /// Create a new session, stamping a BLAKE3 hash of `tool_specs`
520    /// into `.meta.json` as `tool_registry_hash`. The hash is what
521    /// `recursive resume` validates against the current registry —
522    /// if they differ, resume aborts (g151).
523    ///
524    /// Pass `&[]` for `tool_specs` if the caller has no registry
525    /// (e.g. tests, ad-hoc tools), in which case the hash is `None`
526    /// and resume will warn but not abort.
527    ///
528    /// `preset` is the resolved provider preset id (e.g. "deepseek")
529    /// from the active `Config`. Pass `None` for pre-preset-config
530    /// callers or when the user did not opt in; the field is
531    /// `Option<String>` and `skip_serializing_if = "Option::is_none"`
532    /// keeps the on-disk format clean.
533    pub fn create_with_tools(
534        workspace: &Path,
535        goal: &str,
536        model: &str,
537        provider: &str,
538        tool_specs: &[ToolSpec],
539        preset: Option<&str>,
540    ) -> std::io::Result<Self> {
541        let slug = workspace_slug(workspace);
542        let session_id = format!("{}-{}", filesystem_safe_timestamp(), slug);
543        // Sessions live under the per-user data dir, not the project,
544        // so they don't pollute the user's `git status`.
545        let sessions_root = crate::paths::user_sessions_dir(workspace)
546            .map_err(|e| std::io::Error::other(format!("user_sessions_dir: {e}")))?;
547        let session_dir = sessions_root.join(&slug).join(&session_id);
548
549        std::fs::create_dir_all(&session_dir)?;
550
551        // Acquire the per-session lock before opening any files for
552        // writing — guards against two `recursive resume <id>`
553        // invocations clobbering the same transcript.
554        let lock = SessionLock::acquire(&session_dir)?;
555
556        let jsonl_path = session_dir.join("transcript.jsonl");
557        let file = std::fs::OpenOptions::new()
558            .create(true)
559            .append(true)
560            .open(&jsonl_path)?;
561
562        let now = chrono_lite_now();
563        let tool_registry_hash = if tool_specs.is_empty() {
564            None
565        } else {
566            Some(hash_tool_specs(tool_specs))
567        };
568        let meta = SessionMeta {
569            session_id: session_id.clone(),
570            goal: goal.to_string(),
571            model: model.to_string(),
572            provider: provider.to_string(),
573            created_at: now.clone(),
574            updated_at: now.clone(),
575            message_count: 0,
576            status: "active".to_string(),
577            tool_registry_hash,
578            first_prompt: None,
579            last_prompt: None,
580            cost: None,
581            preset: preset.map(|s| s.to_string()),
582            name: None,
583        };
584
585        // Write initial meta file
586        let meta_path = session_dir.join(".meta.json");
587        let meta_json = serde_json::to_string_pretty(&meta)
588            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
589        std::fs::write(&meta_path, meta_json)?;
590
591        Ok(Self {
592            session_id,
593            session_dir,
594            writer: BufWriter::new(file),
595            message_count: 0,
596            last_uuid: None,
597            cumulative_usage: UsageMeta::default(),
598            first_prompt: None,
599            last_prompt: None,
600            _lock: Some(lock),
601            name: None,
602        })
603    }
604
605    /// Re-open an existing session directory for appending.
606    ///
607    /// Reads the existing `.meta.json` to recover `message_count`,
608    /// `created_at`, `goal`, `model`, and `provider`. The
609    /// `tool_registry_hash` is **not** re-validated here — the
610    /// caller (typically the `Cmd::Resume` handler) must have done
611    /// that already.
612    ///
613    /// Acquires `SessionLock::acquire(session_dir)` so a second
614    /// resume on the same session is refused while this writer is
615    /// alive.
616    ///
617    /// Continues the `msg_NNN` sequence from where the existing
618    /// transcript left off (so the first `append()` after
619    /// `open_existing` does not collide with prior messages).
620    pub fn open_existing(session_dir: &Path) -> std::io::Result<Self> {
621        let meta = SessionReader::load_meta(session_dir)?;
622
623        let lock = SessionLock::acquire(session_dir)?;
624
625        let jsonl_path = session_dir.join("transcript.jsonl");
626
627        // Recover last_uuid from the last message entry in the JSONL so the
628        // UUID chain can continue from where the previous run left off (g155).
629        let last_uuid = read_last_message_uuid(&jsonl_path);
630
631        let file = std::fs::OpenOptions::new()
632            .create(true)
633            .append(true)
634            .open(&jsonl_path)?;
635
636        Ok(Self {
637            session_id: meta.session_id,
638            session_dir: session_dir.to_path_buf(),
639            writer: BufWriter::new(file),
640            message_count: meta.message_count,
641            last_uuid,
642            cumulative_usage: UsageMeta::default(),
643            first_prompt: meta.first_prompt,
644            last_prompt: meta.last_prompt,
645            _lock: Some(lock),
646            name: meta.name,
647        })
648    }
649
650    /// Append a message to the JSONL file.
651    ///
652    /// Returns the UUID assigned to this message entry. Bumps
653    /// `SessionMeta.updated_at` on every `assistant` or `user`
654    /// message (skipping per-tool-result writes is an
655    /// optimisation — a turn with N tool calls would otherwise
656    /// rewrite the meta file 2N times). The "most-recent" shortcut
657    /// in `recursive resume` (g151) relies on `updated_at` being
658    /// live for crashed sessions.
659    ///
660    /// `parent_uuid_override` — if `Some`, this UUID is used as `parent_uuid`
661    /// for the new entry instead of `self.last_uuid`. Use this for subagent
662    /// messages that branch off a specific parent agent message (g155).
663    ///
664    /// `usage` — token usage to attach to this entry (non-None for assistant
665    /// messages, g156).
666    pub fn append(
667        &mut self,
668        msg: &Message,
669        parent_uuid_override: Option<&str>,
670        usage: Option<&UsageMeta>,
671    ) -> std::io::Result<String> {
672        self.append_with_audit(msg, None, parent_uuid_override, usage)
673    }
674
675    /// Append a message with optional audit metadata (Goal 153).
676    /// `audit` should only be `Some` for `Role::Tool` messages.
677    pub fn append_with_audit(
678        &mut self,
679        msg: &Message,
680        audit: Option<crate::tools::AuditMeta>,
681        parent_uuid_override: Option<&str>,
682        usage: Option<&UsageMeta>,
683    ) -> std::io::Result<String> {
684        self.message_count += 1;
685        let msg_id = format!("msg_{:03}", self.message_count);
686
687        let parent_id = if self.message_count > 1 {
688            Some(format!("msg_{:03}", self.message_count - 1))
689        } else {
690            None
691        };
692
693        // g155: generate a stable UUID for this entry.
694        let new_uuid = Uuid::new_v4().to_string();
695        let parent_uuid = parent_uuid_override
696            .map(|s| s.to_string())
697            .or_else(|| self.last_uuid.clone());
698
699        // g155: for tool results, track which assistant message issued the call.
700        let source_tool_assistant_uuid = if matches!(msg.role, crate::message::Role::Tool) {
701            // The parent_uuid points to the assistant that issued this tool call.
702            parent_uuid.clone()
703        } else {
704            None
705        };
706
707        let role_str = match msg.role {
708            crate::message::Role::System => "system",
709            crate::message::Role::User => "user",
710            crate::message::Role::Assistant => "assistant",
711            crate::message::Role::Tool => "tool",
712        };
713
714        // g157: track first/last user prompt in memory (written to meta on bump).
715        if matches!(msg.role, crate::message::Role::User) {
716            let prompt: String = msg.content.chars().take(200).collect();
717            if self.first_prompt.is_none() {
718                self.first_prompt = Some(prompt.clone());
719            }
720            self.last_prompt = Some(prompt);
721        }
722
723        // g156: accumulate usage if provided.
724        if let Some(u) = usage {
725            self.cumulative_usage.accumulate(u);
726        }
727
728        let entry = TranscriptEntry {
729            uuid: new_uuid.clone(),
730            parent_uuid,
731            source_tool_assistant_uuid,
732            id: msg_id,
733            parent_id,
734            role: role_str.to_string(),
735            content: msg.content.clone(),
736            tool_calls: msg.tool_calls.clone(),
737            tool_call_id: msg.tool_call_id.clone(),
738            reasoning_content: msg.reasoning_content.clone(),
739            usage: usage.cloned(),
740            timestamp: chrono_lite_now(),
741            audit,
742        };
743
744        let line = serde_json::to_string(&entry)
745            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
746        self.writer.write_all(line.as_bytes())?;
747        self.writer.write_all(b"\n")?;
748        self.writer.flush()?;
749
750        // g155: advance the chain pointer.
751        self.last_uuid = Some(new_uuid.clone());
752
753        // Bump updated_at on user/assistant messages so the
754        // "most-recent" shortcut on resume picks crashed sessions
755        // correctly. Skip on tool results to avoid 2N writes per
756        // tool-call-heavy turn.
757        if matches!(
758            msg.role,
759            crate::message::Role::User | crate::message::Role::Assistant
760        ) {
761            let _ = self.bump_updated_at();
762        }
763
764        Ok(new_uuid)
765    }
766
767    /// Write a compact_boundary system entry directly to the JSONL (g157).
768    ///
769    /// Called by `SessionPersistenceSink` when it receives a
770    /// `AgentEvent::CompactionBoundary` event. The entry is written outside
771    /// the normal `append` flow so it does not increment `message_count` or
772    /// advance the UUID chain.
773    pub fn write_compact_boundary(
774        &mut self,
775        turn: u32,
776        compacted_count: usize,
777        summary_uuid: Option<&str>,
778    ) -> std::io::Result<()> {
779        let entry = CompactBoundaryEntry {
780            entry_type: "system".to_string(),
781            subtype: "compact_boundary".to_string(),
782            turn: Some(turn),
783            compacted_count: Some(compacted_count),
784            summary_uuid: summary_uuid.map(|s| s.to_string()),
785            timestamp: chrono_lite_now(),
786        };
787        let line = serde_json::to_string(&entry)
788            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
789        self.writer.write_all(line.as_bytes())?;
790        self.writer.write_all(b"\n")?;
791        self.writer.flush()
792    }
793
794    /// Update `updated_at`, `message_count`, `first_prompt`, and `last_prompt`
795    /// in `.meta.json`, preserving everything else (goal, model, status, tool hash).
796    /// Best-effort: errors are returned but `append()` swallows them
797    /// so a transient meta-write failure does not abort the run.
798    fn bump_updated_at(&self) -> std::io::Result<()> {
799        let meta_path = self.session_dir.join(".meta.json");
800        let bytes = std::fs::read(&meta_path)?;
801        let mut meta: SessionMeta = serde_json::from_slice(&bytes)
802            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
803        meta.updated_at = chrono_lite_now();
804        meta.message_count = self.message_count;
805        // g157: persist first/last prompt so session picker can show them
806        // without reading the full JSONL.
807        if self.first_prompt.is_some() {
808            meta.first_prompt = self.first_prompt.clone();
809        }
810        if self.last_prompt.is_some() {
811            meta.last_prompt = self.last_prompt.clone();
812        }
813        let json = serde_json::to_string_pretty(&meta)
814            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
815        std::fs::write(&meta_path, json)
816    }
817
818    /// Finalise the session: flush the writer and update the meta file
819    /// with the final message count, status, prompts, and cumulative cost.
820    pub fn finish(&mut self, status: &str) -> std::io::Result<()> {
821        self.writer.flush()?;
822
823        // Read-modify-write so we preserve fields we don't own here
824        // (notably `tool_registry_hash`).
825        let meta_path = self.session_dir.join(".meta.json");
826        let bytes = std::fs::read(&meta_path)?;
827        let mut meta: SessionMeta = serde_json::from_slice(&bytes)
828            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
829        meta.updated_at = chrono_lite_now();
830        meta.message_count = self.message_count;
831        meta.status = status.to_string();
832        // g157: final prompt snapshot.
833        if self.first_prompt.is_some() {
834            meta.first_prompt = self.first_prompt.clone();
835        }
836        if self.last_prompt.is_some() {
837            meta.last_prompt = self.last_prompt.clone();
838        }
839        // g156: write cumulative cost.
840        if !self.cumulative_usage.is_zero() {
841            let mut cost = meta.cost.take().unwrap_or_default();
842            cost.accumulate(&self.cumulative_usage);
843            meta.cost = Some(cost);
844        }
845        // Persist the display name if one was set.
846        if self.name.is_some() {
847            meta.name = self.name.clone();
848        }
849
850        let meta_json = serde_json::to_string_pretty(&meta)
851            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
852        std::fs::write(&meta_path, meta_json)
853    }
854
855    /// Set an optional human-readable display name for this session.
856    /// The name is persisted to `.meta.json` on the next `finish()` call.
857    pub fn set_name(&mut self, name: impl Into<String>) {
858        self.name = Some(name.into());
859    }
860
861    /// Return the session ID.
862    pub fn session_id(&self) -> &str {
863        &self.session_id
864    }
865
866    /// Return the session directory path.
867    pub fn session_dir(&self) -> &Path {
868        &self.session_dir
869    }
870
871    /// Return the number of messages written so far.
872    pub fn message_count(&self) -> u64 {
873        self.message_count
874    }
875
876    /// Return the UUID of the last appended message (g155).
877    pub fn last_uuid(&self) -> Option<&str> {
878        self.last_uuid.as_deref()
879    }
880}
881
882/// Read the UUID of the last message-type (TranscriptEntry) line in a JSONL
883/// file. Skips compact_boundary system entries. Returns `None` if the file
884/// is empty, unreadable, or all entries lack a UUID (pre-g155 files).
885fn read_last_message_uuid(jsonl_path: &Path) -> Option<String> {
886    let file = std::fs::File::open(jsonl_path).ok()?;
887    let reader = std::io::BufReader::new(file);
888    let mut last = None;
889    for line in reader.lines().map_while(Result::ok) {
890        if line.trim().is_empty() {
891            continue;
892        }
893        if let Ok(entry) = serde_json::from_str::<TranscriptEntry>(&line) {
894            if !entry.uuid.is_empty() {
895                last = Some(entry.uuid);
896            }
897        }
898    }
899    last
900}
901
902/// Truncate `transcript.jsonl` (and the session's `.meta.json`
903/// `message_count`) so that only the messages from turns
904/// `0..cutoff_turn` survive.
905///
906/// "Turn N" is defined as the N-th non-system, non-tool user message
907/// in the transcript (0-indexed). The system prompt (if any) and any
908/// seed messages preceding the first user turn are always preserved.
909///
910/// Used by `recursive sessions rewind --to-turn N` to keep transcript
911/// state in sync with the workspace state restored from a checkpoint.
912pub fn truncate_transcript_to_turn(
913    session_dir: &Path,
914    cutoff_turn: usize,
915) -> std::io::Result<TruncateStats> {
916    let jsonl_path = session_dir.join("transcript.jsonl");
917    if !jsonl_path.exists() {
918        return Ok(TruncateStats {
919            kept: 0,
920            dropped: 0,
921        });
922    }
923
924    // Stream-read so we don't load the whole transcript into memory.
925    let file = std::fs::File::open(&jsonl_path)?;
926    let reader = std::io::BufReader::new(file);
927
928    let tmp_path = jsonl_path.with_extension("jsonl.rewind-tmp");
929    let tmp = std::fs::File::create(&tmp_path)?;
930    let mut writer = BufWriter::new(tmp);
931
932    let mut user_seen = 0usize;
933    let mut kept = 0u64;
934    let mut dropped = 0u64;
935    let mut stop = false;
936
937    for line in reader.lines() {
938        let line = line?;
939        if line.trim().is_empty() {
940            continue;
941        }
942
943        if stop {
944            dropped += 1;
945            continue;
946        }
947
948        // Peek role without full deserialisation.
949        let role = serde_json::from_str::<serde_json::Value>(&line)
950            .ok()
951            .and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
952
953        let is_turn_boundary = matches!(role.as_deref(), Some("user"));
954        if is_turn_boundary {
955            if user_seen >= cutoff_turn {
956                // This user message starts the turn we're rewinding;
957                // drop it and everything after.
958                stop = true;
959                dropped += 1;
960                continue;
961            }
962            user_seen += 1;
963        }
964
965        writer.write_all(line.as_bytes())?;
966        writer.write_all(b"\n")?;
967        kept += 1;
968    }
969    writer.flush()?;
970    drop(writer);
971
972    std::fs::rename(&tmp_path, &jsonl_path)?;
973
974    // Update .meta.json message_count if present.
975    let meta_path = session_dir.join(".meta.json");
976    if meta_path.exists() {
977        if let Ok(bytes) = std::fs::read(&meta_path) {
978            if let Ok(mut meta) = serde_json::from_slice::<SessionMeta>(&bytes) {
979                meta.message_count = kept;
980                meta.updated_at = chrono_lite_now();
981                if let Ok(json) = serde_json::to_string_pretty(&meta) {
982                    let _ = std::fs::write(&meta_path, json);
983                }
984            }
985        }
986    }
987
988    Ok(TruncateStats { kept, dropped })
989}
990
991/// Stats returned by [`truncate_transcript_to_turn`].
992#[derive(Debug, Clone, Copy)]
993pub struct TruncateStats {
994    pub kept: u64,
995    pub dropped: u64,
996}
997
998// ---------------------------------------------------------------------------
999// Session lock (Goal 151)
1000// ---------------------------------------------------------------------------
1001//
1002// Implementation lives in `crate::session_lock`. Re-exported here so external
1003// callers using `recursive::session::{SessionLock, SessionLockBusy}` keep
1004// working unchanged.
1005pub use crate::session_lock::{SessionLock, SessionLockBusy};
1006
1007/// Reader for loading sessions from JSONL files.
1008pub struct SessionReader;
1009
1010impl SessionReader {
1011    /// Load all transcript entries from a session directory.
1012    ///
1013    /// If the JSONL contains a `compact_boundary` system entry (g157), all
1014    /// entries **before** the last such boundary are discarded—they were
1015    /// already summarised and the summary is the first entry after the
1016    /// boundary. This makes resume `O(post-compaction size)`.
1017    pub fn load_transcript(session_dir: &Path) -> std::io::Result<Vec<TranscriptEntry>> {
1018        let jsonl_path = session_dir.join("transcript.jsonl");
1019        let file = std::fs::File::open(&jsonl_path)?;
1020        let reader = std::io::BufReader::new(file);
1021
1022        let mut all_entries: Vec<TranscriptEntry> = Vec::new();
1023        // Index of the line immediately after the last compact_boundary we saw.
1024        let mut boundary_after: usize = 0;
1025
1026        for line in reader.lines() {
1027            let line = line?;
1028            if line.trim().is_empty() {
1029                continue;
1030            }
1031            // g157: detect compact_boundary system entries before trying to
1032            // parse as TranscriptEntry (they have a different shape).
1033            if let Ok(sys) = serde_json::from_str::<CompactBoundaryEntry>(&line) {
1034                if sys.entry_type == "system" && sys.subtype == "compact_boundary" {
1035                    // Everything before this boundary is superseded; restart.
1036                    boundary_after = all_entries.len();
1037                    continue;
1038                }
1039            }
1040            match serde_json::from_str::<TranscriptEntry>(&line) {
1041                Ok(entry) => all_entries.push(entry),
1042                Err(e) => {
1043                    // Skip corrupt lines gracefully
1044                    eprintln!(
1045                        "warning: skipping corrupt line in {}: {}",
1046                        jsonl_path.display(),
1047                        e
1048                    );
1049                    continue;
1050                }
1051            }
1052        }
1053        // Discard pre-boundary entries (g157).
1054        Ok(all_entries.split_off(boundary_after))
1055    }
1056
1057    /// Load the transcript and build a UUID → `TranscriptEntry` index
1058    /// alongside the ordered vec. Enables O(1) lookup by UUID (g155).
1059    pub fn load_transcript_indexed(
1060        session_dir: &Path,
1061    ) -> std::io::Result<(
1062        Vec<TranscriptEntry>,
1063        std::collections::HashMap<String, TranscriptEntry>,
1064    )> {
1065        let entries = Self::load_transcript(session_dir)?;
1066        let mut index = std::collections::HashMap::with_capacity(entries.len());
1067        for entry in &entries {
1068            if !entry.uuid.is_empty() {
1069                index.insert(entry.uuid.clone(), entry.clone());
1070            }
1071        }
1072        Ok((entries, index))
1073    }
1074
1075    /// Load the transcript and convert each `TranscriptEntry` to a
1076    /// runtime [`Message`]. Persistence-only fields (`id`,
1077    /// `parent_id`, `uuid`, `parent_uuid`, `timestamp`, `usage`)
1078    /// are dropped here. The result is what `run_resumed` expects
1079    /// as its `seed` argument.
1080    ///
1081    /// The `system` role is **kept** in the returned vec; callers
1082    /// that want to rebuild the system prompt from `Config` can
1083    /// filter it out manually.
1084    pub fn load_messages(session_dir: &Path) -> std::io::Result<Vec<Message>> {
1085        let entries = Self::load_transcript(session_dir)?;
1086        Ok(entries.into_iter().map(entry_to_message).collect())
1087    }
1088
1089    /// Goal-153: scan the transcript for "orphan" tool calls — tool_calls
1090    /// in the last assistant message that have no matching `tool` reply.
1091    ///
1092    /// Returns an empty vec when the transcript is clean (no orphans).
1093    /// Returns the orphan descriptions when one or more tool calls from the
1094    /// last assistant message have no corresponding `tool` result message.
1095    ///
1096    /// This is the **detection** side of durable execution; the *handling*
1097    /// (skip / redo / abort) is done by the caller (`cmd_resume`).
1098    ///
1099    /// `registry` is used to determine `side_effect_at_call` for orphans
1100    /// (their `AuditMeta` was never written because the process died before
1101    /// the call returned).
1102    pub fn scan_orphan_tool_calls(
1103        session_dir: &Path,
1104        registry: &crate::tools::ToolRegistry,
1105    ) -> std::io::Result<Vec<OrphanToolCall>> {
1106        let entries = Self::load_transcript(session_dir)?;
1107        if entries.is_empty() {
1108            return Ok(Vec::new());
1109        }
1110
1111        // Find the last assistant message with tool_calls.
1112        let last_assistant = entries
1113            .iter()
1114            .enumerate()
1115            .rev()
1116            .find(|(_, e)| e.role == "assistant" && !e.tool_calls.is_empty());
1117
1118        let Some((asst_idx, asst_entry)) = last_assistant else {
1119            return Ok(Vec::new());
1120        };
1121
1122        // Collect the set of tool_call_ids that have a matching tool result
1123        // at any position *after* the assistant message.
1124        let answered: std::collections::HashSet<String> = entries[asst_idx + 1..]
1125            .iter()
1126            .filter(|e| e.role == "tool")
1127            .filter_map(|e| e.tool_call_id.clone())
1128            .collect();
1129
1130        let mut orphans = Vec::new();
1131        for tc in &asst_entry.tool_calls {
1132            if !answered.contains(&tc.id) {
1133                let side_effect = registry
1134                    .get(&tc.name)
1135                    .map(|t| t.side_effect_class())
1136                    .unwrap_or(crate::tools::ToolSideEffect::External);
1137                let args_hash = {
1138                    let canonical = tc.arguments.to_string();
1139                    let hash = blake3::hash(canonical.as_bytes());
1140                    hash.to_hex().to_string()
1141                };
1142                orphans.push(OrphanToolCall {
1143                    assistant_msg_id: asst_entry.id.clone(),
1144                    tool_call_id: tc.id.clone(),
1145                    tool_name: tc.name.clone(),
1146                    args_hash,
1147                    side_effect_at_call: side_effect,
1148                });
1149            }
1150        }
1151        Ok(orphans)
1152    }
1153
1154    /// Load the session metadata from a session directory.
1155    pub fn load_meta(session_dir: &Path) -> std::io::Result<SessionMeta> {
1156        let meta_path = session_dir.join(".meta.json");
1157        let bytes = std::fs::read(&meta_path)?;
1158        serde_json::from_slice(&bytes)
1159            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
1160    }
1161
1162    /// List all session directories for a given workspace.
1163    ///
1164    /// Returns a list of session directories sorted by name (which is
1165    /// timestamp-prefixed, so chronological).
1166    pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
1167        let base = match crate::paths::user_sessions_dir(workspace) {
1168            Ok(d) => d,
1169            Err(_) => workspace.join(".recursive").join("sessions"),
1170        };
1171        if !base.is_dir() {
1172            return Ok(Vec::new());
1173        }
1174
1175        let mut sessions = Vec::new();
1176        // Iterate workspace slugs
1177        for entry in std::fs::read_dir(&base)? {
1178            let entry = entry?;
1179            let slug_dir = entry.path();
1180            if !slug_dir.is_dir() {
1181                continue;
1182            }
1183            // Iterate session IDs within each slug
1184            for session_entry in std::fs::read_dir(&slug_dir)? {
1185                let session_entry = session_entry?;
1186                let session_dir = session_entry.path();
1187                if session_dir.is_dir() && session_dir.join(".meta.json").is_file() {
1188                    sessions.push(session_dir);
1189                }
1190            }
1191        }
1192        sessions.sort();
1193        Ok(sessions)
1194    }
1195
1196    /// List all session directories sorted by `.meta.json`
1197    /// `updated_at` descending (most recently active first).
1198    ///
1199    /// Used by `recursive resume` (g151) to pick the most-recent
1200    /// session when no ID is given. Tiebreaks: when two sessions
1201    /// share the same `updated_at` string (RFC3339 has 1-second
1202    /// granularity, so ties happen during fast tests), fall back
1203    /// to `transcript.jsonl` mtime, then session_id lexicographically.
1204    ///
1205    /// Sessions whose `.meta.json` cannot be read are silently
1206    /// excluded — they're either being created or corrupted.
1207    pub fn list_sessions_sorted_by_updated_at(
1208        workspace: &Path,
1209    ) -> std::io::Result<Vec<(PathBuf, SessionMeta)>> {
1210        let dirs = Self::list_sessions(workspace)?;
1211
1212        let mut entries: Vec<(PathBuf, SessionMeta, std::time::SystemTime)> = Vec::new();
1213        for dir in dirs {
1214            let meta = match Self::load_meta(&dir) {
1215                Ok(m) => m,
1216                Err(_) => continue,
1217            };
1218            // Tiebreak: mtime of transcript.jsonl. Falls back to
1219            // UNIX_EPOCH if the file doesn't exist or stat fails.
1220            let mtime = std::fs::metadata(dir.join("transcript.jsonl"))
1221                .and_then(|m| m.modified())
1222                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1223            entries.push((dir, meta, mtime));
1224        }
1225
1226        entries.sort_by(|a, b| {
1227            // Primary: updated_at desc (lexicographic on RFC3339
1228            // is chronological, so reverse comparison sorts desc).
1229            b.1.updated_at
1230                .cmp(&a.1.updated_at)
1231                // Secondary: mtime desc.
1232                .then_with(|| b.2.cmp(&a.2))
1233                // Tertiary: session_id asc (deterministic).
1234                .then_with(|| a.1.session_id.cmp(&b.1.session_id))
1235        });
1236
1237        Ok(entries.into_iter().map(|(p, m, _)| (p, m)).collect())
1238    }
1239
1240    /// List all session directories across all workspaces under a base path.
1241    pub fn list_all_sessions(base: &Path) -> std::io::Result<Vec<PathBuf>> {
1242        let sessions_dir = base.join(".recursive").join("sessions");
1243        if !sessions_dir.is_dir() {
1244            return Ok(Vec::new());
1245        }
1246
1247        let mut sessions = Vec::new();
1248        for entry in std::fs::read_dir(&sessions_dir)? {
1249            let entry = entry?;
1250            let slug_dir = entry.path();
1251            if !slug_dir.is_dir() {
1252                continue;
1253            }
1254            for session_entry in std::fs::read_dir(&slug_dir)? {
1255                let session_entry = session_entry?;
1256                let session_dir = session_entry.path();
1257                if session_dir.is_dir() && session_dir.join(".meta.json").is_file() {
1258                    sessions.push(session_dir);
1259                }
1260            }
1261        }
1262        sessions.sort();
1263        Ok(sessions)
1264    }
1265}
1266
1267/// Convert an absolute workspace path into a filesystem-safe slug.
1268///
1269/// - Replaces `/` with `-`
1270/// - Strips leading `-` (from the root `/`)
1271/// - Truncates to 80 characters
1272fn workspace_slug(workspace: &Path) -> String {
1273    let abs = if workspace.is_absolute() {
1274        workspace.to_path_buf()
1275    } else {
1276        std::env::current_dir().unwrap_or_default().join(workspace)
1277    };
1278
1279    let s: String = abs
1280        .to_string_lossy()
1281        .chars()
1282        .map(|c| match c {
1283            '/' | '\\' | ':' => '-',
1284            c if c.is_control() => '-',
1285            c => c,
1286        })
1287        .collect();
1288    // Strip leading dashes (from root slash / drive letter)
1289    let s = s.trim_start_matches('-').to_string();
1290    // Truncate to 80 chars (safe for multibyte)
1291    if s.len() > 80 {
1292        crate::truncate_str(&s, 80).to_string()
1293    } else {
1294        s
1295    }
1296}
1297
1298// ---------------------------------------------------------------------------
1299// SessionPersistenceSink
1300// ---------------------------------------------------------------------------
1301
1302/// An [`EventSink`] that persists every [`AgentEvent::MessageAppended`] event
1303/// to the session transcript file.
1304///
1305/// Wraps an `Arc<Mutex<SessionWriter>>` and calls
1306/// [`SessionWriter::append`] on every `MessageAppended` event. All other
1307/// event variants are silently ignored.
1308///
1309/// Persistence failures are non-fatal for the agent run but are logged at
1310/// `error` level because a missing line on disk would silently break
1311/// downstream orphan detection (g153).
1312///
1313/// # Locking notes
1314///
1315/// `SessionWriter` is not `Send` across `.await` points, so we keep the
1316/// mutex non-`async` (`std::sync::Mutex`). The critical section inside
1317/// `emit` is purely synchronous I/O — one `serde_json::to_string` +
1318/// `write_all` + `flush` per message — matching every other consumer of
1319/// `Arc<Mutex<SessionWriter>>` in the codebase.
1320pub struct SessionPersistenceSink {
1321    writer: Arc<std::sync::Mutex<SessionWriter>>,
1322}
1323
1324impl SessionPersistenceSink {
1325    /// Create a new `SessionPersistenceSink` backed by the given writer.
1326    pub fn new(writer: Arc<std::sync::Mutex<SessionWriter>>) -> Self {
1327        Self { writer }
1328    }
1329}
1330
1331#[async_trait::async_trait]
1332impl EventSink for SessionPersistenceSink {
1333    async fn emit(&self, event: AgentEvent) {
1334        match event {
1335            AgentEvent::MessageAppended {
1336                message,
1337                parent_uuid,
1338                usage,
1339            } => {
1340                let result = {
1341                    match self.writer.lock() {
1342                        Ok(mut w) => w.append_with_audit(
1343                            &message,
1344                            None,
1345                            parent_uuid.as_deref(),
1346                            usage.as_ref(),
1347                        ),
1348                        Err(poisoned) => {
1349                            let mut w = poisoned.into_inner();
1350                            w.append_with_audit(
1351                                &message,
1352                                None,
1353                                parent_uuid.as_deref(),
1354                                usage.as_ref(),
1355                            )
1356                        }
1357                    }
1358                };
1359                if let Err(e) = result {
1360                    tracing::error!("session persistence: failed to append message: {e}");
1361                }
1362            }
1363            AgentEvent::MessageAppendedWithAudit { message, audit } => {
1364                // Goal 153: tool result with audit metadata.
1365                let result = {
1366                    match self.writer.lock() {
1367                        Ok(mut w) => w.append_with_audit(&message, Some(audit), None, None),
1368                        Err(poisoned) => {
1369                            let mut w = poisoned.into_inner();
1370                            w.append_with_audit(&message, None, None, None)
1371                        }
1372                    }
1373                };
1374                if let Err(e) = result {
1375                    tracing::error!("session persistence: failed to append audited message: {e}");
1376                }
1377            }
1378            AgentEvent::CompactionBoundary {
1379                turn,
1380                compacted_count,
1381                summary_uuid,
1382            } => {
1383                // g157: write a compact_boundary system entry so resume can
1384                // skip the pre-compaction messages.
1385                let result = match self.writer.lock() {
1386                    Ok(mut w) => {
1387                        w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
1388                    }
1389                    Err(poisoned) => {
1390                        let mut w = poisoned.into_inner();
1391                        w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
1392                    }
1393                };
1394                if let Err(e) = result {
1395                    tracing::error!("session persistence: failed to write compact_boundary: {e}");
1396                }
1397            }
1398            _ => {}
1399        }
1400    }
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405    use super::*;
1406    use crate::message::{Message, Role};
1407
1408    #[test]
1409    fn session_round_trip() {
1410        let goal = "fix the bug".to_string();
1411        let model = "gpt-4o-mini".to_string();
1412        let provider = "openai".to_string();
1413        let tool_specs = vec![
1414            ToolSpec {
1415                name: "read_file".into(),
1416                description: "Read a file".into(),
1417                parameters: serde_json::json!({"type":"object"}),
1418            },
1419            ToolSpec {
1420                name: "write_file".into(),
1421                description: "Write a file".into(),
1422                parameters: serde_json::json!({"type":"object"}),
1423            },
1424        ];
1425        let transcript = vec![
1426            Message::system("You are a helpful assistant.".to_string()),
1427            Message::user("fix the bug".to_string()),
1428            Message::assistant("Let me look at the code.".to_string()),
1429        ];
1430
1431        let session = SessionFile::new(
1432            goal.clone(),
1433            model.clone(),
1434            provider.clone(),
1435            &tool_specs,
1436            2,
1437            transcript.clone(),
1438        );
1439
1440        let tmp = tempfile::NamedTempFile::new().unwrap();
1441        session.write_to(tmp.path()).unwrap();
1442
1443        let restored = SessionFile::read_from(tmp.path()).unwrap();
1444        assert_eq!(restored.schema_version, SESSION_SCHEMA_VERSION);
1445        assert_eq!(restored.goal, goal);
1446        assert_eq!(restored.model, model);
1447        assert_eq!(restored.provider, provider);
1448        assert_eq!(restored.steps_consumed, 2);
1449        assert_eq!(restored.transcript.len(), 3);
1450        assert_eq!(
1451            restored.transcript[0].content,
1452            "You are a helpful assistant."
1453        );
1454        assert_eq!(restored.transcript[1].content, "fix the bug");
1455        assert_eq!(restored.transcript[2].content, "Let me look at the code.");
1456    }
1457
1458    #[test]
1459    fn resume_validates_tool_registry_hash() {
1460        let tool_specs = vec![ToolSpec {
1461            name: "read_file".into(),
1462            description: "Read a file".into(),
1463            parameters: serde_json::json!({"type":"object"}),
1464        }];
1465        let session = SessionFile::new(
1466            "test".into(),
1467            "model".into(),
1468            "provider".into(),
1469            &tool_specs,
1470            0,
1471            vec![],
1472        );
1473
1474        // Same specs should validate
1475        assert!(session.validate_tool_registry(&tool_specs).is_ok());
1476
1477        // Different specs should fail
1478        let different_specs = vec![ToolSpec {
1479            name: "write_file".into(),
1480            description: "Write a file".into(),
1481            parameters: serde_json::json!({"type":"object"}),
1482        }];
1483        assert!(session.validate_tool_registry(&different_specs).is_err());
1484    }
1485
1486    #[test]
1487    #[cfg_attr(target_os = "windows", ignore)]
1488    fn session_list_finds_files_in_workspace() {
1489        let tmp = crate::test_util::IsolatedWorkspace::new();
1490        let ws = tmp.path();
1491
1492        // No sessions dir yet
1493        let sessions = list_sessions(ws).unwrap();
1494        assert!(sessions.is_empty());
1495
1496        // Create a session file
1497        let session = SessionFile::new(
1498            "test".into(),
1499            "model".into(),
1500            "provider".into(),
1501            &[],
1502            0,
1503            vec![],
1504        );
1505        let path = default_session_path(ws, "test");
1506        session.write_to(&path).unwrap();
1507
1508        let sessions = list_sessions(ws).unwrap();
1509        assert_eq!(sessions.len(), 1);
1510        assert_eq!(
1511            sessions[0].extension().and_then(|e| e.to_str()),
1512            Some("json")
1513        );
1514    }
1515
1516    #[test]
1517    fn resume_continues_from_seeded_transcript() {
1518        let transcript = vec![
1519            Message::system("sys".to_string()),
1520            Message::user("original goal".to_string()),
1521            Message::assistant("partial work".to_string()),
1522        ];
1523        let session = SessionFile::new(
1524            "original goal".into(),
1525            "model".into(),
1526            "provider".into(),
1527            &[],
1528            1,
1529            transcript.clone(),
1530        );
1531
1532        // The transcript should be preserved exactly
1533        assert_eq!(session.messages().len(), 3);
1534        assert_eq!(session.messages()[0].content, "sys");
1535        assert_eq!(session.messages()[1].content, "original goal");
1536        assert_eq!(session.messages()[2].content, "partial work");
1537
1538        // into_transcript should give back the messages
1539        let restored = session.into_transcript();
1540        assert_eq!(restored.len(), 3);
1541    }
1542
1543    #[test]
1544    fn round_trip_with_tool_calls() {
1545        use crate::llm::ToolCall;
1546
1547        let tool_calls = vec![
1548            ToolCall {
1549                id: "call_001".to_string(),
1550                name: "read_file".to_string(),
1551                arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
1552            },
1553            ToolCall {
1554                id: "call_002".to_string(),
1555                name: "write_file".to_string(),
1556                arguments: serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"}),
1557            },
1558        ];
1559
1560        let transcript = vec![
1561            Message::system("You are an agent.".to_string()),
1562            Message::user("refactor the code".to_string()),
1563            Message::assistant_with_tool_calls(
1564                "I'll read the file first.".to_string(),
1565                tool_calls.clone(),
1566            ),
1567            Message::tool_result("call_001", "fn main() { println!(\"hello\"); }"),
1568        ];
1569
1570        let session = SessionFile::new(
1571            "refactor".into(),
1572            "gpt-4o".into(),
1573            "openai".into(),
1574            &[],
1575            3,
1576            transcript,
1577        );
1578
1579        let tmp = tempfile::NamedTempFile::new().unwrap();
1580        session.write_to(tmp.path()).unwrap();
1581
1582        let restored = SessionFile::read_from(tmp.path()).unwrap();
1583        assert_eq!(restored.transcript.len(), 4);
1584
1585        // Verify the assistant message with tool_calls is preserved
1586        let assistant_msg = &restored.transcript[2];
1587        assert_eq!(assistant_msg.role, Role::Assistant);
1588        assert_eq!(assistant_msg.content, "I'll read the file first.");
1589        assert_eq!(assistant_msg.tool_calls.len(), 2);
1590        assert_eq!(assistant_msg.tool_calls[0].id, "call_001");
1591        assert_eq!(assistant_msg.tool_calls[0].name, "read_file");
1592        assert_eq!(
1593            assistant_msg.tool_calls[0].arguments,
1594            serde_json::json!({"path": "/tmp/foo.rs"})
1595        );
1596        assert_eq!(assistant_msg.tool_calls[1].id, "call_002");
1597        assert_eq!(assistant_msg.tool_calls[1].name, "write_file");
1598        assert_eq!(
1599            assistant_msg.tool_calls[1].arguments,
1600            serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"})
1601        );
1602
1603        // Verify the tool result message
1604        let tool_msg = &restored.transcript[3];
1605        assert_eq!(tool_msg.role, Role::Tool);
1606        assert_eq!(tool_msg.tool_call_id, Some("call_001".to_string()));
1607        assert_eq!(tool_msg.content, "fn main() { println!(\"hello\"); }");
1608    }
1609
1610    #[test]
1611    fn read_from_nonexistent_file() {
1612        let tmp = crate::test_util::IsolatedWorkspace::new();
1613        let bogus_path = tmp.path().join("does_not_exist.json");
1614
1615        let result = SessionFile::read_from(&bogus_path);
1616        assert!(result.is_err());
1617        let err = result.unwrap_err();
1618        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1619    }
1620
1621    #[test]
1622    fn read_from_corrupt_json() {
1623        let tmp = tempfile::NamedTempFile::new().unwrap();
1624        std::fs::write(tmp.path(), "this is not valid json {{{garbage").unwrap();
1625
1626        let result = SessionFile::read_from(tmp.path());
1627        assert!(result.is_err());
1628        let err = result.unwrap_err();
1629        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1630    }
1631
1632    #[test]
1633    fn validate_tool_registry_mismatch() {
1634        let original_specs = vec![
1635            ToolSpec {
1636                name: "read_file".into(),
1637                description: "Read a file".into(),
1638                parameters: serde_json::json!({"type":"object"}),
1639            },
1640            ToolSpec {
1641                name: "write_file".into(),
1642                description: "Write a file".into(),
1643                parameters: serde_json::json!({"type":"object"}),
1644            },
1645        ];
1646
1647        let session = SessionFile::new(
1648            "test".into(),
1649            "model".into(),
1650            "provider".into(),
1651            &original_specs,
1652            0,
1653            vec![],
1654        );
1655
1656        // Validate against a completely different set of tools
1657        let different_specs = vec![ToolSpec {
1658            name: "execute_command".into(),
1659            description: "Run a shell command".into(),
1660            parameters: serde_json::json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
1661        }];
1662
1663        let result = session.validate_tool_registry(&different_specs);
1664        assert!(result.is_err());
1665        let err_msg = result.unwrap_err();
1666        assert!(
1667            err_msg.contains("mismatch"),
1668            "Expected error to contain 'mismatch', got: {err_msg}"
1669        );
1670    }
1671
1672    #[test]
1673    fn default_session_path_sanitizes_special_chars() {
1674        let tmp = crate::test_util::IsolatedWorkspace::new();
1675        let ws = tmp.path();
1676
1677        // Goal with spaces, slashes, unicode, and other special chars
1678        let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
1679        let path = default_session_path(ws, goal);
1680
1681        // Extract just the filename (without the .json extension)
1682        let filename = path.file_stem().unwrap().to_str().unwrap();
1683
1684        // The filename format is "{timestamp}-{sanitized_goal}".
1685        // The timestamp is filesystem-safe (colons replaced with hyphens).
1686        // We verify the goal-derived suffix: strip the timestamp prefix
1687        // (everything up to and including the "Z-" separator).
1688        let goal_suffix = filename
1689            .find("Z-")
1690            .map(|i| &filename[i + 2..])
1691            .expect("filename should contain Z- separator between timestamp and goal");
1692
1693        // The goal suffix should contain only alphanumeric (unicode-aware), underscore, or dash
1694        for ch in goal_suffix.chars() {
1695            assert!(
1696                ch.is_alphanumeric() || ch == '_' || ch == '-',
1697                "Unexpected character '{}' (U+{:04X}) in goal suffix: {}",
1698                ch,
1699                ch as u32,
1700                goal_suffix
1701            );
1702        }
1703
1704        // Spaces, slashes, #, —, ™ should all be stripped
1705        assert!(!goal_suffix.contains(' '));
1706        assert!(!goal_suffix.contains('/'));
1707        assert!(!goal_suffix.contains('#'));
1708        assert!(!goal_suffix.contains('™'));
1709        assert!(!goal_suffix.contains('—'));
1710
1711        // The path should still end inside a sessions/ directory
1712        // (now lives under the user data dir, not the workspace).
1713        let parent = path.parent().expect("session path has parent");
1714        assert_eq!(
1715            parent.file_name().and_then(|n| n.to_str()),
1716            Some("sessions"),
1717            "expected sessions dir as parent, got {}",
1718            path.display()
1719        );
1720        // And should have .json extension
1721        assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
1722    }
1723
1724    // -----------------------------------------------------------------------
1725    // JSONL session tests (Goal 107)
1726    // -----------------------------------------------------------------------
1727
1728    #[test]
1729    fn session_writer_creates_meta_and_jsonl() {
1730        let tmp = crate::test_util::IsolatedWorkspace::new();
1731        let ws = tmp.path();
1732
1733        let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
1734
1735        let session_dir = writer.session_dir().to_path_buf();
1736        assert!(session_dir.join("transcript.jsonl").is_file());
1737        assert!(session_dir.join(".meta.json").is_file());
1738
1739        // Verify meta
1740        let meta = SessionReader::load_meta(&session_dir).unwrap();
1741        assert_eq!(meta.goal, "test goal");
1742        assert_eq!(meta.model, "gpt-4o");
1743        assert_eq!(meta.provider, "openai");
1744        assert_eq!(meta.message_count, 0);
1745        assert_eq!(meta.status, "active");
1746
1747        writer.finish("completed").unwrap();
1748
1749        let meta = SessionReader::load_meta(&session_dir).unwrap();
1750        assert_eq!(meta.message_count, 0);
1751        assert_eq!(meta.status, "completed");
1752    }
1753
1754    #[test]
1755    fn session_writer_appends_lines() {
1756        let tmp = crate::test_util::IsolatedWorkspace::new();
1757        let ws = tmp.path();
1758
1759        let mut writer = SessionWriter::create(ws, "test", "gpt-4o", "openai").unwrap();
1760
1761        let id1 = writer.append(&Message::user("hello"), None, None).unwrap();
1762        // append now returns a UUID v4 (g155); just verify it's unique and non-empty
1763        assert_eq!(id1.len(), 36, "uuid should be 36 chars");
1764
1765        let id2 = writer
1766            .append(&Message::assistant("hi there"), None, None)
1767            .unwrap();
1768        assert_eq!(id2.len(), 36);
1769        assert_ne!(id1, id2, "each message gets a unique uuid");
1770
1771        let session_dir = writer.session_dir().to_path_buf();
1772        writer.finish("completed").unwrap();
1773
1774        // Load and verify — sequential id/parent_id are still written (g155 compat)
1775        let entries = SessionReader::load_transcript(&session_dir).unwrap();
1776        assert_eq!(entries.len(), 2);
1777        assert_eq!(entries[0].id, "msg_001");
1778        assert_eq!(entries[0].parent_id, None);
1779        assert_eq!(entries[0].parent_uuid, None, "root has no parent_uuid");
1780        assert!(!entries[0].uuid.is_empty(), "uuid must be present");
1781        assert_eq!(entries[0].role, "user");
1782        assert_eq!(entries[0].content, "hello");
1783
1784        assert_eq!(entries[1].id, "msg_002");
1785        assert_eq!(entries[1].parent_id, Some("msg_001".to_string()));
1786        assert_eq!(
1787            entries[1].parent_uuid,
1788            Some(entries[0].uuid.clone()),
1789            "parent_uuid points to first entry"
1790        );
1791        assert_eq!(entries[1].role, "assistant");
1792        assert_eq!(entries[1].content, "hi there");
1793    }
1794
1795    #[test]
1796    fn session_reader_round_trips() {
1797        let tmp = crate::test_util::IsolatedWorkspace::new();
1798        let ws = tmp.path();
1799
1800        let mut writer = SessionWriter::create(ws, "round trip", "gpt-4o", "openai").unwrap();
1801
1802        writer
1803            .append(&Message::system("You are a bot."), None, None)
1804            .unwrap();
1805        writer
1806            .append(&Message::user("do something"), None, None)
1807            .unwrap();
1808        writer
1809            .append(&Message::assistant("I will do it."), None, None)
1810            .unwrap();
1811
1812        let session_dir = writer.session_dir().to_path_buf();
1813        writer.finish("completed").unwrap();
1814
1815        let entries = SessionReader::load_transcript(&session_dir).unwrap();
1816        assert_eq!(entries.len(), 3);
1817        assert_eq!(entries[0].role, "system");
1818        assert_eq!(entries[1].role, "user");
1819        assert_eq!(entries[2].role, "assistant");
1820    }
1821
1822    #[test]
1823    fn session_writer_finish_updates_meta() {
1824        let tmp = crate::test_util::IsolatedWorkspace::new();
1825        let ws = tmp.path();
1826
1827        let mut writer = SessionWriter::create(ws, "meta test", "gpt-4o", "openai").unwrap();
1828        writer.append(&Message::user("msg1"), None, None).unwrap();
1829        writer
1830            .append(&Message::assistant("msg2"), None, None)
1831            .unwrap();
1832        let session_dir = writer.session_dir().to_path_buf();
1833        writer.finish("completed").unwrap();
1834
1835        let meta = SessionReader::load_meta(&session_dir).unwrap();
1836        assert_eq!(meta.message_count, 2);
1837        assert_eq!(meta.status, "completed");
1838    }
1839
1840    /// Preset-config goal: the `preset` field is recorded on
1841    /// `SessionMeta` so a future reader can see "this run was on
1842    /// deepseek" without re-deriving from `api_base`. Verifies the
1843    /// round-trip: create with `Some("deepseek")`, reload, assert
1844    /// the field is preserved. Also confirms the default (None)
1845    /// path is not serialized at all (`skip_serializing_if`) so
1846    /// pre-preset-config session files stay byte-compat on read.
1847    #[test]
1848    fn session_meta_preserves_preset_field() {
1849        let tmp = crate::test_util::IsolatedWorkspace::new();
1850        let ws = tmp.path();
1851
1852        // With a preset — should round-trip. The writer is dropped
1853        // before the second writer is created so the per-session
1854        // lock is released; otherwise the second `create_with_tools`
1855        // would fail with SessionLockBusy when both session_ids
1856        // collide on the same second.
1857        let (_session_dir, preset_after) = {
1858            let mut writer = SessionWriter::create_with_tools(
1859                ws,
1860                "preset run",
1861                "deepseek-chat",
1862                "openai",
1863                &[],
1864                Some("deepseek"),
1865            )
1866            .unwrap();
1867            let dir = writer.session_dir().to_path_buf();
1868            writer.finish("completed").unwrap();
1869            let meta = SessionReader::load_meta(&dir).unwrap();
1870            (dir, meta.preset)
1871        };
1872        assert_eq!(preset_after.as_deref(), Some("deepseek"));
1873
1874        // Without a preset — `None` is kept on read, and the JSON
1875        // should not include a `preset` key (skip_serializing_if).
1876        let writer2 =
1877            SessionWriter::create_with_tools(ws, "no preset run", "gpt-4o", "openai", &[], None)
1878                .unwrap();
1879        let session_dir2 = writer2.session_dir().to_path_buf();
1880        drop(writer2);
1881        let meta2 = SessionReader::load_meta(&session_dir2).unwrap();
1882        assert!(meta2.preset.is_none());
1883        let raw = std::fs::read_to_string(session_dir2.join(".meta.json")).unwrap();
1884        assert!(
1885            !raw.contains("\"preset\""),
1886            "preset key should be absent when None, got: {raw}"
1887        );
1888    }
1889
1890    #[test]
1891    fn list_sessions_finds_sessions() {
1892        let tmp = crate::test_util::IsolatedWorkspace::new();
1893        let ws = tmp.path();
1894
1895        // No sessions yet
1896        let sessions = SessionReader::list_sessions(ws).unwrap();
1897        assert!(sessions.is_empty());
1898
1899        // Create one session
1900        let writer = SessionWriter::create(ws, "session1", "gpt-4o", "openai").unwrap();
1901        let dir1 = writer.session_dir().to_path_buf();
1902        drop(writer);
1903
1904        let sessions = SessionReader::list_sessions(ws).unwrap();
1905        assert_eq!(sessions.len(), 1);
1906        assert_eq!(sessions[0], dir1);
1907    }
1908
1909    #[test]
1910    fn crash_partial_line_skipped() {
1911        let tmp = crate::test_util::IsolatedWorkspace::new();
1912        let ws = tmp.path();
1913
1914        let mut writer = SessionWriter::create(ws, "crash test", "gpt-4o", "openai").unwrap();
1915        writer
1916            .append(&Message::user("good line"), None, None)
1917            .unwrap();
1918        let session_dir = writer.session_dir().to_path_buf();
1919        writer.finish("crashed").unwrap();
1920
1921        // Append a corrupt line manually
1922        use std::io::Write;
1923        let jsonl_path = session_dir.join("transcript.jsonl");
1924        let mut f = std::fs::OpenOptions::new()
1925            .append(true)
1926            .open(&jsonl_path)
1927            .unwrap();
1928        writeln!(f, "this is not json").unwrap();
1929        drop(f);
1930
1931        // Should still load the good line
1932        let entries = SessionReader::load_transcript(&session_dir).unwrap();
1933        assert_eq!(entries.len(), 1);
1934        assert_eq!(entries[0].content, "good line");
1935    }
1936
1937    #[test]
1938    fn filesystem_safe_timestamp_has_no_colons() {
1939        let ts = filesystem_safe_timestamp();
1940        assert!(!ts.contains(':'));
1941        assert!(ts.ends_with('Z'));
1942    }
1943
1944    #[test]
1945    fn workspace_slug_matches_expected() {
1946        // Absolute path
1947        let p = Path::new("/home/user/projects/my-app");
1948        let slug = workspace_slug(p);
1949        assert!(!slug.starts_with('-'));
1950        assert!(slug.contains("home-user-projects-my-app"));
1951        assert!(slug.len() <= 80);
1952
1953        // Relative path
1954        let p = Path::new(".");
1955        let slug = workspace_slug(p);
1956        assert!(!slug.is_empty());
1957        assert!(slug.len() <= 80);
1958    }
1959
1960    #[test]
1961    fn truncate_transcript_to_turn_drops_at_user_boundary() {
1962        let dir = crate::test_util::IsolatedWorkspace::new();
1963        let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
1964        // Sequence: system, user(turn 0), assistant, user(turn 1),
1965        // assistant, user(turn 2), assistant.
1966        w.append(&Message::system("sys".to_string()), None, None)
1967            .unwrap();
1968        w.append(&Message::user("u0".to_string()), None, None)
1969            .unwrap();
1970        w.append(&Message::assistant("a0".to_string()), None, None)
1971            .unwrap();
1972        w.append(&Message::user("u1".to_string()), None, None)
1973            .unwrap();
1974        w.append(&Message::assistant("a1".to_string()), None, None)
1975            .unwrap();
1976        w.append(&Message::user("u2".to_string()), None, None)
1977            .unwrap();
1978        w.append(&Message::assistant("a2".to_string()), None, None)
1979            .unwrap();
1980        w.finish("done").unwrap();
1981
1982        let session_dir = w.session_dir().to_path_buf();
1983
1984        // Rewind to turn 1 → keep system + u0 + a0; drop u1 onwards.
1985        let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
1986        assert_eq!(stats.kept, 3);
1987        assert_eq!(stats.dropped, 4);
1988
1989        let entries = SessionReader::load_transcript(&session_dir).unwrap();
1990        assert_eq!(entries.len(), 3);
1991        assert_eq!(entries[0].role, "system");
1992        assert_eq!(entries[1].role, "user");
1993        assert_eq!(entries[1].content, "u0");
1994        assert_eq!(entries[2].role, "assistant");
1995        assert_eq!(entries[2].content, "a0");
1996
1997        // Meta should reflect the new count.
1998        let meta = SessionReader::load_meta(&session_dir).unwrap();
1999        assert_eq!(meta.message_count, 3);
2000    }
2001
2002    #[test]
2003    fn truncate_transcript_to_zero_drops_all_turns_keeps_system() {
2004        let dir = crate::test_util::IsolatedWorkspace::new();
2005        let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
2006        w.append(&Message::system("sys".to_string()), None, None)
2007            .unwrap();
2008        w.append(&Message::user("u0".to_string()), None, None)
2009            .unwrap();
2010        w.append(&Message::assistant("a0".to_string()), None, None)
2011            .unwrap();
2012        w.finish("done").unwrap();
2013        let session_dir = w.session_dir().to_path_buf();
2014
2015        let stats = truncate_transcript_to_turn(&session_dir, 0).unwrap();
2016        assert_eq!(stats.kept, 1, "system message should remain");
2017        assert_eq!(stats.dropped, 2);
2018
2019        let entries = SessionReader::load_transcript(&session_dir).unwrap();
2020        assert_eq!(entries.len(), 1);
2021        assert_eq!(entries[0].role, "system");
2022    }
2023
2024    #[test]
2025    fn truncate_transcript_missing_file_is_noop() {
2026        let dir = crate::test_util::IsolatedWorkspace::new();
2027        // No session created → no transcript.jsonl. Should not panic.
2028        let stats = truncate_transcript_to_turn(dir.path(), 5).unwrap();
2029        assert_eq!(stats.kept, 0);
2030        assert_eq!(stats.dropped, 0);
2031    }
2032
2033    // ---------------------------------------------------------------
2034    // Goal 151: resume by ID — new test coverage
2035    // ---------------------------------------------------------------
2036
2037    #[test]
2038    fn load_messages_drops_persistence_fields() {
2039        let tmp = crate::test_util::IsolatedWorkspace::new();
2040        let ws = tmp.path();
2041        let mut writer = SessionWriter::create(ws, "g151 test", "model", "openai").unwrap();
2042        let session_dir = writer.session_dir().to_path_buf();
2043
2044        writer
2045            .append(&Message::user("hello".to_string()), None, None)
2046            .unwrap();
2047        writer
2048            .append(&Message::assistant("hi back".to_string()), None, None)
2049            .unwrap();
2050        writer.finish("success").unwrap();
2051        drop(writer);
2052
2053        // load_messages strips id / parent_id / timestamp.
2054        let msgs = SessionReader::load_messages(&session_dir).unwrap();
2055        assert_eq!(msgs.len(), 2);
2056        assert_eq!(msgs[0].role, Role::User);
2057        assert_eq!(msgs[0].content, "hello");
2058        assert_eq!(msgs[1].role, Role::Assistant);
2059        assert_eq!(msgs[1].content, "hi back");
2060
2061        // Confirm the persisted entries actually had the fields we
2062        // claim to drop, so this test isn't a no-op.
2063        let entries = SessionReader::load_transcript(&session_dir).unwrap();
2064        assert_eq!(entries.len(), 2);
2065        assert_eq!(entries[0].id, "msg_001");
2066        assert!(!entries[0].timestamp.is_empty());
2067    }
2068
2069    #[test]
2070    fn meta_round_trip_with_tool_registry_hash() {
2071        let tmp = crate::test_util::IsolatedWorkspace::new();
2072        let ws = tmp.path();
2073
2074        let specs = vec![ToolSpec {
2075            name: "read_file".into(),
2076            description: "Read".into(),
2077            parameters: serde_json::json!({"type":"object"}),
2078        }];
2079        let writer =
2080            SessionWriter::create_with_tools(ws, "with hash", "model", "openai", &specs, None)
2081                .unwrap();
2082        let session_dir = writer.session_dir().to_path_buf();
2083        drop(writer);
2084
2085        let meta = SessionReader::load_meta(&session_dir).unwrap();
2086        let hash = meta
2087            .tool_registry_hash
2088            .as_ref()
2089            .expect("expected hash to be Some(_)");
2090        assert_eq!(*hash, hash_tool_specs(&specs));
2091    }
2092
2093    #[test]
2094    fn meta_round_trip_old_format_no_hash() {
2095        // Synthesise a `.meta.json` that lacks the `tool_registry_hash`
2096        // field (representing a pre-g151 session record). Reload and
2097        // confirm it parses cleanly with `tool_registry_hash: None`.
2098        let tmp = crate::test_util::IsolatedWorkspace::new();
2099        let session_dir = tmp
2100            .path()
2101            .join(".recursive")
2102            .join("sessions")
2103            .join("legacy");
2104        std::fs::create_dir_all(&session_dir).unwrap();
2105
2106        let raw = r#"{
2107  "session_id": "legacy-id",
2108  "goal": "old goal",
2109  "model": "model",
2110  "provider": "openai",
2111  "created_at": "2020-01-01T00:00:00Z",
2112  "updated_at": "2020-01-01T00:00:00Z",
2113  "message_count": 0,
2114  "status": "active"
2115}"#;
2116        std::fs::write(session_dir.join(".meta.json"), raw).unwrap();
2117        std::fs::write(session_dir.join("transcript.jsonl"), "").unwrap();
2118
2119        let meta = SessionReader::load_meta(&session_dir).unwrap();
2120        assert_eq!(meta.session_id, "legacy-id");
2121        assert!(meta.tool_registry_hash.is_none());
2122    }
2123
2124    #[test]
2125    fn append_bumps_updated_at() {
2126        let tmp = crate::test_util::IsolatedWorkspace::new();
2127        let ws = tmp.path();
2128        let mut writer = SessionWriter::create(ws, "bump", "model", "openai").unwrap();
2129        let session_dir = writer.session_dir().to_path_buf();
2130
2131        let meta_before = SessionReader::load_meta(&session_dir).unwrap();
2132        // Sleep a hair past the 1-sec timestamp granularity so the
2133        // RFC3339 string actually changes. chrono_lite_now() rounds
2134        // to the second.
2135        std::thread::sleep(std::time::Duration::from_millis(1100));
2136
2137        writer
2138            .append(&Message::user("ping".to_string()), None, None)
2139            .unwrap();
2140
2141        let meta_after = SessionReader::load_meta(&session_dir).unwrap();
2142        assert_ne!(
2143            meta_before.updated_at, meta_after.updated_at,
2144            "expected updated_at to advance after append; before={} after={}",
2145            meta_before.updated_at, meta_after.updated_at
2146        );
2147    }
2148
2149    #[test]
2150    fn open_existing_continues_msg_numbering() {
2151        let tmp = crate::test_util::IsolatedWorkspace::new();
2152        let ws = tmp.path();
2153        let mut writer = SessionWriter::create(ws, "resume-num", "model", "openai").unwrap();
2154        let session_dir = writer.session_dir().to_path_buf();
2155
2156        writer
2157            .append(&Message::user("u1".to_string()), None, None)
2158            .unwrap();
2159        writer
2160            .append(&Message::assistant("a1".to_string()), None, None)
2161            .unwrap();
2162        writer
2163            .append(&Message::user("u2".to_string()), None, None)
2164            .unwrap();
2165        // Drop the writer WITHOUT calling finish() — the lock file is
2166        // released on Drop, but we never marked the session done.
2167        drop(writer);
2168
2169        // Re-open and append more.
2170        let mut writer2 = SessionWriter::open_existing(&session_dir).unwrap();
2171        let id = writer2
2172            .append(&Message::assistant("a2".to_string()), None, None)
2173            .unwrap();
2174        // append now returns a UUID; just verify it's non-empty
2175        assert!(!id.is_empty(), "expected non-empty UUID from append");
2176        drop(writer2);
2177
2178        let entries = SessionReader::load_transcript(&session_dir).unwrap();
2179        assert_eq!(entries.len(), 4);
2180        assert_eq!(entries[0].id, "msg_001");
2181        assert_eq!(entries[1].id, "msg_002");
2182        assert_eq!(entries[2].id, "msg_003");
2183        assert_eq!(entries[3].id, "msg_004");
2184        assert_eq!(entries[3].parent_id.as_deref(), Some("msg_003"));
2185    }
2186
2187    #[test]
2188    fn lock_alive_pid_blocks_acquire() {
2189        let tmp = crate::test_util::IsolatedWorkspace::new();
2190        let session_dir = tmp.path().join("session-A");
2191        std::fs::create_dir_all(&session_dir).unwrap();
2192
2193        // First acquire succeeds; lock file now holds OUR pid.
2194        let lock = SessionLock::acquire(&session_dir).unwrap();
2195
2196        // Second acquire by the same process: pid is alive (it's
2197        // us!), so it must refuse.
2198        let err = SessionLock::acquire(&session_dir).expect_err("second acquire should fail");
2199        // Match the inner SessionLockBusy via Display.
2200        assert!(
2201            err.to_string()
2202                .contains(&format!("pid {}", std::process::id())),
2203            "expected error to mention our pid {}, got: {}",
2204            std::process::id(),
2205            err
2206        );
2207
2208        drop(lock);
2209    }
2210
2211    // Other lock tests (dead-pid recovery, cross-host abort, drop release)
2212    // live in `crate::session_lock` because they poke at the implementation
2213    // internals (`SentinelInfo`, `SESSION_LOCK_FILE`, `current_hostname`).
2214
2215    // -- SessionPersistenceSink tests --------------------------------------
2216
2217    fn make_isolated_writer() -> (
2218        crate::test_util::IsolatedWorkspace,
2219        Arc<std::sync::Mutex<SessionWriter>>,
2220    ) {
2221        let ws = crate::test_util::IsolatedWorkspace::new();
2222        let writer = SessionWriter::create(ws.path(), "test goal", "gpt-4o", "openai").unwrap();
2223        (ws, Arc::new(std::sync::Mutex::new(writer)))
2224    }
2225
2226    /// `SessionPersistenceSink` appends a message with all fields to disk,
2227    /// and the round-trip preserves content, tool_calls, and reasoning_content.
2228    #[tokio::test]
2229    async fn message_appended_round_trips_through_sink() {
2230        use crate::event::{AgentEvent, EventSink};
2231        use crate::llm::ToolCall as LlmToolCall;
2232
2233        let (_ws, sw) = make_isolated_writer();
2234        let sink = SessionPersistenceSink::new(sw.clone());
2235
2236        let tc = LlmToolCall {
2237            id: "call_1".into(),
2238            name: "my_tool".into(),
2239            arguments: serde_json::json!({"x": 1}),
2240        };
2241        let msg = Message {
2242            role: Role::Assistant,
2243            content: "response text".into(),
2244            tool_calls: vec![tc],
2245            tool_call_id: None,
2246            reasoning_content: Some("I thought about it".into()),
2247        };
2248        sink.emit(AgentEvent::MessageAppended {
2249            message: msg.clone(),
2250            parent_uuid: None,
2251            usage: None,
2252        })
2253        .await;
2254
2255        // Other events are silently ignored.
2256        sink.emit(AgentEvent::PlanConfirmed).await;
2257
2258        let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
2259        drop(sink);
2260        drop(sw);
2261
2262        let transcript = SessionReader::load_messages(&session_dir).unwrap();
2263        assert_eq!(transcript.len(), 1, "exactly one message written");
2264        let loaded = &transcript[0];
2265        assert_eq!(loaded.content, "response text");
2266        assert_eq!(
2267            loaded.reasoning_content.as_deref(),
2268            Some("I thought about it")
2269        );
2270        assert_eq!(loaded.tool_calls.len(), 1);
2271        assert_eq!(loaded.tool_calls[0].name, "my_tool");
2272    }
2273
2274    /// A poisoned mutex is recovered gracefully: subsequent `emit` calls
2275    /// still append and do not panic.
2276    #[tokio::test]
2277    async fn sink_recovers_from_poisoned_mutex() {
2278        use crate::event::{AgentEvent, EventSink};
2279
2280        let (_ws, sw) = make_isolated_writer();
2281        let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
2282
2283        // Poison the mutex by panicking inside a lock guard on another thread.
2284        let sw2 = sw.clone();
2285        let _ = std::panic::catch_unwind(move || {
2286            let _guard = sw2.lock().unwrap();
2287            panic!("intentional poison");
2288        });
2289        assert!(sw.is_poisoned(), "mutex must be poisoned after the panic");
2290
2291        let sink = SessionPersistenceSink::new(sw);
2292        // This must not panic even though the mutex is poisoned.
2293        sink.emit(AgentEvent::MessageAppended {
2294            message: Message::user("after poison"),
2295            parent_uuid: None,
2296            usage: None,
2297        })
2298        .await;
2299
2300        let transcript = SessionReader::load_messages(&session_dir).unwrap();
2301        assert_eq!(transcript.len(), 1);
2302        assert_eq!(transcript[0].content, "after poison");
2303    }
2304
2305    // ── chrono_lite_now / epoch_day_to_ymd ──────────────────────────────────
2306
2307    #[test]
2308    fn epoch_day_to_ymd_unix_epoch() {
2309        // Day 0 = 1970-01-01
2310        assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
2311    }
2312
2313    #[test]
2314    fn epoch_day_to_ymd_known_dates() {
2315        // 2024-01-01 = day 19723 since epoch
2316        assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
2317        // 2000-02-29 (leap day) = day 11016
2318        assert_eq!(epoch_day_to_ymd(11016), (2000, 2, 29));
2319        // 2100-03-01 (2100 is NOT a leap year, so 2100-02-29 doesn't exist)
2320        // 2100-01-01 = day 47482
2321        assert_eq!(epoch_day_to_ymd(47482), (2100, 1, 1));
2322    }
2323
2324    #[test]
2325    fn chrono_lite_now_format() {
2326        let ts = chrono_lite_now();
2327        // Must match YYYY-MM-DDTHH:MM:SSZ
2328        assert_eq!(ts.len(), 20, "unexpected length: {ts}");
2329        assert_eq!(&ts[4..5], "-", "missing first dash: {ts}");
2330        assert_eq!(&ts[7..8], "-", "missing second dash: {ts}");
2331        assert_eq!(&ts[10..11], "T", "missing T separator: {ts}");
2332        assert_eq!(&ts[13..14], ":", "missing first colon: {ts}");
2333        assert_eq!(&ts[16..17], ":", "missing second colon: {ts}");
2334        assert_eq!(&ts[19..20], "Z", "missing Z suffix: {ts}");
2335        // All digit fields must parse as numbers
2336        let year: u32 = ts[0..4].parse().expect("year");
2337        let month: u32 = ts[5..7].parse().expect("month");
2338        let day: u32 = ts[8..10].parse().expect("day");
2339        let hour: u32 = ts[11..13].parse().expect("hour");
2340        let min: u32 = ts[14..16].parse().expect("minute");
2341        let sec: u32 = ts[17..19].parse().expect("second");
2342        assert!(year >= 2024, "year looks wrong: {year}");
2343        assert!((1..=12).contains(&month), "month out of range: {month}");
2344        assert!((1..=31).contains(&day), "day out of range: {day}");
2345        assert!(hour < 24, "hour out of range: {hour}");
2346        assert!(min < 60, "minute out of range: {min}");
2347        assert!(sec < 60, "second out of range: {sec}");
2348    }
2349}