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