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