summaryrefslogtreecommitdiff
path: root/libpanto/src/file_system_jsonl_store.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/file_system_jsonl_store.zig')
-rw-r--r--libpanto/src/file_system_jsonl_store.zig2075
1 files changed, 2075 insertions, 0 deletions
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig
new file mode 100644
index 0000000..10b65b3
--- /dev/null
+++ b/libpanto/src/file_system_jsonl_store.zig
@@ -0,0 +1,2075 @@
+//! Session lifecycle: create, open, replay, append.
+//!
+//! Backed by an append-only JSONL file on disk. The on-disk types live in
+//! `session.zig`. This module owns:
+//!
+//! - Path resolution (sessions dir is supplied by the caller; we own the
+//! filename and writes within it).
+//! - The in-memory entry index (`by_id` map + leaf pointer).
+//! - Deferred file creation: the file is not written until the first
+//! assistant message persists. Until that point, all entries are
+//! buffered in memory.
+//! - Append semantics: once flushed, every completed entry is written
+//! and synced to disk immediately.
+//! - Crash recovery: on open, the file is parsed line-by-line; the first
+//! line that fails to parse causes everything from that line onward to
+//! be truncated from the file.
+//! - One-time format migration when a future version reads a v1 file
+//! (currently a no-op; the hook is in place).
+//! - Rebuilding a `Conversation` from the entry tree, plus determining
+//! the active provider/model.
+//!
+//! The library-vs-CLI boundary: callers pass an absolute path to the
+//! per-cwd sessions directory. We compute the per-session filename
+//! ourselves (`<uuidv7>.jsonl`) and lazily mkdir the directory on the
+//! first flush. The CLI owns XDG resolution, encoded-cwd grouping, and
+//! the `--resume` flag plumbing.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+
+const session_mod = @import("session.zig");
+const conversation_mod = @import("conversation.zig");
+const session_store_mod = @import("session_store.zig");
+
+pub const SessionHeader = session_mod.SessionHeader;
+pub const SessionEntry = session_mod.SessionEntry;
+pub const MessageEntry = session_mod.MessageEntry;
+pub const StoredMessage = session_mod.StoredMessage;
+pub const StoredMessageRole = session_mod.StoredMessageRole;
+pub const StoredSystemMode = session_mod.StoredSystemMode;
+pub const StoredContentBlock = session_mod.StoredContentBlock;
+pub const Usage = session_mod.Usage;
+pub const CURRENT_VERSION = session_mod.CURRENT_VERSION;
+
+// =============================================================================
+// IDs and timestamps
+// =============================================================================
+
+/// Generate a UUIDv7 (RFC 9562 §5.7). Returns a 36-character canonical
+/// hex string with hyphens. Caller owns.
+///
+/// Layout:
+/// - 48 bits: unix_ts_ms (big-endian)
+/// - 4 bits: version (7)
+/// - 12 bits: random
+/// - 2 bits: variant (10)
+/// - 62 bits: random
+pub fn newUuidV7(allocator: Allocator, io: Io) ![]u8 {
+ const ts = Io.Timestamp.now(io, .real);
+ const now_ms: u64 = @intCast(@max(ts.toMilliseconds(), 0));
+
+ var rand_bytes: [10]u8 = undefined;
+ io.random(&rand_bytes);
+
+ var b: [16]u8 = undefined;
+ // Timestamp (48 bits, big-endian).
+ b[0] = @intCast((now_ms >> 40) & 0xFF);
+ b[1] = @intCast((now_ms >> 32) & 0xFF);
+ b[2] = @intCast((now_ms >> 24) & 0xFF);
+ b[3] = @intCast((now_ms >> 16) & 0xFF);
+ b[4] = @intCast((now_ms >> 8) & 0xFF);
+ b[5] = @intCast(now_ms & 0xFF);
+ // Version (4 high bits = 0x7) + 12 bits random.
+ b[6] = 0x70 | (rand_bytes[0] & 0x0F);
+ b[7] = rand_bytes[1];
+ // Variant (2 high bits = 10) + 62 bits random.
+ b[8] = 0x80 | (rand_bytes[2] & 0x3F);
+ b[9] = rand_bytes[3];
+ b[10] = rand_bytes[4];
+ b[11] = rand_bytes[5];
+ b[12] = rand_bytes[6];
+ b[13] = rand_bytes[7];
+ b[14] = rand_bytes[8];
+ b[15] = rand_bytes[9];
+
+ return try std.fmt.allocPrint(
+ allocator,
+ "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}",
+ .{ b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] },
+ );
+}
+
+/// Generate a fresh 8-character hex entry id. Caller owns.
+fn newEntryIdInto(buf: []u8, io: Io) void {
+ std.debug.assert(buf.len == 8);
+ var bytes: [4]u8 = undefined;
+ io.random(&bytes);
+ _ = std.fmt.bufPrint(buf, "{x:0>2}{x:0>2}{x:0>2}{x:0>2}", .{ bytes[0], bytes[1], bytes[2], bytes[3] }) catch unreachable;
+}
+
+/// Format `now` as an ISO 8601 UTC string with millisecond precision.
+/// Example: `2026-04-25T17:40:15.990Z`. Caller owns.
+pub fn isoTimestamp(allocator: Allocator, io: Io) ![]u8 {
+ const ts = Io.Timestamp.now(io, .real);
+ const ms_total: i64 = ts.toMilliseconds();
+ const seconds_total: i64 = @divTrunc(ms_total, 1000);
+ const ms: u64 = @intCast(@mod(ms_total, 1000));
+
+ const epoch_secs = std.time.epoch.EpochSeconds{ .secs = @intCast(seconds_total) };
+ const epoch_day = epoch_secs.getEpochDay();
+ const day_secs = epoch_secs.getDaySeconds();
+ const year_day = epoch_day.calculateYearDay();
+ const month_day = year_day.calculateMonthDay();
+
+ return try std.fmt.allocPrint(
+ allocator,
+ "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z",
+ .{
+ @as(u32, year_day.year),
+ month_day.month.numeric(),
+ @as(u32, month_day.day_index) + 1,
+ day_secs.getHoursIntoDay(),
+ day_secs.getMinutesIntoHour(),
+ day_secs.getSecondsIntoMinute(),
+ ms,
+ },
+ );
+}
+
+// =============================================================================
+// FileInfo (per-file listing scan result)
+// =============================================================================
+
+/// Internal scan result for one session file. Carries the file `path` (the
+/// catalog needs it to open/resolve) plus everything the public
+/// `session_store.SessionInfo` needs.
+const FileInfo = struct {
+ path: []u8,
+ id: []u8,
+ created: []u8, // ISO 8601 from header timestamp
+ modified: []u8, // ISO 8601 from last activity, falling back to header
+ message_count: usize,
+ last_user_message: []u8,
+ stamp: ?session_mod.WireStamp, // last-used wire identity
+
+ pub fn deinit(self: FileInfo, alloc: Allocator) void {
+ alloc.free(self.path);
+ alloc.free(self.id);
+ alloc.free(self.created);
+ alloc.free(self.modified);
+ alloc.free(self.last_user_message);
+ if (self.stamp) |s| s.deinit(alloc);
+ }
+};
+
+// =============================================================================
+// SessionFile
+// =============================================================================
+
+pub const Error = error{
+ NoSessionsFound,
+ AmbiguousSessionId,
+ SessionNotFound,
+ InvalidSessionFile,
+} || Allocator.Error || Io.Cancelable;
+
+pub const SessionFile = struct {
+ allocator: Allocator,
+ io: Io,
+
+ /// Absolute path to the per-cwd sessions directory. Lazily created.
+ session_dir: []u8,
+ /// Absolute path to the file we *will* write to (computed at init).
+ /// May not yet exist on disk if `flushed = false`.
+ session_file: []u8,
+
+ /// Header. Allocated at init for new sessions; reloaded from the file
+ /// on resume.
+ header: SessionHeader,
+
+ /// Entries indexed in insertion order. The first entry's `parent_id`
+ /// is null; each subsequent entry's `parent_id` points to its parent
+ /// (currently always the previous entry).
+ entries: std.ArrayList(SessionEntry),
+ /// id → entry index in `entries`. Used both for parent-id lookups
+ /// and for collision detection in `newEntryId`.
+ by_id: std.StringHashMap(usize),
+ /// id of the most recently appended entry, or null if no entries yet.
+ /// Borrowed from the entry; do not free.
+ leaf_id: ?[]const u8,
+
+ /// True once the file exists on disk. False during the "buffered"
+ /// pre-assistant phase. See module-level docs.
+ flushed: bool,
+ /// Number of bytes written to `session_file` so far. Used as the
+ /// offset for the next positional write. Only meaningful when
+ /// `flushed = true`.
+ written_bytes: u64,
+
+ // ---------- Construction ----------
+
+ /// Create a new session in memory. Allocates a UUIDv7, computes the
+ /// file path, but does NOT touch the filesystem. The file is created
+ /// on the first assistant-message flush.
+ ///
+ /// `session_dir` is duplicated; the caller retains ownership of the
+ /// passed slice.
+ pub fn init(
+ allocator: Allocator,
+ io: Io,
+ session_dir: []const u8,
+ cwd: []const u8,
+ ) !SessionFile {
+ const id = try newUuidV7(allocator, io);
+ defer allocator.free(id);
+ return initWithId(allocator, io, session_dir, cwd, id);
+ }
+
+ /// Like `init`, but uses a caller-supplied session id (duped here)
+ /// rather than minting a fresh UUIDv7. Used by the catalog when a
+ /// `Session` handle was minted (with its id) before the first append.
+ pub fn initWithId(
+ allocator: Allocator,
+ io: Io,
+ session_dir: []const u8,
+ cwd: []const u8,
+ session_id: []const u8,
+ ) !SessionFile {
+ const dir = try allocator.dupe(u8, session_dir);
+ errdefer allocator.free(dir);
+
+ const id = try allocator.dupe(u8, session_id);
+ errdefer allocator.free(id);
+
+ const timestamp = try isoTimestamp(allocator, io);
+ errdefer allocator.free(timestamp);
+
+ const cwd_copy = try allocator.dupe(u8, cwd);
+ errdefer allocator.free(cwd_copy);
+
+ const filename = try std.fmt.allocPrint(allocator, "{s}.jsonl", .{id});
+ defer allocator.free(filename);
+ const file_path = try std.fs.path.join(allocator, &.{ dir, filename });
+ errdefer allocator.free(file_path);
+
+ return .{
+ .allocator = allocator,
+ .io = io,
+ .session_dir = dir,
+ .session_file = file_path,
+ .header = .{
+ .version = CURRENT_VERSION,
+ .id = id,
+ .timestamp = timestamp,
+ .cwd = cwd_copy,
+ },
+ .entries = .empty,
+ .by_id = std.StringHashMap(usize).init(allocator),
+ .leaf_id = null,
+ .flushed = false,
+ .written_bytes = 0,
+ };
+ }
+
+ /// Open and replay an existing session file. Truncates from the first
+ /// corrupted line. Runs format migration if needed and rewrites the
+ /// file once.
+ pub fn open(
+ allocator: Allocator,
+ io: Io,
+ file_path: []const u8,
+ ) !SessionFile {
+ const path_copy = try allocator.dupe(u8, file_path);
+ errdefer allocator.free(path_copy);
+
+ // The session dir is the file's parent directory.
+ const dir_path = std.fs.path.dirname(path_copy) orelse ".";
+ const dir = try allocator.dupe(u8, dir_path);
+ errdefer allocator.free(dir);
+
+ const bytes = try readWholeFile(allocator, io, path_copy);
+ defer allocator.free(bytes);
+
+ // Walk line-by-line. The first failure causes a truncation back to
+ // the start of that line.
+ var entries: std.ArrayList(SessionEntry) = .empty;
+ errdefer {
+ for (entries.items) |e| e.deinit(allocator);
+ entries.deinit(allocator);
+ }
+ var by_id = std.StringHashMap(usize).init(allocator);
+ errdefer by_id.deinit();
+
+ var header_opt: ?SessionHeader = null;
+ errdefer if (header_opt) |h| h.deinit(allocator);
+
+ var cursor: usize = 0;
+ var valid_bytes: u64 = 0; // length of the file prefix that parses cleanly
+ var saw_corruption: bool = false;
+
+ while (cursor < bytes.len) {
+ // Find the next newline (or EOF).
+ const rest = bytes[cursor..];
+ const nl_rel = std.mem.indexOfScalar(u8, rest, '\n');
+ const line_end_excl: usize = if (nl_rel) |n| cursor + n else bytes.len;
+ const line = bytes[cursor..line_end_excl];
+ const next_cursor: usize = if (nl_rel != null) line_end_excl + 1 else bytes.len;
+
+ // Allow blank lines silently (just whitespace), but a non-empty
+ // trimmed line that won't parse triggers truncation.
+ const trimmed = std.mem.trim(u8, line, " \t\r");
+ if (trimmed.len == 0) {
+ if (nl_rel == null) break;
+ cursor = next_cursor;
+ valid_bytes = cursor;
+ continue;
+ }
+
+ // If the final line has no trailing newline AND we hit EOF, it
+ // is presumed truncated mid-write. Treat as corruption.
+ if (nl_rel == null) {
+ saw_corruption = true;
+ break;
+ }
+
+ const fe = session_mod.parseLine(allocator, line) catch {
+ saw_corruption = true;
+ break;
+ };
+
+ switch (fe) {
+ .header => |h| {
+ if (header_opt != null) {
+ // Two headers — treat as corruption from this line on.
+ h.deinit(allocator);
+ saw_corruption = true;
+ break;
+ }
+ if (entries.items.len != 0) {
+ // Header arrived after entries — malformed.
+ h.deinit(allocator);
+ saw_corruption = true;
+ break;
+ }
+ header_opt = h;
+ },
+ .entry => |e| {
+ const idx = entries.items.len;
+ entries.append(allocator, e) catch |err| {
+ e.deinit(allocator);
+ return err;
+ };
+ by_id.put(e.base().id, idx) catch |err| {
+ // Rolling back the append is awkward; in practice
+ // OOM here is fatal anyway.
+ return err;
+ };
+ },
+ }
+
+ cursor = next_cursor;
+ valid_bytes = cursor;
+ }
+
+ // No header at all — refuse to load.
+ const header = header_opt orelse return error.InvalidSessionFile;
+
+ // Truncate the file if anything beyond `valid_bytes` is corrupt.
+ if (saw_corruption and valid_bytes < bytes.len) {
+ try truncateFileTo(io, path_copy, valid_bytes);
+ }
+
+ var migrated_header = header;
+ var did_migrate = migrate(allocator, &migrated_header, &entries);
+ if (elideDanglingToolUses(allocator, &entries)) {
+ did_migrate = true;
+ }
+ // We didn't reassign by_id during migration; rebuild if needed.
+ if (did_migrate) {
+ by_id.clearRetainingCapacity();
+ for (entries.items, 0..) |e, i| {
+ try by_id.put(e.base().id, i);
+ }
+ try rewriteFile(allocator, io, path_copy, migrated_header, entries.items);
+ }
+
+ const leaf_id: ?[]const u8 = if (entries.items.len > 0)
+ entries.items[entries.items.len - 1].base().id
+ else
+ null;
+
+ // Compute final file length on disk so future appends use the
+ // correct offset.
+ const stat = try statFileForLength(io, path_copy);
+
+ return .{
+ .allocator = allocator,
+ .io = io,
+ .session_dir = dir,
+ .session_file = path_copy,
+ .header = migrated_header,
+ .entries = entries,
+ .by_id = by_id,
+ .leaf_id = leaf_id,
+ .flushed = true,
+ .written_bytes = stat,
+ };
+ }
+
+ pub fn deinit(self: *SessionFile) void {
+ self.header.deinit(self.allocator);
+ for (self.entries.items) |e| e.deinit(self.allocator);
+ self.entries.deinit(self.allocator);
+ self.by_id.deinit();
+ self.allocator.free(self.session_dir);
+ self.allocator.free(self.session_file);
+ }
+
+ // ---------- Accessors ----------
+
+ pub fn getCwd(self: *const SessionFile) []const u8 {
+ return self.header.cwd;
+ }
+
+ pub fn getSessionId(self: *const SessionFile) []const u8 {
+ return self.header.id;
+ }
+
+ pub fn getSessionFile(self: *const SessionFile) []const u8 {
+ return self.session_file;
+ }
+
+ pub fn getSessionDir(self: *const SessionFile) []const u8 {
+ return self.session_dir;
+ }
+
+ pub fn getLeafId(self: *const SessionFile) ?[]const u8 {
+ return self.leaf_id;
+ }
+
+ pub fn getEntry(self: *const SessionFile, id: []const u8) ?*const SessionEntry {
+ const idx = self.by_id.get(id) orelse return null;
+ return &self.entries.items[idx];
+ }
+
+ pub fn getEntries(self: *const SessionFile) []const SessionEntry {
+ return self.entries.items;
+ }
+
+ pub fn isFlushed(self: *const SessionFile) bool {
+ return self.flushed;
+ }
+
+ // ---------- Active model resolution ----------
+
+ /// Determine the active provider/model by walking entries leaf→root
+ /// and finding the last user-message entry with provider/model
+ /// stamped. Returns null only when no user message has been appended
+ /// yet, which is only reachable on a freshly-`init`'d session before
+ /// the first user prompt (and therefore before any disk flush).
+ ///
+ /// Returns a borrowed stamp owned by the manager; do not free.
+ pub fn activeStamp(self: *const SessionFile) ?session_mod.WireStamp {
+ var i = self.entries.items.len;
+ while (i > 0) : (i -= 1) {
+ const e = self.entries.items[i - 1];
+ switch (e) {
+ .message => |m| {
+ if (m.stamp) |st| return st;
+ },
+ }
+ }
+ return null;
+ }
+
+ // ---------- Appending ----------
+
+ /// Append a message entry. `msg` is consumed (ownership transferred)
+ /// regardless of success — on error, the message is deinit'd before
+ /// the error is returned.
+ ///
+ /// If `flushed`: writes the new line immediately.
+ /// If not flushed and `msg.role == .assistant`: writes the header +
+ /// all buffered entries + the new entry, then sets `flushed`.
+ /// Otherwise: buffers in memory only.
+ pub fn appendMessage(
+ self: *SessionFile,
+ msg: StoredMessage,
+ // Wire-format provider identity for the entry. Null on system
+ // messages. Borrowed; duplicated here into the entry.
+ stamp: ?session_mod.WireStamp,
+ ) ![]const u8 {
+ // Build the entry up-front, taking ownership of the inputs.
+ var msg_local = msg;
+ errdefer msg_local.deinit(self.allocator);
+
+ const id_buf = try self.newEntryId();
+ errdefer self.allocator.free(id_buf);
+
+ const timestamp = try isoTimestamp(self.allocator, self.io);
+ errdefer self.allocator.free(timestamp);
+
+ const parent_id_copy: ?[]const u8 = if (self.leaf_id) |l| try self.allocator.dupe(u8, l) else null;
+ errdefer if (parent_id_copy) |p| self.allocator.free(p);
+
+ const stamp_copy: ?session_mod.WireStamp = if (stamp) |st| try st.dupe(self.allocator) else null;
+ errdefer if (stamp_copy) |st| st.deinit(self.allocator);
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp },
+ .stamp = stamp_copy,
+ .message = msg_local,
+ } };
+
+ // The entry now owns msg_local + id_buf + timestamp + parent_id_copy +
+ // provider/model copies. Cancel the errdefers individually.
+ // (Zig's errdefer behavior: they only run on error returns; pushing the
+ // entry into entries.items before any further fallible step means an
+ // error in by_id.put() would double-free. Instead, do the put first
+ // against a not-yet-stored id.)
+
+ const idx = self.entries.items.len;
+
+ // Ensure capacity before touching anything.
+ try self.entries.ensureUnusedCapacity(self.allocator, 1);
+ try self.by_id.ensureUnusedCapacity(1);
+
+ // Persist BEFORE inserting into the in-memory structures, so that on
+ // I/O failure we don't have a dangling in-memory entry the caller
+ // thinks was saved. (Failure leaves the file unchanged for an
+ // unflushed session, and unchanged-except-for-EOF for a flushed one.)
+ const is_assistant = entry.message.message.role == .assistant;
+ if (self.flushed) {
+ try self.persistEntry(entry);
+ } else if (is_assistant) {
+ try self.flushBuffered(entry);
+ }
+ // If not flushed and not assistant: nothing to do; the entry will be
+ // flushed alongside the eventual first assistant entry.
+
+ // Now insert into in-memory structures. All allocations are kept.
+ self.entries.appendAssumeCapacity(entry);
+ self.by_id.putAssumeCapacity(entry.base().id, idx);
+ self.leaf_id = entry.base().id;
+ return entry.base().id;
+ }
+
+ /// Returns a freshly allocated 8-character hex id, guaranteed not to
+ /// collide with any existing entry id in this session.
+ fn newEntryId(self: *SessionFile) ![]u8 {
+ const max_tries = 100;
+ var i: usize = 0;
+ while (i < max_tries) : (i += 1) {
+ const buf = try self.allocator.alloc(u8, 8);
+ errdefer self.allocator.free(buf);
+ newEntryIdInto(buf[0..8], self.io);
+ if (!self.by_id.contains(buf)) {
+ return buf;
+ }
+ self.allocator.free(buf);
+ }
+ // Fall back to a UUID prefix if 100 retries all collided. With 4
+ // random bytes per id and a session with <<2^16 entries, the
+ // probability of getting here is effectively zero, but we want a
+ // hard guarantee.
+ const long = try newUuidV7(self.allocator, self.io);
+ defer self.allocator.free(long);
+ const buf = try self.allocator.alloc(u8, 8);
+ @memcpy(buf, long[0..8]);
+ return buf;
+ }
+
+ // ---------- Persistence ----------
+
+ /// Write the header + all currently-buffered entries + `new_entries`
+ /// to the file as a single batch. Creates the directory and file.
+ fn flushBufferedMany(self: *SessionFile, new_entries: []const SessionEntry) !void {
+ try mkdirP(self.io, self.session_dir);
+
+ const file = try Io.Dir.cwd().createFile(self.io, self.session_file, .{
+ .truncate = true,
+ .read = false,
+ });
+ defer file.close(self.io);
+
+ var offset: u64 = 0;
+ const header_line = try session_mod.serializeHeader(self.allocator, self.header);
+ defer self.allocator.free(header_line);
+ try file.writePositionalAll(self.io, header_line, offset);
+ offset += header_line.len;
+ try file.writePositionalAll(self.io, "\n", offset);
+ offset += 1;
+
+ for (self.entries.items) |e| {
+ const line = try session_mod.serializeEntry(self.allocator, e);
+ defer self.allocator.free(line);
+ try file.writePositionalAll(self.io, line, offset);
+ offset += line.len;
+ try file.writePositionalAll(self.io, "\n", offset);
+ offset += 1;
+ }
+
+ for (new_entries) |entry| {
+ const line = try session_mod.serializeEntry(self.allocator, entry);
+ defer self.allocator.free(line);
+ try file.writePositionalAll(self.io, line, offset);
+ offset += line.len;
+ try file.writePositionalAll(self.io, "\n", offset);
+ offset += 1;
+ }
+
+ file.sync(self.io) catch {};
+ self.flushed = true;
+ self.written_bytes = offset;
+ }
+
+ fn flushBuffered(self: *SessionFile, final_entry: SessionEntry) !void {
+ try self.flushBufferedMany(&.{final_entry});
+ }
+
+ /// Append a single line for `entry` to the open session file. Caller
+ /// must have already verified `flushed`.
+ fn persistEntry(self: *SessionFile, entry: SessionEntry) !void {
+ try self.persistEntries(&.{entry});
+ }
+
+ pub fn appendMessagesAtomic(
+ self: *SessionFile,
+ messages: []StoredMessage,
+ stamps: []const ?session_mod.WireStamp,
+ ) !void {
+ std.debug.assert(messages.len == stamps.len);
+ if (messages.len == 0) return;
+
+ const base_len = self.entries.items.len;
+ try self.entries.ensureUnusedCapacity(self.allocator, messages.len);
+ try self.by_id.ensureUnusedCapacity(@intCast(messages.len));
+
+ var entries = try self.allocator.alloc(SessionEntry, messages.len);
+ defer self.allocator.free(entries);
+ var built: usize = 0;
+ errdefer {
+ for (entries[0..built]) |*e| e.deinit(self.allocator);
+ }
+
+ var prev_leaf = self.leaf_id;
+ for (messages, 0..) |msg, i| {
+ const msg_local = msg;
+ const id_buf = try self.newEntryId();
+ errdefer self.allocator.free(id_buf);
+ const timestamp = try isoTimestamp(self.allocator, self.io);
+ errdefer self.allocator.free(timestamp);
+ const parent_id_copy: ?[]const u8 = if (prev_leaf) |l| try self.allocator.dupe(u8, l) else null;
+ errdefer if (parent_id_copy) |p| self.allocator.free(p);
+ const stamp_copy: ?session_mod.WireStamp = if (stamps[i]) |st| try st.dupe(self.allocator) else null;
+ errdefer if (stamp_copy) |st| st.deinit(self.allocator);
+ entries[i] = .{ .message = .{
+ .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp },
+ .stamp = stamp_copy,
+ .message = msg_local,
+ } };
+ built += 1;
+ prev_leaf = entries[i].base().id;
+ }
+
+ if (self.flushed) {
+ try self.persistEntries(entries);
+ } else {
+ try self.flushBufferedMany(entries);
+ }
+
+ for (entries, 0..) |entry, i| {
+ self.entries.appendAssumeCapacity(entry);
+ self.by_id.putAssumeCapacity(entry.base().id, base_len + i);
+ }
+ self.leaf_id = entries[entries.len - 1].base().id;
+ built = 0;
+ }
+
+ fn persistEntries(self: *SessionFile, entries: []const SessionEntry) !void {
+ const file = try Io.Dir.cwd().openFile(self.io, self.session_file, .{
+ .mode = .write_only,
+ });
+ defer file.close(self.io);
+
+ var offset = self.written_bytes;
+ for (entries) |entry| {
+ const line = try session_mod.serializeEntry(self.allocator, entry);
+ defer self.allocator.free(line);
+ try file.writePositionalAll(self.io, line, offset);
+ offset += line.len;
+ try file.writePositionalAll(self.io, "\n", offset);
+ offset += 1;
+ }
+ file.sync(self.io) catch {};
+ self.written_bytes = offset;
+ }
+
+ // =============================================================================
+ // Conversation rebuild
+ // =============================================================================
+
+ /// Build a fresh `Conversation` from the entry log. Caller owns the
+ /// returned conversation (call `deinit`).
+ pub fn rebuildConversation(self: *const SessionFile) !conversation_mod.Conversation {
+ var conv = conversation_mod.Conversation.init(self.allocator);
+ errdefer conv.deinit();
+
+ for (self.entries.items) |entry| {
+ switch (entry) {
+ .message => |me| try appendMessageToConv(&conv, self.allocator, me.message),
+ }
+ }
+ return conv;
+ }
+
+};
+
+/// Best-effort extraction of plain prompt text from a user `StoredMessage`.
+/// Used to populate `SessionInfo.last_user_message`. Returns null if the
+/// message carries no plain text block. Caller owns the returned slice.
+fn extractUserText(alloc: Allocator, msg: StoredMessage) !?[]u8 {
+ for (msg.content) |block| {
+ if (block == .text) {
+ return try alloc.dupe(u8, block.text.text);
+ }
+ }
+ return null;
+}
+
+fn appendMessageToConv(
+ conv: *conversation_mod.Conversation,
+ allocator: Allocator,
+ disk_msg: StoredMessage,
+) !void {
+ var content: std.ArrayList(conversation_mod.ContentBlock) = .empty;
+ errdefer {
+ for (content.items) |*b| {
+ var mut = b.*;
+ mut.deinit(allocator);
+ }
+ content.deinit(allocator);
+ }
+ try content.ensureTotalCapacity(allocator, disk_msg.content.len);
+ const sys_mode: conversation_mod.SystemMode = switch (disk_msg.mode) {
+ .append => .append,
+ .replace => .replace,
+ };
+ for (disk_msg.content) |db| {
+ var block = try session_mod.diskContentBlockToInternal(allocator, db);
+ // System-role text blocks become `.System` blocks carrying the
+ // message's recorded mode, so the append/replace derivation works
+ // on the rebuilt conversation exactly as it did when written.
+ if (disk_msg.role == .system and block == .Text) {
+ const tb = block.Text;
+ block = .{ .System = .{ .text = tb, .mode = sys_mode } };
+ }
+ content.appendAssumeCapacity(block);
+ }
+ const role: conversation_mod.MessageRole = switch (disk_msg.role) {
+ .system => .system,
+ .user => .user,
+ .assistant => .assistant,
+ };
+ // Carry the recorded usage forward so compaction can size the retention
+ // window after a session is reopened (it's null for user/system).
+ try conv.messages.append(allocator, .{
+ .role = role,
+ .content = content,
+ .usage = disk_msg.usage,
+ });
+}
+
+// =============================================================================
+// Migration
+// =============================================================================
+
+/// Future format migrations land here. Returns true if anything changed
+/// (which triggers a one-time file rewrite).
+fn migrate(
+ allocator: Allocator,
+ header: *SessionHeader,
+ entries: *std.ArrayList(SessionEntry),
+) bool {
+ _ = allocator;
+ _ = entries;
+ if (header.version >= CURRENT_VERSION) return false;
+ // No earlier versions exist yet. When v2 lands, transform v1 entries
+ // here and bump `header.version`.
+ return false;
+}
+
+fn elideDanglingToolUses(allocator: Allocator, entries: *std.ArrayList(SessionEntry)) bool {
+ var needed: std.StringHashMap(void) = .init(allocator);
+ defer needed.deinit();
+ var changed = false;
+
+ var i = entries.items.len;
+ while (i > 0) {
+ i -= 1;
+ const entry = &entries.items[i];
+ if (entry.* != .message) continue;
+ const msg = &entry.message.message;
+
+ if (msg.role == .user) {
+ for (msg.content) |block| {
+ if (block == .tool_result) {
+ needed.put(block.tool_result.tool_use_id, {}) catch {};
+ }
+ }
+ continue;
+ }
+
+ if (msg.role != .assistant) continue;
+ var kept: std.ArrayList(StoredContentBlock) = .empty;
+ defer kept.deinit(allocator);
+ var removed = false;
+ for (msg.content) |block| {
+ if (block == .tool_use and !needed.contains(block.tool_use.id)) {
+ block.deinit(allocator);
+ removed = true;
+ continue;
+ }
+ kept.append(allocator, block) catch unreachable;
+ }
+ if (!removed) continue;
+ allocator.free(msg.content);
+ msg.content = kept.toOwnedSlice(allocator) catch unreachable;
+ changed = true;
+ }
+ return changed;
+}
+
+// =============================================================================
+// File utilities
+// =============================================================================
+
+fn readWholeFile(allocator: Allocator, io: Io, path: []const u8) ![]u8 {
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return error.InvalidSessionFile,
+ else => return err,
+ };
+ defer file.close(io);
+
+ const len = file.length(io) catch {
+ // Fall back to a streaming read of a reasonable upper bound.
+ // Sessions over ~10 MB are out of scope for phase 4.
+ var list: std.ArrayList(u8) = .empty;
+ defer list.deinit(allocator);
+ var chunk: [4096]u8 = undefined;
+ while (true) {
+ const n = file.readStreaming(io, &.{&chunk}) catch break;
+ if (n == 0) break;
+ try list.appendSlice(allocator, chunk[0..n]);
+ }
+ return try list.toOwnedSlice(allocator);
+ };
+
+ const buf = try allocator.alloc(u8, @intCast(len));
+ errdefer allocator.free(buf);
+ _ = try file.readPositionalAll(io, buf, 0);
+ return buf;
+}
+
+fn statFileForLength(io: Io, path: []const u8) !u64 {
+ const file = try Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only });
+ defer file.close(io);
+ return try file.length(io);
+}
+
+fn truncateFileTo(io: Io, path: []const u8, new_length: u64) !void {
+ const file = try Io.Dir.cwd().openFile(io, path, .{ .mode = .write_only });
+ defer file.close(io);
+ try file.setLength(io, new_length);
+ file.sync(io) catch {};
+}
+
+/// Write a fresh file containing `header` followed by `entries`. Truncates
+/// any existing content. Used after a migration rewrites the format.
+fn rewriteFile(
+ allocator: Allocator,
+ io: Io,
+ path: []const u8,
+ header: SessionHeader,
+ entries: []const SessionEntry,
+) !void {
+ const file = try Io.Dir.cwd().createFile(io, path, .{
+ .truncate = true,
+ .read = false,
+ });
+ defer file.close(io);
+
+ var offset: u64 = 0;
+ const header_line = try session_mod.serializeHeader(allocator, header);
+ defer allocator.free(header_line);
+ try file.writePositionalAll(io, header_line, offset);
+ offset += header_line.len;
+ try file.writePositionalAll(io, "\n", offset);
+ offset += 1;
+ for (entries) |e| {
+ const line = try session_mod.serializeEntry(allocator, e);
+ defer allocator.free(line);
+ try file.writePositionalAll(io, line, offset);
+ offset += line.len;
+ try file.writePositionalAll(io, "\n", offset);
+ offset += 1;
+ }
+ file.sync(io) catch {};
+}
+
+fn mkdirP(io: Io, path: []const u8) !void {
+ Io.Dir.cwd().createDirPath(io, path) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return err,
+ };
+}
+
+// =============================================================================
+// Listing
+// =============================================================================
+
+/// List sessions in `session_dir`. Returns a slice of `SessionInfo`s
+/// sorted by `modified` descending (most recent first). Caller owns the
+/// slice and each `SessionInfo`.
+///
+/// If the directory does not exist, returns an empty slice (no error).
+///
+/// If `on_progress` is non-null, it is invoked after each file is parsed.
+pub fn listSessions(
+ allocator: Allocator,
+ io: Io,
+ session_dir: []const u8,
+ on_progress: ?*const fn (loaded: usize, total: usize) void,
+) ![]FileInfo {
+ var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) {
+ error.FileNotFound => return try allocator.alloc(FileInfo, 0),
+ else => return err,
+ };
+ defer dir.close(io);
+
+ var names: std.ArrayList([]u8) = .empty;
+ defer {
+ for (names.items) |n| allocator.free(n);
+ names.deinit(allocator);
+ }
+
+ var it = dir.iterate();
+ while (try it.next(io)) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue;
+ const copy = try allocator.dupe(u8, entry.name);
+ errdefer allocator.free(copy);
+ try names.append(allocator, copy);
+ }
+
+ var infos: std.ArrayList(FileInfo) = .empty;
+ errdefer {
+ for (infos.items) |i| i.deinit(allocator);
+ infos.deinit(allocator);
+ }
+ try infos.ensureTotalCapacity(allocator, names.items.len);
+
+ var loaded: usize = 0;
+ for (names.items) |name| {
+ const full = try std.fs.path.join(allocator, &.{ session_dir, name });
+ defer allocator.free(full);
+ const info_opt = buildFileInfo(allocator, io, full) catch null;
+ if (info_opt) |info| {
+ infos.appendAssumeCapacity(info);
+ }
+ loaded += 1;
+ if (on_progress) |cb| cb(loaded, names.items.len);
+ }
+
+ const slice = try infos.toOwnedSlice(allocator);
+ std.sort.pdq(FileInfo, slice, {}, fileInfoNewerFirst);
+ return slice;
+}
+
+fn fileInfoNewerFirst(_: void, a: FileInfo, b: FileInfo) bool {
+ return std.mem.order(u8, a.modified, b.modified) == .gt;
+}
+
+fn buildFileInfo(
+ allocator: Allocator,
+ io: Io,
+ file_path: []const u8,
+) !?FileInfo {
+ const bytes = readWholeFile(allocator, io, file_path) catch return null;
+ defer allocator.free(bytes);
+
+ var header_opt: ?SessionHeader = null;
+ defer if (header_opt) |h| h.deinit(allocator);
+
+ var message_count: usize = 0;
+ var last_activity: ?[]u8 = null;
+ defer if (last_activity) |la| allocator.free(la);
+ var last_user: ?[]u8 = null;
+ defer if (last_user) |lu| allocator.free(lu);
+ var last_stamp: ?session_mod.WireStamp = null;
+ defer if (last_stamp) |st| st.deinit(allocator);
+
+ var lines = std.mem.splitScalar(u8, bytes, '\n');
+ while (lines.next()) |line| {
+ const trimmed = std.mem.trim(u8, line, " \t\r");
+ if (trimmed.len == 0) continue;
+ const fe = session_mod.parseLine(allocator, trimmed) catch break;
+ switch (fe) {
+ .header => |h| {
+ if (header_opt != null) {
+ h.deinit(allocator);
+ } else {
+ header_opt = h;
+ }
+ },
+ .entry => |e| {
+ defer e.deinit(allocator);
+ switch (e) {
+ .message => |m| {
+ if (m.message.role == .user or m.message.role == .assistant) {
+ message_count += 1;
+ if (last_activity) |la| allocator.free(la);
+ last_activity = try allocator.dupe(u8, m.base.timestamp);
+ }
+ if (m.stamp) |st| {
+ if (last_stamp) |old| old.deinit(allocator);
+ last_stamp = try st.dupe(allocator);
+ }
+ if (m.message.role == .user) {
+ if (try extractUserText(allocator, m.message)) |ut| {
+ if (last_user) |lu| allocator.free(lu);
+ last_user = ut;
+ }
+ }
+ },
+ }
+ },
+ }
+ }
+
+ const header = header_opt orelse return null;
+
+ const path = try allocator.dupe(u8, file_path);
+ errdefer allocator.free(path);
+ const id = try allocator.dupe(u8, header.id);
+ errdefer allocator.free(id);
+ const created = try allocator.dupe(u8, header.timestamp);
+ errdefer allocator.free(created);
+ const modified = if (last_activity) |la| blk: {
+ last_activity = null;
+ break :blk la;
+ } else try allocator.dupe(u8, header.timestamp);
+ errdefer allocator.free(modified);
+ const last_user_message = if (last_user) |lu| blk: {
+ last_user = null;
+ break :blk lu;
+ } else try allocator.dupe(u8, "");
+ errdefer allocator.free(last_user_message);
+ const stamp_out = if (last_stamp) |st| blk: {
+ last_stamp = null;
+ break :blk st;
+ } else null;
+
+ return .{
+ .path = path,
+ .id = id,
+ .created = created,
+ .modified = modified,
+ .message_count = message_count,
+ .last_user_message = last_user_message,
+ .stamp = stamp_out,
+ };
+}
+
+// =============================================================================
+// Recent / resume helpers
+// =============================================================================
+
+/// Find the most recent session file in `session_dir`. Returns null if
+/// none exist. Caller owns the returned path.
+pub fn findMostRecentSession(allocator: Allocator, io: Io, session_dir: []const u8) !?[]u8 {
+ var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) {
+ error.FileNotFound => return null,
+ else => return err,
+ };
+ defer dir.close(io);
+
+ var best_name: ?[]u8 = null;
+ errdefer if (best_name) |b| allocator.free(b);
+
+ var it = dir.iterate();
+ while (try it.next(io)) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue;
+ if (best_name) |b| {
+ // Lexicographic compare. UUIDv7 filenames sort chronologically.
+ if (std.mem.order(u8, entry.name, b) == .gt) {
+ allocator.free(b);
+ best_name = try allocator.dupe(u8, entry.name);
+ }
+ } else {
+ best_name = try allocator.dupe(u8, entry.name);
+ }
+ }
+
+ const name = best_name orelse return null;
+ defer allocator.free(name);
+ best_name = null;
+ return try std.fs.path.join(allocator, &.{ session_dir, name });
+}
+
+/// Resolve a (possibly abbreviated) session id to a session file path
+/// within `session_dir`. Errors if no match or ambiguous prefix.
+pub fn resolveSessionId(
+ allocator: Allocator,
+ io: Io,
+ session_dir: []const u8,
+ id_or_prefix: []const u8,
+) ![]u8 {
+ var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) {
+ error.FileNotFound => return error.SessionNotFound,
+ else => return err,
+ };
+ defer dir.close(io);
+
+ var match: ?[]u8 = null;
+ errdefer if (match) |m| allocator.free(m);
+
+ var it = dir.iterate();
+ while (try it.next(io)) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue;
+ // Strip `.jsonl` for the prefix match.
+ const stem = entry.name[0 .. entry.name.len - ".jsonl".len];
+ if (!std.mem.startsWith(u8, stem, id_or_prefix)) continue;
+ if (match != null) return error.AmbiguousSessionId;
+ match = try allocator.dupe(u8, entry.name);
+ }
+
+ const name = match orelse return error.SessionNotFound;
+ defer allocator.free(name);
+ match = null;
+ return try std.fs.path.join(allocator, &.{ session_dir, name });
+}
+
+// =============================================================================
+// FileSystemJSONLStore — the directory-backed catalog (SessionStore impl)
+// =============================================================================
+
+/// A directory-backed `SessionStore`: each session is one `<id>.jsonl` file
+/// under `dir`. The catalog mints `Session` handles, lists/resolves files,
+/// loads conversations, and routes appends to the right `SessionFile`.
+///
+/// Open `SessionFile`s are cached by id for the catalog's lifetime so the
+/// buffered-until-first-assistant write discipline survives across the
+/// separate user-prompt and assistant-turn appends of a single turn.
+///
+/// `dir` is the already-resolved sessions directory (the panto CLI derives
+/// the per-cwd grouping; the store itself is cwd-agnostic). `cwd` is stamped
+/// into new session headers for display/provenance only.
+pub const FileSystemJSONLStore = struct {
+ allocator: Allocator,
+ io: Io,
+ dir: []u8, // owned: the sessions directory
+ cwd: []u8, // owned: recorded in new session headers
+ open: std.StringHashMap(*SessionFile),
+
+ pub fn init(allocator: Allocator, io: Io, dir: []const u8, cwd: []const u8) !FileSystemJSONLStore {
+ const dir_copy = try allocator.dupe(u8, dir);
+ errdefer allocator.free(dir_copy);
+ const cwd_copy = try allocator.dupe(u8, cwd);
+ return .{
+ .allocator = allocator,
+ .io = io,
+ .dir = dir_copy,
+ .cwd = cwd_copy,
+ .open = std.StringHashMap(*SessionFile).init(allocator),
+ };
+ }
+
+ pub fn deinit(self: *FileSystemJSONLStore) void {
+ var it = self.open.iterator();
+ while (it.next()) |e| {
+ e.value_ptr.*.deinit();
+ self.allocator.destroy(e.value_ptr.*);
+ self.allocator.free(e.key_ptr.*);
+ }
+ self.open.deinit();
+ self.allocator.free(self.dir);
+ self.allocator.free(self.cwd);
+ }
+
+ /// Borrow (opening if needed) the `SessionFile` for `id`, caching it.
+ /// Returns null if the file does not exist on disk and `create_missing`
+ /// is false.
+ fn fileFor(self: *FileSystemJSONLStore, id: []const u8, create_missing: bool) !?*SessionFile {
+ if (self.open.get(id)) |sf| return sf;
+ // Locate the file by exact id.
+ const path = try std.fs.path.join(self.allocator, &.{ self.dir, id });
+ defer self.allocator.free(path);
+ const full = try std.fmt.allocPrint(self.allocator, "{s}.jsonl", .{path});
+ defer self.allocator.free(full);
+
+ const exists = blk: {
+ Io.Dir.cwd().access(self.io, full, .{}) catch break :blk false;
+ break :blk true;
+ };
+ if (!exists and !create_missing) return null;
+
+ const sf = try self.allocator.create(SessionFile);
+ errdefer self.allocator.destroy(sf);
+ sf.* = if (exists)
+ try SessionFile.open(self.allocator, self.io, full)
+ else
+ try SessionFile.initWithId(self.allocator, self.io, self.dir, self.cwd, id);
+ errdefer sf.deinit();
+
+ const key = try self.allocator.dupe(u8, id);
+ errdefer self.allocator.free(key);
+ try self.open.put(key, sf);
+ return sf;
+ }
+
+ fn infoFromFileInfo(self: *FileSystemJSONLStore, fi: FileInfo) !session_store_mod.SessionInfo {
+ const id = try self.allocator.dupe(u8, fi.id);
+ errdefer self.allocator.free(id);
+ const created = try self.allocator.dupe(u8, fi.created);
+ errdefer self.allocator.free(created);
+ const modified = try self.allocator.dupe(u8, fi.modified);
+ errdefer self.allocator.free(modified);
+ const last_user = try self.allocator.dupe(u8, fi.last_user_message);
+ errdefer self.allocator.free(last_user);
+ const base_url = try self.allocator.dupe(u8, if (fi.stamp) |s| s.base_url else "");
+ errdefer self.allocator.free(base_url);
+ const model = try self.allocator.dupe(u8, if (fi.stamp) |s| s.model else "");
+ return .{
+ .id = id,
+ .created = created,
+ .modified = modified,
+ .message_count = fi.message_count,
+ .last_user_message = last_user,
+ .api_style = if (fi.stamp) |s| s.api_style else .openai_chat,
+ .base_url = base_url,
+ .model = model,
+ .reasoning = if (fi.stamp) |s| s.reasoning else .default,
+ };
+ }
+
+ // ---------- vtable ----------
+
+ fn createVT(ctx: *anyopaque) session_store_mod.Session {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ // Mint a fresh id; nothing hits disk until the first append.
+ const id = newUuidV7(self.allocator, self.io) catch "";
+ // The SessionFile is created lazily on first append via fileFor.
+ const info: session_store_mod.SessionInfo = .{
+ .id = id,
+ .created = self.allocator.dupe(u8, "") catch "",
+ .modified = self.allocator.dupe(u8, "") catch "",
+ .message_count = 0,
+ .last_user_message = self.allocator.dupe(u8, "") catch "",
+ .api_style = .openai_chat,
+ .base_url = self.allocator.dupe(u8, "") catch "",
+ .model = self.allocator.dupe(u8, "") catch "",
+ .reasoning = .default,
+ };
+ return .{ .info = info, .store = self.store() };
+ }
+
+ fn listVT(ctx: *anyopaque) anyerror![]session_store_mod.SessionInfo {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const fis = try listSessions(self.allocator, self.io, self.dir, null);
+ defer {
+ for (fis) |fi| fi.deinit(self.allocator);
+ self.allocator.free(fis);
+ }
+ var out = try self.allocator.alloc(session_store_mod.SessionInfo, fis.len);
+ var built: usize = 0;
+ errdefer {
+ for (out[0..built]) |i| i.deinit(self.allocator);
+ self.allocator.free(out);
+ }
+ for (fis, 0..) |fi, i| {
+ out[i] = try self.infoFromFileInfo(fi);
+ built += 1;
+ }
+ return out;
+ }
+
+ fn freeSessionInfosVT(ctx: *anyopaque, infos: []session_store_mod.SessionInfo) void {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ for (infos) |i| i.deinit(self.allocator);
+ self.allocator.free(infos);
+ }
+
+ fn resolveVT(ctx: *anyopaque, id_or_prefix: []const u8) anyerror!?session_store_mod.Session {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const path = resolveSessionId(self.allocator, self.io, self.dir, id_or_prefix) catch |err| switch (err) {
+ error.SessionNotFound => return null,
+ else => return err,
+ };
+ defer self.allocator.free(path);
+ return try self.sessionFromPath(path);
+ }
+
+ fn latestVT(ctx: *anyopaque) anyerror!?session_store_mod.Session {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const path = (try findMostRecentSession(self.allocator, self.io, self.dir)) orelse return null;
+ defer self.allocator.free(path);
+ return try self.sessionFromPath(path);
+ }
+
+ fn sessionFromPath(self: *FileSystemJSONLStore, path: []const u8) !?session_store_mod.Session {
+ const fi = (try buildFileInfo(self.allocator, self.io, path)) orelse return null;
+ defer fi.deinit(self.allocator);
+ const info = try self.infoFromFileInfo(fi);
+ return .{ .info = info, .store = self.store() };
+ }
+
+ fn loadVT(ctx: *anyopaque, id: []const u8) anyerror!?conversation_mod.Conversation {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ const sf = (try self.fileFor(id, false)) orelse return null;
+ return try sf.rebuildConversation();
+ }
+
+ fn appendMessagesVT(
+ ctx: *anyopaque,
+ session_id: []const u8,
+ messages: []session_store_mod.PersistentMessage,
+ ) anyerror!void {
+ const self: *FileSystemJSONLStore = @ptrCast(@alignCast(ctx));
+ if (messages.len == 0) return;
+ const sf = (try self.fileFor(session_id, true)).?;
+
+ // Convert each rich PersistentMessage to a StoredMessage + wire stamp.
+ // The FS store deliberately ignores the `conversation` and
+ // `tools_available` provenance fields.
+ var stored = try self.allocator.alloc(StoredMessage, messages.len);
+ var stamps = try self.allocator.alloc(?session_mod.WireStamp, messages.len);
+ defer self.allocator.free(stored);
+ defer self.allocator.free(stamps);
+ var built: usize = 0;
+ errdefer for (stored[0..built]) |sm| sm.deinit(self.allocator);
+
+ for (messages, 0..) |pm, i| {
+ stored[i] = try persistentToStored(self.allocator, pm);
+ // System entries carry no wire stamp; user/assistant do.
+ stamps[i] = if (pm.message.role == .system) null else .{
+ .api_style = pm.identity.api_style,
+ .base_url = pm.identity.base_url,
+ .model = pm.identity.model,
+ .reasoning = pm.identity.reasoning,
+ };
+ built += 1;
+ }
+
+ // appendMessagesAtomic consumes the StoredMessages; it dupes stamps.
+ try sf.appendMessagesAtomic(stored, stamps);
+ }
+
+ const store_vtable: session_store_mod.SessionStore.VTable = .{
+ .create = createVT,
+ .list = listVT,
+ .freeSessionInfos = freeSessionInfosVT,
+ .resolve = resolveVT,
+ .latest = latestVT,
+ .load = loadVT,
+ .appendMessages = appendMessagesVT,
+ };
+
+ /// Wrap this catalog as a neutral `SessionStore`. The handle borrows
+ /// `self`; `self` must outlive it.
+ pub fn store(self: *FileSystemJSONLStore) session_store_mod.SessionStore {
+ return .{ .ptr = self, .vtable = &store_vtable };
+ }
+};
+
+/// Convert a rich in-memory `PersistentMessage` to the on-disk
+/// `StoredMessage`. Strings are duplicated; the source is untouched.
+fn persistentToStored(
+ alloc: Allocator,
+ pm: session_store_mod.PersistentMessage,
+) !StoredMessage {
+ const msg = pm.message;
+ const blocks = try alloc.alloc(session_mod.StoredContentBlock, msg.content.items.len);
+ var allocated: usize = 0;
+ errdefer {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ }
+ for (msg.content.items) |block| {
+ blocks[allocated] = try session_mod.contentBlockToDisk(alloc, block);
+ allocated += 1;
+ }
+ const mode: session_mod.StoredSystemMode = blk: {
+ for (msg.content.items) |block| {
+ if (block == .System and block.System.mode == .replace) break :blk .replace;
+ }
+ break :blk .append;
+ };
+ const stop_reason: ?[]const u8 = if (msg.role == .assistant) try alloc.dupe(u8, "stop") else null;
+ errdefer if (stop_reason) |s| alloc.free(s);
+ const metadata: ?[]const u8 = if (msg.metadata) |m| try alloc.dupe(u8, m) else null;
+ const role: session_mod.StoredMessageRole = switch (msg.role) {
+ .system => .system,
+ .user => .user,
+ .assistant => .assistant,
+ };
+ return .{
+ .role = role,
+ .content = blocks,
+ .mode = mode,
+ .stop_reason = stop_reason,
+ .usage = pm.usage,
+ .metadata = metadata,
+ };
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+const testing = std.testing;
+
+/// Borrowed wire stamps for tests (no allocation; the manager dupes them).
+fn oaStamp() session_mod.WireStamp {
+ return .{ .api_style = .openai_chat, .base_url = "https://api.openai.com/v1", .model = "gpt-4o" };
+}
+fn anStamp() session_mod.WireStamp {
+ return .{ .api_style = .anthropic_messages, .base_url = "https://api.anthropic.com", .model = "claude-sonnet-4-20250514" };
+}
+
+test "newUuidV7: produces 36-char hyphenated string with version 7" {
+ const io = testing.io;
+ const id = try newUuidV7(testing.allocator, io);
+ defer testing.allocator.free(id);
+ try testing.expectEqual(@as(usize, 36), id.len);
+ // Position 14 is the version nibble — should be '7'.
+ try testing.expectEqual(@as(u8, '7'), id[14]);
+ // Hyphens at canonical positions.
+ try testing.expectEqual(@as(u8, '-'), id[8]);
+ try testing.expectEqual(@as(u8, '-'), id[13]);
+ try testing.expectEqual(@as(u8, '-'), id[18]);
+ try testing.expectEqual(@as(u8, '-'), id[23]);
+}
+
+test "isoTimestamp: well-formed ISO 8601 with millisecond precision" {
+ const ts = try isoTimestamp(testing.allocator, testing.io);
+ defer testing.allocator.free(ts);
+ try testing.expectEqual(@as(usize, 24), ts.len);
+ try testing.expectEqual(@as(u8, '-'), ts[4]);
+ try testing.expectEqual(@as(u8, 'T'), ts[10]);
+ try testing.expectEqual(@as(u8, '.'), ts[19]);
+ try testing.expectEqual(@as(u8, 'Z'), ts[23]);
+}
+
+// ---- In-memory + filesystem tests (use a tmp dir) ----
+
+const TmpSessionDir = struct {
+ parent: std.testing.TmpDir,
+ abs_path: []u8,
+
+ fn init(allocator: Allocator) !TmpSessionDir {
+ var parent = std.testing.tmpDir(.{});
+ errdefer parent.cleanup();
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const n = try parent.dir.realPath(testing.io, &path_buf);
+ const abs = try allocator.dupe(u8, path_buf[0..n]);
+ return .{ .parent = parent, .abs_path = abs };
+ }
+
+ fn deinit(self: *TmpSessionDir, allocator: Allocator) void {
+ allocator.free(self.abs_path);
+ self.parent.cleanup();
+ }
+};
+
+test "SessionFile.init: does not create file yet" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+
+ // Use a non-existent subdirectory inside the tmp dir to also exercise
+ // lazy directory creation.
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var mgr = try SessionFile.init(
+ testing.allocator,
+ io,
+ sessions,
+ "/some/cwd",
+ );
+ defer mgr.deinit();
+
+ try testing.expect(!mgr.isFlushed());
+
+ // The directory should not exist yet.
+ const stat_err = Io.Dir.cwd().openDir(io, sessions, .{});
+ try testing.expectError(error.FileNotFound, stat_err);
+}
+
+test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ const session_file: []u8 = blk: {
+ var mgr = try SessionFile.init(
+ testing.allocator,
+ io,
+ sessions,
+ "/proj/foo",
+ );
+ defer mgr.deinit();
+
+ // System message — non-assistant, should NOT trigger flush.
+ const sys_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
+ sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } };
+ _ = try mgr.appendMessage(
+ .{ .role = .system, .content = sys_blocks },
+ null,
+ );
+ try testing.expect(!mgr.isFlushed());
+
+ // User message — also doesn't flush.
+ const usr_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
+ usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } };
+ _ = try mgr.appendMessage(
+ .{ .role = .user, .content = usr_blocks },
+ oaStamp(),
+ );
+ try testing.expect(!mgr.isFlushed());
+
+ // Assistant message — triggers flush.
+ const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
+ a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
+ _ = try mgr.appendMessage(
+ .{
+ .role = .assistant,
+ .content = a_blocks,
+ .stop_reason = try testing.allocator.dupe(u8, "stop"),
+ },
+ oaStamp(),
+ );
+ try testing.expect(mgr.isFlushed());
+
+ // Append another user/assistant round.
+ const u_two = try testing.allocator.alloc(StoredContentBlock, 1);
+ u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, oaStamp());
+
+ const a2 = try testing.allocator.alloc(StoredContentBlock, 1);
+ a2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "4") } };
+ _ = try mgr.appendMessage(
+ .{ .role = .assistant, .content = a2, .stop_reason = try testing.allocator.dupe(u8, "stop") },
+ oaStamp(),
+ );
+
+ try testing.expectEqual(@as(usize, 5), mgr.getEntries().len);
+
+ break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
+ };
+ defer testing.allocator.free(session_file);
+
+ // Verify the file exists and is well-formed.
+ {
+ const bytes = try readWholeFile(testing.allocator, io, session_file);
+ defer testing.allocator.free(bytes);
+ // 1 header + 5 entries + trailing \n on each = 6 newlines.
+ var nl_count: usize = 0;
+ for (bytes) |b| if (b == '\n') {
+ nl_count += 1;
+ };
+ try testing.expectEqual(@as(usize, 6), nl_count);
+ }
+
+ // Resume.
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
+ defer resumed.deinit();
+ try testing.expect(resumed.isFlushed());
+ try testing.expectEqual(@as(usize, 5), resumed.getEntries().len);
+ try testing.expectEqualStrings("/proj/foo", resumed.getCwd());
+
+ // Continue the conversation.
+ const u_three = try testing.allocator.alloc(StoredContentBlock, 1);
+ u_three[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "thanks") } };
+ _ = try resumed.appendMessage(.{ .role = .user, .content = u_three }, oaStamp());
+ try testing.expectEqual(@as(usize, 6), resumed.getEntries().len);
+}
+
+test "SessionFile: assistant message tags the message metadata and the entry leaf id is the assistant entry" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ const u_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
+ u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } };
+ const user_id = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, oaStamp());
+
+ const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
+ a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } };
+ const asst_id = try mgr.appendMessage(.{ .role = .assistant, .content = a_blocks }, null);
+
+ // Leaf is the assistant entry.
+ try testing.expectEqualStrings(asst_id, mgr.getLeafId().?);
+ // Parent of assistant is the user entry.
+ const assistant_entry = mgr.getEntry(asst_id).?;
+ try testing.expectEqualStrings(user_id, assistant_entry.base().parent_id.?);
+ // User entry's parent is null (no system).
+ const user_entry = mgr.getEntry(user_id).?;
+ try testing.expect(user_entry.base().parent_id == null);
+}
+
+test "SessionFile: activeStamp is null before any user message, then tracks the latest user stamp" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ // No user messages yet — there is no "active" model on disk yet.
+ try testing.expect(mgr.activeStamp() == null);
+
+ // Stamp a user message with anthropic.
+ const u_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
+ u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, anStamp());
+
+ {
+ const am = mgr.activeStamp().?;
+ try testing.expectEqual(session_mod.APIStyle.anthropic_messages, am.api_style);
+ try testing.expectEqualStrings("claude-sonnet-4-20250514", am.model);
+ }
+}
+
+test "SessionFile: rebuildConversation reconstructs system/user/assistant turn" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ const sys = try testing.allocator.alloc(StoredContentBlock, 1);
+ sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "you are helpful") } };
+ _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null);
+
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
+ u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
+ a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi!") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
+
+ var conv = try mgr.rebuildConversation();
+ defer conv.deinit();
+ try testing.expectEqual(@as(usize, 3), conv.messages.items.len);
+ try testing.expectEqual(conversation_mod.MessageRole.system, conv.messages.items[0].role);
+ try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].System.text.items);
+ try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[1].role);
+ try testing.expectEqualStrings("hello", conv.messages.items[1].content.items[0].Text.items);
+ try testing.expectEqual(conversation_mod.MessageRole.assistant, conv.messages.items[2].role);
+ try testing.expectEqualStrings("hi!", conv.messages.items[2].content.items[0].Text.items);
+}
+
+test "SessionFile: crash recovery truncates corrupted trailing line" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ // Build a valid session first.
+ const session_file: []u8 = blk: {
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
+ u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
+ a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
+ break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
+ };
+ defer testing.allocator.free(session_file);
+
+ // Corrupt the file: append a partial JSON line at the end.
+ const garbage = "{\"type\":\"message\",\"id\":\"deadbeef\",\"parent";
+ {
+ const file = try Io.Dir.cwd().openFile(io, session_file, .{ .mode = .write_only });
+ defer file.close(io);
+ const len = try file.length(io);
+ try file.writePositionalAll(io, garbage, len);
+ }
+ // Confirm the file got bigger.
+ {
+ const f = try Io.Dir.cwd().openFile(io, session_file, .{ .mode = .read_only });
+ defer f.close(io);
+ const corrupted_len = try f.length(io);
+ try testing.expect(corrupted_len > garbage.len);
+ }
+
+ // Now resume — the partial line should be truncated.
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
+ defer resumed.deinit();
+ try testing.expectEqual(@as(usize, 2), resumed.getEntries().len);
+
+ // And the file on disk should match.
+ {
+ const bytes = try readWholeFile(testing.allocator, io, session_file);
+ defer testing.allocator.free(bytes);
+ try testing.expect(!std.mem.endsWith(u8, bytes, "parent"));
+ // Should end with a newline after the assistant entry.
+ try testing.expectEqual(@as(u8, '\n'), bytes[bytes.len - 1]);
+ }
+}
+
+test "listSessions: returns most recent first, with counts" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ // Create two sessions.
+ for (0..2) |i| {
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
+ u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
+ a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
+ // Small sleep so UUIDv7 timestamps differ.
+ io.sleep(.fromMilliseconds(2), .real) catch {};
+ _ = i;
+ }
+
+ const infos = try listSessions(testing.allocator, io, sessions, null);
+ defer {
+ for (infos) |fi| fi.deinit(testing.allocator);
+ testing.allocator.free(infos);
+ }
+ try testing.expectEqual(@as(usize, 2), infos.len);
+ try testing.expectEqual(@as(usize, 2), infos[0].message_count);
+ try testing.expect(std.mem.order(u8, infos[0].modified, infos[1].modified) != .lt);
+}
+
+test "findMostRecentSession: picks lexicographically greatest" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ // Pre-resolution before any sessions exist → null.
+ try testing.expect((try findMostRecentSession(testing.allocator, io, sessions)) == null);
+
+ // Create two.
+ var second_file: ?[]u8 = null;
+ defer if (second_file) |s| testing.allocator.free(s);
+
+ for (0..2) |i| {
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
+ u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
+ a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
+ if (i == 1) second_file = try testing.allocator.dupe(u8, mgr.getSessionFile());
+ io.sleep(.fromMilliseconds(2), .real) catch {};
+ }
+
+ const found = (try findMostRecentSession(testing.allocator, io, sessions)).?;
+ defer testing.allocator.free(found);
+ try testing.expectEqualStrings(second_file.?, found);
+}
+
+test "SessionFile: tool-use round-trip — assistant w/ ToolUse, user w/ ToolResult, assistant" {
+ const io = testing.io;
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ const session_file: []u8 = blk: {
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
+ u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "list files") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+
+ // Assistant emits a ToolUse.
+ const am1 = try testing.allocator.alloc(StoredContentBlock, 2);
+ am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } };
+ am1[1] = .{ .tool_use = .{
+ .id = try testing.allocator.dupe(u8, "tool_abc"),
+ .name = try testing.allocator.dupe(u8, "bash"),
+ .input = try testing.allocator.dupe(u8, "{\"command\":\"ls\"}"),
+ } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null);
+
+ // Tool-result user message.
+ const tr = try testing.allocator.alloc(StoredContentBlock, 1);
+ const trp = try testing.allocator.alloc(session_mod.StoredResultPart, 1);
+ trp[0] = .{ .text = try testing.allocator.dupe(u8, "a.txt\nb.txt") };
+ tr[0] = .{ .tool_result = .{
+ .tool_use_id = try testing.allocator.dupe(u8, "tool_abc"),
+ .parts = trp,
+ } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = tr }, oaStamp());
+
+ // Final assistant reply.
+ const a2 = try testing.allocator.alloc(StoredContentBlock, 1);
+ a2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "two files: a.txt and b.txt") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a2 }, null);
+
+ break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
+ };
+ defer testing.allocator.free(session_file);
+
+ // Reopen and verify content blocks survive.
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
+ defer resumed.deinit();
+ const entries = resumed.getEntries();
+ try testing.expectEqual(@as(usize, 4), entries.len);
+
+ // [1] = assistant with ToolUse
+ try testing.expectEqual(StoredMessageRole.assistant, entries[1].message.message.role);
+ try testing.expectEqual(@as(usize, 2), entries[1].message.message.content.len);
+ try testing.expect(entries[1].message.message.content[1] == .tool_use);
+ try testing.expectEqualStrings("bash", entries[1].message.message.content[1].tool_use.name);
+ try testing.expectEqualStrings("{\"command\":\"ls\"}", entries[1].message.message.content[1].tool_use.input);
+
+ // [2] = user with ToolResult, stamped with wire identity.
+ try testing.expectEqual(StoredMessageRole.user, entries[2].message.message.role);
+ try testing.expectEqual(session_mod.APIStyle.openai_chat, entries[2].message.stamp.?.api_style);
+ try testing.expect(entries[2].message.message.content[0] == .tool_result);
+ try testing.expectEqualStrings("tool_abc", entries[2].message.message.content[0].tool_result.tool_use_id);
+ try testing.expectEqualStrings("a.txt\nb.txt", entries[2].message.message.content[0].tool_result.parts[0].text);
+
+ // Conversation rebuild yields the same shape.
+ var conv = try resumed.rebuildConversation();
+ defer conv.deinit();
+ try testing.expectEqual(@as(usize, 4), conv.messages.items.len);
+ try testing.expect(conv.messages.items[1].content.items[1] == .ToolUse);
+ try testing.expect(conv.messages.items[2].content.items[0] == .ToolResult);
+}
+
+test "SessionFile: linear chain — each entry's parent_id is the previous entry's id" {
+ const io = testing.io;
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ // Three rounds: sys, user, asst, user, asst.
+ const sys = try testing.allocator.alloc(StoredContentBlock, 1);
+ sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "sys") } };
+ _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null);
+
+ const u_one = try testing.allocator.alloc(StoredContentBlock, 1);
+ u_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u1") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_one }, oaStamp());
+
+ const a_one = try testing.allocator.alloc(StoredContentBlock, 1);
+ a_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a1") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_one }, null);
+
+ const u_two = try testing.allocator.alloc(StoredContentBlock, 1);
+ u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u2") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, oaStamp());
+
+ const a_two = try testing.allocator.alloc(StoredContentBlock, 1);
+ a_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a2") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_two }, null);
+
+ const entries = mgr.getEntries();
+ try testing.expectEqual(@as(usize, 5), entries.len);
+ try testing.expect(entries[0].base().parent_id == null);
+ for (entries[1..], 1..) |e, i| {
+ try testing.expectEqualStrings(entries[i - 1].base().id, e.base().parent_id.?);
+ }
+}
+
+test "resolveSessionId: unique prefix → match, ambiguous → error" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ // Create one session.
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+ const u = try testing.allocator.alloc(StoredContentBlock, 1);
+ u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = u }, oaStamp());
+ const a = try testing.allocator.alloc(StoredContentBlock, 1);
+ a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
+
+ const id = mgr.getSessionId();
+ const prefix = id[0..8];
+
+ const resolved = try resolveSessionId(testing.allocator, io, sessions, prefix);
+ defer testing.allocator.free(resolved);
+ try testing.expectEqualStrings(mgr.getSessionFile(), resolved);
+
+ try testing.expectError(error.SessionNotFound, resolveSessionId(testing.allocator, io, sessions, "ffffffff"));
+}
+
+test "compaction summary round-trips through persist + resume + rebuild" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ const session_file: []u8 = blk: {
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ // System + an old turn that will be superseded.
+ const sys = try testing.allocator.alloc(StoredContentBlock, 1);
+ sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "you are helpful") } };
+ _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null);
+
+ const uo = try testing.allocator.alloc(StoredContentBlock, 1);
+ uo[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "old q") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = uo }, oaStamp());
+ const ao = try testing.allocator.alloc(StoredContentBlock, 1);
+ ao[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "old a") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = ao }, null);
+
+ // Compaction: summary message + duplicated kept suffix.
+ const cs = try testing.allocator.alloc(StoredContentBlock, 1);
+ cs[0] = .{ .compaction_summary = .{ .text = try testing.allocator.dupe(u8, "SUMMARY") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = cs }, oaStamp());
+
+ const ur = try testing.allocator.alloc(StoredContentBlock, 1);
+ ur[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "recent q") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = ur }, oaStamp());
+
+ break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
+ };
+ defer testing.allocator.free(session_file);
+
+ var resumed = try SessionFile.open(testing.allocator, io, session_file);
+ defer resumed.deinit();
+ var conv = try resumed.rebuildConversation();
+ defer conv.deinit();
+
+ // The compaction summary block survived as a CompactionSummary.
+ const anchor = conversation_mod.latestCompactionIndex(conv.messages.items).?;
+ try testing.expectEqualStrings(
+ "SUMMARY",
+ conv.messages.items[anchor].content.items[0].CompactionSummary.text.items,
+ );
+
+ // The active window is [summary, recent q].
+ const window = conversation_mod.activeMessageWindow(conv.messages.items);
+ try testing.expectEqual(@as(usize, 2), window.len);
+ try testing.expectEqualStrings("recent q", window[1].content.items[0].Text.items);
+
+ // System prompt survives (derived independently).
+ var sys_blocks = try conversation_mod.effectiveSystemBlocks(testing.allocator, conv.messages.items);
+ defer sys_blocks.deinit(testing.allocator);
+ try testing.expectEqual(@as(usize, 1), sys_blocks.items.len);
+ try testing.expectEqualStrings("you are helpful", sys_blocks.items[0]);
+}
+
+test "loadConversation: trailing user prompt is split out as dangling, excluded from conversation" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var mgr = try SessionFile.init(testing.allocator, io, sessions, "/c");
+ defer mgr.deinit();
+
+ // A completed user/assistant round, then a trailing user prompt with no
+ // following assistant. The dangling-prompt recovery feature was dropped
+ // in R2: the trailing user message simply round-trips into the rebuilt
+ // conversation like any other.
+ const um1 = try testing.allocator.alloc(StoredContentBlock, 1);
+ um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, oaStamp());
+ const am1 = try testing.allocator.alloc(StoredContentBlock, 1);
+ am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
+ _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null);
+ const um2 = try testing.allocator.alloc(StoredContentBlock, 1);
+ um2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } };
+ _ = try mgr.appendMessage(.{ .role = .user, .content = um2 }, oaStamp());
+
+ var conv = try mgr.rebuildConversation();
+ defer conv.deinit();
+ // All three messages are present (dangling recovery dropped).
+ try testing.expectEqual(@as(usize, 3), conv.messages.items.len);
+ try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[2].role);
+}
+
+test "FileSystemJSONLStore catalog: create → append → load round-trips" {
+ const io = testing.io;
+
+ var td = try TmpSessionDir.init(testing.allocator);
+ defer td.deinit(testing.allocator);
+ const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" });
+ defer testing.allocator.free(sessions);
+
+ var catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions, "/c");
+ defer catalog.deinit();
+ const store = catalog.store();
+
+ var sess = store.create();
+ defer sess.info.deinit(testing.allocator);
+
+ // Build a user + assistant PersistentMessage batch (borrows in-memory
+ // messages owned here).
+ var conv = conversation_mod.Conversation.init(testing.allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("ping");
+ try conv.addAssistantMessage(&.{});
+
+ const id: session_store_mod.WireIdentity = .{ .api_style = .openai_chat, .base_url = "u", .model = "m" };
+ var batch = [_]session_store_mod.PersistentMessage{
+ .{ .message = conv.messages.items[0], .identity = id },
+ .{ .message = conv.messages.items[1], .identity = id },
+ };
+ try sess.append(&batch);
+
+ // The session's last-used api_style updated after append.
+ try testing.expectEqual(session_store_mod.APIStyle.openai_chat, sess.info.api_style);
+
+ // Load it back by id.
+ var loaded = (try store.load(sess.info.id)).?;
+ defer loaded.deinit();
+ try testing.expectEqual(@as(usize, 2), loaded.messages.items.len);
+ try testing.expectEqual(conversation_mod.MessageRole.user, loaded.messages.items[0].role);
+}