Skip to main content

agent_abstraction/
session.rs

1//! Binding a caller-owned session *name* to an agent's native session id.
2//!
3//! A consumer threads one stable name ("thread-42") across turns; this module
4//! keeps the mapping to whatever handle the agent actually understands, so the
5//! consumer never extracts or re-passes an id itself.
6//!
7//! Two agents let the caller **mint** the id ([`SessionSupport::Minted`]):
8//! Claude via `--session-id`, Copilot via the same flag in both directions. For
9//! those the binding is written *before* the process starts, so a run that
10//! crashes mid-turn still leaves a resumable session. Codex only **prints** its
11//! `thread_id`, so its binding can only be recorded after the run produced one.
12//!
13//! Layout is one JSON file per session, `<dir>/<project-slug>/<name>.json`,
14//! partitioned by project so the same name in two checkouts never collides.
15//! Writes go through a temp file and a rename, so a concurrent reader never sees
16//! a half-written record.
17
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::agent::{Agent, Continue, SessionSupport};
26use crate::error::{Error, Result};
27
28/// One named conversation.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[non_exhaustive]
31pub struct SessionRecord {
32    /// The caller's stable name, exactly as they supplied it. The on-disk
33    /// filename is an encoded form of this; the record keeps the original so a
34    /// listing hands back the name the caller actually used.
35    pub name: String,
36    /// The project this session belongs to.
37    pub project: String,
38    /// The agent that owns it. A session cannot migrate between agents.
39    pub agent: Agent,
40    /// The agent's native handle, which the next turn resumes with.
41    pub token: String,
42    /// Unix epoch seconds when the session was first created.
43    pub created: i64,
44    /// Unix epoch seconds of the most recent turn.
45    pub updated: i64,
46}
47
48/// Whether the next turn starts a conversation or continues one.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Phase {
52    /// No prior record: this turn opens the conversation.
53    Create,
54    /// A record exists: this turn appends to it.
55    Continue,
56    /// A record exists and this turn branches off it, leaving it untouched.
57    Fork,
58}
59
60/// The store of named sessions.
61#[derive(Debug, Clone)]
62pub struct SessionStore {
63    dir: PathBuf,
64}
65
66/// An exclusive lease on one named session.
67///
68/// The file remains on disk after release, but the OS lock does not: closing
69/// the handle, including when a process is killed, releases it. Keeping the
70/// inert file avoids an unlink race where a new opener could lock a different
71/// inode while the prior holder still owns the old one.
72pub(crate) struct SessionLease {
73    file: fs::File,
74}
75
76impl Drop for SessionLease {
77    fn drop(&mut self) {
78        let _ = fs2::FileExt::unlock(&self.file);
79    }
80}
81
82impl SessionStore {
83    /// A store rooted at `dir`. The directory is created lazily on first write.
84    pub fn open(dir: impl Into<PathBuf>) -> Self {
85        Self { dir: dir.into() }
86    }
87
88    /// The default per-user location: `$XDG_STATE_HOME/agent-abstraction/sessions`
89    /// (falling back to `~/.local/state`), or `%LOCALAPPDATA%` on Windows.
90    /// `None` when neither the platform state dir nor `$HOME` can be resolved.
91    #[must_use]
92    pub fn default_dir() -> Option<PathBuf> {
93        let base = if cfg!(windows) {
94            std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
95        } else {
96            std::env::var_os("XDG_STATE_HOME")
97                .map(PathBuf::from)
98                .or_else(|| {
99                    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("state"))
100                })
101        };
102        Some(base?.join("agent-abstraction").join("sessions"))
103    }
104
105    /// The file backing `name` for `project`. Pure path arithmetic.
106    ///
107    /// The agent is **not** part of the key on purpose: one name must resolve to
108    /// one file across agents, so asking for a Claude session under a name
109    /// Codex already owns is a loud [`Error::SessionConflict`] rather than two
110    /// unrelated conversations quietly sharing a name.
111    #[must_use]
112    pub fn path_of(&self, project: &Path, name: &str) -> PathBuf {
113        self.dir
114            .join(project_slug(project))
115            .join(format!("{}.json", encode_segment(name)))
116    }
117
118    /// Claim one named session until the returned guard is dropped.
119    ///
120    /// Advisory file locks are cross-process and are released by the OS when a
121    /// holder dies, so a crashed agent host cannot strand a stale lease. The
122    /// lock is non-blocking: a queue or server can report contention and decide
123    /// its own retry policy instead of tying up a worker indefinitely.
124    pub(crate) fn lease(&self, project: &Path, name: &str) -> Result<SessionLease> {
125        let path = self.path_of(project, name).with_extension("lock");
126        let store_err = |source| Error::Store {
127            path: path.display().to_string(),
128            source,
129        };
130        if let Some(parent) = path.parent() {
131            fs::create_dir_all(parent).map_err(store_err)?;
132            restrict_to_owner(parent).map_err(store_err)?;
133        }
134        let file = fs::OpenOptions::new()
135            .create(true)
136            .truncate(false)
137            .read(true)
138            .write(true)
139            .open(&path)
140            .map_err(store_err)?;
141        match fs2::FileExt::try_lock_exclusive(&file) {
142            Ok(()) => Ok(SessionLease { file }),
143            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
144                Err(Error::SessionBusy {
145                    name: name.to_string(),
146                    project: project.display().to_string(),
147                })
148            }
149            Err(error) => Err(store_err(error)),
150        }
151    }
152
153    /// The stored record, or `None` when there is none.
154    ///
155    /// Only a genuinely absent file is `Ok(None)`. A permission error, an I/O
156    /// failure or a corrupt record is an [`Error::Store`], because treating
157    /// those as "no session" silently starts a new conversation and abandons
158    /// one the caller believes they are still in.
159    ///
160    /// # Errors
161    /// [`Error::Store`] if the record exists but cannot be read or parsed.
162    pub fn get(&self, project: &Path, name: &str) -> Result<Option<SessionRecord>> {
163        let path = self.path_of(project, name);
164        let text = match fs::read_to_string(&path) {
165            Ok(text) => text,
166            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
167            Err(source) => {
168                return Err(Error::Store {
169                    path: path.display().to_string(),
170                    source,
171                });
172            }
173        };
174        serde_json::from_str(&text)
175            .map(Some)
176            .map_err(|e| Error::Store {
177                path: path.display().to_string(),
178                source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
179            })
180    }
181
182    /// Every session recorded for `project`, in unspecified order.
183    ///
184    /// A record that cannot be read or parsed is an error rather than an
185    /// omission: silently returning a short list makes a corrupt store look
186    /// like a store with fewer sessions. Use [`SessionStore::list_lossy`] when
187    /// skipping bad records is genuinely what you want.
188    ///
189    /// # Errors
190    /// [`Error::Store`] if the directory or any record within it is unreadable.
191    pub fn list(&self, project: &Path) -> Result<Vec<SessionRecord>> {
192        let dir = self.dir.join(project_slug(project));
193        let store_err = |path: &Path, source| Error::Store {
194            path: path.display().to_string(),
195            source,
196        };
197        let entries = match fs::read_dir(&dir) {
198            Ok(entries) => entries,
199            // No directory means no sessions, which is not a fault.
200            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
201            Err(e) => return Err(store_err(&dir, e)),
202        };
203        let mut out = Vec::new();
204        for entry in entries {
205            let path = entry.map_err(|e| store_err(&dir, e))?.path();
206            // Only records belong in the result. Temp files can be present
207            // during an atomic write and `.lock` files persist so every process
208            // coordinates on the same inode.
209            if path.extension().is_none_or(|ext| ext != "json") {
210                continue;
211            }
212            let text = fs::read_to_string(&path).map_err(|e| store_err(&path, e))?;
213            out.push(serde_json::from_str(&text).map_err(|e| {
214                store_err(
215                    &path,
216                    std::io::Error::new(std::io::ErrorKind::InvalidData, e),
217                )
218            })?);
219        }
220        Ok(out)
221    }
222
223    /// Every readable session for `project`, skipping any that are not.
224    ///
225    /// The deliberately lossy counterpart to [`SessionStore::list`], for a UI
226    /// that would rather show the sessions it can than fail the whole listing.
227    #[must_use]
228    pub fn list_lossy(&self, project: &Path) -> Vec<SessionRecord> {
229        let dir = self.dir.join(project_slug(project));
230        let Ok(entries) = fs::read_dir(dir) else {
231            return Vec::new();
232        };
233        entries
234            .flatten()
235            .filter_map(|e| fs::read_to_string(e.path()).ok())
236            .filter_map(|text| serde_json::from_str(&text).ok())
237            .collect()
238    }
239
240    /// Decide how `name` continues, and produce the continuation to run with.
241    ///
242    /// For a minting agent with no prior record this allocates the id here and
243    /// now, so the caller can persist the binding before spawning.
244    ///
245    /// Crate-internal: it returns `Continue`, which is machinery rather than
246    /// API. Callers reach this through [`crate::Request::session`], and can see
247    /// the decision it made via [`crate::Request::session_phase`].
248    ///
249    /// # Errors
250    /// [`Error::SessionConflict`] when the name already belongs to another
251    /// agent; [`Error::Unsupported`] when `fork` is asked of an agent that
252    /// cannot fork, or when the agent exposes no session id at all.
253    pub(crate) fn plan(
254        &self,
255        agent: Agent,
256        project: &Path,
257        name: &str,
258        fork: bool,
259    ) -> Result<(Phase, Continue)> {
260        let caps = agent.caps();
261        if caps.session == SessionSupport::None {
262            return Err(Error::Unsupported {
263                agent,
264                what: "named sessions (it exposes no session id headlessly)",
265            });
266        }
267        let existing = self.get(project, name)?;
268        if let Some(record) = &existing {
269            if record.agent != agent {
270                return Err(Error::SessionConflict {
271                    name: name.to_string(),
272                    bound: record.agent,
273                    requested: agent,
274                });
275            }
276        }
277
278        Ok(match (existing, fork) {
279            (Some(record), true) => {
280                if !caps.fork {
281                    return Err(Error::Unsupported {
282                        agent,
283                        what: "forking a session headlessly",
284                    });
285                }
286                (Phase::Fork, Continue::Fork(record.token))
287            }
288            (Some(record), false) => (Phase::Continue, Continue::Resume(record.token)),
289            // Forking a conversation that does not exist yet is just starting
290            // one; there is nothing to branch from.
291            (None, _) => (
292                Phase::Create,
293                match caps.session {
294                    SessionSupport::Minted => Continue::NewWith(Uuid::new_v4().to_string()),
295                    // The id only exists once the agent prints it.
296                    SessionSupport::Printed | SessionSupport::None => Continue::New,
297                },
298            ),
299        })
300    }
301
302    /// Record `token` as the handle for `name`, preserving the original
303    /// creation time when the session already existed.
304    ///
305    /// # Errors
306    /// [`Error::Store`] if the record cannot be written.
307    pub fn bind(
308        &self,
309        agent: Agent,
310        project: &Path,
311        name: &str,
312        token: &str,
313    ) -> Result<SessionRecord> {
314        // The same invariant `plan` enforces, applied here too: `bind` is
315        // public, so the check cannot live only on the path that happens to
316        // call it first.
317        if let Some(existing) = self.get(project, name)?
318            && existing.agent != agent
319        {
320            return Err(Error::SessionConflict {
321                name: name.to_string(),
322                bound: existing.agent,
323                requested: agent,
324            });
325        }
326
327        let now = now_secs();
328        let record = SessionRecord {
329            name: name.to_string(),
330            project: project.display().to_string(),
331            agent,
332            token: token.to_string(),
333            created: self.get(project, name)?.map_or(now, |r| r.created),
334            updated: now,
335        };
336
337        let path = self.path_of(project, name);
338        let store_err = |source| Error::Store {
339            path: path.display().to_string(),
340            source,
341        };
342        if let Some(parent) = path.parent() {
343            fs::create_dir_all(parent).map_err(store_err)?;
344            restrict_to_owner(parent).map_err(store_err)?;
345        }
346        let mut text = serde_json::to_string_pretty(&record)
347            .map_err(|e| store_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
348        text.push('\n');
349
350        // Write beside the target and rename, so a reader never observes a
351        // partial record. The temp name carries the pid and a counter: a single
352        // shared `<name>.json.tmp` would let two concurrent writers for the same
353        // session scribble over each other's half-written file and then rename
354        // the result into place.
355        let tmp = path.with_extension(format!("{}.{}.tmp", std::process::id(), next_temp_id()));
356        write_private(&tmp, text.as_bytes()).map_err(store_err)?;
357        // Rename is atomic within a directory, so the last writer wins cleanly
358        // rather than producing a torn record.
359        fs::rename(&tmp, &path).map_err(|e| {
360            // Do not leave the temp file behind if the rename failed.
361            let _ = fs::remove_file(&tmp);
362            store_err(e)
363        })?;
364        // Syncing the file persists its contents, not the directory entry that
365        // names it. Without this a crash can leave the rename unrecorded and
366        // the session lost, which is the failure this store exists to avoid.
367        if let Some(parent) = path.parent() {
368            sync_dir(parent).map_err(store_err)?;
369        }
370        Ok(record)
371    }
372
373    /// Drop the binding for `name`. Removing an absent session is not an error.
374    ///
375    /// # Errors
376    /// [`Error::Store`] if an existing record cannot be removed.
377    pub fn forget(&self, project: &Path, name: &str) -> Result<()> {
378        let path = self.path_of(project, name);
379        match fs::remove_file(&path) {
380            Ok(()) => Ok(()),
381            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
382            Err(source) => Err(Error::Store {
383                path: path.display().to_string(),
384                source,
385            }),
386        }
387    }
388}
389
390/// A per-process counter making each temp filename unique, so concurrent writes
391/// to one session cannot share a scratch file.
392fn next_temp_id() -> u64 {
393    use std::sync::atomic::{AtomicU64, Ordering};
394    static COUNTER: AtomicU64 = AtomicU64::new(0);
395    COUNTER.fetch_add(1, Ordering::Relaxed)
396}
397
398/// Write `bytes` to a newly created file that only the owner can read.
399///
400/// Session tokens resume conversations, so they are closer to a credential than
401/// to a cache entry and should not be readable by other users on the machine.
402/// Permissions are set at creation rather than afterwards, leaving no window
403/// where the file exists world-readable.
404fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
405    use std::io::Write as _;
406
407    let mut options = fs::OpenOptions::new();
408    options.write(true).create_new(true);
409    #[cfg(unix)]
410    {
411        use std::os::unix::fs::OpenOptionsExt as _;
412        options.mode(0o600);
413    }
414    let mut file = options.open(path)?;
415    file.write_all(bytes)?;
416    // Flush to disk before the rename, so a crash cannot leave an empty record
417    // where a valid one is expected.
418    file.sync_all()
419}
420
421/// Flush a directory entry to disk. A no-op where directories cannot be opened
422/// for syncing, which is the case on Windows.
423fn sync_dir(dir: &Path) -> std::io::Result<()> {
424    #[cfg(unix)]
425    {
426        fs::File::open(dir)?.sync_all()?;
427    }
428    #[cfg(not(unix))]
429    let _ = dir;
430    Ok(())
431}
432
433/// Restrict a directory to its owner. A no-op on platforms without Unix modes,
434/// where the parent directory's inherited ACL governs instead.
435fn restrict_to_owner(dir: &Path) -> std::io::Result<()> {
436    #[cfg(unix)]
437    {
438        use std::os::unix::fs::PermissionsExt as _;
439        fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
440    }
441    #[cfg(not(unix))]
442    let _ = dir;
443    Ok(())
444}
445
446/// Seconds since the epoch. A pre-1970 clock reads as 0 rather than panicking;
447/// these timestamps are for display, not for correctness.
448fn now_secs() -> i64 {
449    SystemTime::now()
450        .duration_since(UNIX_EPOCH)
451        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
452}
453
454/// The longest encoded stem written before truncation applies. Filenames cap
455/// near 255 bytes on every filesystem this targets, leaving room for the
456/// disambiguating suffix and the extension.
457const MAX_STEM: usize = 200;
458
459/// Encode an arbitrary name as one filesystem-safe path segment, injectively.
460///
461/// Percent-encodes every byte outside `[a-z0-9._-]`, which keeps the mapping
462/// reversible and, more importantly, **collision-free**. A lossy scheme that
463/// folded unsafe characters to `-` would map `café` and `cafe-` onto one file,
464/// and the second session to use that name would silently resume the first
465/// one's conversation.
466///
467/// Uppercase letters are encoded rather than lowercased because macOS and
468/// Windows are case-insensitive: leaving them intact would let `Chat` and `chat`
469/// collide on exactly the platforms this crate targets. `%` is itself always
470/// encoded, so an escape marker is unambiguous and no literal character can be
471/// mistaken for one.
472///
473/// The tradeoff this makes, deliberately: an ASCII name stays readable on disk
474/// (`greet-flow.json`), while a non-ASCII one becomes verbose, since every byte
475/// outside the safe set costs three characters and a multi-byte character
476/// several of those (`日本語` encodes to 27). Readability is a debugging
477/// convenience; a collision resumes the wrong conversation. So the scheme keeps
478/// names distinguishable first and legible second, and a caller who wants
479/// pretty filenames should choose ASCII names.
480///
481/// Names too long to encode whole are truncated and disambiguated with a 64-bit
482/// FNV-1a of the full input. Note the weaker guarantee there: encoding is
483/// injective, but any fixed-width digest of unbounded input cannot be, so two
484/// names sharing a 200-character encoded prefix *and* a hash would collide.
485/// That needs on the order of 2^32 such names to become likely, which is not a
486/// concern for names a host chooses. It is not a cryptographic guarantee: if
487/// session names are attacker-controlled, hash them yourself before passing
488/// them here.
489fn encode_segment(name: &str) -> String {
490    use std::fmt::Write as _;
491
492    let mut out = String::with_capacity(name.len());
493    for byte in name.bytes() {
494        if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
495        {
496            out.push(byte as char);
497        } else {
498            // Uppercase hex only, so an escape never varies by case either.
499            let _ = write!(out, "%{byte:02X}");
500        }
501    }
502    if out.is_empty() {
503        // Only the empty name reaches here, and it needs a segment no other
504        // input can produce. A bare `%` qualifies: every literal `%` escapes to
505        // `%25`, so no non-empty name ever encodes to it. Mapping empty to a
506        // word like "unnamed" would collide with the literal name `unnamed`.
507        return "%".into();
508    }
509    if out.len() > MAX_STEM {
510        // Cut between encoded units so a half-written `%4` is never emitted.
511        let mut cut = MAX_STEM;
512        while cut > 0 && !is_encoding_boundary(&out, cut) {
513            cut -= 1;
514        }
515        return format!("{}-{:016x}", &out[..cut], fnv1a(name.as_bytes()));
516    }
517    out
518}
519
520/// Whether `at` splits `s` between encoded units rather than inside a `%XX`.
521fn is_encoding_boundary(s: &str, at: usize) -> bool {
522    let b = s.as_bytes();
523    !((at >= 1 && b[at - 1] == b'%') || (at >= 2 && b[at - 2] == b'%'))
524}
525
526/// FNV-1a, 64-bit. Chosen over [`std::hash::DefaultHasher`], whose algorithm is
527/// explicitly allowed to change between Rust releases: that would silently
528/// repoint every stored session on a toolchain upgrade. This is fixed forever.
529/// It is not cryptographic and does not need to be, since it only disambiguates
530/// names the caller chose.
531fn fnv1a(bytes: &[u8]) -> u64 {
532    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
533    for byte in bytes {
534        hash ^= u64::from(*byte);
535        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
536    }
537    hash
538}
539
540/// A project path reduced to one path segment, so sessions partition by project
541/// without nesting the whole absolute path.
542fn project_slug(project: &Path) -> String {
543    encode_segment(&project.display().to_string())
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    /// A store in a unique temp directory, plus the project path to use.
551    fn store(tag: &str) -> (SessionStore, PathBuf) {
552        let dir = std::env::temp_dir().join(format!(
553            "agent-abstraction-{tag}-{}-{}",
554            std::process::id(),
555            now_secs()
556        ));
557        (SessionStore::open(dir), PathBuf::from("/home/me/proj"))
558    }
559
560    #[test]
561    fn names_and_projects_reduce_to_one_safe_segment() {
562        // Readable names pass through untouched, which is the point of encoding
563        // only what has to be encoded.
564        assert_eq!(encode_segment("greet-flow"), "greet-flow");
565        assert_eq!(encode_segment("v1.2_final"), "v1.2_final");
566        // The empty name gets a segment no other input can produce. Mapping it
567        // to a word would collide with a caller who literally used that word.
568        assert_eq!(encode_segment(""), "%");
569        assert_ne!(encode_segment(""), encode_segment("unnamed"));
570
571        // `.` stays literal for readability, so traversal safety rests entirely
572        // on the separator being encoded. A `..` embedded in a segment is inert.
573        for name in ["../../etc/passwd", "..", ".", "a/b", "a\\b"] {
574            let encoded = encode_segment(name);
575            assert!(!encoded.contains('/'), "{name:?} kept a separator");
576            assert!(!encoded.contains('\\'), "{name:?} kept a separator");
577            assert!(
578                Path::new(&encoded).components().count() == 1,
579                "{name:?} encoded to more than one component"
580            );
581        }
582        assert!(!project_slug(Path::new("/home/me/My Proj")).contains('/'));
583    }
584
585    /// The property that matters: distinct names never share a file. The old
586    /// fold-to-dash scheme mapped `café` and `cafe-` together, so the second
587    /// session to use that name silently resumed the first one's conversation.
588    #[test]
589    fn distinct_names_never_share_an_encoded_segment() {
590        let names = [
591            "café",
592            "cafe-",
593            "cafe",
594            "Chat",
595            "chat",
596            "CHAT",
597            "a/b",
598            "a-b",
599            "a b",
600            "..",
601            "%41",
602            "A",
603            "",
604            "unnamed",
605            "日本語",
606            "🙂",
607        ];
608        let mut seen = std::collections::HashMap::new();
609        for name in names {
610            // Compare case-insensitively: macOS and Windows would treat two
611            // segments differing only by case as the same file.
612            let key = encode_segment(name).to_ascii_lowercase();
613            if let Some(previous) = seen.insert(key.clone(), name) {
614                panic!("{name:?} and {previous:?} both encode to {key:?}");
615            }
616        }
617    }
618
619    #[test]
620    fn a_very_long_name_stays_within_filename_limits_and_stays_unique() {
621        let a = "x".repeat(5_000);
622        let b = format!("{a}different");
623        let (ea, eb) = (encode_segment(&a), encode_segment(&b));
624
625        // Room for the `.json` extension under a 255-byte filename cap.
626        assert!(ea.len() < 250, "{}", ea.len());
627        assert!(eb.len() < 250);
628        assert_ne!(ea, eb, "truncation must not collapse distinct names");
629    }
630
631    #[test]
632    fn truncation_never_splits_an_escape_sequence() {
633        // All-uppercase encodes to three bytes per character, forcing the cut.
634        let encoded = encode_segment(&"A".repeat(2_000));
635        let stem = encoded.rsplit_once('-').unwrap().0;
636        // Every `%` in the stem must still be followed by two hex digits.
637        for (i, _) in stem.match_indices('%') {
638            assert!(i + 2 < stem.len(), "escape split at {i} in {stem:?}");
639        }
640    }
641
642    /// The record keeps the caller's name verbatim, so a listing can hand back
643    /// what they actually passed rather than a mangled path segment.
644    #[test]
645    fn the_record_preserves_the_original_name() {
646        let (store, project) = store("original-name");
647        store
648            .bind(Agent::Claude, &project, "Greet Flow ☕", "t-1")
649            .unwrap();
650        let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap();
651        assert_eq!(record.name, "Greet Flow ☕");
652        assert_eq!(store.list(&project).unwrap()[0].name, "Greet Flow ☕");
653        fs::remove_dir_all(&store.dir).ok();
654    }
655
656    #[test]
657    fn a_path_traversing_name_cannot_escape_the_store() {
658        let (store, project) = store("escape");
659        for name in ["../../etc/passwd", "..", "/etc/passwd", "a/../../b"] {
660            let path = store.path_of(&project, name);
661            assert!(path.starts_with(&store.dir), "{name:?} escaped to {path:?}");
662            // The whole name has to land in exactly one filename, so no part of
663            // it can be reinterpreted as a directory step.
664            assert_eq!(
665                path.strip_prefix(&store.dir).unwrap().components().count(),
666                2,
667                "{name:?} produced extra path components: {path:?}"
668            );
669        }
670    }
671
672    #[test]
673    fn a_missing_session_plans_a_create() {
674        let (store, project) = store("create");
675        let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
676        assert_eq!(phase, Phase::Create);
677        // Claude mints, so the id exists before the process does.
678        let Continue::NewWith(id) = cont else {
679            panic!("a minting agent must allocate an id up front, got {cont:?}")
680        };
681        assert!(Uuid::parse_str(&id).is_ok(), "{id} must be a UUID");
682    }
683
684    #[test]
685    fn a_printing_agent_starts_without_an_id() {
686        let (store, project) = store("printed");
687        let (phase, cont) = store.plan(Agent::Codex, &project, "chat", false).unwrap();
688        assert_eq!(phase, Phase::Create);
689        assert_eq!(cont, Continue::New, "codex's id only exists once printed");
690    }
691
692    #[test]
693    fn a_bound_session_plans_a_continue_and_survives_a_round_trip() {
694        let (store, project) = store("continue");
695        store
696            .bind(Agent::Claude, &project, "chat", "sess-1")
697            .unwrap();
698
699        let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
700        assert_eq!(phase, Phase::Continue);
701        assert_eq!(cont, Continue::Resume("sess-1".into()));
702
703        let record = store.get(&project, "chat").unwrap().unwrap();
704        assert_eq!(record.token, "sess-1");
705        assert_eq!(record.agent, Agent::Claude);
706        fs::remove_dir_all(&store.dir).ok();
707    }
708
709    #[test]
710    fn rebinding_refreshes_the_token_but_keeps_the_creation_time() {
711        let (store, project) = store("rebind");
712        let first = store
713            .bind(Agent::Claude, &project, "chat", "sess-1")
714            .unwrap();
715        let second = store
716            .bind(Agent::Claude, &project, "chat", "sess-2")
717            .unwrap();
718        assert_eq!(second.token, "sess-2");
719        assert_eq!(second.created, first.created);
720        assert!(second.updated >= first.updated);
721        fs::remove_dir_all(&store.dir).ok();
722    }
723
724    #[test]
725    fn a_session_cannot_migrate_between_agents() {
726        let (store, project) = store("conflict");
727        store
728            .bind(Agent::Claude, &project, "chat", "sess-1")
729            .unwrap();
730        let err = store
731            .plan(Agent::Codex, &project, "chat", false)
732            .unwrap_err();
733        assert!(
734            matches!(err, Error::SessionConflict { bound, requested, .. }
735                if bound == Agent::Claude && requested == Agent::Codex),
736            "got {err:?}"
737        );
738        fs::remove_dir_all(&store.dir).ok();
739    }
740
741    #[test]
742    fn forking_is_refused_by_agents_that_cannot_fork() {
743        let (store, project) = store("fork");
744        store.bind(Agent::Codex, &project, "chat", "t-1").unwrap();
745        assert!(matches!(
746            store.plan(Agent::Codex, &project, "chat", true),
747            Err(Error::Unsupported { .. })
748        ));
749
750        store.bind(Agent::Claude, &project, "c2", "sess-1").unwrap();
751        let (phase, cont) = store.plan(Agent::Claude, &project, "c2", true).unwrap();
752        assert_eq!(phase, Phase::Fork);
753        assert_eq!(cont, Continue::Fork("sess-1".into()));
754        fs::remove_dir_all(&store.dir).ok();
755    }
756
757    #[test]
758    fn forking_a_session_that_does_not_exist_yet_just_creates_one() {
759        let (store, project) = store("fork-new");
760        let (phase, _) = store.plan(Agent::Claude, &project, "fresh", true).unwrap();
761        assert_eq!(phase, Phase::Create, "nothing to branch from yet");
762    }
763
764    #[test]
765    fn a_corrupt_record_is_reported_rather_than_silently_ignored() {
766        let (store, project) = store("corrupt");
767        let path = store.path_of(&project, "chat");
768        fs::create_dir_all(path.parent().unwrap()).unwrap();
769        fs::write(&path, b"{ not json").unwrap();
770        // Treating this as "no session" would silently abandon a conversation
771        // the caller believes they are still in.
772        assert!(matches!(
773            store.get(&project, "chat"),
774            Err(Error::Store { .. })
775        ));
776        assert!(matches!(
777            store.plan(Agent::Claude, &project, "chat", false),
778            Err(Error::Store { .. })
779        ));
780        fs::remove_dir_all(&store.dir).ok();
781    }
782
783    #[test]
784    fn sessions_list_per_project_and_forgetting_is_idempotent() {
785        let (store, project) = store("list");
786        store.bind(Agent::Claude, &project, "a", "t-a").unwrap();
787        store.bind(Agent::Claude, &project, "b", "t-b").unwrap();
788        let mut names: Vec<_> = store
789            .list(&project)
790            .unwrap()
791            .into_iter()
792            .map(|r| r.name)
793            .collect();
794        names.sort();
795        assert_eq!(names, ["a", "b"]);
796
797        store.forget(&project, "a").unwrap();
798        assert!(store.get(&project, "a").unwrap().is_none());
799        // Forgetting twice is not an error.
800        store.forget(&project, "a").unwrap();
801        assert_eq!(store.list(&project).unwrap().len(), 1);
802        fs::remove_dir_all(&store.dir).ok();
803    }
804
805    #[test]
806    fn the_same_name_in_two_projects_does_not_collide() {
807        let (store, project) = store("projects");
808        let other = PathBuf::from("/home/me/other");
809        store.bind(Agent::Claude, &project, "chat", "t-1").unwrap();
810        store.bind(Agent::Claude, &other, "chat", "t-2").unwrap();
811        assert_eq!(store.get(&project, "chat").unwrap().unwrap().token, "t-1");
812        assert_eq!(store.get(&other, "chat").unwrap().unwrap().token, "t-2");
813        fs::remove_dir_all(&store.dir).ok();
814    }
815}