diff options
| author | t <t@tjp.lol> | 2026-06-07 11:35:49 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-07 11:35:49 -0600 |
| commit | b14859b9726185ab873356390068e887b7f486d3 (patch) | |
| tree | 6530d8ee62e7639b50c99a67d23f89e25ef572dc /libpanto/src/session_manager.zig | |
| parent | d36a51358efbc48de3d5b732727455efc60acae1 (diff) | |
R2: redesign session store with wire-format identity
Replace the single-session SessionManager seam with a directory-backed
catalog. session_manager.zig -> file_system_jsonl_store.zig; the old
machinery becomes internal SessionFile, and a new FileSystemJSONLStore
implements the redesigned SessionStore vtable (create/list/resolve/latest/
load/appendMessages) minting Session/SessionInfo handles.
Session logs now record wire-format provider identity
({api_style, base_url, model, reasoning}) instead of a single provider
string or CLI aliases; no api_key material is ever stored. Disk* content
types renamed Stored*; the rich audit-oriented write record is
PersistentMessage (in-memory Message + WireIdentity + provenance).
Agent.init now takes a Session; persist_provider/persist_model display
strings deleted (banner stays alias-based, resume picks default model).
Message.metadata round-trips. Dangling-prompt recovery dropped. CLI
migrated to the catalog store for run/resume/list. Clean break, no version
bump (old logs wiped).
Diffstat (limited to 'libpanto/src/session_manager.zig')
| -rw-r--r-- | libpanto/src/session_manager.zig | 1886 |
1 files changed, 0 insertions, 1886 deletions
diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig deleted file mode 100644 index b659506..0000000 --- a/libpanto/src/session_manager.zig +++ /dev/null @@ -1,1886 +0,0 @@ -//! Session lifecycle: create, open, replay, append. -//! -//! Backed by an append-only JSONL file on disk. The on-disk types live in -//! `session.zig`. This module owns: -//! -//! - Path resolution (sessions dir is supplied by the caller; we own the -//! filename and writes within it). -//! - The in-memory entry index (`by_id` map + leaf pointer). -//! - Deferred file creation: the file is not written until the first -//! assistant message persists. Until that point, all entries are -//! buffered in memory. -//! - Append semantics: once flushed, every completed entry is written -//! and synced to disk immediately. -//! - Crash recovery: on open, the file is parsed line-by-line; the first -//! line that fails to parse causes everything from that line onward to -//! be truncated from the file. -//! - One-time format migration when a future version reads a v1 file -//! (currently a no-op; the hook is in place). -//! - Rebuilding a `Conversation` from the entry tree, plus determining -//! the active provider/model. -//! -//! The library-vs-CLI boundary: callers pass an absolute path to the -//! per-cwd sessions directory. We compute the per-session filename -//! ourselves (`<uuidv7>.jsonl`) and lazily mkdir the directory on the -//! first flush. The CLI owns XDG resolution, encoded-cwd grouping, and -//! the `--resume` flag plumbing. - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const Io = std.Io; - -const session_mod = @import("session.zig"); -const conversation_mod = @import("conversation.zig"); -const session_store_mod = @import("session_store.zig"); - -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 DiskSystemMode = session_mod.DiskSystemMode; -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); - } - - var migrated_header = header; - var did_migrate = migrate(allocator, &migrated_header, &entries); - if (elideDanglingToolUses(allocator, &entries)) { - did_migrate = true; - } - // We didn't reassign by_id during migration; rebuild if needed. - if (did_migrate) { - by_id.clearRetainingCapacity(); - for (entries.items, 0..) |e, i| { - try by_id.put(e.base().id, i); - } - try rewriteFile(allocator, io, path_copy, migrated_header, entries.items); - } - - const leaf_id: ?[]const u8 = if (entries.items.len > 0) - entries.items[entries.items.len - 1].base().id - else - null; - - // Compute final file length on disk so future appends use the - // correct offset. - const stat = try statFileForLength(io, path_copy); - - return .{ - .allocator = allocator, - .io = io, - .session_dir = dir, - .session_file = path_copy, - .header = migrated_header, - .entries = entries, - .by_id = by_id, - .leaf_id = leaf_id, - .flushed = true, - .written_bytes = stat, - }; - } - - pub fn deinit(self: *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 + `new_entries` - /// to the file as a single batch. Creates the directory and file. - fn flushBufferedMany(self: *SessionManager, new_entries: []const SessionEntry) !void { - try mkdirP(self.io, self.session_dir); - - const file = try Io.Dir.cwd().createFile(self.io, self.session_file, .{ - .truncate = true, - .read = false, - }); - defer file.close(self.io); - - var offset: u64 = 0; - const header_line = try session_mod.serializeHeader(self.allocator, self.header); - defer self.allocator.free(header_line); - try file.writePositionalAll(self.io, header_line, offset); - offset += header_line.len; - try file.writePositionalAll(self.io, "\n", offset); - offset += 1; - - for (self.entries.items) |e| { - const line = try session_mod.serializeEntry(self.allocator, e); - defer self.allocator.free(line); - try file.writePositionalAll(self.io, line, offset); - offset += line.len; - try file.writePositionalAll(self.io, "\n", offset); - offset += 1; - } - - for (new_entries) |entry| { - const line = try session_mod.serializeEntry(self.allocator, entry); - defer self.allocator.free(line); - try file.writePositionalAll(self.io, line, offset); - offset += line.len; - try file.writePositionalAll(self.io, "\n", offset); - offset += 1; - } - - file.sync(self.io) catch {}; - self.flushed = true; - self.written_bytes = offset; - } - - fn flushBuffered(self: *SessionManager, final_entry: SessionEntry) !void { - try self.flushBufferedMany(&.{final_entry}); - } - - /// Append a single line for `entry` to the open session file. Caller - /// must have already verified `flushed`. - fn persistEntry(self: *SessionManager, entry: SessionEntry) !void { - try self.persistEntries(&.{entry}); - } - - pub fn appendMessagesAtomic( - self: *SessionManager, - messages: []DiskMessage, - providers: []const ?[]const u8, - models: []const ?[]const u8, - ) !void { - std.debug.assert(messages.len == providers.len); - std.debug.assert(messages.len == models.len); - if (messages.len == 0) return; - - const base_len = self.entries.items.len; - try self.entries.ensureUnusedCapacity(self.allocator, messages.len); - try self.by_id.ensureUnusedCapacity(@intCast(messages.len)); - - var entries = try self.allocator.alloc(SessionEntry, messages.len); - defer self.allocator.free(entries); - var built: usize = 0; - errdefer { - for (entries[0..built]) |*e| e.deinit(self.allocator); - } - - var prev_leaf = self.leaf_id; - for (messages, 0..) |msg, i| { - const msg_local = msg; - const id_buf = try self.newEntryId(); - errdefer self.allocator.free(id_buf); - const timestamp = try isoTimestamp(self.allocator, self.io); - errdefer self.allocator.free(timestamp); - const parent_id_copy: ?[]const u8 = if (prev_leaf) |l| try self.allocator.dupe(u8, l) else null; - errdefer if (parent_id_copy) |p| self.allocator.free(p); - const provider_copy: ?[]const u8 = if (providers[i]) |p| try self.allocator.dupe(u8, p) else null; - errdefer if (provider_copy) |p| self.allocator.free(p); - const model_copy: ?[]const u8 = if (models[i]) |m| try self.allocator.dupe(u8, m) else null; - errdefer if (model_copy) |m| self.allocator.free(m); - entries[i] = .{ .message = .{ - .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp }, - .provider = provider_copy, - .model = model_copy, - .message = msg_local, - } }; - built += 1; - prev_leaf = entries[i].base().id; - } - - if (self.flushed) { - try self.persistEntries(entries); - } else { - try self.flushBufferedMany(entries); - } - - for (entries, 0..) |entry, i| { - self.entries.appendAssumeCapacity(entry); - self.by_id.putAssumeCapacity(entry.base().id, base_len + i); - } - self.leaf_id = entries[entries.len - 1].base().id; - built = 0; - } - - fn persistEntries(self: *SessionManager, entries: []const SessionEntry) !void { - const file = try Io.Dir.cwd().openFile(self.io, self.session_file, .{ - .mode = .write_only, - }); - defer file.close(self.io); - - var offset = self.written_bytes; - for (entries) |entry| { - const line = try session_mod.serializeEntry(self.allocator, entry); - defer self.allocator.free(line); - try file.writePositionalAll(self.io, line, offset); - offset += line.len; - try file.writePositionalAll(self.io, "\n", offset); - offset += 1; - } - file.sync(self.io) catch {}; - self.written_bytes = offset; - } - - // ============================================================================= - // Conversation rebuild - // ============================================================================= - - /// Build a fresh `Conversation` from the entry log. Caller owns the - /// returned conversation (call `deinit`). - pub fn rebuildConversation(self: *const 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; - } - - // ============================================================================= - // SessionStore interface - // ============================================================================= - - /// Reconstruct a linear `Conversation` plus an optional dangling - /// trailing user prompt (a user entry with no following assistant — - /// e.g. a crash/quit right after submission). The returned conversation - /// **excludes** that dangling user turn; its text is returned as - /// `dangling_user` (owned by `alloc`) so a resumed agent never - /// auto-sends it. - pub fn loadConversation( - self: *const SessionManager, - alloc: Allocator, - ) !session_store_mod.LoadedSession { - // Detect a dangling trailing user entry: the last message entry is - // a user message and nothing follows it. (Entries are linear; the - // last entry is the leaf.) - const items = self.entries.items; - var dangling: ?[]const u8 = null; - var stop_at: usize = items.len; - if (items.len > 0) { - const last = items[items.len - 1]; - switch (last) { - .message => |me| { - if (me.message.role == .user) { - dangling = try extractUserText(alloc, me.message); - // Only treat it as dangling (and exclude it) when we - // could actually recover prompt text; otherwise keep - // it in the conversation rather than silently drop. - if (dangling != null) stop_at = items.len - 1; - } - }, - } - } - errdefer if (dangling) |d| alloc.free(d); - - var conv = conversation_mod.Conversation.init(alloc); - errdefer conv.deinit(); - for (items[0..stop_at]) |entry| { - switch (entry) { - .message => |me| try appendMessageToConv(&conv, alloc, me.message), - } - } - - return .{ .conversation = conv, .dangling_user = dangling }; - } - - fn appendMessagesVT( - ctx: *anyopaque, - messages: []session_store_mod.DiskMessage, - providers: []const ?[]const u8, - models: []const ?[]const u8, - ) anyerror!void { - const self: *SessionManager = @ptrCast(@alignCast(ctx)); - try self.appendMessagesAtomic(messages, providers, models); - } - - fn loadConversationVT( - ctx: *anyopaque, - alloc: Allocator, - ) anyerror!session_store_mod.LoadedSession { - const self: *const SessionManager = @ptrCast(@alignCast(ctx)); - return self.loadConversation(alloc); - } - - fn sessionIdVT(ctx: *anyopaque) []const u8 { - const self: *const SessionManager = @ptrCast(@alignCast(ctx)); - return self.getSessionId(); - } - - fn activeModelVT(ctx: *anyopaque) ?session_store_mod.ActiveModel { - const self: *const SessionManager = @ptrCast(@alignCast(ctx)); - const am = self.activeModel() orelse return null; - return .{ .provider = am.provider, .model = am.model }; - } - - const store_vtable: session_store_mod.SessionStore.VTable = .{ - .appendMessages = appendMessagesVT, - .loadConversation = loadConversationVT, - .sessionId = sessionIdVT, - .activeModel = activeModelVT, - }; - - /// Wrap this concrete manager as a neutral `SessionStore`. The returned - /// store borrows `self`; `self` must outlive it. - pub fn store(self: *SessionManager) session_store_mod.SessionStore { - return .{ .ptr = self, .vtable = &store_vtable }; - } -}; - -/// Best-effort extraction of plain prompt text from a user `DiskMessage`. -/// Returns null if the message carries no plain text block (e.g. it is a -/// tool-result-only user message, which is never a "dangling prompt"). -/// Caller owns the returned slice. -fn extractUserText(alloc: Allocator, msg: DiskMessage) !?[]const u8 { - for (msg.content) |block| { - if (block == .text) { - return try alloc.dupe(u8, block.text.text); - } - } - return null; -} - -fn appendMessageToConv( - conv: *conversation_mod.Conversation, - allocator: Allocator, - disk_msg: 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); - const sys_mode: conversation_mod.SystemMode = switch (disk_msg.mode) { - .append => .append, - .replace => .replace, - }; - for (disk_msg.content) |db| { - var block = try session_mod.diskContentBlockToInternal(allocator, db); - // System-role text blocks become `.System` blocks carrying the - // message's recorded mode, so the append/replace derivation works - // on the rebuilt conversation exactly as it did when written. - if (disk_msg.role == .system and block == .Text) { - const tb = block.Text; - block = .{ .System = .{ .text = tb, .mode = sys_mode } }; - } - content.appendAssumeCapacity(block); - } - const role: conversation_mod.MessageRole = switch (disk_msg.role) { - .system => .system, - .user => .user, - .assistant => .assistant, - }; - // Carry the recorded usage forward so compaction can size the retention - // window after a session is reopened (it's null for user/system). - try conv.messages.append(allocator, .{ - .role = role, - .content = content, - .usage = disk_msg.usage, - }); -} - -// ============================================================================= -// Migration -// ============================================================================= - -/// Future format migrations land here. Returns true if anything changed -/// (which triggers a one-time file rewrite). -fn migrate( - allocator: Allocator, - header: *SessionHeader, - entries: *std.ArrayList(SessionEntry), -) bool { - _ = allocator; - _ = entries; - if (header.version >= CURRENT_VERSION) return false; - // No earlier versions exist yet. When v2 lands, transform v1 entries - // here and bump `header.version`. - return false; -} - -fn elideDanglingToolUses(allocator: Allocator, entries: *std.ArrayList(SessionEntry)) bool { - var needed: std.StringHashMap(void) = .init(allocator); - defer needed.deinit(); - var changed = false; - - var i = entries.items.len; - while (i > 0) { - i -= 1; - const entry = &entries.items[i]; - if (entry.* != .message) continue; - const msg = &entry.message.message; - - if (msg.role == .user) { - for (msg.content) |block| { - if (block == .tool_result) { - needed.put(block.tool_result.tool_use_id, {}) catch {}; - } - } - continue; - } - - if (msg.role != .assistant) continue; - var kept: std.ArrayList(DiskContentBlock) = .empty; - defer kept.deinit(allocator); - var removed = false; - for (msg.content) |block| { - if (block == .tool_use and !needed.contains(block.tool_use.id)) { - block.deinit(allocator); - removed = true; - continue; - } - kept.append(allocator, block) catch unreachable; - } - if (!removed) continue; - allocator.free(msg.content); - msg.content = kept.toOwnedSlice(allocator) catch unreachable; - changed = true; - } - return changed; -} - -// ============================================================================= -// File utilities -// ============================================================================= - -fn readWholeFile(allocator: Allocator, io: Io, path: []const u8) ![]u8 { - const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { - error.FileNotFound => return error.InvalidSessionFile, - else => return err, - }; - defer file.close(io); - - const len = file.length(io) catch { - // Fall back to a streaming read of a reasonable upper bound. - // Sessions over ~10 MB are out of scope for phase 4. - var list: std.ArrayList(u8) = .empty; - defer list.deinit(allocator); - var chunk: [4096]u8 = undefined; - while (true) { - const n = file.readStreaming(io, &.{&chunk}) catch break; - if (n == 0) break; - try list.appendSlice(allocator, chunk[0..n]); - } - return try list.toOwnedSlice(allocator); - }; - - const buf = try allocator.alloc(u8, @intCast(len)); - errdefer allocator.free(buf); - _ = try file.readPositionalAll(io, buf, 0); - return buf; -} - -fn statFileForLength(io: Io, path: []const u8) !u64 { - const file = try Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }); - defer file.close(io); - return try file.length(io); -} - -fn truncateFileTo(io: Io, path: []const u8, new_length: u64) !void { - const file = try Io.Dir.cwd().openFile(io, path, .{ .mode = .write_only }); - defer file.close(io); - try file.setLength(io, new_length); - file.sync(io) catch {}; -} - -/// Write a fresh file containing `header` followed by `entries`. Truncates -/// any existing content. Used after a migration rewrites the format. -fn rewriteFile( - allocator: Allocator, - io: Io, - path: []const u8, - header: SessionHeader, - entries: []const SessionEntry, -) !void { - const file = try Io.Dir.cwd().createFile(io, path, .{ - .truncate = true, - .read = false, - }); - defer file.close(io); - - var offset: u64 = 0; - const header_line = try session_mod.serializeHeader(allocator, header); - defer allocator.free(header_line); - try file.writePositionalAll(io, header_line, offset); - offset += header_line.len; - try file.writePositionalAll(io, "\n", offset); - offset += 1; - for (entries) |e| { - const line = try session_mod.serializeEntry(allocator, e); - defer allocator.free(line); - try file.writePositionalAll(io, line, offset); - offset += line.len; - try file.writePositionalAll(io, "\n", offset); - offset += 1; - } - file.sync(io) catch {}; -} - -fn mkdirP(io: Io, path: []const u8) !void { - Io.Dir.cwd().createDirPath(io, path) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; -} - -// ============================================================================= -// Listing -// ============================================================================= - -/// List sessions in `session_dir`. Returns a slice of `SessionInfo`s -/// sorted by `modified` descending (most recent first). Caller owns the -/// slice and each `SessionInfo`. -/// -/// If the directory does not exist, returns an empty slice (no error). -/// -/// If `on_progress` is non-null, it is invoked after each file is parsed. -pub fn listSessions( - allocator: Allocator, - io: Io, - session_dir: []const u8, - on_progress: ?*const fn (loaded: usize, total: usize) void, -) ![]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].System.text.items); - try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[1].role); - try testing.expectEqualStrings("hello", conv.messages.items[1].content.items[0].Text.items); - try testing.expectEqual(conversation_mod.MessageRole.assistant, conv.messages.items[2].role); - try testing.expectEqualStrings("hi!", conv.messages.items[2].content.items[0].Text.items); -} - -test "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 am1 = try testing.allocator.alloc(DiskContentBlock, 2); - am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } }; - am1[1] = .{ .tool_use = .{ - .id = try testing.allocator.dupe(u8, "tool_abc"), - .name = try testing.allocator.dupe(u8, "bash"), - .input = try testing.allocator.dupe(u8, "{\"command\":\"ls\"}"), - } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null); - - // Tool-result user message. - const tr = try testing.allocator.alloc(DiskContentBlock, 1); - const trp = try testing.allocator.alloc(session_mod.DiskResultPart, 1); - trp[0] = .{ .text = try testing.allocator.dupe(u8, "a.txt\nb.txt") }; - tr[0] = .{ .tool_result = .{ - .tool_use_id = try testing.allocator.dupe(u8, "tool_abc"), - .parts = trp, - } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = tr }, "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.parts[0].text); - - // Conversation rebuild yields the same shape. - var conv = try resumed.rebuildConversation(); - defer conv.deinit(); - try testing.expectEqual(@as(usize, 4), conv.messages.items.len); - try testing.expect(conv.messages.items[1].content.items[1] == .ToolUse); - try testing.expect(conv.messages.items[2].content.items[0] == .ToolResult); -} - -test "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")); -} - -test "compaction summary round-trips through persist + resume + rebuild" { - const io = testing.io; - - var td = try TmpSessionDir.init(testing.allocator); - defer td.deinit(testing.allocator); - const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); - defer testing.allocator.free(sessions); - - const session_file: []u8 = blk: { - var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); - defer mgr.deinit(); - - // System + an old turn that will be superseded. - 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 uo = try testing.allocator.alloc(DiskContentBlock, 1); - uo[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "old q") } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = uo }, "openai", "gpt-4o"); - const ao = try testing.allocator.alloc(DiskContentBlock, 1); - ao[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "old a") } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = ao }, null, null); - - // Compaction: summary message + duplicated kept suffix. - const cs = try testing.allocator.alloc(DiskContentBlock, 1); - cs[0] = .{ .compaction_summary = .{ .text = try testing.allocator.dupe(u8, "SUMMARY") } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = cs }, "openai", "gpt-4o"); - - const ur = try testing.allocator.alloc(DiskContentBlock, 1); - ur[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "recent q") } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = ur }, "openai", "gpt-4o"); - - break :blk try testing.allocator.dupe(u8, mgr.getSessionFile()); - }; - defer testing.allocator.free(session_file); - - var resumed = try SessionManager.open(testing.allocator, io, session_file); - defer resumed.deinit(); - var conv = try resumed.rebuildConversation(); - defer conv.deinit(); - - // The compaction summary block survived as a CompactionSummary. - const anchor = conversation_mod.latestCompactionIndex(conv.messages.items).?; - try testing.expectEqualStrings( - "SUMMARY", - conv.messages.items[anchor].content.items[0].CompactionSummary.text.items, - ); - - // The active window is [summary, recent q]. - const window = conversation_mod.activeMessageWindow(conv.messages.items); - try testing.expectEqual(@as(usize, 2), window.len); - try testing.expectEqualStrings("recent q", window[1].content.items[0].Text.items); - - // System prompt survives (derived independently). - var sys_blocks = try conversation_mod.effectiveSystemBlocks(testing.allocator, conv.messages.items); - defer sys_blocks.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 1), sys_blocks.items.len); - try testing.expectEqualStrings("you are helpful", sys_blocks.items[0]); -} - -test "loadConversation: trailing user prompt is split out as dangling, excluded from conversation" { - const io = testing.io; - - var td = try TmpSessionDir.init(testing.allocator); - defer td.deinit(testing.allocator); - const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); - defer testing.allocator.free(sessions); - - var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); - defer mgr.deinit(); - - // A completed user/assistant round, then a dangling user prompt with no - // following assistant (simulating a crash right after submission). - const um1 = try testing.allocator.alloc(DiskContentBlock, 1); - um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, "openai", "gpt-4o"); - const am1 = try testing.allocator.alloc(DiskContentBlock, 1); - am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null); - const um2 = try testing.allocator.alloc(DiskContentBlock, 1); - um2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = um2 }, "openai", "gpt-4o"); - - var loaded = try mgr.loadConversation(testing.allocator); - defer loaded.deinit(testing.allocator); - - // Dangling prompt recovered. - try testing.expect(loaded.dangling_user != null); - try testing.expectEqualStrings("what's 2+2?", loaded.dangling_user.?); - // Conversation excludes the dangling user turn: [user hi, assistant hello]. - try testing.expectEqual(@as(usize, 2), loaded.conversation.messages.items.len); - try testing.expectEqual(conversation_mod.MessageRole.user, loaded.conversation.messages.items[0].role); - try testing.expectEqual(conversation_mod.MessageRole.assistant, loaded.conversation.messages.items[1].role); -} - -test "loadConversation: no dangling prompt when log ends with 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); - - var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); - defer mgr.deinit(); - - const um1 = try testing.allocator.alloc(DiskContentBlock, 1); - um1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; - _ = try mgr.appendMessage(.{ .role = .user, .content = um1 }, "openai", "gpt-4o"); - const am1 = try testing.allocator.alloc(DiskContentBlock, 1); - am1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; - _ = try mgr.appendMessage(.{ .role = .assistant, .content = am1 }, null, null); - - var loaded = try mgr.loadConversation(testing.allocator); - defer loaded.deinit(testing.allocator); - try testing.expect(loaded.dangling_user == null); - try testing.expectEqual(@as(usize, 2), loaded.conversation.messages.items.len); -} - -test "store(): SessionStore wrapper delegates to the concrete manager" { - 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 s = mgr.store(); - - // Append a user+assistant batch through the neutral interface. - var messages = try testing.allocator.alloc(DiskMessage, 2); - defer testing.allocator.free(messages); - const uc = try testing.allocator.alloc(DiskContentBlock, 1); - uc[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } }; - messages[0] = .{ .role = .user, .content = uc }; - const ac = try testing.allocator.alloc(DiskContentBlock, 1); - ac[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } }; - messages[1] = .{ .role = .assistant, .content = ac }; - - try s.appendMessages(messages, &.{ "openai", null }, &.{ "gpt-4o", null }); - try testing.expectEqual(@as(usize, 2), mgr.getEntries().len); - - const am = s.activeModel().?; - try testing.expectEqualStrings("openai", am.provider); - try testing.expectEqualStrings("gpt-4o", am.model); - try testing.expectEqualStrings(mgr.getSessionId(), s.sessionId()); -} |
