//! 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 (`.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")); }