Skip to main content

sid_isnt_done/
session.rs

1//! Session lifecycle management.
2//!
3//! A [`SidSession`] represents a single interactive run of the agent.  Each
4//! session occupies a timestamped directory under the workspace's sessions
5//! root and maintains append-only journals for events, API calls, and tool
6//! stream fragments.  Sessions can be created fresh, created as compacted
7//! continuations of an earlier session, or resumed from their on-disk state.
8
9use std::fs;
10use std::fs::OpenOptions;
11use std::io::{BufRead, BufReader, Write};
12use std::path::Path as StdPath;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::sync::Mutex as StdMutex;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use base64::Engine as _;
19use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
20use handled::SError;
21use serde::{Deserialize, Serialize};
22use time::OffsetDateTime;
23use utf8path::Path;
24
25/// Default subdirectory name under the config root where sessions are stored.
26pub const SESSIONS_DIR: &str = "sessions";
27/// Environment variable that overrides the sessions root directory.
28pub const SID_SESSIONS_ENV: &str = "SID_SESSIONS";
29/// Environment variable exported to tools with the active session directory.
30pub const SID_SESSION_DIR_ENV: &str = "SID_SESSION_DIR";
31/// Environment variable exported to tools with the active session identifier.
32pub const SID_SESSION_ID_ENV: &str = "SID_SESSION_ID";
33/// When set, tool scratch directories are preserved after successful invocations.
34pub const SID_KEEP_TOOL_SCRATCH_ENV: &str = "SID_KEEP_TOOL_SCRATCH";
35/// When set, tool scratch directories are preserved after failed invocations.
36pub const SID_KEEP_FAILED_TOOL_SCRATCH_ENV: &str = "SID_KEEP_FAILED_TOOL_SCRATCH";
37
38const SESSION_METADATA_FILE: &str = "session.json";
39const TRANSCRIPT_FILE: &str = "transcript.json";
40const EVENTS_JOURNAL_FILE: &str = "events.jsonl";
41const API_JOURNAL_FILE: &str = "api.jsonl";
42const TOOL_STREAMS_JOURNAL_FILE: &str = "tool-streams.jsonl";
43const BASH_STATE_FILE: &str = "bash-state.sh";
44
45/// Identity snapshot of the agent that produced a compacted session summary.
46///
47/// Stored in the session metadata so that a resumed session can reconstruct
48/// a memory-expert agent for follow-up questions.
49#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
50pub struct CompactionExpertConfig {
51    /// Agent identifier at the time of compaction, if available.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub agent_id: Option<String>,
54    /// Model that produced the compacted summary.
55    pub model: String,
56    /// System prompt the compacting agent was using.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub system_prompt: Option<String>,
59    /// Prompt appended when replaying the summary writer as a memory expert.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub memory_expert_prompt: Option<String>,
62}
63
64/// Pointer back to the parent session from which a compacted session was derived.
65#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
66pub struct CompactionProvenance {
67    /// Session identifier of the parent session.
68    pub session_id: String,
69    /// Filesystem path to the parent session directory.
70    pub session_dir: String,
71    /// Expert configuration for the agent that wrote the summary.
72    pub expert: CompactionExpertConfig,
73}
74
75/// On-disk representation of a single interactive agent session.
76///
77/// See the [module-level documentation](self) for an overview of the session
78/// lifecycle.
79#[derive(Debug)]
80pub struct SidSession {
81    id: String,
82    sessions_root: PathBuf,
83    root: PathBuf,
84    tmp_dir: PathBuf,
85    bash_tmp_dir: PathBuf,
86    events_path: PathBuf,
87    api_path: PathBuf,
88    tool_streams_path: PathBuf,
89    bash_state_path: PathBuf,
90    compaction_provenance: Option<CompactionProvenance>,
91    counters: SessionCounters,
92    journal_lock: StdMutex<()>,
93    stream_lock: Arc<StdMutex<()>>,
94}
95
96#[derive(Debug, Default)]
97struct SessionCounters {
98    event_entry: AtomicU64,
99    api_entry: AtomicU64,
100    api_call: AtomicU64,
101    active_api_call: AtomicU64,
102    tool_invocation: AtomicU64,
103}
104
105#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
106struct SessionCounterState {
107    event_entry: u64,
108    api_entry: u64,
109    api_call: u64,
110    tool_invocation: u64,
111}
112
113impl SessionCounters {
114    fn from_state(state: SessionCounterState) -> Self {
115        Self {
116            event_entry: AtomicU64::new(state.event_entry),
117            api_entry: AtomicU64::new(state.api_entry),
118            api_call: AtomicU64::new(state.api_call),
119            active_api_call: AtomicU64::new(state.api_call),
120            tool_invocation: AtomicU64::new(state.tool_invocation),
121        }
122    }
123
124    fn next_event_entry(&self) -> u64 {
125        self.event_entry.fetch_add(1, Ordering::Relaxed) + 1
126    }
127
128    fn next_api_entry(&self) -> u64 {
129        self.api_entry.fetch_add(1, Ordering::Relaxed) + 1
130    }
131
132    fn start_api_call(&self) -> u64 {
133        let api_seq = self.api_call.fetch_add(1, Ordering::Relaxed) + 1;
134        self.active_api_call.store(api_seq, Ordering::Relaxed);
135        api_seq
136    }
137
138    fn current_api_call(&self) -> u64 {
139        let api_seq = self.active_api_call.load(Ordering::Relaxed);
140        if api_seq == 0 {
141            self.start_api_call()
142        } else {
143            api_seq
144        }
145    }
146
147    fn next_tool_invocation(&self) -> u64 {
148        self.tool_invocation.fetch_add(1, Ordering::Relaxed) + 1
149    }
150}
151
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub(crate) struct ToolInvocationDirs {
154    pub(crate) sequence: u64,
155    pub(crate) root: PathBuf,
156    pub(crate) scratch_dir: PathBuf,
157    pub(crate) temp_dir: PathBuf,
158}
159
160#[derive(Clone, Debug)]
161pub(crate) struct ToolStreamJournal {
162    path: PathBuf,
163    lock: Arc<StdMutex<()>>,
164}
165
166#[derive(Clone, Debug)]
167pub(crate) struct ToolStartEvent<'a> {
168    pub(crate) tool_seq: u64,
169    pub(crate) request_id: &'a str,
170    pub(crate) tool: &'a str,
171    pub(crate) canonical_tool: &'a str,
172    pub(crate) tool_use_id: &'a str,
173    pub(crate) agent: &'a str,
174    pub(crate) scratch_dir: &'a StdPath,
175}
176
177#[derive(Clone, Debug)]
178pub(crate) struct ToolFinishEvent<'a> {
179    pub(crate) tool_seq: u64,
180    pub(crate) request_id: &'a str,
181    pub(crate) status: Option<&'a str>,
182    pub(crate) exit_code: Option<i32>,
183    pub(crate) success: bool,
184    pub(crate) result_ok: Option<bool>,
185    pub(crate) output_len: Option<usize>,
186    pub(crate) error: Option<&'a str>,
187    pub(crate) scratch_preserved: bool,
188    pub(crate) scratch_dir: Option<&'a StdPath>,
189    pub(crate) cleanup_error: Option<&'a str>,
190}
191
192#[derive(Clone, Debug)]
193struct SessionTimestamp {
194    id: String,
195    created_at: String,
196    created_unix_micros: i128,
197}
198
199#[derive(Debug, Deserialize)]
200struct SessionMetadata {
201    id: String,
202    #[serde(default)]
203    compacted_from: Option<CompactionProvenance>,
204}
205
206#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
207struct EventJournalState {
208    max_seq: u64,
209    max_tool_seq: u64,
210}
211
212#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
213struct ApiJournalState {
214    max_seq: u64,
215    max_api_seq: u64,
216}
217
218impl SidSession {
219    /// Create a fresh session under the default sessions root for `config_root`.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error when the sessions directory cannot be created or a
224    /// unique session identifier cannot be allocated.
225    pub fn create(config_root: &Path) -> Result<Self, SError> {
226        Self::create_in(resolve_sessions_root(config_root)?)
227    }
228
229    /// Create a compacted continuation session linked to a parent via `provenance`.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error when the sessions directory cannot be created or a
234    /// unique session identifier cannot be allocated.
235    pub fn create_compacted(
236        config_root: &Path,
237        provenance: CompactionProvenance,
238    ) -> Result<Self, SError> {
239        Self::create_compacted_in(resolve_sessions_root(config_root)?, provenance)
240    }
241
242    /// Resume an existing session identified by `spec`.
243    ///
244    /// `spec` can be an absolute path to a session directory, a path to its
245    /// `session.json` metadata file, or a bare session name relative to the
246    /// default sessions root.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error when the session directory does not exist or its
251    /// metadata cannot be read.
252    pub fn resume(config_root: &Path, spec: &str) -> Result<Self, SError> {
253        let default_sessions_root = resolve_sessions_root(config_root)?;
254        let root = resolve_existing_session_root(&default_sessions_root, spec)?;
255        let sessions_root = root
256            .parent()
257            .map(StdPath::to_path_buf)
258            .unwrap_or(default_sessions_root);
259        Self::resume_from_root(sessions_root, root)
260    }
261
262    pub(crate) fn create_in(sessions_root: PathBuf) -> Result<Self, SError> {
263        Self::create_in_with_provenance(sessions_root, None)
264    }
265
266    pub(crate) fn create_compacted_in(
267        sessions_root: PathBuf,
268        provenance: CompactionProvenance,
269    ) -> Result<Self, SError> {
270        Self::create_in_with_provenance(sessions_root, Some(provenance))
271    }
272
273    fn create_in_with_provenance(
274        sessions_root: PathBuf,
275        provenance: Option<CompactionProvenance>,
276    ) -> Result<Self, SError> {
277        fs::create_dir_all(&sessions_root).map_err(|err| {
278            session_error("io_error", "failed to create sessions directory")
279                .with_string_field("path", sessions_root.to_string_lossy().as_ref())
280                .with_string_field("cause", &err.to_string())
281        })?;
282
283        for _ in 0..8 {
284            let timestamp = now_session_timestamp();
285            let root = sessions_root.join(&timestamp.id);
286            match fs::create_dir(&root) {
287                Ok(()) => {
288                    return Self::from_created_root(
289                        timestamp,
290                        sessions_root,
291                        root,
292                        provenance.clone(),
293                    );
294                }
295                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
296                Err(err) => {
297                    return Err(
298                        session_error("io_error", "failed to create session directory")
299                            .with_string_field("path", root.to_string_lossy().as_ref())
300                            .with_string_field("cause", &err.to_string()),
301                    );
302                }
303            }
304        }
305
306        Err(session_error(
307            "session_id_collision",
308            "failed to allocate a unique timestamp session id",
309        ))
310    }
311
312    #[cfg(test)]
313    pub(crate) fn resume_in(sessions_root: PathBuf, spec: &str) -> Result<Self, SError> {
314        let root = resolve_existing_session_root(&sessions_root, spec)?;
315        let sessions_root = root
316            .parent()
317            .map(StdPath::to_path_buf)
318            .unwrap_or(sessions_root);
319        Self::resume_from_root(sessions_root, root)
320    }
321
322    fn from_created_root(
323        timestamp: SessionTimestamp,
324        sessions_root: PathBuf,
325        root: PathBuf,
326        compaction_provenance: Option<CompactionProvenance>,
327    ) -> Result<Self, SError> {
328        let tmp_dir = root.join("tmp");
329        let bash_tmp_dir = tmp_dir.join("bash");
330        fs::create_dir_all(&bash_tmp_dir).map_err(|err| {
331            session_error(
332                "io_error",
333                "failed to create session runtime temporary directory",
334            )
335            .with_string_field("path", bash_tmp_dir.to_string_lossy().as_ref())
336            .with_string_field("cause", &err.to_string())
337        })?;
338
339        let session = Self {
340            id: timestamp.id,
341            sessions_root,
342            root: root.clone(),
343            tmp_dir,
344            bash_tmp_dir,
345            events_path: root.join(EVENTS_JOURNAL_FILE),
346            api_path: root.join(API_JOURNAL_FILE),
347            tool_streams_path: root.join(TOOL_STREAMS_JOURNAL_FILE),
348            bash_state_path: root.join(BASH_STATE_FILE),
349            compaction_provenance,
350            counters: SessionCounters::default(),
351            journal_lock: StdMutex::new(()),
352            stream_lock: Arc::new(StdMutex::new(())),
353        };
354        session.write_metadata(&timestamp.created_at, timestamp.created_unix_micros)?;
355        session.log_session_start()?;
356        Ok(session)
357    }
358
359    fn resume_from_root(sessions_root: PathBuf, root: PathBuf) -> Result<Self, SError> {
360        let metadata = read_session_metadata(&root)?;
361        let tmp_dir = root.join("tmp");
362        let bash_tmp_dir = tmp_dir.join("bash");
363        fs::create_dir_all(&bash_tmp_dir).map_err(|err| {
364            session_error(
365                "io_error",
366                "failed to create session runtime temporary directory",
367            )
368            .with_string_field("path", bash_tmp_dir.to_string_lossy().as_ref())
369            .with_string_field("cause", &err.to_string())
370        })?;
371
372        let counters = load_session_counter_state(&root, &tmp_dir)?;
373        let session = Self {
374            id: metadata.id,
375            sessions_root,
376            root: root.clone(),
377            tmp_dir,
378            bash_tmp_dir,
379            events_path: root.join(EVENTS_JOURNAL_FILE),
380            api_path: root.join(API_JOURNAL_FILE),
381            tool_streams_path: root.join(TOOL_STREAMS_JOURNAL_FILE),
382            bash_state_path: root.join(BASH_STATE_FILE),
383            compaction_provenance: metadata.compacted_from,
384            counters: SessionCounters::from_state(counters),
385            journal_lock: StdMutex::new(()),
386            stream_lock: Arc::new(StdMutex::new(())),
387        };
388        session.log_session_resume()?;
389        Ok(session)
390    }
391
392    /// Return the session's unique identifier (a timestamp-based string).
393    pub fn id(&self) -> &str {
394        &self.id
395    }
396
397    /// Return the parent directory that contains all session directories.
398    pub fn sessions_root(&self) -> &PathBuf {
399        &self.sessions_root
400    }
401
402    /// Return the on-disk root directory for this session.
403    pub fn root(&self) -> &PathBuf {
404        &self.root
405    }
406
407    /// Return the compaction provenance if this session was created via compaction.
408    pub fn compaction_provenance(&self) -> Option<&CompactionProvenance> {
409        self.compaction_provenance.as_ref()
410    }
411
412    /// Return the path to the transcript JSON file for this session.
413    pub fn transcript_path(&self) -> PathBuf {
414        self.root.join(TRANSCRIPT_FILE)
415    }
416
417    pub(crate) fn bash_tmp_dir(&self) -> &PathBuf {
418        &self.bash_tmp_dir
419    }
420
421    pub(crate) fn bash_state_path(&self) -> &PathBuf {
422        &self.bash_state_path
423    }
424
425    /// Read the saved bash session state, if any.
426    ///
427    /// Returns `Ok(None)` when no state file exists or the file is empty.
428    ///
429    /// # Errors
430    ///
431    /// Returns an error on I/O failure.
432    pub fn read_bash_state(&self) -> Result<Option<String>, SError> {
433        match fs::read_to_string(&self.bash_state_path) {
434            Ok(state) if state.trim().is_empty() => Ok(None),
435            Ok(state) => Ok(Some(state)),
436            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
437            Err(err) => Err(session_error("io_error", "failed to read bash state")
438                .with_string_field("path", self.bash_state_path.to_string_lossy().as_ref())
439                .with_string_field("cause", &err.to_string())),
440        }
441    }
442
443    /// Persist bash session state to disk for later resumption.
444    ///
445    /// # Errors
446    ///
447    /// Returns an error on I/O failure.
448    pub fn write_bash_state(&self, state: &str) -> Result<(), SError> {
449        fs::write(&self.bash_state_path, state).map_err(|err| {
450            session_error("io_error", "failed to write bash state")
451                .with_string_field("path", self.bash_state_path.to_string_lossy().as_ref())
452                .with_string_field("cause", &err.to_string())
453        })
454    }
455
456    pub(crate) fn clear_bash_state(&self) -> Result<(), SError> {
457        match fs::remove_file(&self.bash_state_path) {
458            Ok(()) => Ok(()),
459            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
460            Err(err) => Err(session_error("io_error", "failed to clear bash state")
461                .with_string_field("path", self.bash_state_path.to_string_lossy().as_ref())
462                .with_string_field("cause", &err.to_string())),
463        }
464    }
465
466    pub(crate) fn tool_stream_journal(&self) -> ToolStreamJournal {
467        ToolStreamJournal {
468            path: self.tool_streams_path.clone(),
469            lock: self.stream_lock.clone(),
470        }
471    }
472
473    pub(crate) fn create_tool_invocation_dirs(
474        &self,
475        request_id: &str,
476    ) -> Result<ToolInvocationDirs, SError> {
477        let sequence = self.counters.next_tool_invocation();
478        let root = self.tmp_dir.join(format!("tool-{sequence:06}"));
479        let scratch_dir = root.clone();
480        let temp_dir = root.join("tmp");
481        fs::create_dir(&root).map_err(|err| {
482            session_error("io_error", "failed to create tool scratch directory")
483                .with_string_field("path", root.to_string_lossy().as_ref())
484                .with_string_field("request_id", request_id)
485                .with_string_field("cause", &err.to_string())
486        })?;
487        fs::create_dir_all(&temp_dir).map_err(|err| {
488            session_error("io_error", "failed to create tool temporary directory")
489                .with_string_field("path", temp_dir.to_string_lossy().as_ref())
490                .with_string_field("request_id", request_id)
491                .with_string_field("cause", &err.to_string())
492        })?;
493        Ok(ToolInvocationDirs {
494            sequence,
495            root,
496            scratch_dir,
497            temp_dir,
498        })
499    }
500
501    pub(crate) fn log_api_request(&self, value: &impl Serialize) -> Result<(), SError> {
502        let api_seq = self.counters.start_api_call();
503        self.log_api_payload("request", api_seq, value)
504    }
505
506    pub(crate) fn log_api_response(&self, value: &impl Serialize) -> Result<(), SError> {
507        let api_seq = self.counters.current_api_call();
508        self.log_api_payload("response", api_seq, value)
509    }
510
511    pub(crate) fn log_tool_start(&self, event: ToolStartEvent<'_>) -> Result<(), SError> {
512        #[derive(Serialize)]
513        struct ToolStartRecord {
514            seq: u64,
515            ts: String,
516            kind: &'static str,
517            tool_seq: u64,
518            request_id: String,
519            tool: String,
520            canonical_tool: String,
521            tool_use_id: String,
522            agent: String,
523            scratch_dir: String,
524        }
525
526        let record = ToolStartRecord {
527            seq: self.next_event_seq(),
528            ts: now_created_at(),
529            kind: "tool_start",
530            tool_seq: event.tool_seq,
531            request_id: event.request_id.to_string(),
532            tool: event.tool.to_string(),
533            canonical_tool: event.canonical_tool.to_string(),
534            tool_use_id: event.tool_use_id.to_string(),
535            agent: event.agent.to_string(),
536            scratch_dir: event.scratch_dir.to_string_lossy().into_owned(),
537        };
538        self.append_journal(&self.events_path, &record)
539    }
540
541    pub(crate) fn log_tool_finish(&self, event: ToolFinishEvent<'_>) -> Result<(), SError> {
542        #[derive(Serialize)]
543        struct ToolFinishRecord {
544            seq: u64,
545            ts: String,
546            kind: &'static str,
547            tool_seq: u64,
548            request_id: String,
549            #[serde(skip_serializing_if = "Option::is_none")]
550            status: Option<String>,
551            #[serde(skip_serializing_if = "Option::is_none")]
552            exit_code: Option<i32>,
553            success: bool,
554            #[serde(skip_serializing_if = "Option::is_none")]
555            result_ok: Option<bool>,
556            #[serde(skip_serializing_if = "Option::is_none")]
557            output_len: Option<usize>,
558            #[serde(skip_serializing_if = "Option::is_none")]
559            error: Option<String>,
560            scratch_preserved: bool,
561            #[serde(skip_serializing_if = "Option::is_none")]
562            scratch_dir: Option<String>,
563            #[serde(skip_serializing_if = "Option::is_none")]
564            cleanup_error: Option<String>,
565        }
566
567        let record = ToolFinishRecord {
568            seq: self.next_event_seq(),
569            ts: now_created_at(),
570            kind: "tool_finish",
571            tool_seq: event.tool_seq,
572            request_id: event.request_id.to_string(),
573            status: event.status.map(str::to_string),
574            exit_code: event.exit_code,
575            success: event.success,
576            result_ok: event.result_ok,
577            output_len: event.output_len,
578            error: event.error.map(str::to_string),
579            scratch_preserved: event.scratch_preserved,
580            scratch_dir: event
581                .scratch_dir
582                .map(|path| path.to_string_lossy().into_owned()),
583            cleanup_error: event.cleanup_error.map(str::to_string),
584        };
585        self.append_journal(&self.events_path, &record)
586    }
587
588    fn log_session_start(&self) -> Result<(), SError> {
589        #[derive(Serialize)]
590        struct SessionStartRecord<'a> {
591            seq: u64,
592            ts: String,
593            kind: &'static str,
594            session_id: &'a str,
595        }
596
597        let record = SessionStartRecord {
598            seq: self.next_event_seq(),
599            ts: now_created_at(),
600            kind: "session_start",
601            session_id: &self.id,
602        };
603        self.append_journal(&self.events_path, &record)
604    }
605
606    fn log_session_resume(&self) -> Result<(), SError> {
607        #[derive(Serialize)]
608        struct SessionResumeRecord<'a> {
609            seq: u64,
610            ts: String,
611            kind: &'static str,
612            session_id: &'a str,
613        }
614
615        let record = SessionResumeRecord {
616            seq: self.next_event_seq(),
617            ts: now_created_at(),
618            kind: "session_resume",
619            session_id: &self.id,
620        };
621        self.append_journal(&self.events_path, &record)
622    }
623
624    fn log_api_payload(
625        &self,
626        kind: &'static str,
627        api_seq: u64,
628        value: &impl Serialize,
629    ) -> Result<(), SError> {
630        #[derive(Serialize)]
631        struct ApiRecord<'a, T: Serialize + ?Sized> {
632            seq: u64,
633            ts: String,
634            kind: &'static str,
635            api_seq: u64,
636            payload: &'a T,
637        }
638
639        let record = ApiRecord {
640            seq: self.counters.next_api_entry(),
641            ts: now_created_at(),
642            kind,
643            api_seq,
644            payload: value,
645        };
646        self.append_journal(&self.api_path, &record)
647    }
648
649    fn write_metadata(&self, created_at: &str, created_unix_micros: i128) -> Result<(), SError> {
650        #[derive(Serialize)]
651        struct Metadata<'a> {
652            id: &'a str,
653            created_at: &'a str,
654            created_unix_micros: i128,
655            uuid: Option<&'a str>,
656            pid: u32,
657            sessions_root: String,
658            session_dir: String,
659            #[serde(skip_serializing_if = "Option::is_none")]
660            compacted_from: Option<&'a CompactionProvenance>,
661        }
662
663        let metadata = Metadata {
664            id: &self.id,
665            created_at,
666            created_unix_micros,
667            uuid: None,
668            pid: std::process::id(),
669            sessions_root: self.sessions_root.to_string_lossy().into_owned(),
670            session_dir: self.root.to_string_lossy().into_owned(),
671            compacted_from: self.compaction_provenance.as_ref(),
672        };
673        self.write_json(self.root.join(SESSION_METADATA_FILE), &metadata)
674    }
675
676    fn write_json(&self, path: PathBuf, value: &impl Serialize) -> Result<(), SError> {
677        let payload = serde_json::to_vec_pretty(value).map_err(|err| {
678            session_error("json_serialize_error", "failed to serialize session log")
679                .with_string_field("path", path.to_string_lossy().as_ref())
680                .with_string_field("cause", &err.to_string())
681        })?;
682        fs::write(&path, payload).map_err(|err| {
683            session_error("io_error", "failed to write session log")
684                .with_string_field("path", path.to_string_lossy().as_ref())
685                .with_string_field("cause", &err.to_string())
686        })
687    }
688
689    fn append_journal(&self, path: &StdPath, value: &impl Serialize) -> Result<(), SError> {
690        let _guard = self.journal_lock.lock().map_err(|_| {
691            session_error("lock_poisoned", "session journal lock was poisoned")
692                .with_string_field("path", path.to_string_lossy().as_ref())
693        })?;
694        append_jsonl(path, value)
695    }
696
697    fn next_event_seq(&self) -> u64 {
698        self.counters.next_event_entry()
699    }
700}
701
702impl Drop for SidSession {
703    fn drop(&mut self) {
704        if keep_any_tool_scratch() {
705            return;
706        }
707        let _ = fs::remove_dir_all(&self.tmp_dir);
708    }
709}
710
711impl ToolStreamJournal {
712    pub(crate) fn append(&self, tool_seq: u64, stream: &str, bytes: &[u8]) -> Result<(), SError> {
713        if bytes.is_empty() {
714            return Ok(());
715        }
716
717        #[derive(Serialize)]
718        struct ToolStreamRecord<'a> {
719            tool_seq: u64,
720            ts: String,
721            stream: &'a str,
722            #[serde(skip_serializing_if = "Option::is_none")]
723            text: Option<&'a str>,
724            #[serde(skip_serializing_if = "Option::is_none")]
725            data_b64: Option<String>,
726        }
727
728        let (text, data_b64) = match std::str::from_utf8(bytes) {
729            Ok(text) => (Some(text), None),
730            Err(_) => (None, Some(BASE64_STANDARD.encode(bytes))),
731        };
732        let record = ToolStreamRecord {
733            tool_seq,
734            ts: now_created_at(),
735            stream,
736            text,
737            data_b64,
738        };
739
740        let _guard = self.lock.lock().map_err(|_| {
741            session_error("lock_poisoned", "session tool stream lock was poisoned")
742                .with_string_field("path", self.path.to_string_lossy().as_ref())
743        })?;
744        append_jsonl(&self.path, &record)
745    }
746}
747
748pub(crate) fn should_keep_tool_scratch(failed: bool) -> bool {
749    env_truthy(SID_KEEP_TOOL_SCRATCH_ENV)
750        || (failed && env_truthy(SID_KEEP_FAILED_TOOL_SCRATCH_ENV))
751}
752
753fn keep_any_tool_scratch() -> bool {
754    env_truthy(SID_KEEP_TOOL_SCRATCH_ENV) || env_truthy(SID_KEEP_FAILED_TOOL_SCRATCH_ENV)
755}
756
757fn env_truthy(name: &str) -> bool {
758    match std::env::var(name) {
759        Ok(value) => matches!(
760            value.trim().to_ascii_lowercase().as_str(),
761            "1" | "true" | "yes" | "on"
762        ),
763        Err(_) => false,
764    }
765}
766
767/// Return the transcript file path for a given session directory.
768pub fn transcript_path_for_session_dir(root: &StdPath) -> PathBuf {
769    root.join(TRANSCRIPT_FILE)
770}
771
772/// Read compaction provenance from a session directory's metadata file.
773///
774/// Returns `Ok(None)` when the session was not created via compaction.
775///
776/// # Errors
777///
778/// Returns an error when the metadata file cannot be read or parsed.
779pub fn read_compaction_provenance_from_dir(
780    root: &StdPath,
781) -> Result<Option<CompactionProvenance>, SError> {
782    Ok(read_session_metadata(root)?.compacted_from)
783}
784
785fn resolve_sessions_root(config_root: &Path) -> Result<PathBuf, SError> {
786    match std::env::var(SID_SESSIONS_ENV) {
787        Ok(path) if !path.is_empty() => Ok(PathBuf::from(path)),
788        Ok(_) | Err(std::env::VarError::NotPresent) => {
789            Ok(PathBuf::from(config_root.as_str()).join(SESSIONS_DIR))
790        }
791        Err(std::env::VarError::NotUnicode(_)) => Err(session_error(
792            "invalid_sid_sessions",
793            "SID_SESSIONS is not valid UTF-8",
794        )),
795    }
796}
797
798fn resolve_existing_session_root(sessions_root: &StdPath, spec: &str) -> Result<PathBuf, SError> {
799    let direct = PathBuf::from(spec);
800    if direct.exists() {
801        return normalize_existing_session_root(direct, spec);
802    }
803
804    normalize_existing_session_root(sessions_root.join(spec), spec)
805}
806
807fn normalize_existing_session_root(candidate: PathBuf, spec: &str) -> Result<PathBuf, SError> {
808    let root = if candidate.is_file() {
809        if candidate.file_name().and_then(|name| name.to_str()) == Some(SESSION_METADATA_FILE) {
810            candidate
811                .parent()
812                .map(StdPath::to_path_buf)
813                .ok_or_else(|| {
814                    session_error(
815                        "invalid_session_path",
816                        "session metadata has no parent directory",
817                    )
818                    .with_string_field("path", candidate.to_string_lossy().as_ref())
819                })?
820        } else {
821            return Err(session_error(
822                "invalid_session_path",
823                "resume target must be a session directory",
824            )
825            .with_string_field("path", candidate.to_string_lossy().as_ref()));
826        }
827    } else {
828        candidate
829    };
830
831    if root.is_dir() {
832        Ok(root)
833    } else {
834        Err(
835            session_error("session_not_found", "failed to locate session to resume")
836                .with_string_field("session", spec),
837        )
838    }
839}
840
841fn read_session_metadata(root: &StdPath) -> Result<SessionMetadata, SError> {
842    let path = root.join(SESSION_METADATA_FILE);
843    let payload = fs::read_to_string(&path).map_err(|err| {
844        session_error("io_error", "failed to read session metadata")
845            .with_string_field("path", path.to_string_lossy().as_ref())
846            .with_string_field("cause", &err.to_string())
847    })?;
848    serde_json::from_str(&payload).map_err(|err| {
849        session_error("json_parse_error", "failed to parse session metadata")
850            .with_string_field("path", path.to_string_lossy().as_ref())
851            .with_string_field("cause", &err.to_string())
852    })
853}
854
855fn load_session_counter_state(
856    root: &StdPath,
857    tmp_dir: &StdPath,
858) -> Result<SessionCounterState, SError> {
859    let events = inspect_event_journal(&root.join(EVENTS_JOURNAL_FILE))?;
860    let api = inspect_api_journal(&root.join(API_JOURNAL_FILE))?;
861    let tmp_tool_seq = max_tool_sequence_in_tmp(tmp_dir)?;
862    Ok(SessionCounterState {
863        event_entry: events.max_seq,
864        api_entry: api.max_seq,
865        api_call: api.max_api_seq,
866        tool_invocation: events.max_tool_seq.max(tmp_tool_seq),
867    })
868}
869
870fn inspect_event_journal(path: &StdPath) -> Result<EventJournalState, SError> {
871    let mut state = EventJournalState::default();
872    scan_jsonl(path, |entry| {
873        state.max_seq = state.max_seq.max(u64_field(entry, "seq")?);
874        if let Some(tool_seq) = optional_u64_field(entry, "tool_seq")? {
875            state.max_tool_seq = state.max_tool_seq.max(tool_seq);
876        }
877        Ok(())
878    })?;
879    Ok(state)
880}
881
882fn inspect_api_journal(path: &StdPath) -> Result<ApiJournalState, SError> {
883    let mut state = ApiJournalState::default();
884    scan_jsonl(path, |entry| {
885        state.max_seq = state.max_seq.max(u64_field(entry, "seq")?);
886        if let Some(api_seq) = optional_u64_field(entry, "api_seq")? {
887            state.max_api_seq = state.max_api_seq.max(api_seq);
888        }
889        Ok(())
890    })?;
891    Ok(state)
892}
893
894fn scan_jsonl(
895    path: &StdPath,
896    mut visit: impl FnMut(&serde_json::Value) -> Result<(), SError>,
897) -> Result<(), SError> {
898    let file = match fs::File::open(path) {
899        Ok(file) => file,
900        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
901        Err(err) => {
902            return Err(session_error("io_error", "failed to open session journal")
903                .with_string_field("path", path.to_string_lossy().as_ref())
904                .with_string_field("cause", &err.to_string()));
905        }
906    };
907    let reader = BufReader::new(file);
908    for (line_no, line) in reader.lines().enumerate() {
909        let line = line.map_err(|err| {
910            session_error("io_error", "failed to read session journal")
911                .with_string_field("path", path.to_string_lossy().as_ref())
912                .with_string_field("line", &(line_no + 1).to_string())
913                .with_string_field("cause", &err.to_string())
914        })?;
915        if line.trim().is_empty() {
916            continue;
917        }
918        let entry = serde_json::from_str::<serde_json::Value>(&line).map_err(|err| {
919            session_error("json_parse_error", "failed to parse session journal entry")
920                .with_string_field("path", path.to_string_lossy().as_ref())
921                .with_string_field("line", &(line_no + 1).to_string())
922                .with_string_field("cause", &err.to_string())
923        })?;
924        visit(&entry)?;
925    }
926    Ok(())
927}
928
929fn u64_field(entry: &serde_json::Value, field: &str) -> Result<u64, SError> {
930    entry
931        .get(field)
932        .and_then(serde_json::Value::as_u64)
933        .ok_or_else(|| {
934            session_error("invalid_journal_entry", "missing numeric journal field")
935                .with_string_field("field", field)
936        })
937}
938
939fn optional_u64_field(entry: &serde_json::Value, field: &str) -> Result<Option<u64>, SError> {
940    match entry.get(field) {
941        Some(value) => value.as_u64().map(Some).ok_or_else(|| {
942            session_error("invalid_journal_entry", "journal field must be numeric")
943                .with_string_field("field", field)
944        }),
945        None => Ok(None),
946    }
947}
948
949fn max_tool_sequence_in_tmp(tmp_dir: &StdPath) -> Result<u64, SError> {
950    let mut max_seq = 0;
951    let entries = match fs::read_dir(tmp_dir) {
952        Ok(entries) => entries,
953        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0),
954        Err(err) => {
955            return Err(
956                session_error("io_error", "failed to read session tmp directory")
957                    .with_string_field("path", tmp_dir.to_string_lossy().as_ref())
958                    .with_string_field("cause", &err.to_string()),
959            );
960        }
961    };
962    for entry in entries {
963        let entry = entry.map_err(|err| {
964            session_error("io_error", "failed to read session tmp directory entry")
965                .with_string_field("path", tmp_dir.to_string_lossy().as_ref())
966                .with_string_field("cause", &err.to_string())
967        })?;
968        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
969            continue;
970        };
971        let Some(suffix) = name.strip_prefix("tool-") else {
972            continue;
973        };
974        let Ok(sequence) = suffix.parse::<u64>() else {
975            continue;
976        };
977        max_seq = max_seq.max(sequence);
978    }
979    Ok(max_seq)
980}
981
982fn append_jsonl(path: &StdPath, value: &impl Serialize) -> Result<(), SError> {
983    let mut payload = serde_json::to_vec(value).map_err(|err| {
984        session_error(
985            "json_serialize_error",
986            "failed to serialize session journal entry",
987        )
988        .with_string_field("path", path.to_string_lossy().as_ref())
989        .with_string_field("cause", &err.to_string())
990    })?;
991    payload.push(b'\n');
992
993    let mut file = OpenOptions::new()
994        .create(true)
995        .append(true)
996        .open(path)
997        .map_err(|err| {
998            session_error("io_error", "failed to open session journal")
999                .with_string_field("path", path.to_string_lossy().as_ref())
1000                .with_string_field("cause", &err.to_string())
1001        })?;
1002    file.write_all(&payload).map_err(|err| {
1003        session_error("io_error", "failed to append session journal")
1004            .with_string_field("path", path.to_string_lossy().as_ref())
1005            .with_string_field("cause", &err.to_string())
1006    })
1007}
1008
1009fn now_session_timestamp() -> SessionTimestamp {
1010    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
1011    format_session_timestamp(now)
1012}
1013
1014fn now_created_at() -> String {
1015    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
1016    format_session_timestamp(now).created_at
1017}
1018
1019fn format_session_timestamp(now: OffsetDateTime) -> SessionTimestamp {
1020    let offset_seconds = now.offset().whole_seconds();
1021    let offset_sign = if offset_seconds < 0 { '-' } else { '+' };
1022    let offset_abs = offset_seconds.abs();
1023    let offset_hours = offset_abs / 3600;
1024    let offset_minutes = (offset_abs % 3600) / 60;
1025    let micros = now.microsecond();
1026    let month = u8::from(now.month());
1027
1028    let id = format!(
1029        "{:04}-{:02}-{:02}T{:02}-{:02}-{:02}.{:06}{}{:02}{:02}",
1030        now.year(),
1031        month,
1032        now.day(),
1033        now.hour(),
1034        now.minute(),
1035        now.second(),
1036        micros,
1037        offset_sign,
1038        offset_hours,
1039        offset_minutes,
1040    );
1041    let created_at = format!(
1042        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}{}{:02}:{:02}",
1043        now.year(),
1044        month,
1045        now.day(),
1046        now.hour(),
1047        now.minute(),
1048        now.second(),
1049        micros,
1050        offset_sign,
1051        offset_hours,
1052        offset_minutes,
1053    );
1054    let created_unix_micros = now.unix_timestamp_nanos() / 1_000;
1055    SessionTimestamp {
1056        id,
1057        created_at,
1058        created_unix_micros,
1059    }
1060}
1061
1062fn session_error(code: &str, message: &str) -> SError {
1063    SError::new("sid-session")
1064        .with_code(code)
1065        .with_message(message)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071    use crate::test_support::unique_temp_dir;
1072    use serde_json::json;
1073
1074    #[test]
1075    fn session_create_allocates_timestamp_directory() {
1076        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1077        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1078
1079        assert_timestamp_session_id(session.id());
1080        assert_eq!(session.root(), &sessions_root.join(session.id()));
1081        assert!(session.root().join(SESSION_METADATA_FILE).is_file());
1082        assert!(session.root().join(EVENTS_JOURNAL_FILE).is_file());
1083        assert!(!session.root().join("api").exists());
1084        assert!(!session.root().join("tools").exists());
1085        assert!(session.root().join("tmp/bash").is_dir());
1086
1087        let metadata: serde_json::Value = serde_json::from_str(
1088            &fs::read_to_string(session.root().join(SESSION_METADATA_FILE)).unwrap(),
1089        )
1090        .unwrap();
1091        assert_eq!(metadata["id"], json!(session.id()));
1092        assert_timestamp_created_at(metadata["created_at"].as_str().unwrap());
1093        assert!(metadata["created_unix_micros"].as_i64().unwrap() > 0);
1094        assert_eq!(metadata["uuid"], serde_json::Value::Null);
1095        assert_eq!(metadata["pid"], json!(std::process::id()));
1096        assert!(metadata.get("compacted_from").is_none());
1097
1098        fs::remove_dir_all(sessions_root).unwrap();
1099    }
1100
1101    #[test]
1102    fn compacted_session_persists_provenance_and_reload() {
1103        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1104        let parent = SidSession::create_in(sessions_root.clone()).unwrap();
1105        let provenance = CompactionProvenance {
1106            session_id: parent.id().to_string(),
1107            session_dir: parent.root().to_string_lossy().into_owned(),
1108            expert: CompactionExpertConfig {
1109                agent_id: Some("compact".to_string()),
1110                model: "claude-sonnet-4-5".to_string(),
1111                system_prompt: Some("Summarize carefully.".to_string()),
1112                memory_expert_prompt: Some("Answer from memory only.".to_string()),
1113            },
1114        };
1115
1116        let child =
1117            SidSession::create_compacted_in(sessions_root.clone(), provenance.clone()).unwrap();
1118        assert_eq!(child.compaction_provenance(), Some(&provenance));
1119
1120        let metadata: serde_json::Value = serde_json::from_str(
1121            &fs::read_to_string(child.root().join(SESSION_METADATA_FILE)).unwrap(),
1122        )
1123        .unwrap();
1124        assert_eq!(metadata["compacted_from"]["session_id"], json!(parent.id()));
1125        assert_eq!(
1126            metadata["compacted_from"]["session_dir"],
1127            json!(parent.root().to_string_lossy().as_ref())
1128        );
1129        assert_eq!(
1130            metadata["compacted_from"]["expert"]["agent_id"],
1131            json!("compact")
1132        );
1133
1134        let resumed = SidSession::resume_in(sessions_root.clone(), child.id()).unwrap();
1135        assert_eq!(resumed.compaction_provenance(), Some(&provenance));
1136        assert_eq!(
1137            read_compaction_provenance_from_dir(child.root()).unwrap(),
1138            Some(provenance.clone())
1139        );
1140        assert_eq!(
1141            transcript_path_for_session_dir(child.root()),
1142            child.root().join(TRANSCRIPT_FILE)
1143        );
1144
1145        fs::remove_dir_all(sessions_root).unwrap();
1146    }
1147
1148    #[test]
1149    fn api_logging_uses_single_jsonl_journal() {
1150        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1151        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1152
1153        session
1154            .log_api_request(&json!({ "messages": ["hello"] }))
1155            .unwrap();
1156        session
1157            .log_api_response(&json!({ "id": "msg_123" }))
1158            .unwrap();
1159        session
1160            .log_api_request(&json!({ "messages": ["retry"] }))
1161            .unwrap();
1162        session
1163            .log_api_response(&json!({ "id": "msg_456" }))
1164            .unwrap();
1165
1166        let lines = fs::read_to_string(session.root().join(API_JOURNAL_FILE)).unwrap();
1167        let entries = lines
1168            .lines()
1169            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1170            .collect::<Vec<_>>();
1171        assert_eq!(entries.len(), 4);
1172        assert_eq!(entries[0]["seq"], json!(1));
1173        assert_eq!(entries[0]["kind"], json!("request"));
1174        assert_eq!(entries[0]["api_seq"], json!(1));
1175        assert_eq!(entries[0]["payload"], json!({ "messages": ["hello"] }));
1176        assert_eq!(entries[1]["seq"], json!(2));
1177        assert_eq!(entries[1]["kind"], json!("response"));
1178        assert_eq!(entries[1]["api_seq"], json!(1));
1179        assert_eq!(entries[1]["payload"], json!({ "id": "msg_123" }));
1180        assert_eq!(entries[2]["seq"], json!(3));
1181        assert_eq!(entries[2]["kind"], json!("request"));
1182        assert_eq!(entries[2]["api_seq"], json!(2));
1183        assert_eq!(entries[2]["payload"], json!({ "messages": ["retry"] }));
1184        assert_eq!(entries[3]["seq"], json!(4));
1185        assert_eq!(entries[3]["kind"], json!("response"));
1186        assert_eq!(entries[3]["api_seq"], json!(2));
1187        assert_eq!(entries[3]["payload"], json!({ "id": "msg_456" }));
1188
1189        fs::remove_dir_all(sessions_root).unwrap();
1190    }
1191
1192    #[test]
1193    fn tool_invocation_dirs_are_ordered_under_session_tmp() {
1194        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1195        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1196
1197        let first = session.create_tool_invocation_dirs("sidreq_first").unwrap();
1198        let second = session
1199            .create_tool_invocation_dirs("sidreq_second")
1200            .unwrap();
1201
1202        assert_eq!(first.sequence, 1);
1203        assert_eq!(second.sequence, 2);
1204        assert!(first.root.ends_with("tmp/tool-000001"));
1205        assert!(second.root.ends_with("tmp/tool-000002"));
1206        assert_eq!(first.scratch_dir, first.root);
1207        assert_eq!(first.temp_dir, first.root.join("tmp"));
1208
1209        fs::remove_dir_all(sessions_root).unwrap();
1210    }
1211
1212    #[test]
1213    fn tool_stream_journal_preserves_utf8_and_binary_chunks() {
1214        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1215        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1216        let journal = session.tool_stream_journal();
1217
1218        journal.append(1, "stdout", b"hello\n").unwrap();
1219        journal.append(1, "stderr", &[0, 159, 146, 150]).unwrap();
1220
1221        let lines = fs::read_to_string(session.root().join(TOOL_STREAMS_JOURNAL_FILE)).unwrap();
1222        let entries = lines
1223            .lines()
1224            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1225            .collect::<Vec<_>>();
1226        assert_eq!(entries[0]["tool_seq"], json!(1));
1227        assert_eq!(entries[0]["stream"], json!("stdout"));
1228        assert_eq!(entries[0]["text"], json!("hello\n"));
1229        assert_eq!(entries[1]["stream"], json!("stderr"));
1230        assert_eq!(entries[1]["data_b64"], json!("AJ+Slg=="));
1231
1232        fs::remove_dir_all(sessions_root).unwrap();
1233    }
1234
1235    #[test]
1236    fn session_resume_reuses_session_directory_and_continues_counters() {
1237        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1238        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1239
1240        session
1241            .log_api_request(&json!({ "messages": ["hello"] }))
1242            .unwrap();
1243        session.log_api_response(&json!({ "id": "msg_1" })).unwrap();
1244        session.create_tool_invocation_dirs("sidreq_first").unwrap();
1245        session.write_bash_state("export FOO=bar\n").unwrap();
1246
1247        let resumed = SidSession::resume_in(sessions_root.clone(), session.id()).unwrap();
1248        assert_eq!(resumed.root(), session.root());
1249        assert_eq!(
1250            resumed.read_bash_state().unwrap(),
1251            Some("export FOO=bar\n".to_string())
1252        );
1253
1254        resumed
1255            .log_api_request(&json!({ "messages": ["again"] }))
1256            .unwrap();
1257        resumed.log_api_response(&json!({ "id": "msg_2" })).unwrap();
1258        let next_tool = resumed
1259            .create_tool_invocation_dirs("sidreq_second")
1260            .unwrap();
1261        assert_eq!(next_tool.sequence, 2);
1262
1263        let events = fs::read_to_string(resumed.root().join(EVENTS_JOURNAL_FILE)).unwrap();
1264        let events = events
1265            .lines()
1266            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1267            .collect::<Vec<_>>();
1268        assert_eq!(
1269            events,
1270            vec![
1271                json!({
1272                    "seq": 1,
1273                    "kind": "session_start",
1274                    "session_id": session.id(),
1275                    "ts": events[0]["ts"].clone(),
1276                }),
1277                json!({
1278                    "seq": 2,
1279                    "kind": "session_resume",
1280                    "session_id": session.id(),
1281                    "ts": events[1]["ts"].clone(),
1282                }),
1283            ]
1284        );
1285
1286        let api = fs::read_to_string(resumed.root().join(API_JOURNAL_FILE)).unwrap();
1287        let api = api
1288            .lines()
1289            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1290            .collect::<Vec<_>>();
1291        assert_eq!(api[0]["api_seq"], json!(1));
1292        assert_eq!(api[1]["api_seq"], json!(1));
1293        assert_eq!(api[2]["api_seq"], json!(2));
1294        assert_eq!(api[3]["api_seq"], json!(2));
1295        assert_eq!(api[2]["seq"], json!(3));
1296        assert_eq!(api[3]["seq"], json!(4));
1297
1298        fs::remove_dir_all(sessions_root).unwrap();
1299    }
1300
1301    #[test]
1302    fn session_resume_advances_past_stale_tool_directories() {
1303        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1304        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1305
1306        let first = session.create_tool_invocation_dirs("sidreq_first").unwrap();
1307        assert_eq!(first.sequence, 1);
1308
1309        let resumed = SidSession::resume_in(sessions_root.clone(), session.id()).unwrap();
1310        let second = resumed
1311            .create_tool_invocation_dirs("sidreq_second")
1312            .unwrap();
1313        assert_eq!(second.sequence, 2);
1314
1315        fs::remove_dir_all(sessions_root).unwrap();
1316    }
1317
1318    #[test]
1319    fn clear_bash_state_removes_snapshot_file() {
1320        let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1321        let session = SidSession::create_in(sessions_root.clone()).unwrap();
1322
1323        session.write_bash_state("export FOO=bar\n").unwrap();
1324        assert_eq!(
1325            session.read_bash_state().unwrap(),
1326            Some("export FOO=bar\n".to_string())
1327        );
1328
1329        session.clear_bash_state().unwrap();
1330        assert_eq!(session.read_bash_state().unwrap(), None);
1331
1332        fs::remove_dir_all(sessions_root).unwrap();
1333    }
1334
1335    fn assert_timestamp_session_id(id: &str) {
1336        assert_eq!(id.len(), 31);
1337        assert_eq!(&id[4..5], "-");
1338        assert_eq!(&id[7..8], "-");
1339        assert_eq!(&id[10..11], "T");
1340        assert_eq!(&id[13..14], "-");
1341        assert_eq!(&id[16..17], "-");
1342        assert_eq!(&id[19..20], ".");
1343        assert!(matches!(&id[26..27], "+" | "-"));
1344        assert!(id[..4].chars().all(|ch| ch.is_ascii_digit()));
1345        assert!(id[27..31].chars().all(|ch| ch.is_ascii_digit()));
1346    }
1347
1348    fn assert_timestamp_created_at(value: &str) {
1349        assert_eq!(value.len(), 32);
1350        assert_eq!(&value[4..5], "-");
1351        assert_eq!(&value[7..8], "-");
1352        assert_eq!(&value[10..11], "T");
1353        assert_eq!(&value[13..14], ":");
1354        assert_eq!(&value[16..17], ":");
1355        assert_eq!(&value[19..20], ".");
1356        assert!(matches!(&value[26..27], "+" | "-"));
1357        assert_eq!(&value[29..30], ":");
1358    }
1359}