Skip to main content

harness/openai_compatible/
session.rs

1//! Session persistence — what makes a run *stateful* and `resume`-able.
2//!
3//! The model is OpenCode's (MIT): a session is a metadata **record** plus an
4//! ordered **transcript**, addressed by id under a host-provided data root. We
5//! diverge in granularity — one JSON file holds the whole transcript (the model
6//! only needs the message list replayed; the UI consumes the live `RunEvent`
7//! stream), rather than OpenCode's per-message/part files.
8//!
9//! Persistence is **opt-in**: a harness with no session dir runs ephemerally
10//! (no disk writes); one configured via `OpenHarness::with_session_dir`
11//! persists here and can resume by id. Layout under the root:
12//! `sessions/<id>.jsonl` — one file per session: a `session` header line, then
13//! one `message` line per turn, appended. Pi stores sessions this way; Codex
14//! and Claude Code likewise keep a session to one JSONL file.
15//!
16//! Two properties come from that shape. Appending is O(1) in the length of the
17//! conversation, where rewriting a whole-array file to record one exchange is
18//! O(n) — and a whole-array file is only meaningful complete, so a process
19//! killed mid-write lost the conversation rather than the turn. A torn append
20//! costs the final line. And with the metadata in the same file as the
21//! transcript, the two cannot disagree; as separate writes they could, if the
22//! process died between them.
23//!
24//! Listing reads only each file's first line, so it never parses transcripts.
25//! (Codex goes further with one global index — a single read for any number of
26//! sessions — which is the better answer at large session counts.)
27//!
28//! The earlier two-file layout (`sessions/<id>.json` + `messages/<id>.{jsonl,json}`)
29//! is still read, so sessions written by an older build resume.
30
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use serde::{Deserialize, Serialize};
36
37use super::wire::ChatMessage;
38
39/// A persisted session's metadata — cheap to list without loading the
40/// transcript, so a sessions view can render titles/models without reading
41/// every message file. Forward-compatible: new fields carry `#[serde(default)]`
42/// so an older on-disk record still deserializes.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SessionRecord {
45    pub id: String,
46    /// A short display title (derived from the opening prompt; host-renamable).
47    #[serde(default)]
48    pub title: Option<String>,
49    /// The model the session was started with.
50    #[serde(default)]
51    pub model: Option<String>,
52    /// The working directory the session ran in (for project grouping/display).
53    #[serde(default)]
54    pub cwd: Option<String>,
55    /// The parent session, set when this is a `task` subagent's child session
56    /// (`None` for a top-level session). Lets a host show a session tree.
57    #[serde(default)]
58    pub parent_id: Option<String>,
59    /// Epoch milliseconds at creation and at the last update.
60    pub created_at: u64,
61    pub updated_at: u64,
62}
63
64/// Current time in epoch milliseconds (saturates to 0 before the epoch, which
65/// can't happen in practice).
66pub(crate) fn now_millis() -> u64 {
67    SystemTime::now()
68        .duration_since(UNIX_EPOCH)
69        .map(|d| d.as_millis() as u64)
70        .unwrap_or(0)
71}
72
73/// Mint a new, time-ordered session id — `ses_<epoch_nanos>_<counter>`. Nanos
74/// give ordering + practical uniqueness; the process-local counter breaks ties
75/// within the same nanosecond. Dep-free (no `uuid`/`getrandom`).
76pub(crate) fn new_session_id() -> String {
77    static COUNTER: AtomicU64 = AtomicU64::new(0);
78    let nanos = SystemTime::now()
79        .duration_since(UNIX_EPOCH)
80        .map(|d| d.as_nanos())
81        .unwrap_or(0);
82    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
83    format!("ses_{nanos:x}_{n:x}")
84}
85
86/// A short session title from the opening prompt — its first non-empty line,
87/// capped at 60 chars.
88pub(crate) fn title_from_prompt(prompt: &str) -> String {
89    let first = prompt.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
90    let title: String = first.chars().take(60).collect();
91    if title.is_empty() {
92        "New session".to_owned()
93    } else {
94        title
95    }
96}
97
98/// A JSON-file session store rooted at a host-provided directory. Used only
99/// when the harness has a session dir configured.
100#[derive(Debug, Clone)]
101pub(crate) struct FileStore {
102    root: PathBuf,
103}
104
105impl FileStore {
106    pub(crate) fn new(root: impl Into<PathBuf>) -> Self {
107        Self { root: root.into() }
108    }
109
110    /// A session is one file: a header record, then one message per line.
111    fn log_path(&self, id: &str) -> PathBuf {
112        self.root.join("sessions").join(format!("{id}.jsonl"))
113    }
114
115    /// The two-file layout this replaced — a metadata `.json` beside a
116    /// transcript under `messages/`. Read so existing sessions still resume.
117    fn legacy_record_path(&self, id: &str) -> PathBuf {
118        self.root.join("sessions").join(format!("{id}.json"))
119    }
120    fn legacy_messages_paths(&self, id: &str) -> [PathBuf; 2] {
121        let dir = self.root.join("messages");
122        [dir.join(format!("{id}.jsonl")), dir.join(format!("{id}.json"))]
123    }
124
125    /// Write a session's header. Creates the file, or rewrites the header in
126    /// place when a host renames a session — rare enough that the rewrite does
127    /// not matter, unlike the per-turn append.
128    pub(crate) fn put_record(&self, record: &SessionRecord) -> Result<(), String> {
129        let messages = self.load_messages(&record.id)?;
130        self.write_log(&record.id, record, &messages)
131    }
132
133    /// Read a session's metadata: the header line, or the legacy file.
134    pub(crate) fn get_record(&self, id: &str) -> Result<Option<SessionRecord>, String> {
135        match read_header(&self.log_path(id)) {
136            Some(record) => Ok(Some(record)),
137            None => read_json_opt(&self.legacy_record_path(id)),
138        }
139    }
140
141    /// All known sessions, newest-updated first. An unreadable header is
142    /// skipped rather than failing the whole list — a half-written file
143    /// shouldn't hide every other session.
144    pub(crate) fn list_records(&self) -> Result<Vec<SessionRecord>, String> {
145        let dir = self.root.join("sessions");
146        let entries = match std::fs::read_dir(&dir) {
147            Ok(e) => e,
148            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
149            Err(e) => return Err(format!("listing sessions in {}: {e}", dir.display())),
150        };
151        let mut out: Vec<SessionRecord> = entries
152            .flatten()
153            .filter_map(|entry| {
154                let path = entry.path();
155                match path.extension().and_then(|x| x.to_str()) {
156                    // Only the header is read, so listing does not parse
157                    // transcripts however long they are.
158                    Some("jsonl") => read_header(&path),
159                    Some("json") => read_json_opt::<SessionRecord>(&path).ok().flatten(),
160                    _ => None,
161                }
162            })
163            .collect();
164        out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
165        Ok(out)
166    }
167
168    /// Bump a session's `updated_at`. No-op if the session is absent.
169    pub(crate) fn touch(&self, id: &str, updated_at: u64) -> Result<(), String> {
170        if let Some(mut record) = self.get_record(id)? {
171            record.updated_at = updated_at;
172            self.put_record(&record)?;
173        }
174        Ok(())
175    }
176
177    /// A session's transcript, from the header file or the legacy layout.
178    ///
179    /// A trailing partial line is dropped rather than failing the read: it is
180    /// the signature of a process killed mid-append, and the turns before it
181    /// are intact and worth keeping.
182    pub(crate) fn load_messages(&self, id: &str) -> Result<Vec<ChatMessage>, String> {
183        if let Some(text) = read_to_string_opt(&self.log_path(id))? {
184            return Ok(text.lines().filter_map(|line| parse_line(line).message()).collect());
185        }
186        let [jsonl, json] = self.legacy_messages_paths(id);
187        if let Some(text) = read_to_string_opt(&jsonl)? {
188            return Ok(text.lines().filter_map(|line| serde_json::from_str(line).ok()).collect());
189        }
190        Ok(read_json_opt(&json)?.unwrap_or_default())
191    }
192
193    /// Append the messages added since the last save — O(1) in the length of
194    /// the conversation, where rewriting the whole transcript to record one
195    /// exchange was O(n), and where a kill mid-append costs the final line
196    /// rather than a document that no longer parses.
197    pub(crate) fn append_messages(&self, id: &str, messages: &[ChatMessage]) -> Result<(), String> {
198        if messages.is_empty() {
199            return Ok(());
200        }
201        let path = self.log_path(id);
202        ensure_parent(&path)?;
203        let mut file = std::fs::OpenOptions::new()
204            .create(true)
205            .append(true)
206            .open(&path)
207            .map_err(|e| format!("opening {}: {e}", path.display()))?;
208        std::io::Write::write_all(&mut file, encode_messages(messages)?.as_bytes())
209            .map_err(|e| format!("appending to {}: {e}", path.display()))
210    }
211
212    /// Replace a session's transcript wholesale, keeping its header — for
213    /// compaction, which replaces old turns with a summary rather than
214    /// extending them.
215    pub(crate) fn replace_messages(&self, id: &str, messages: &[ChatMessage]) -> Result<(), String> {
216        let header = self.get_record(id)?;
217        match header {
218            Some(record) => self.write_log(id, &record, messages),
219            // No header yet (a subagent's transcript is written before one
220            // exists): the messages alone are still worth keeping.
221            None => {
222                let path = self.log_path(id);
223                ensure_parent(&path)?;
224                write_atomic(&path, &encode_messages(messages)?)
225            }
226        }
227    }
228
229    /// Rewrite a session file as header + messages.
230    fn write_log(&self, id: &str, record: &SessionRecord, messages: &[ChatMessage]) -> Result<(), String> {
231        let path = self.log_path(id);
232        ensure_parent(&path)?;
233        let header = serde_json::to_string(&LogLine::Session(record.clone()))
234            .map_err(|e| format!("serializing the header for {}: {e}", path.display()))?;
235        write_atomic(&path, &format!("{header}\n{}", encode_messages(messages)?))
236    }
237}
238
239/// One line of a session file, tagged so the header and the messages can share
240/// it. Pi's sessions are shaped this way; keeping metadata in the same file as
241/// the transcript is what makes it impossible for the two to disagree after a
242/// crash between two separate writes.
243#[derive(Serialize, Deserialize)]
244#[serde(tag = "type", rename_all = "snake_case")]
245enum LogLine {
246    Session(SessionRecord),
247    Message(ChatMessage),
248}
249
250impl LogLine {
251    fn message(self) -> Option<ChatMessage> {
252        match self {
253            Self::Message(message) => Some(message),
254            Self::Session(_) => None,
255        }
256    }
257}
258
259fn parse_line(line: &str) -> LogLine {
260    serde_json::from_str(line).unwrap_or(LogLine::Session(SessionRecord {
261        id: String::new(),
262        title: None,
263        model: None,
264        cwd: None,
265        parent_id: None,
266        created_at: 0,
267        updated_at: 0,
268    }))
269}
270
271/// The header of a session file, without reading past the first line.
272fn read_header(path: &Path) -> Option<SessionRecord> {
273    let file = std::fs::File::open(path).ok()?;
274    let mut first = String::new();
275    std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut first).ok()?;
276    match serde_json::from_str(&first).ok()? {
277        LogLine::Session(record) if !record.id.is_empty() => Some(record),
278        _ => None,
279    }
280}
281
282fn encode_messages(messages: &[ChatMessage]) -> Result<String, String> {
283    let mut out = String::new();
284    for message in messages {
285        let line = serde_json::to_string(&LogLine::Message(message.clone()))
286            .map_err(|e| format!("serializing a message: {e}"))?;
287        out.push_str(&line);
288        out.push('\n');
289    }
290    Ok(out)
291}
292
293fn ensure_parent(path: &Path) -> Result<(), String> {
294    if let Some(parent) = path.parent() {
295        std::fs::create_dir_all(parent).map_err(|e| format!("creating {}: {e}", parent.display()))?;
296    }
297    Ok(())
298}
299
300fn read_to_string_opt(path: &Path) -> Result<Option<String>, String> {
301    match std::fs::read_to_string(path) {
302        Ok(text) => Ok(Some(text)),
303        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
304        Err(e) => Err(format!("reading {}: {e}", path.display())),
305    }
306}
307
308/// Replace a file's contents atomically: sibling temp, then rename.
309fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
310    let temp = path.with_extension(format!("{}.tmp", std::process::id()));
311    std::fs::write(&temp, contents).map_err(|e| format!("writing {}: {e}", temp.display()))?;
312    std::fs::rename(&temp, path).map_err(|e| {
313        let _ = std::fs::remove_file(&temp);
314        format!("replacing {}: {e}", path.display())
315    })
316}
317
318fn read_json_opt<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, String> {
319    match std::fs::read_to_string(path) {
320        Ok(s) => serde_json::from_str(&s).map(Some).map_err(|e| format!("parsing {}: {e}", path.display())),
321        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
322        Err(e) => Err(format!("reading {}: {e}", path.display())),
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    fn scratch(tag: &str) -> PathBuf {
331        let dir = std::env::temp_dir().join(format!("hl-session-{tag}-{}", std::process::id()));
332        let _ = std::fs::remove_dir_all(&dir);
333        dir
334    }
335
336    #[test]
337    fn record_roundtrip_and_list_is_newest_first() {
338        let dir = scratch("rec");
339        let store = FileStore::new(&dir);
340        assert!(store.get_record("nope").unwrap().is_none());
341        assert!(store.list_records().unwrap().is_empty());
342
343        let a = SessionRecord { id: "a".into(), title: Some("first".into()), model: Some("m".into()), cwd: None, parent_id: None, created_at: 100, updated_at: 100 };
344        let b = SessionRecord { id: "b".into(), title: None, model: None, cwd: None, parent_id: None, created_at: 200, updated_at: 200 };
345        store.put_record(&a).unwrap();
346        store.put_record(&b).unwrap();
347        assert_eq!(store.get_record("a").unwrap().unwrap().title.as_deref(), Some("first"));
348        let ids: Vec<String> = store.list_records().unwrap().into_iter().map(|r| r.id).collect();
349        assert_eq!(ids, ["b", "a"], "newest updated_at first");
350        let _ = std::fs::remove_dir_all(&dir);
351    }
352
353    #[test]
354    fn the_header_and_the_transcript_live_in_one_file() {
355        // The reason for the layout: two files written separately can disagree
356        // if the process dies between them. One file makes that impossible,
357        // and listing still only reads the first line.
358        let dir = scratch("onefile");
359        let store = FileStore::new(&dir);
360        let record = SessionRecord {
361            id: "s1".to_owned(),
362            title: Some("a chat".to_owned()),
363            model: Some("m".to_owned()),
364            cwd: None,
365            parent_id: None,
366            created_at: 1,
367            updated_at: 2,
368        };
369        store.put_record(&record).unwrap();
370        store.append_messages("s1", &[ChatMessage::user("hello")]).unwrap();
371
372        assert!(dir.join("sessions").join("s1.jsonl").is_file());
373        assert!(!dir.join("messages").exists(), "no second file to fall out of step");
374
375        let listed = store.list_records().unwrap();
376        assert_eq!(listed.len(), 1);
377        assert_eq!(listed[0].title.as_deref(), Some("a chat"));
378        assert_eq!(store.load_messages("s1").unwrap().len(), 1, "the header is not a message");
379        let _ = std::fs::remove_dir_all(&dir);
380    }
381
382    #[test]
383    fn a_truncated_last_line_costs_only_that_turn() {
384        // The failure a full rewrite could not survive: a process killed
385        // mid-write. Appending makes the damage the final line, and the turns
386        // before it still load.
387        let dir = scratch("torn");
388        let store = FileStore::new(&dir);
389        store
390            .append_messages("s1", &[ChatMessage::user("first"), ChatMessage::user("second")])
391            .unwrap();
392
393        let path = dir.join("sessions").join("s1.jsonl");
394        let mut raw = std::fs::read_to_string(&path).unwrap();
395        raw.push_str("{\"role\":\"user\",\"cont"); // killed mid-append
396        std::fs::write(&path, raw).unwrap();
397
398        let loaded = store.load_messages("s1").unwrap();
399        assert_eq!(loaded.len(), 2, "the intact turns survive a torn tail");
400        assert_eq!(loaded[0].content.as_deref(), Some("first"));
401        let _ = std::fs::remove_dir_all(&dir);
402    }
403
404    #[test]
405    fn a_session_written_by_an_older_build_still_loads() {
406        // The two-file layout: a whole-array transcript under `messages/`.
407        // Dropping it would silently lose every conversation a user had.
408        let dir = scratch("legacy");
409        let store = FileStore::new(&dir);
410        let legacy = dir.join("messages").join("s1.json");
411        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
412        std::fs::write(&legacy, serde_json::to_string(&vec![ChatMessage::user("from before")]).unwrap())
413            .unwrap();
414
415        let loaded = store.load_messages("s1").unwrap();
416        assert_eq!(loaded.len(), 1);
417        assert_eq!(loaded[0].content.as_deref(), Some("from before"));
418        let _ = std::fs::remove_dir_all(&dir);
419    }
420
421    #[test]
422    fn appending_extends_rather_than_replacing() {
423        let dir = scratch("append");
424        let store = FileStore::new(&dir);
425        store.append_messages("s1", &[ChatMessage::user("one")]).unwrap();
426        store.append_messages("s1", &[ChatMessage::user("two")]).unwrap();
427        assert_eq!(store.load_messages("s1").unwrap().len(), 2);
428
429        // Compaction is the one caller that must shorten the log.
430        store.replace_messages("s1", &[ChatMessage::user("summary")]).unwrap();
431        let loaded = store.load_messages("s1").unwrap();
432        assert_eq!(loaded.len(), 1, "replace truncates, it does not extend");
433        assert_eq!(loaded[0].content.as_deref(), Some("summary"));
434        let _ = std::fs::remove_dir_all(&dir);
435    }
436
437    #[test]
438    fn messages_roundtrip_and_touch() {
439        let dir = scratch("msg");
440        let store = FileStore::new(&dir);
441        assert!(store.load_messages("s1").unwrap().is_empty());
442
443        let msgs = vec![ChatMessage::user("hi"), ChatMessage::tool_result("c1", "done")];
444        store.append_messages("s1", &msgs).unwrap();
445        let loaded = store.load_messages("s1").unwrap();
446        assert_eq!(loaded.len(), 2);
447        assert_eq!(loaded[0].content.as_deref(), Some("hi"));
448
449        // touch is a no-op without a record, then bumps updated_at once present.
450        store.touch("s1", 999).unwrap();
451        assert!(store.get_record("s1").unwrap().is_none());
452        store.put_record(&SessionRecord { id: "s1".into(), title: None, model: None, cwd: None, parent_id: None, created_at: 1, updated_at: 1 }).unwrap();
453        store.touch("s1", 999).unwrap();
454        assert_eq!(store.get_record("s1").unwrap().unwrap().updated_at, 999);
455        let _ = std::fs::remove_dir_all(&dir);
456    }
457
458    #[test]
459    fn an_unreadable_session_reads_as_an_error_and_not_as_an_absent_one() {
460        // "Missing" and "unreadable" must not collapse into one answer. A
461        // sessions directory that cannot be read presenting as "you have no
462        // sessions" is how a conversation disappears with nothing failing.
463        let dir = scratch("unreadable");
464        let store = FileStore::new(&dir);
465        std::fs::create_dir_all(&dir).unwrap();
466
467        // A file where the sessions directory should be.
468        std::fs::write(dir.join("sessions"), "not a directory").unwrap();
469        assert!(store.list_records().is_err(), "an unlistable directory is not an empty one");
470        std::fs::remove_file(dir.join("sessions")).unwrap();
471
472        // A directory where a transcript should be.
473        std::fs::create_dir_all(dir.join("sessions").join("s1.jsonl")).unwrap();
474        assert!(store.load_messages("s1").is_err(), "an unreadable transcript is not an empty one");
475
476        // A directory where a legacy record should be.
477        std::fs::create_dir_all(dir.join("sessions").join("s2.json")).unwrap();
478        assert!(store.get_record("s2").is_err(), "an unreadable record is not an absent one");
479        let _ = std::fs::remove_dir_all(&dir);
480    }
481
482    #[test]
483    fn a_record_written_by_an_older_build_still_loads_and_lists() {
484        // The legacy layout kept metadata in its own `sessions/<id>.json`.
485        // Reading only the transcript would resume the conversation while
486        // losing its title and model, so the session comes back nameless.
487        let dir = scratch("legacyrec");
488        let store = FileStore::new(&dir);
489        let path = dir.join("sessions").join("s1.json");
490        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
491        let record = SessionRecord {
492            id: "s1".to_owned(),
493            title: Some("an older chat".to_owned()),
494            model: Some("m".to_owned()),
495            cwd: None,
496            parent_id: None,
497            created_at: 5,
498            updated_at: 5,
499        };
500        std::fs::write(&path, serde_json::to_string(&record).unwrap()).unwrap();
501
502        assert_eq!(store.get_record("s1").unwrap().unwrap().title.as_deref(), Some("an older chat"));
503        let listed = store.list_records().unwrap();
504        assert_eq!(listed.len(), 1, "a legacy record lists alongside current ones");
505        assert_eq!(listed[0].id, "s1");
506        let _ = std::fs::remove_dir_all(&dir);
507    }
508
509    #[test]
510    fn a_header_without_an_id_is_not_a_session() {
511        // A first line that parses does not make it a header: an id is what a
512        // session is addressed by, so listing one puts an entry in a sessions
513        // view that cannot be opened.
514        let dir = scratch("noid");
515        let store = FileStore::new(&dir);
516        let path = dir.join("sessions").join("s1.jsonl");
517        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
518        std::fs::write(&path, "{\"type\":\"session\",\"id\":\"\",\"created_at\":0,\"updated_at\":0}\n").unwrap();
519
520        assert!(store.get_record("s1").unwrap().is_none());
521        assert!(store.list_records().unwrap().is_empty(), "an id-less header is skipped, not listed");
522        let _ = std::fs::remove_dir_all(&dir);
523    }
524
525    #[test]
526    fn session_timestamps_come_from_a_real_clock() {
527        // `updated_at` is what orders a sessions list. A constant clock leaves
528        // every session equal and the order arbitrary, which a round trip
529        // through the store cannot notice.
530        let now = now_millis();
531        assert!(now > 1_700_000_000_000, "epoch milliseconds, not seconds and not a constant: {now}");
532    }
533
534    #[test]
535    fn new_session_id_is_unique_and_prefixed() {
536        let a = new_session_id();
537        let b = new_session_id();
538        assert_ne!(a, b);
539        assert!(a.starts_with("ses_"));
540    }
541
542    #[test]
543    fn title_is_first_line_capped() {
544        assert_eq!(title_from_prompt("Fix the parser\nand tests"), "Fix the parser");
545        assert_eq!(title_from_prompt("  \n  hello  "), "hello");
546        assert_eq!(title_from_prompt(""), "New session");
547        assert_eq!(title_from_prompt(&"x".repeat(100)).chars().count(), 60);
548    }
549}