diff options
| author | T <t@tjp.lol> | 2026-05-27 12:45:20 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 16:21:52 -0600 |
| commit | 6545cdfd8f2bc865aa06a2b5515056daf58ba111 (patch) | |
| tree | 5393cefda42dad12eb90612c6b7ff3c50d8eb800 /libpanto/src/session_manager.zig | |
| parent | b1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff) | |
session files
Diffstat (limited to 'libpanto/src/session_manager.zig')
| -rw-r--r-- | libpanto/src/session_manager.zig | 1509 |
1 files changed, 1509 insertions, 0 deletions
diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig new file mode 100644 index 0000000..bf9585d --- /dev/null +++ b/libpanto/src/session_manager.zig @@ -0,0 +1,1509 @@ +//! 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"); + +pub const SessionHeader = session_mod.SessionHeader; +pub const SessionEntry = session_mod.SessionEntry; +pub const MessageEntry = session_mod.MessageEntry; +pub const DiskMessage = session_mod.DiskMessage; +pub const DiskMessageRole = session_mod.DiskMessageRole; +pub const DiskContentBlock = session_mod.DiskContentBlock; +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, + }, + ); +} + +// ============================================================================= +// SessionInfo (listing) +// ============================================================================= + +pub const SessionInfo = struct { + path: []u8, + id: []u8, + cwd: []u8, + created: []u8, // ISO 8601 from header timestamp + modified: []u8, // ISO 8601 from last user/assistant entry, falling back to header, then file mtime + message_count: usize, + + pub fn deinit(self: SessionInfo, alloc: Allocator) void { + alloc.free(self.path); + alloc.free(self.id); + alloc.free(self.cwd); + alloc.free(self.created); + alloc.free(self.modified); + } +}; + +pub fn freeSessionInfos(alloc: Allocator, infos: []SessionInfo) void { + for (infos) |info| info.deinit(alloc); + alloc.free(infos); +} + +// ============================================================================= +// SessionManager +// ============================================================================= + +pub const Error = error{ + NoSessionsFound, + AmbiguousSessionId, + SessionNotFound, + InvalidSessionFile, +} || Allocator.Error || Io.Cancelable; + +pub const SessionManager = 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, + ) !SessionManager { + const dir = try allocator.dupe(u8, session_dir); + errdefer allocator.free(dir); + + const id = try newUuidV7(allocator, io); + 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, + ) !SessionManager { + 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); + } + + // Run format migration. Currently a no-op for version 1, but the + // hook exists so future versions can rewrite the file. + var migrated_header = header; + const did_migrate = migrate(allocator, &migrated_header, &entries); + // 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: *SessionManager) 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 SessionManager) []const u8 { + return self.header.cwd; + } + + pub fn getSessionId(self: *const SessionManager) []const u8 { + return self.header.id; + } + + pub fn getSessionFile(self: *const SessionManager) []const u8 { + return self.session_file; + } + + pub fn getSessionDir(self: *const SessionManager) []const u8 { + return self.session_dir; + } + + pub fn getLeafId(self: *const SessionManager) ?[]const u8 { + return self.leaf_id; + } + + pub fn getEntry(self: *const SessionManager, 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 SessionManager) []const SessionEntry { + return self.entries.items; + } + + pub fn isFlushed(self: *const SessionManager) 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 borrowed slices owned by the manager; do not free. + pub fn activeModel(self: *const SessionManager) ?struct { provider: []const u8, model: []const u8 } { + var i = self.entries.items.len; + while (i > 0) : (i -= 1) { + const e = self.entries.items[i - 1]; + switch (e) { + .message => |m| { + if (m.message.role == .user) { + if (m.provider) |p| { + if (m.model) |mo| { + return .{ .provider = p, .model = mo }; + } + } + } + }, + } + } + 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: *SessionManager, + msg: DiskMessage, + // Top-level stamps on the entry (vs. inside the message). + // Stamped only on user messages. + provider: ?[]const u8, + model: ?[]const u8, + ) ![]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 provider_copy: ?[]const u8 = if (provider) |p| try self.allocator.dupe(u8, p) else null; + errdefer if (provider_copy) |p| self.allocator.free(p); + + const model_copy: ?[]const u8 = if (model) |m| try self.allocator.dupe(u8, m) else null; + errdefer if (model_copy) |m| self.allocator.free(m); + + const entry: SessionEntry = .{ .message = .{ + .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp }, + .provider = provider_copy, + .model = model_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: *SessionManager) ![]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 + `final_entry` + /// to the file as a single batch. Creates the directory and file. + /// Called only when `flushed = false` and we just got an assistant + /// message. + fn flushBuffered(self: *SessionManager, final_entry: SessionEntry) !void { + // Ensure the directory exists. + try mkdirP(self.io, self.session_dir); + + // Open (create exclusively isn't required; if a stale file with the + // same UUIDv7 existed we'd just overwrite — UUIDv7s are unique). + 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; + // Header + 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; + + // Buffered entries. + 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; + } + + // Final (the assistant message we just got). + const final_line = try session_mod.serializeEntry(self.allocator, final_entry); + defer self.allocator.free(final_line); + try file.writePositionalAll(self.io, final_line, offset); + offset += final_line.len; + try file.writePositionalAll(self.io, "\n", offset); + offset += 1; + + // Best-effort fsync — the directory itself doesn't need syncing + // for our purposes (we don't unlink/rename); the file body does. + file.sync(self.io) catch {}; + + self.flushed = true; + self.written_bytes = offset; + } + + /// Append a single line for `entry` to the open session file. Caller + /// must have already verified `flushed`. + fn persistEntry(self: *SessionManager, entry: SessionEntry) !void { + const file = try Io.Dir.cwd().openFile(self.io, self.session_file, .{ + .mode = .write_only, + }); + defer file.close(self.io); + + const line = try session_mod.serializeEntry(self.allocator, entry); + defer self.allocator.free(line); + + try file.writePositionalAll(self.io, line, self.written_bytes); + self.written_bytes += line.len; + try file.writePositionalAll(self.io, "\n", self.written_bytes); + self.written_bytes += 1; + file.sync(self.io) catch {}; + } + + // ============================================================================= + // Conversation rebuild + // ============================================================================= + + /// Build a fresh `Conversation` from the entry log. Caller owns the + /// returned conversation (call `deinit`). + pub fn rebuildConversation(self: *const SessionManager) !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; + } +}; + +fn appendMessageToConv( + conv: *conversation_mod.Conversation, + allocator: Allocator, + disk_msg: DiskMessage, +) !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); + for (disk_msg.content) |db| { + const block = try session_mod.diskContentBlockToInternal(allocator, db); + content.appendAssumeCapacity(block); + } + const role: conversation_mod.MessageRole = switch (disk_msg.role) { + .system => .system, + .user => .user, + .assistant => .assistant, + }; + try conv.messages.append(allocator, .{ .role = role, .content = content }); +} + +// ============================================================================= +// 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; +} + +// ============================================================================= +// 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, +) ![]SessionInfo { + var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) { + error.FileNotFound => return try allocator.alloc(SessionInfo, 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(SessionInfo) = .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 = buildSessionInfo(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(SessionInfo, slice, {}, sessionInfoNewerFirst); + return slice; +} + +fn sessionInfoNewerFirst(_: void, a: SessionInfo, b: SessionInfo) bool { + return std.mem.order(u8, a.modified, b.modified) == .gt; +} + +fn buildSessionInfo( + allocator: Allocator, + io: Io, + file_path: []const u8, +) !?SessionInfo { + 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 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); + } + }, + } + }, + } + } + + const header = header_opt orelse return null; + // We will keep header alive through the deinit defer, and dupe its + // fields into the result. Cheap, avoids any ownership shuffling. + + 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 cwd = try allocator.dupe(u8, header.cwd); + errdefer allocator.free(cwd); + 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); + + return .{ + .path = path, + .id = id, + .cwd = cwd, + .created = created, + .modified = modified, + .message_count = message_count, + }; +} + +// ============================================================================= +// 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 }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +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 "SessionManager.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 SessionManager.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 "SessionManager: 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 SessionManager.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(DiskContentBlock, 1); + sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } }; + _ = try mgr.appendMessage( + .{ .role = .system, .content = sys_blocks }, + null, + null, + ); + try testing.expect(!mgr.isFlushed()); + + // User message — also doesn't flush. + const usr_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } }; + _ = try mgr.appendMessage( + .{ .role = .user, .content = usr_blocks }, + "openai", + "gpt-4o", + ); + try testing.expect(!mgr.isFlushed()); + + // Assistant message — triggers flush. + const a_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; + _ = try mgr.appendMessage( + .{ + .role = .assistant, + .content = a_blocks, + .provider = try testing.allocator.dupe(u8, "openai"), + .model = try testing.allocator.dupe(u8, "gpt-4o"), + .stop_reason = try testing.allocator.dupe(u8, "stop"), + }, + null, + null, + ); + try testing.expect(mgr.isFlushed()); + + // Append another user/assistant round. + const u_two = try testing.allocator.alloc(DiskContentBlock, 1); + u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, "openai", "gpt-4o"); + + const a2 = try testing.allocator.alloc(DiskContentBlock, 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") }, + null, + null, + ); + + 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 SessionManager.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(DiskContentBlock, 1); + u_three[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "thanks") } }; + _ = try resumed.appendMessage(.{ .role = .user, .content = u_three }, "openai", "gpt-4o"); + try testing.expectEqual(@as(usize, 6), resumed.getEntries().len); +} + +test "SessionManager: 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const u_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } }; + const user_id = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, "openai", "gpt-4o"); + + const a_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } }; + const asst_id = try mgr.appendMessage(.{ .role = .assistant, .content = a_blocks }, null, 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 "SessionManager: activeModel 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 SessionManager.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.activeModel() == null); + + // Stamp a user message with anthropic. + const u_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, "anthropic", "claude-sonnet-4-20250514"); + + { + const am = mgr.activeModel().?; + try testing.expectEqualStrings("anthropic", am.provider); + try testing.expectEqualStrings("claude-sonnet-4-20250514", am.model); + } +} + +test "SessionManager: 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const sys = try testing.allocator.alloc(DiskContentBlock, 1); + sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "you are helpful") } }; + _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null); + + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi!") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, 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].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 "SessionManager: 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, 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 SessionManager.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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null); + // Small sleep so UUIDv7 timestamps differ. + io.sleep(.fromMilliseconds(2), .real) catch {}; + _ = i; + } + + const infos = try listSessions(testing.allocator, io, sessions, null); + defer freeSessionInfos(testing.allocator, 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, 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 "SessionManager: 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "list files") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + + // Assistant emits a ToolUse. + const a1 = try testing.allocator.alloc(DiskContentBlock, 2); + a1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } }; + a1[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 = a1 }, null, null); + + // Tool-result user message. + const tr = try testing.allocator.alloc(DiskContentBlock, 1); + tr[0] = .{ .tool_result = .{ + .tool_use_id = try testing.allocator.dupe(u8, "tool_abc"), + .content = try testing.allocator.dupe(u8, "a.txt\nb.txt"), + } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = tr }, "openai", "gpt-4o"); + + // Final assistant reply. + const a2 = try testing.allocator.alloc(DiskContentBlock, 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, 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 SessionManager.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(DiskMessageRole.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 provider/model. + try testing.expectEqual(DiskMessageRole.user, entries[2].message.message.role); + try testing.expectEqualStrings("openai", entries[2].message.provider.?); + 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.content); + + // 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 "SessionManager: 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + // Three rounds: sys, user, asst, user, asst. + const sys = try testing.allocator.alloc(DiskContentBlock, 1); + sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "sys") } }; + _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null); + + const u_one = try testing.allocator.alloc(DiskContentBlock, 1); + u_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u1") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_one }, "openai", "gpt-4o"); + + const a_one = try testing.allocator.alloc(DiskContentBlock, 1); + a_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a1") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_one }, null, null); + + const u_two = try testing.allocator.alloc(DiskContentBlock, 1); + u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u2") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, "openai", "gpt-4o"); + + const a_two = try testing.allocator.alloc(DiskContentBlock, 1); + a_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a2") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_two }, null, 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 SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, 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")); +} |
