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.zig2103
1 files changed, 0 insertions, 2103 deletions
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig
deleted file mode 100644
index 9bc9436..0000000
--- a/libpanto/src/file_system_jsonl_store.zig
+++ /dev/null
@@ -1,2103 +0,0 @@
-//! 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");
-const turn_persist = @import("turn_persist.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,
- metadata: ?[]const u8,
- ) !SessionFile {
- const id = try newUuidV7(allocator, io);
- defer allocator.free(id);
- return initWithId(allocator, io, session_dir, metadata, 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,
- metadata: ?[]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 metadata_copy: ?[]const u8 = if (metadata) |m| try allocator.dupe(u8, m) else null;
- errdefer if (metadata_copy) |m| allocator.free(m);
-
- 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,
- .metadata = metadata_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);
- }
-
- // Drop assistant tool_use blocks left dangling (no matching
- // tool_result) by an interrupted prior turn. If anything changed,
- // rebuild the id index and rewrite the file once.
- if (elideDanglingToolUses(allocator, &entries)) {
- by_id.clearRetainingCapacity();
- for (entries.items, 0..) |e, i| {
- try by_id.put(e.base().id, i);
- }
- try rewriteFile(allocator, io, path_copy, 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 = 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 getSessionFile(self: *const SessionFile) []const u8 {
- return self.session_file;
- }
-
- pub fn isFlushed(self: *const SessionFile) bool {
- return self.flushed;
- }
-
- // ---------- Appending ----------
-
- /// Append a single message, returning its entry id. A thin wrapper over
- /// `appendMessagesAtomic` (which handles `len == 1`); used by the tests.
- /// `msg` is consumed (ownership transferred).
- pub fn appendMessage(
- self: *SessionFile,
- msg: StoredMessage,
- // Wire-format provider identity for the entry. Null on system
- // messages. Borrowed; duplicated into the entry.
- stamp: ?session_mod.WireStamp,
- ) ![]const u8 {
- var msgs = [_]StoredMessage{msg};
- const stamps = [_]?session_mod.WireStamp{stamp};
- try self.appendMessagesAtomic(&msgs, &stamps);
- return self.leaf_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;
- }
-
- 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 {
- // File is created on the first assistant message (see module docs).
- for (messages) |m| if (m.role == .assistant) {
- try self.flushBufferedMany(entries);
- break;
- };
- }
-
- 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, me.stamp),
- }
- }
- 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,
- stamp: ?session_mod.WireStamp,
-) !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 } };
- }
- if (block == .Thinking and stamp != null) {
- block.Thinking.signature_origin = try conversation_mod.SignatureOrigin.init(
- allocator,
- stamp.?.api_style,
- stamp.?.base_url,
- stamp.?.model,
- );
- }
- content.appendAssumeCapacity(block);
- }
- const role: conversation_mod.MessageRole = switch (disk_msg.role) {
- .system => .system,
- .user => .user,
- .assistant => .assistant,
- };
- // Reconstruct the per-message producing identity from the wire stamp, so
- // a later in-session compaction preserves it (rather than re-stamping the
- // restated turn with the compaction model). Borrowed `stamp` slices are
- // duped into the conversation allocator.
- const identity: ?session_store_mod.WireIdentity = if (stamp) |st|
- try conversation_mod.dupeWireIdentity(allocator, .{
- .api_style = st.api_style,
- .base_url = st.base_url,
- .model = st.model,
- .reasoning = st.reasoning,
- .thinking = st.thinking,
- .effort = st.effort,
- .thinking_budget_tokens = st.thinking_budget_tokens,
- .thinking_interleaved = st.thinking_interleaved,
- })
- else
- null;
- errdefer if (identity) |id| conversation_mod.freeWireIdentity(allocator, id);
- // 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,
- .identity = identity,
- });
-}
-
-// =============================================================================
-// Entry repair on load
-// =============================================================================
-
-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
-// =============================================================================
-
-/// 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). Optional
-/// `metadata` is stamped into new session headers for display/provenance only.
-pub const FileSystemJSONLStore = struct {
- allocator: Allocator,
- io: Io,
- dir: []u8, // owned: the sessions directory
- metadata: ?[]u8, // owned: recorded in new session headers
- open: std.StringHashMap(*SessionFile),
-
- pub fn init(allocator: Allocator, io: Io, dir: []const u8) !FileSystemJSONLStore {
- return initWithMetadata(allocator, io, dir, null);
- }
-
- pub fn initWithMetadata(allocator: Allocator, io: Io, dir: []const u8, metadata: ?[]const u8) !FileSystemJSONLStore {
- const dir_copy = try allocator.dupe(u8, dir);
- errdefer allocator.free(dir_copy);
- const metadata_copy: ?[]u8 = if (metadata) |m| try allocator.dupe(u8, m) else null;
- return .{
- .allocator = allocator,
- .io = io,
- .dir = dir_copy,
- .metadata = metadata_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);
- if (self.metadata) |m| self.allocator.free(m);
- }
-
- /// 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.metadata, 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));
- // Most-recently-*modified* wins, matching the `list()` sort order —
- // not newest-created (lexicographic UUIDv7 filename).
- const fis = try listSessions(self.allocator, self.io, self.dir, null);
- defer {
- for (fis) |fi| fi.deinit(self.allocator);
- self.allocator.free(fis);
- }
- if (fis.len == 0) return null;
- const info = try self.infoFromFileInfo(fis[0]);
- return .{ .info = info, .store = self.store() };
- }
-
- 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,
- .thinking = pm.identity.thinking,
- .effort = pm.identity.effort,
- .thinking_budget_tokens = pm.identity.thinking_budget_tokens,
- .thinking_interleaved = pm.identity.thinking_interleaved,
- };
- 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 helper: append a single-text user message (see the `addUserMessage`
-/// signature change to a block slice).
-fn addUserText(conv: *conversation_mod.Conversation, text: []const u8) !void {
- const tb = try conversation_mod.textualBlockFromSlice(conv.allocator, text);
- var block: conversation_mod.ContentBlock = .{ .Text = tb };
- errdefer block.deinit(conv.allocator);
- try conv.addUserMessage(&.{block});
-}
-
-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,
- "{\"cwd\":\"/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,
- "{\"cwd\":\"/proj/foo\"}",
- );
- defer mgr.deinit();
-
- // System message buffers in memory — nothing on disk yet.
- 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 — still buffered.
- 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 — first flush: header + all buffered entries.
- 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.entries.items.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.entries.items.len);
- try testing.expectEqualStrings("{\"cwd\":\"/proj/foo\"}", resumed.header.metadata.?);
-
- // 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.entries.items.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, null);
- 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.leaf_id.?);
- // Parent of assistant is the user entry.
- const assistant_entry = mgr.entries.items[mgr.by_id.get(asst_id).?];
- try testing.expectEqualStrings(user_id, assistant_entry.base().parent_id.?);
- // User entry's parent is null (no system).
- const user_entry = mgr.entries.items[mgr.by_id.get(user_id).?];
- try testing.expect(user_entry.base().parent_id == null);
-}
-
-/// Test helper: the active provider/model stamp — the last entry carrying a
-/// wire stamp, walking leaf→root. Null when no stamped message exists yet.
-fn activeStamp(sf: *const SessionFile) ?session_mod.WireStamp {
- var i = sf.entries.items.len;
- while (i > 0) : (i -= 1) {
- switch (sf.entries.items[i - 1]) {
- .message => |m| if (m.stamp) |st| return st,
- }
- }
- return 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, null);
- defer mgr.deinit();
-
- // No user messages yet — there is no "active" model on disk yet.
- try testing.expect(activeStamp(&mgr) == 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 = activeStamp(&mgr).?;
- 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, null);
- 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, null);
- 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.entries.items.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, null);
- 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 "latest: picks most-recently-modified, not newest-created" {
- 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);
- defer catalog.deinit();
- const st = catalog.store();
-
- // Before any sessions exist → null.
- try testing.expect((try st.latest()) == null);
-
- // Create session A, then session B (newer id), then append to A again
- // so A is the most recently *modified*.
- var first_id: ?[]u8 = null;
- defer if (first_id) |s| testing.allocator.free(s);
-
- for (0..2) |i| {
- var mgr = try SessionFile.init(testing.allocator, io, sessions, null);
- 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 }, oaStamp());
- if (i == 0) first_id = try testing.allocator.dupe(u8, mgr.header.id);
- io.sleep(.fromMilliseconds(2), .real) catch {};
- }
- {
- const path = try resolveSessionId(testing.allocator, io, sessions, first_id.?);
- defer testing.allocator.free(path);
- var mgr = try SessionFile.open(testing.allocator, io, path);
- defer mgr.deinit();
- 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 }, oaStamp());
- }
-
- var latest = (try st.latest()).?;
- defer latest.info.deinit(testing.allocator);
- try testing.expectEqualStrings(first_id.?, latest.info.id);
-}
-
-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, null);
- 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.entries.items;
- 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, null);
- 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.entries.items;
- 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, null);
- 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.header.id;
- 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, null);
- 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, null);
- 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);
- 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 addUserText(&conv, "ping");
- try conv.addAssistantMessage(&.{}, null);
-
- 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);
-}
-
-test "FileSystemJSONLStore load restores thinking signature origin from message 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 catalog = try FileSystemJSONLStore.init(testing.allocator, io, sessions);
- defer catalog.deinit();
- const store = catalog.store();
-
- var sess = store.create();
- defer sess.info.deinit(testing.allocator);
-
- var conv = conversation_mod.Conversation.init(testing.allocator);
- defer conv.deinit();
- try addUserText(&conv, "ping");
- try conv.addAssistantMessage(&.{
- .{ .Thinking = .{
- .text = try conversation_mod.textualBlockFromSlice(testing.allocator, "thinking..."),
- .signature = try testing.allocator.dupe(u8, "sig123"),
- } },
- .{ .Text = try conversation_mod.textualBlockFromSlice(testing.allocator, "pong") },
- }, null);
-
- const id: session_store_mod.WireIdentity = .{
- .api_style = .anthropic_messages,
- .base_url = "https://api.anthropic.com",
- .model = "claude-sonnet-4-20250514",
- };
- var batch = [_]session_store_mod.PersistentMessage{
- .{ .message = conv.messages.items[0], .identity = id },
- .{ .message = conv.messages.items[1], .identity = id },
- };
- try sess.append(&batch);
-
- var loaded = (try store.load(sess.info.id)).?;
- defer loaded.deinit();
- const thinking = loaded.messages.items[1].content.items[0].Thinking;
- try testing.expect(thinking.signature_origin != null);
- try testing.expect(thinking.signature_origin.?.matches(
- .anthropic_messages,
- "https://api.anthropic.com",
- "claude-sonnet-4-20250514",
- ));
-}
-
-test "persistTurn: a message's own identity overrides the uniform persist identity" {
- // Regression for the compaction re-stamping bug: a kept-verbatim turn
- // produced by model A must persist with A's wire identity even when the
- // turn is persisted under model B (the compaction model). persistTurn
- // stamps the uniform identity only on messages that don't already carry
- // one; a message with its own identity keeps it.
- 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);
- defer catalog.deinit();
- const store = catalog.store();
- var sess = store.create();
- defer sess.info.deinit(testing.allocator);
-
- var conv = conversation_mod.Conversation.init(testing.allocator);
- defer conv.deinit();
- // User turn carries no identity (will be stamped with the persist id).
- try addUserText(&conv, "ping");
- // Assistant turn was produced by model A — stamp its identity directly,
- // as a live turn / a reloaded turn would have.
- try conv.addAssistantMessage(&.{
- .{ .Thinking = .{
- .text = try conversation_mod.textualBlockFromSlice(testing.allocator, "thinking..."),
- .signature = try testing.allocator.dupe(u8, "sig123"),
- } },
- .{ .Text = try conversation_mod.textualBlockFromSlice(testing.allocator, "pong") },
- }, null);
- conv.messages.items[1].identity = try conversation_mod.dupeWireIdentity(testing.allocator, .{
- .api_style = .anthropic_messages,
- .base_url = "https://api.anthropic.com",
- .model = "claude-sonnet-4-20250514",
- });
-
- // Persist under model B (a different, "compaction" identity).
- const persist_id: session_store_mod.WireIdentity = .{
- .api_style = .openai_chat,
- .base_url = "https://api.openai.com/v1",
- .model = "gpt-4o",
- };
- try turn_persist.persistTurn(testing.allocator, &sess, &conv, 0, persist_id, &.{});
-
- // Reload: the user turn took model B's identity; the assistant turn kept
- // model A's — so its thinking signature replays to A, not B.
- var loaded = (try store.load(sess.info.id)).?;
- defer loaded.deinit();
-
- const asst_origin = loaded.messages.items[1].content.items[0].Thinking.signature_origin.?;
- try testing.expect(asst_origin.matches(.anthropic_messages, "https://api.anthropic.com", "claude-sonnet-4-20250514"));
- try testing.expect(!asst_origin.matches(.openai_chat, "https://api.openai.com/v1", "gpt-4o"));
-
- const user_id = loaded.messages.items[0].identity.?;
- try testing.expectEqual(session_store_mod.APIStyle.openai_chat, user_id.api_style);
- try testing.expectEqualStrings("gpt-4o", user_id.model);
-}