summaryrefslogtreecommitdiff
path: root/libpanto/src/session.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/session.zig')
-rw-r--r--libpanto/src/session.zig961
1 files changed, 961 insertions, 0 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
new file mode 100644
index 0000000..e77d121
--- /dev/null
+++ b/libpanto/src/session.zig
@@ -0,0 +1,961 @@
+//! 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 };
+
+pub const DiskMessage = struct {
+ role: DiskMessageRole,
+ content: []DiskContentBlock, // owned
+ // 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.
+///
+/// All four input categories sum to the total prompt tokens that were
+/// billed for the turn:
+/// `input + cache_read + cache_write` = total prompt tokens.
+///
+/// `reasoning` is a **subset** of `output`, not additive — it's the
+/// portion of the output that the model spent on internal reasoning
+/// (OpenAI o-series, Anthropic extended thinking). Cost is computed
+/// from `output` only; `reasoning` is tracked for display.
+///
+/// All fields are `u64` and default to 0. Providers that don't report a
+/// given category leave it at 0. Future cache-tier breakdowns (e.g.
+/// Anthropic's 5m vs 1h tiers) can be added as new fields without
+/// breaking the format.
+pub const Usage = struct {
+ /// Fresh input tokens billed at the model's base input rate.
+ input: u64 = 0,
+ /// Output tokens billed at the model's base output rate.
+ output: u64 = 0,
+ /// Input tokens served from cache (typ. 0.1× base input rate on
+ /// Anthropic, 0.5× on OpenAI).
+ cache_read: u64 = 0,
+ /// Input tokens written to a new cache entry (Anthropic only at
+ /// time of writing; typ. 1.25× base input rate). OpenAI's cache
+ /// hits don't bill a write premium, so this stays 0 there.
+ cache_write: u64 = 0,
+ /// Subset of `output` spent on reasoning. For OpenAI o-series
+ /// models and Anthropic extended thinking. Display-only — cost is
+ /// already accounted for via `output`.
+ reasoning: u64 = 0,
+};
+
+// =============================================================================
+// Content blocks
+// =============================================================================
+
+pub const DiskContentBlock = union(enum) {
+ text: DiskTextBlock,
+ thinking: DiskThinkingBlock,
+ tool_use: DiskToolUseBlock,
+ tool_result: DiskToolResultBlock,
+
+ 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),
+ }
+ }
+};
+
+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);
+ }
+};
+
+pub const DiskToolResultBlock = struct {
+ tool_use_id: []const u8, // owned
+ content: []const u8, // owned
+ pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void {
+ alloc.free(self.tool_use_id);
+ alloc.free(self.content);
+ }
+};
+
+// =============================================================================
+// 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));
+ 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);
+ try s.objectField("content");
+ try s.write(b.content);
+ 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;
+
+ 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,
+ .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 content = try dupeStringField(allocator, obj, "content");
+ return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } };
+ } else {
+ return error.UnknownBlockType;
+ }
+}
+
+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);
+ const content = try allocator.dupe(u8, tr.content.items);
+ return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } };
+ },
+ }
+}
+
+/// 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);
+ const content = try conversation.textualBlockFromSlice(allocator, b.content);
+ return .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } };
+ },
+ }
+}
+
+// =============================================================================
+// 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);
+ content[0] = .{ .tool_result = .{
+ .tool_use_id = try dupe(a, "tool_abc"),
+ .content = try dupe(a, "file1.txt\nfile2.txt"),
+ } };
+
+ 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.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.content);
+ try testing.expectEqualStrings("anthropic", got.provider.?);
+}
+
+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.?);
+}