//! On-disk session entry types and JSON serialization. //! //! These types are the wire format of pantograph's session log. They are //! intentionally separate from the in-memory `Conversation`/`Message`/ //! `ContentBlock` model: //! //! - The in-memory model holds only what providers need to serialize a //! request (`role`, `content`). //! - The on-disk model holds the full event-log story: provider/model //! used per request, assistant stop reason and usage, timestamps, and //! enough tree structure (`id`/`parent_id`) to allow future branching. //! //! Bridge functions at the bottom convert between the two. The bridge is //! lossy by design: assistant metadata (provider/model/stop_reason/usage) //! is recorded in entries but does NOT round-trip into the in-memory //! conversation, because providers don't need it for request serialization. //! //! Format version: 1 (see `CURRENT_VERSION`). Migrations live in //! `session_manager.zig`. const std = @import("std"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const conversation = @import("conversation.zig"); /// Bumped whenever the on-disk format changes in a way that older readers /// cannot tolerate. Older files are upgraded by `migrate()` on load and /// the file is rewritten once. pub const CURRENT_VERSION: u32 = 1; // ============================================================================= // Header // ============================================================================= /// First (and only) line of a session file. Metadata only — not part of /// the entry tree (no id/parent_id). pub const SessionHeader = struct { version: u32, id: []const u8, // UUIDv7 string, owned timestamp: []const u8, // ISO 8601, owned cwd: []const u8, // owned pub fn deinit(self: SessionHeader, alloc: Allocator) void { alloc.free(self.id); alloc.free(self.timestamp); alloc.free(self.cwd); } }; // ============================================================================= // Entries // ============================================================================= /// Fields shared by every non-header entry. pub const EntryBase = struct { id: []const u8, // 8-char hex, owned parent_id: ?[]const u8, // owned, null for first entry timestamp: []const u8, // ISO 8601, owned pub fn deinit(self: EntryBase, alloc: Allocator) void { alloc.free(self.id); if (self.parent_id) |p| alloc.free(p); alloc.free(self.timestamp); } }; pub const SessionEntry = union(enum) { message: MessageEntry, pub fn base(self: SessionEntry) EntryBase { return switch (self) { .message => |m| m.base, }; } pub fn deinit(self: SessionEntry, alloc: Allocator) void { switch (self) { .message => |m| m.deinit(alloc), } } }; pub const MessageEntry = struct { base: EntryBase, /// Recorded on user message entries (both human prompts and tool-result /// messages). Both are submissions to a provider API; the stamp says /// which one. Null on system and assistant entries. provider: ?[]const u8 = null, // owned model: ?[]const u8 = null, // owned message: DiskMessage, pub fn deinit(self: MessageEntry, alloc: Allocator) void { self.base.deinit(alloc); if (self.provider) |p| alloc.free(p); if (self.model) |m| alloc.free(m); self.message.deinit(alloc); } }; pub const DiskMessageRole = enum { system, user, assistant }; /// Mode for a system-role message. Mirrors `conversation.SystemMode`. /// `append` adds to the effective prompt; `replace` discards all prior /// system text. Only meaningful on system messages; absent on disk means /// `append` (back-compatible with pre-mode logs). pub const DiskSystemMode = enum { append, replace }; pub const DiskMessage = struct { role: DiskMessageRole, content: []DiskContentBlock, // owned /// System-message mode. Recorded only for system-role messages; an /// absent `mode` on disk parses back as `.append`. mode: DiskSystemMode = .append, // Assistant-only metadata. Null for system/user messages. provider: ?[]const u8 = null, // owned model: ?[]const u8 = null, // owned stop_reason: ?[]const u8 = null, // owned usage: ?Usage = null, pub fn deinit(self: DiskMessage, alloc: Allocator) void { for (self.content) |block| block.deinit(alloc); alloc.free(self.content); if (self.provider) |p| alloc.free(p); if (self.model) |m| alloc.free(m); if (self.stop_reason) |s| alloc.free(s); } }; /// Token usage reported by a provider for a single assistant turn. /// /// Defined in `conversation.zig` (so in-memory `Message`s can carry it /// without a module cycle) and re-exported here for the on-disk types and /// historical call sites that import it as `session.Usage`. pub const Usage = conversation.Usage; // ============================================================================= // Content blocks // ============================================================================= pub const DiskContentBlock = union(enum) { text: DiskTextBlock, thinking: DiskThinkingBlock, tool_use: DiskToolUseBlock, tool_result: DiskToolResultBlock, compaction_summary: DiskCompactionSummaryBlock, pub fn deinit(self: DiskContentBlock, alloc: Allocator) void { switch (self) { .text => |b| b.deinit(alloc), .thinking => |b| b.deinit(alloc), .tool_use => |b| b.deinit(alloc), .tool_result => |b| b.deinit(alloc), .compaction_summary => |b| b.deinit(alloc), } } }; pub const DiskTextBlock = struct { text: []const u8, // owned pub fn deinit(self: DiskTextBlock, alloc: Allocator) void { alloc.free(self.text); } }; pub const DiskThinkingBlock = struct { thinking: []const u8, // owned /// Anthropic's opaque integrity token. Other providers do not produce /// one. Preserved here so resumed sessions can be sent back to /// Anthropic with the original thinking block intact. signature: ?[]const u8 = null, // owned pub fn deinit(self: DiskThinkingBlock, alloc: Allocator) void { alloc.free(self.thinking); if (self.signature) |s| alloc.free(s); } }; pub const DiskToolUseBlock = struct { id: []const u8, // owned name: []const u8, // owned input: []const u8, // raw JSON bytes, owned pub fn deinit(self: DiskToolUseBlock, alloc: Allocator) void { alloc.free(self.id); alloc.free(self.name); alloc.free(self.input); } }; /// One on-disk tool-result part: either text or an inline base64 media /// attachment (no sidecar files). pub const DiskResultPart = union(enum) { text: []const u8, // owned media: struct { media_type: []const u8, // owned data: []const u8, // owned (base64) }, pub fn deinit(self: DiskResultPart, alloc: Allocator) void { switch (self) { .text => |t| alloc.free(t), .media => |m| { alloc.free(m.media_type); alloc.free(m.data); }, } } }; pub const DiskToolResultBlock = struct { tool_use_id: []const u8, // owned parts: []DiskResultPart, // owned is_error: bool = false, pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void { alloc.free(self.tool_use_id); for (self.parts) |p| p.deinit(alloc); alloc.free(self.parts); } }; /// A compaction summary block: the synthetic seed text standing in for a /// compacted conversation prefix. Sits alone in a `user`-role message. See /// `conversation.CompactionSummaryBlock`. pub const DiskCompactionSummaryBlock = struct { text: []const u8, // owned pub fn deinit(self: DiskCompactionSummaryBlock, alloc: Allocator) void { alloc.free(self.text); } }; // ============================================================================= // File entry (header or entry) // ============================================================================= pub const FileEntry = union(enum) { header: SessionHeader, entry: SessionEntry, pub fn deinit(self: FileEntry, alloc: Allocator) void { switch (self) { .header => |h| h.deinit(alloc), .entry => |e| e.deinit(alloc), } } }; // ============================================================================= // Serialization // ============================================================================= /// Serialize the header as a single JSON line. Caller owns returned bytes. /// The returned slice does NOT include a trailing newline. pub fn serializeHeader(allocator: Allocator, header: SessionHeader) ![]u8 { var aw: Writer.Allocating = .init(allocator); errdefer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; try s.beginObject(); try s.objectField("type"); try s.write("session"); try s.objectField("version"); try s.write(header.version); try s.objectField("id"); try s.write(header.id); try s.objectField("timestamp"); try s.write(header.timestamp); try s.objectField("cwd"); try s.write(header.cwd); try s.endObject(); return try aw.toOwnedSlice(); } /// Serialize an entry as a single JSON line. Caller owns returned bytes. pub fn serializeEntry(allocator: Allocator, entry: SessionEntry) ![]u8 { var aw: Writer.Allocating = .init(allocator); errdefer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; try writeEntry(&s, entry); return try aw.toOwnedSlice(); } fn writeEntry(s: *std.json.Stringify, entry: SessionEntry) !void { switch (entry) { .message => |m| try writeMessageEntry(s, m), } } fn writeMessageEntry(s: *std.json.Stringify, m: MessageEntry) !void { try s.beginObject(); try s.objectField("type"); try s.write("message"); try s.objectField("id"); try s.write(m.base.id); try s.objectField("parentId"); if (m.base.parent_id) |p| try s.write(p) else try s.write(null); try s.objectField("timestamp"); try s.write(m.base.timestamp); // Top-level provider/model on user message entries. if (m.provider) |p| { try s.objectField("provider"); try s.write(p); } if (m.model) |mm| { try s.objectField("model"); try s.write(mm); } try s.objectField("message"); try writeDiskMessage(s, m.message); try s.endObject(); } fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { try s.beginObject(); try s.objectField("role"); try s.write(@tagName(msg.role)); // `mode` is meaningful only for system messages. Emit it there so the // append/replace semantics round-trip; omit it everywhere else. if (msg.role == .system) { try s.objectField("mode"); try s.write(@tagName(msg.mode)); } try s.objectField("content"); try s.beginArray(); for (msg.content) |block| { try writeDiskBlock(s, block); } try s.endArray(); if (msg.provider) |p| { try s.objectField("provider"); try s.write(p); } if (msg.model) |mm| { try s.objectField("model"); try s.write(mm); } if (msg.stop_reason) |sr| { try s.objectField("stopReason"); try s.write(sr); } if (msg.usage) |u| { try s.objectField("usage"); try s.beginObject(); try s.objectField("input"); try s.write(u.input); try s.objectField("output"); try s.write(u.output); // Omit zero-valued auxiliary fields to keep older / unused // sessions compact. Readers default missing fields to 0, so // round-trip behavior is preserved. if (u.cache_read != 0) { try s.objectField("cacheRead"); try s.write(u.cache_read); } if (u.cache_write != 0) { try s.objectField("cacheWrite"); try s.write(u.cache_write); } if (u.reasoning != 0) { try s.objectField("reasoning"); try s.write(u.reasoning); } try s.endObject(); } try s.endObject(); } fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void { switch (block) { .text => |b| { try s.beginObject(); try s.objectField("type"); try s.write("text"); try s.objectField("text"); try s.write(b.text); try s.endObject(); }, .thinking => |b| { try s.beginObject(); try s.objectField("type"); try s.write("thinking"); try s.objectField("thinking"); try s.write(b.thinking); if (b.signature) |sig| { try s.objectField("signature"); try s.write(sig); } try s.endObject(); }, .tool_use => |b| { try s.beginObject(); try s.objectField("type"); try s.write("toolUse"); try s.objectField("id"); try s.write(b.id); try s.objectField("name"); try s.write(b.name); try s.objectField("input"); try s.write(b.input); try s.endObject(); }, .tool_result => |b| { try s.beginObject(); try s.objectField("type"); try s.write("toolResult"); try s.objectField("toolUseId"); try s.write(b.tool_use_id); // Persist the error marker only when set, so existing // (success) tool-result logs serialize byte-identically. if (b.is_error) { try s.objectField("isError"); try s.write(true); } // `parts` is an array of {type:"text",text} and // {type:"image",mimeType,data} (data = inline base64). try s.objectField("parts"); try s.beginArray(); for (b.parts) |part| { switch (part) { .text => |t| { try s.beginObject(); try s.objectField("type"); try s.write("text"); try s.objectField("text"); try s.write(t); try s.endObject(); }, .media => |m| { try s.beginObject(); try s.objectField("type"); try s.write("image"); try s.objectField("mimeType"); try s.write(m.media_type); try s.objectField("data"); try s.write(m.data); try s.endObject(); }, } } try s.endArray(); try s.endObject(); }, .compaction_summary => |b| { try s.beginObject(); try s.objectField("type"); try s.write("compactionSummary"); try s.objectField("text"); try s.write(b.text); try s.endObject(); }, } } // ============================================================================= // Parsing // ============================================================================= pub const ParseError = error{ InvalidJson, MissingField, UnknownType, UnknownRole, UnknownBlockType, } || Allocator.Error; /// Parse one JSON line into a `FileEntry`. Caller owns all bytes. pub fn parseLine(allocator: Allocator, line: []const u8) ParseError!FileEntry { var parsed = std.json.parseFromSlice(std.json.Value, allocator, line, .{}) catch { return error.InvalidJson; }; defer parsed.deinit(); return parseValue(allocator, parsed.value); } fn parseValue(allocator: Allocator, v: std.json.Value) ParseError!FileEntry { if (v != .object) return error.InvalidJson; const type_v = v.object.get("type") orelse return error.MissingField; if (type_v != .string) return error.MissingField; const t = type_v.string; if (std.mem.eql(u8, t, "session")) { return .{ .header = try parseHeaderFromObject(allocator, v.object) }; } else if (std.mem.eql(u8, t, "message")) { return .{ .entry = .{ .message = try parseMessageEntry(allocator, v.object) } }; } else { return error.UnknownType; } } fn parseHeaderFromObject(allocator: Allocator, obj: std.json.ObjectMap) ParseError!SessionHeader { const version: u32 = blk: { if (obj.get("version")) |vv| { if (vv == .integer) break :blk @intCast(vv.integer); } break :blk 1; }; const id = try dupeStringField(allocator, obj, "id"); errdefer allocator.free(id); const timestamp = try dupeStringField(allocator, obj, "timestamp"); errdefer allocator.free(timestamp); const cwd = try dupeStringField(allocator, obj, "cwd"); errdefer allocator.free(cwd); return .{ .version = version, .id = id, .timestamp = timestamp, .cwd = cwd, }; } fn parseMessageEntry(allocator: Allocator, obj: std.json.ObjectMap) ParseError!MessageEntry { const id = try dupeStringField(allocator, obj, "id"); errdefer allocator.free(id); const timestamp = try dupeStringField(allocator, obj, "timestamp"); errdefer allocator.free(timestamp); const parent_id: ?[]const u8 = blk: { const pv = obj.get("parentId") orelse break :blk null; if (pv == .null) break :blk null; if (pv != .string) return error.MissingField; break :blk try allocator.dupe(u8, pv.string); }; errdefer if (parent_id) |p| allocator.free(p); const provider: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "provider"); errdefer if (provider) |p| allocator.free(p); const model: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "model"); errdefer if (model) |m| allocator.free(m); const msg_v = obj.get("message") orelse return error.MissingField; if (msg_v != .object) return error.MissingField; const msg = try parseDiskMessage(allocator, msg_v.object); return .{ .base = .{ .id = id, .parent_id = parent_id, .timestamp = timestamp }, .provider = provider, .model = model, .message = msg, }; } fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskMessage { const role_v = obj.get("role") orelse return error.MissingField; if (role_v != .string) return error.MissingField; const role = std.meta.stringToEnum(DiskMessageRole, role_v.string) orelse return error.UnknownRole; // `mode` is optional; absent defaults to `.append`. Unknown values are // tolerated as `.append` rather than rejecting an otherwise-valid log. const mode: DiskSystemMode = blk: { const mv = obj.get("mode") orelse break :blk .append; if (mv != .string) break :blk .append; break :blk std.meta.stringToEnum(DiskSystemMode, mv.string) orelse .append; }; const content_v = obj.get("content") orelse return error.MissingField; if (content_v != .array) return error.MissingField; var content_list = try std.ArrayList(DiskContentBlock).initCapacity(allocator, content_v.array.items.len); errdefer { for (content_list.items) |b| b.deinit(allocator); content_list.deinit(allocator); } for (content_v.array.items) |item| { if (item != .object) return error.UnknownBlockType; const block = try parseDiskBlock(allocator, item.object); try content_list.append(allocator, block); } const content = try content_list.toOwnedSlice(allocator); errdefer { for (content) |b| b.deinit(allocator); allocator.free(content); } const provider: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "provider"); errdefer if (provider) |p| allocator.free(p); const model: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "model"); errdefer if (model) |m| allocator.free(m); const stop_reason: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "stopReason"); errdefer if (stop_reason) |s| allocator.free(s); var usage: ?Usage = null; if (obj.get("usage")) |uv| { if (uv == .object) { usage = .{ .input = readU64(uv.object, "input"), .output = readU64(uv.object, "output"), .cache_read = readU64(uv.object, "cacheRead"), .cache_write = readU64(uv.object, "cacheWrite"), .reasoning = readU64(uv.object, "reasoning"), }; } } return .{ .role = role, .content = content, .mode = mode, .provider = provider, .model = model, .stop_reason = stop_reason, .usage = usage, }; } fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskContentBlock { const type_v = obj.get("type") orelse return error.MissingField; if (type_v != .string) return error.MissingField; const t = type_v.string; if (std.mem.eql(u8, t, "text")) { const text = try dupeStringField(allocator, obj, "text"); return .{ .text = .{ .text = text } }; } else if (std.mem.eql(u8, t, "thinking")) { const text = try dupeStringField(allocator, obj, "thinking"); errdefer allocator.free(text); const sig = try dupeOptionalStringField(allocator, obj, "signature"); return .{ .thinking = .{ .thinking = text, .signature = sig } }; } else if (std.mem.eql(u8, t, "toolUse")) { const id = try dupeStringField(allocator, obj, "id"); errdefer allocator.free(id); const name = try dupeStringField(allocator, obj, "name"); errdefer allocator.free(name); const input = try dupeStringField(allocator, obj, "input"); return .{ .tool_use = .{ .id = id, .name = name, .input = input } }; } else if (std.mem.eql(u8, t, "toolResult")) { const tuid = try dupeStringField(allocator, obj, "toolUseId"); errdefer allocator.free(tuid); const parts = try parseDiskResultParts(allocator, obj); // Missing `isError` in older logs defaults to false. const is_err = readBool(obj, "isError"); return .{ .tool_result = .{ .tool_use_id = tuid, .parts = parts, .is_error = is_err } }; } else if (std.mem.eql(u8, t, "compactionSummary")) { const text = try dupeStringField(allocator, obj, "text"); return .{ .compaction_summary = .{ .text = text } }; } else { return error.UnknownBlockType; } } /// Parse the `parts` array of a `toolResult` disk block. Falls back to a /// legacy single `content` string field (older session logs) -> one text /// part. Each element is {type:"text",text} or {type:"image",mimeType,data}. fn parseDiskResultParts(allocator: Allocator, obj: std.json.ObjectMap) ParseError![]DiskResultPart { var list: std.ArrayList(DiskResultPart) = .empty; errdefer { for (list.items) |p| p.deinit(allocator); list.deinit(allocator); } const parts_v = obj.get("parts"); if (parts_v == null or parts_v.? == .null) { // Legacy: a single `content` string. const content = try dupeStringField(allocator, obj, "content"); try list.append(allocator, .{ .text = content }); return list.toOwnedSlice(allocator); } if (parts_v.? != .array) return error.MissingField; for (parts_v.?.array.items) |item| { if (item != .object) return error.MissingField; const po = item.object; const pt_v = po.get("type") orelse return error.MissingField; if (pt_v != .string) return error.MissingField; if (std.mem.eql(u8, pt_v.string, "text")) { const text = try dupeStringField(allocator, po, "text"); try list.append(allocator, .{ .text = text }); } else if (std.mem.eql(u8, pt_v.string, "image")) { const mt = try dupeStringField(allocator, po, "mimeType"); errdefer allocator.free(mt); const data = try dupeStringField(allocator, po, "data"); try list.append(allocator, .{ .media = .{ .media_type = mt, .data = data } }); } else { return error.UnknownBlockType; } } return list.toOwnedSlice(allocator); } fn readBool(obj: std.json.ObjectMap, name: []const u8) bool { const v = obj.get(name) orelse return false; if (v != .bool) return false; return v.bool; } fn readU64(obj: std.json.ObjectMap, name: []const u8) u64 { const v = obj.get(name) orelse return 0; if (v != .integer) return 0; if (v.integer < 0) return 0; return @intCast(v.integer); } fn dupeStringField(allocator: Allocator, obj: std.json.ObjectMap, name: []const u8) ParseError![]const u8 { const v = obj.get(name) orelse return error.MissingField; if (v != .string) return error.MissingField; return try allocator.dupe(u8, v.string); } fn dupeOptionalStringField(allocator: Allocator, obj: std.json.ObjectMap, name: []const u8) ParseError!?[]const u8 { const v = obj.get(name) orelse return null; if (v == .null) return null; if (v != .string) return error.MissingField; return try allocator.dupe(u8, v.string); } // ============================================================================= // Bridge between in-memory and on-disk content blocks // ============================================================================= /// Convert an in-memory `ContentBlock` to a `DiskContentBlock`. All strings /// are duplicated; the source block remains untouched and the resulting /// disk block is independently owned. pub fn contentBlockToDisk( allocator: Allocator, block: conversation.ContentBlock, ) !DiskContentBlock { switch (block) { .Text => |tb| { const text = try allocator.dupe(u8, tb.items); return .{ .text = .{ .text = text } }; }, .Thinking => |tb| { const text = try allocator.dupe(u8, tb.text.items); errdefer allocator.free(text); const sig: ?[]const u8 = if (tb.signature) |s| try allocator.dupe(u8, s) else null; return .{ .thinking = .{ .thinking = text, .signature = sig } }; }, .ToolUse => |tu| { const id = try allocator.dupe(u8, tu.id); errdefer allocator.free(id); const name = try allocator.dupe(u8, tu.name); errdefer allocator.free(name); const input = try allocator.dupe(u8, tu.input.items); return .{ .tool_use = .{ .id = id, .name = name, .input = input } }; }, .ToolResult => |tr| { const tuid = try allocator.dupe(u8, tr.tool_use_id); errdefer allocator.free(tuid); var parts: std.ArrayList(DiskResultPart) = .empty; errdefer { for (parts.items) |p| p.deinit(allocator); parts.deinit(allocator); } try parts.ensureTotalCapacity(allocator, tr.parts.items.len); for (tr.parts.items) |src| { switch (src) { .text => |tb| parts.appendAssumeCapacity(.{ .text = try allocator.dupe(u8, tb.items) }), .media => |m| { const mt = try allocator.dupe(u8, m.media_type); errdefer allocator.free(mt); const data = try allocator.dupe(u8, m.data.items); parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } }); }, } } return .{ .tool_result = .{ .tool_use_id = tuid, .parts = try parts.toOwnedSlice(allocator), .is_error = tr.is_error, } }; }, // A `.System` block becomes a disk text block; its mode rides on // the enclosing `DiskMessage.mode` (set by the session manager), // not on the block itself. .System => |sb| { const text = try allocator.dupe(u8, sb.text.items); return .{ .text = .{ .text = text } }; }, .CompactionSummary => |cs| { const text = try allocator.dupe(u8, cs.text.items); return .{ .compaction_summary = .{ .text = text } }; }, } } /// Convert a `DiskContentBlock` to an in-memory `ContentBlock`. Allocates /// fresh owned buffers for every string field. The returned block is /// independently owned. pub fn diskContentBlockToInternal( allocator: Allocator, block: DiskContentBlock, ) !conversation.ContentBlock { switch (block) { .text => |b| { const tb = try conversation.textualBlockFromSlice(allocator, b.text); return .{ .Text = tb }; }, .thinking => |b| { const tb = try conversation.textualBlockFromSlice(allocator, b.thinking); errdefer { var mut = tb; mut.deinit(allocator); } const sig: ?[]const u8 = if (b.signature) |s| try allocator.dupe(u8, s) else null; return .{ .Thinking = .{ .text = tb, .signature = sig } }; }, .tool_use => |b| { const id = try allocator.dupe(u8, b.id); errdefer allocator.free(id); const name = try allocator.dupe(u8, b.name); errdefer allocator.free(name); const input = try conversation.textualBlockFromSlice(allocator, b.input); return .{ .ToolUse = .{ .id = id, .name = name, .input = input } }; }, .tool_result => |b| { const tuid = try allocator.dupe(u8, b.tool_use_id); errdefer allocator.free(tuid); var parts: std.ArrayList(conversation.ResultPartStored) = .empty; errdefer { for (parts.items) |*p| p.deinit(allocator); parts.deinit(allocator); } try parts.ensureTotalCapacity(allocator, b.parts.len); for (b.parts) |src| { switch (src) { .text => |t| parts.appendAssumeCapacity(.{ .text = try conversation.textualBlockFromSlice(allocator, t) }), .media => |m| { const mt = try allocator.dupe(u8, m.media_type); errdefer allocator.free(mt); const data = try conversation.textualBlockFromSlice(allocator, m.data); parts.appendAssumeCapacity(.{ .media = .{ .media_type = mt, .data = data } }); }, } } return .{ .ToolResult = .{ .tool_use_id = tuid, .parts = parts, .is_error = b.is_error } }; }, .compaction_summary => |b| { const tb = try conversation.textualBlockFromSlice(allocator, b.text); return .{ .CompactionSummary = .{ .text = tb } }; }, } } // ============================================================================= // Tests // ============================================================================= const testing = std.testing; fn dupe(allocator: Allocator, s: []const u8) ![]const u8 { return try allocator.dupe(u8, s); } test "serialize/parse header round-trip" { const a = testing.allocator; const header: SessionHeader = .{ .version = 1, .id = try dupe(a, "019dc5ba-53f6-71a5-ab8f-b1f8709c2572"), .timestamp = try dupe(a, "2026-04-25T17:40:15.990Z"), .cwd = try dupe(a, "/Users/travis/Code/pantograph"), }; defer header.deinit(a); const line = try serializeHeader(a, header); defer a.free(line); var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expect(fe == .header); try testing.expectEqual(@as(u32, 1), fe.header.version); try testing.expectEqualStrings(header.id, fe.header.id); try testing.expectEqualStrings(header.cwd, fe.header.cwd); } test "serialize/parse user message entry round-trip (with provider/model stamp)" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "hello world") } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "a1b2c3d4"), .parent_id = try dupe(a, "00000000"), .timestamp = try dupe(a, "2026-04-25T17:40:16.000Z"), }, .provider = try dupe(a, "openai"), .model = try dupe(a, "gpt-4o"), .message = .{ .role = .user, .content = content, }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expect(fe == .entry); const got = fe.entry.message; try testing.expectEqualStrings("a1b2c3d4", got.base.id); try testing.expectEqualStrings("00000000", got.base.parent_id.?); try testing.expectEqualStrings("openai", got.provider.?); try testing.expectEqualStrings("gpt-4o", got.model.?); try testing.expectEqual(DiskMessageRole.user, got.message.role); try testing.expectEqual(@as(usize, 1), got.message.content.len); try testing.expectEqualStrings("hello world", got.message.content[0].text.text); } test "serialize/parse assistant message entry with metadata" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 3); content[0] = .{ .thinking = .{ .thinking = try dupe(a, "let me think"), .signature = try dupe(a, "sig-xyz"), } }; content[1] = .{ .text = .{ .text = try dupe(a, "I'll check.") } }; content[2] = .{ .tool_use = .{ .id = try dupe(a, "tool_abc"), .name = try dupe(a, "bash"), .input = try dupe(a, "{\"command\":\"ls\"}"), } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "b2c3d4e5"), .parent_id = try dupe(a, "a1b2c3d4"), .timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"), }, .message = .{ .role = .assistant, .content = content, .provider = try dupe(a, "anthropic"), .model = try dupe(a, "claude-sonnet-4-20250514"), .stop_reason = try dupe(a, "toolUse"), .usage = .{ .input = 1500, .output = 85 }, }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); var fe = try parseLine(a, line); defer fe.deinit(a); const got = fe.entry.message; try testing.expectEqual(DiskMessageRole.assistant, got.message.role); try testing.expectEqual(@as(usize, 3), got.message.content.len); try testing.expectEqualStrings("let me think", got.message.content[0].thinking.thinking); try testing.expectEqualStrings("sig-xyz", got.message.content[0].thinking.signature.?); try testing.expectEqualStrings("bash", got.message.content[2].tool_use.name); try testing.expectEqualStrings("{\"command\":\"ls\"}", got.message.content[2].tool_use.input); try testing.expectEqualStrings("anthropic", got.message.provider.?); try testing.expectEqualStrings("toolUse", got.message.stop_reason.?); try testing.expect(got.message.usage != null); try testing.expectEqual(@as(u64, 1500), got.message.usage.?.input); try testing.expectEqual(@as(u64, 85), got.message.usage.?.output); } test "serialize/parse tool result message entry" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); var trp = try a.alloc(DiskResultPart, 1); trp[0] = .{ .text = try dupe(a, "file1.txt\nfile2.txt") }; content[0] = .{ .tool_result = .{ .tool_use_id = try dupe(a, "tool_abc"), .parts = trp, } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "c3d4e5f6"), .parent_id = try dupe(a, "b2c3d4e5"), .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), }, .provider = try dupe(a, "anthropic"), .model = try dupe(a, "claude-sonnet-4-20250514"), .message = .{ .role = .user, .content = content, }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); var fe = try parseLine(a, line); defer fe.deinit(a); const got = fe.entry.message; try testing.expectEqual(DiskMessageRole.user, got.message.role); try testing.expectEqualStrings("tool_abc", got.message.content[0].tool_result.tool_use_id); try testing.expectEqual(@as(usize, 1), got.message.content[0].tool_result.parts.len); try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.parts[0].text); try testing.expectEqualStrings("anthropic", got.provider.?); // Unset is_error defaults to false and serializes without the field. try testing.expect(!got.message.content[0].tool_result.is_error); try testing.expect(std.mem.indexOf(u8, line, "isError") == null); } test "serialize/parse tool result preserves is_error = true" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); var trp = try a.alloc(DiskResultPart, 1); trp[0] = .{ .text = try dupe(a, "file not found") }; content[0] = .{ .tool_result = .{ .tool_use_id = try dupe(a, "tool_err"), .parts = trp, .is_error = true, } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "e1"), .parent_id = try dupe(a, "e0"), .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), }, .provider = try dupe(a, "anthropic"), .model = try dupe(a, "claude-sonnet-4-20250514"), .message = .{ .role = .user, .content = content }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); try testing.expect(std.mem.indexOf(u8, line, "\"isError\":true") != null); var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expect(fe.entry.message.message.content[0].tool_result.is_error); } test "parse tool result without isError defaults to false" { const a = testing.allocator; // A legacy line predating the is_error field. const line = \\{"type":"message","id":"x","parentId":"y","timestamp":"t","provider":"anthropic","model":"m","message":{"role":"user","content":[{"type":"toolResult","toolUseId":"t1","parts":[{"type":"text","text":"ok"}]}]}} ; var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expect(!fe.entry.message.message.content[0].tool_result.is_error); } test "serialize/parse tool result with text + image part round-trips" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); var trp = try a.alloc(DiskResultPart, 2); trp[0] = .{ .text = try dupe(a, "here is the image") }; trp[1] = .{ .media = .{ .media_type = try dupe(a, "image/png"), .data = try dupe(a, "iVBORw0KGgo="), } }; content[0] = .{ .tool_result = .{ .tool_use_id = try dupe(a, "tool_img"), .parts = trp, } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "img00001"), .parent_id = try dupe(a, "img00000"), .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), }, .provider = try dupe(a, "anthropic"), .model = try dupe(a, "claude-sonnet-4-20250514"), .message = .{ .role = .user, .content = content }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); var fe = try parseLine(a, line); defer fe.deinit(a); const tr = fe.entry.message.message.content[0].tool_result; try testing.expectEqualStrings("tool_img", tr.tool_use_id); try testing.expectEqual(@as(usize, 2), tr.parts.len); try testing.expectEqualStrings("here is the image", tr.parts[0].text); try testing.expectEqualStrings("image/png", tr.parts[1].media.media_type); try testing.expectEqualStrings("iVBORw0KGgo=", tr.parts[1].media.data); } test "system message mode round-trips; absent mode defaults to append" { const a = testing.allocator; // replace-mode system entry round-trips. { var content = try a.alloc(DiskContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "fresh seed") } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "aabbccdd"), .parent_id = null, .timestamp = try dupe(a, "2026-04-25T17:40:00Z"), }, .message = .{ .role = .system, .content = content, .mode = .replace, }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); try testing.expect(std.mem.indexOf(u8, line, "\"mode\":\"replace\"") != null); var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expectEqual(DiskSystemMode.replace, fe.entry.message.message.mode); } // A legacy system entry with no `mode` parses back as append. { const line = \\{"type":"message","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z","message":{"role":"system","content":[{"type":"text","text":"hi"}]}} ; var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expectEqual(DiskSystemMode.append, fe.entry.message.message.mode); } } test "parse: null parentId is handled" { const a = testing.allocator; const line = \\{"type":"message","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z","message":{"role":"system","content":[{"type":"text","text":"hi"}]}} ; var fe = try parseLine(a, line); defer fe.deinit(a); try testing.expect(fe.entry.message.base.parent_id == null); } test "parse: malformed JSON is reported" { const a = testing.allocator; try testing.expectError(error.InvalidJson, parseLine(a, "not json")); try testing.expectError(error.InvalidJson, parseLine(a, "{\"type\":\"message\"")); } test "parse: unknown entry type is reported" { const a = testing.allocator; const line = \\{"type":"future_entry","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z"} ; try testing.expectError(error.UnknownType, parseLine(a, line)); } test "contentBlockToDisk: Text round-trips via in-memory" { const a = testing.allocator; var tb = try conversation.textualBlockFromSlice(a, "hello"); defer tb.deinit(a); const block: conversation.ContentBlock = .{ .Text = tb }; const disk = try contentBlockToDisk(a, block); defer disk.deinit(a); try testing.expectEqualStrings("hello", disk.text.text); } test "diskContentBlockToInternal: ToolUse preserves id/name/input" { const a = testing.allocator; const disk: DiskContentBlock = .{ .tool_use = .{ .id = try a.dupe(u8, "tu_1"), .name = try a.dupe(u8, "bash"), .input = try a.dupe(u8, "{\"command\":\"ls\"}"), } }; defer disk.deinit(a); var inmem = try diskContentBlockToInternal(a, disk); defer inmem.deinit(a); try testing.expectEqualStrings("tu_1", inmem.ToolUse.id); try testing.expectEqualStrings("bash", inmem.ToolUse.name); try testing.expectEqualStrings("{\"command\":\"ls\"}", inmem.ToolUse.input.items); } test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "deadbeef"), .parent_id = null, .timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"), }, .message = .{ .role = .assistant, .content = content, .provider = try dupe(a, "anthropic"), .model = try dupe(a, "claude-sonnet-4-20250514"), .stop_reason = try dupe(a, "stop"), .usage = .{ .input = 100, .output = 50, .cache_read = 800, .cache_write = 200, .reasoning = 30, }, }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); // Every non-zero field should appear in the serialized JSON. try testing.expect(std.mem.indexOf(u8, line, "\"input\":100") != null); try testing.expect(std.mem.indexOf(u8, line, "\"output\":50") != null); try testing.expect(std.mem.indexOf(u8, line, "\"cacheRead\":800") != null); try testing.expect(std.mem.indexOf(u8, line, "\"cacheWrite\":200") != null); try testing.expect(std.mem.indexOf(u8, line, "\"reasoning\":30") != null); var fe = try parseLine(a, line); defer fe.deinit(a); const u = fe.entry.message.message.usage.?; try testing.expectEqual(@as(u64, 100), u.input); try testing.expectEqual(@as(u64, 50), u.output); try testing.expectEqual(@as(u64, 800), u.cache_read); try testing.expectEqual(@as(u64, 200), u.cache_write); try testing.expectEqual(@as(u64, 30), u.reasoning); } test "Usage: zero-valued auxiliary fields are omitted but parse back as 0" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "deadbeef"), .parent_id = null, .timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"), }, .message = .{ .role = .assistant, .content = content, .usage = .{ .input = 100, .output = 50 }, }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); try testing.expect(std.mem.indexOf(u8, line, "cacheRead") == null); try testing.expect(std.mem.indexOf(u8, line, "cacheWrite") == null); try testing.expect(std.mem.indexOf(u8, line, "reasoning") == null); var fe = try parseLine(a, line); defer fe.deinit(a); const u = fe.entry.message.message.usage.?; try testing.expectEqual(@as(u64, 0), u.cache_read); try testing.expectEqual(@as(u64, 0), u.cache_write); try testing.expectEqual(@as(u64, 0), u.reasoning); } test "diskContentBlockToInternal: Thinking preserves signature" { const a = testing.allocator; const disk: DiskContentBlock = .{ .thinking = .{ .thinking = try a.dupe(u8, "reasoning..."), .signature = try a.dupe(u8, "sig123"), } }; defer disk.deinit(a); var inmem = try diskContentBlockToInternal(a, disk); defer inmem.deinit(a); try testing.expectEqualStrings("reasoning...", inmem.Thinking.text.items); try testing.expectEqualStrings("sig123", inmem.Thinking.signature.?); } test "compactionSummary block round-trips through serialize/parse" { const a = testing.allocator; var content = try a.alloc(DiskContentBlock, 1); content[0] = .{ .compaction_summary = .{ .text = try dupe(a, "earlier history summary") } }; const entry: SessionEntry = .{ .message = .{ .base = .{ .id = try dupe(a, "cafef00d"), .parent_id = null, .timestamp = try dupe(a, "2026-04-25T17:40:00Z"), }, .message = .{ .role = .user, .content = content }, } }; defer entry.deinit(a); const line = try serializeEntry(a, entry); defer a.free(line); try testing.expect(std.mem.indexOf(u8, line, "\"type\":\"compactionSummary\"") != null); var fe = try parseLine(a, line); defer fe.deinit(a); const got = fe.entry.message; try testing.expectEqual(DiskMessageRole.user, got.message.role); try testing.expectEqualStrings("earlier history summary", got.message.content[0].compaction_summary.text); } test "compactionSummary bridges in-memory <-> disk both directions" { const a = testing.allocator; // in-memory -> disk const tb = try conversation.textualBlockFromSlice(a, "S1"); const block: conversation.ContentBlock = .{ .CompactionSummary = .{ .text = tb } }; defer { var mut = block; mut.deinit(a); } const disk = try contentBlockToDisk(a, block); defer disk.deinit(a); try testing.expectEqualStrings("S1", disk.compaction_summary.text); // disk -> in-memory var inmem = try diskContentBlockToInternal(a, disk); defer inmem.deinit(a); try testing.expectEqualStrings("S1", inmem.CompactionSummary.text.items); }