summaryrefslogtreecommitdiff
path: root/src/session.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/session.zig
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/session.zig')
-rw-r--r--src/session.zig1606
1 files changed, 1606 insertions, 0 deletions
diff --git a/src/session.zig b/src/session.zig
new file mode 100644
index 0000000..35fd09b
--- /dev/null
+++ b/src/session.zig
@@ -0,0 +1,1606 @@
+//! 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`). No prior versions exist;
+//! when v2 lands, a migration step on load can transform v1 entries.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Writer = std.Io.Writer;
+
+const conversation = @import("conversation.zig");
+const config = @import("config.zig");
+
+pub const APIStyle = config.APIStyle;
+pub const ReasoningEffort = config.ReasoningEffort;
+pub const Thinking = config.Thinking;
+pub const Effort = config.Effort;
+
+/// Wire-format provider identity stamped on a message entry. This is the
+/// ground truth of which endpoint a turn was sent to — never a CLI config
+/// alias, and never any `api_key` material. Recorded on user/assistant
+/// entries; null on system entries.
+pub const WireStamp = struct {
+ api_style: APIStyle,
+ base_url: []const u8, // owned
+ model: []const u8, // owned
+ /// OpenAI only. Defaults to `.default` (field omitted on the wire).
+ reasoning: ReasoningEffort = .default,
+ /// Anthropic only. Defaults to `.enabled`.
+ thinking: Thinking = .enabled,
+ /// Anthropic only; only meaningful when `thinking == .adaptive`.
+ effort: Effort = .medium,
+ /// Anthropic only; only meaningful when `thinking == .enabled`. `null`
+ /// means "use the config default" (falls back to `max_tokens - 1`).
+ thinking_budget_tokens: ?u32 = 32_000,
+ /// Anthropic only; only meaningful when `thinking == .enabled`.
+ thinking_interleaved: bool = false,
+
+ pub fn deinit(self: WireStamp, alloc: Allocator) void {
+ alloc.free(self.base_url);
+ alloc.free(self.model);
+ }
+
+ pub fn dupe(self: WireStamp, alloc: Allocator) !WireStamp {
+ const burl = try alloc.dupe(u8, self.base_url);
+ errdefer alloc.free(burl);
+ const mdl = try alloc.dupe(u8, self.model);
+ return .{
+ .api_style = self.api_style,
+ .base_url = burl,
+ .model = mdl,
+ .reasoning = self.reasoning,
+ .thinking = self.thinking,
+ .effort = self.effort,
+ .thinking_budget_tokens = self.thinking_budget_tokens,
+ .thinking_interleaved = self.thinking_interleaved,
+ };
+ }
+};
+
+/// Bumped whenever the on-disk format changes in a way that older readers
+/// cannot tolerate. When that happens, add a load-time migration that
+/// upgrades older files and rewrites them 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
+ /// Opaque session-wide metadata bag. Round-trips verbatim; `libpanto`
+ /// never interprets it. The panto CLI records `{ "cwd": ... }` here.
+ metadata: ?[]const u8 = null, // owned
+
+ pub fn deinit(self: SessionHeader, alloc: Allocator) void {
+ alloc.free(self.id);
+ alloc.free(self.timestamp);
+ if (self.metadata) |m| alloc.free(m);
+ }
+};
+
+// =============================================================================
+// 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,
+ /// Wire-format provider identity for this entry. Recorded on user and
+ /// assistant message entries (both are tied to a provider API call);
+ /// null on system entries.
+ stamp: ?WireStamp = null,
+ message: StoredMessage,
+
+ pub fn deinit(self: MessageEntry, alloc: Allocator) void {
+ self.base.deinit(alloc);
+ if (self.stamp) |s| s.deinit(alloc);
+ self.message.deinit(alloc);
+ }
+};
+
+pub const StoredMessageRole = 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 StoredSystemMode = enum { append, replace };
+
+pub const StoredMessage = struct {
+ role: StoredMessageRole,
+ content: []StoredContentBlock, // owned
+ /// System-message mode. Recorded only for system-role messages; an
+ /// absent `mode` on disk parses back as `.append`.
+ mode: StoredSystemMode = .append,
+ /// Assistant-only stop reason. Null for system/user messages.
+ stop_reason: ?[]const u8 = null, // owned
+ usage: ?Usage = null,
+ /// Opaque per-message metadata bag (see `conversation.Message.metadata`).
+ /// Round-trips verbatim; `libpanto` never interprets it.
+ metadata: ?[]const u8 = null, // owned
+
+ pub fn deinit(self: StoredMessage, alloc: Allocator) void {
+ for (self.content) |block| block.deinit(alloc);
+ alloc.free(self.content);
+ if (self.stop_reason) |s| alloc.free(s);
+ if (self.metadata) |m| alloc.free(m);
+ }
+};
+
+/// 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 StoredContentBlock = union(enum) {
+ text: StoredTextBlock,
+ thinking: StoredThinkingBlock,
+ tool_use: StoredToolUseBlock,
+ tool_result: StoredToolResultBlock,
+ compaction_summary: StoredCompactionSummaryBlock,
+
+ pub fn deinit(self: StoredContentBlock, 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 StoredTextBlock = struct {
+ text: []const u8, // owned
+ pub fn deinit(self: StoredTextBlock, alloc: Allocator) void {
+ alloc.free(self.text);
+ }
+};
+
+pub const StoredThinkingBlock = 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: StoredThinkingBlock, alloc: Allocator) void {
+ alloc.free(self.thinking);
+ if (self.signature) |s| alloc.free(s);
+ }
+};
+
+pub const StoredToolUseBlock = struct {
+ id: []const u8, // owned
+ name: []const u8, // owned
+ input: []const u8, // raw JSON bytes, owned
+ pub fn deinit(self: StoredToolUseBlock, 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 StoredResultPart = union(enum) {
+ text: []const u8, // owned
+ media: struct {
+ media_type: []const u8, // owned
+ data: []const u8, // owned (base64)
+ },
+ pub fn deinit(self: StoredResultPart, alloc: Allocator) void {
+ switch (self) {
+ .text => |t| alloc.free(t),
+ .media => |m| {
+ alloc.free(m.media_type);
+ alloc.free(m.data);
+ },
+ }
+ }
+};
+
+pub const StoredToolResultBlock = struct {
+ tool_use_id: []const u8, // owned
+ parts: []StoredResultPart, // owned
+ is_error: bool = false,
+ pub fn deinit(self: StoredToolResultBlock, 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 StoredCompactionSummaryBlock = struct {
+ text: []const u8, // owned
+ pub fn deinit(self: StoredCompactionSummaryBlock, 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);
+ if (header.metadata) |md| {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, md, .{});
+ defer parsed.deinit();
+ try s.objectField("metadata");
+ try s.write(parsed.value);
+ }
+ 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);
+ // Wire-format provider identity on user/assistant entries.
+ if (m.stamp) |st| try writeWireStamp(s, st);
+ try s.objectField("message");
+ try writeDiskMessage(s, m.message);
+ try s.endObject();
+}
+
+fn writeWireStamp(s: *std.json.Stringify, st: WireStamp) !void {
+ try s.objectField("apiStyle");
+ try s.write(@tagName(st.api_style));
+ try s.objectField("baseUrl");
+ try s.write(st.base_url);
+ try s.objectField("model");
+ try s.write(st.model);
+ // OpenAI: emit reasoning only when non-default (keeps logs compact).
+ if (st.reasoning != .default) {
+ try s.objectField("reasoning");
+ try s.write(@tagName(st.reasoning));
+ }
+ // Anthropic: emit thinking fields only when they differ from defaults.
+ if (st.thinking != .enabled) {
+ try s.objectField("thinking");
+ try s.write(@tagName(st.thinking));
+ }
+ if (st.effort != .medium) {
+ try s.objectField("effort");
+ try s.write(@tagName(st.effort));
+ }
+ if (st.thinking_budget_tokens) |b| {
+ if (b != 32_000) {
+ try s.objectField("thinkingBudgetTokens");
+ try s.write(b);
+ }
+ } else {
+ // null means "use max_tokens - 1"; record the absence explicitly
+ // so round-trips preserve the null intent.
+ try s.objectField("thinkingBudgetTokens");
+ try s.write(null);
+ }
+ if (st.thinking_interleaved) {
+ try s.objectField("thinkingInterleaved");
+ try s.write(true);
+ }
+}
+
+fn writeDiskMessage(s: *std.json.Stringify, msg: StoredMessage) !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.stop_reason) |sr| {
+ try s.objectField("stopReason");
+ try s.write(sr);
+ }
+ if (msg.metadata) |md| {
+ try s.objectField("metadata");
+ try s.write(md);
+ }
+ 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: StoredContentBlock) !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 metadata: ?[]const u8 = blk: {
+ if (obj.get("metadata")) |mv| {
+ break :blk try std.json.Stringify.valueAlloc(allocator, mv, .{});
+ }
+ if (obj.get("cwd")) |cv| {
+ if (cv != .string) return error.MissingField;
+ const cwd_json = try std.json.Stringify.valueAlloc(allocator, cv, .{});
+ defer allocator.free(cwd_json);
+ break :blk try std.fmt.allocPrint(allocator, "{{\"cwd\":{s}}}", .{cwd_json});
+ }
+ break :blk null;
+ };
+ errdefer if (metadata) |m| allocator.free(m);
+ return .{
+ .version = version,
+ .id = id,
+ .timestamp = timestamp,
+ .metadata = metadata,
+ };
+}
+
+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 stamp = try parseWireStamp(allocator, obj);
+ errdefer if (stamp) |st| st.deinit(allocator);
+
+ 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 },
+ .stamp = stamp,
+ .message = msg,
+ };
+}
+
+/// Parse the wire-format provider stamp from a message entry object.
+/// Returns null when no `apiStyle` field is present (system entries).
+fn parseWireStamp(allocator: Allocator, obj: std.json.ObjectMap) ParseError!?WireStamp {
+ const style_v = obj.get("apiStyle") orelse return null;
+ if (style_v != .string) return null;
+ const api_style = std.meta.stringToEnum(APIStyle, style_v.string) orelse return error.MissingField;
+ const base_url = try dupeStringField(allocator, obj, "baseUrl");
+ errdefer allocator.free(base_url);
+ const model = try dupeStringField(allocator, obj, "model");
+ errdefer allocator.free(model);
+ // OpenAI: absent reasoning defaults to .default.
+ const reasoning: ReasoningEffort = blk: {
+ const rv = obj.get("reasoning") orelse break :blk .default;
+ if (rv != .string) break :blk .default;
+ break :blk std.meta.stringToEnum(ReasoningEffort, rv.string) orelse .default;
+ };
+ // Anthropic: absent fields default to the same values as the config defaults.
+ const thinking: Thinking = blk: {
+ const tv = obj.get("thinking") orelse break :blk .enabled;
+ if (tv != .string) break :blk .enabled;
+ break :blk std.meta.stringToEnum(Thinking, tv.string) orelse .enabled;
+ };
+ const effort: Effort = blk: {
+ const ev = obj.get("effort") orelse break :blk .medium;
+ if (ev != .string) break :blk .medium;
+ break :blk std.meta.stringToEnum(Effort, ev.string) orelse .medium;
+ };
+ const thinking_budget_tokens: ?u32 = blk: {
+ const bv = obj.get("thinkingBudgetTokens") orelse break :blk 32_000;
+ if (bv == .null) break :blk null;
+ if (bv != .integer) break :blk 32_000;
+ if (bv.integer < 0) break :blk 32_000;
+ break :blk @intCast(bv.integer);
+ };
+ const thinking_interleaved: bool = blk: {
+ const iv = obj.get("thinkingInterleaved") orelse break :blk false;
+ if (iv != .bool) break :blk false;
+ break :blk iv.bool;
+ };
+ return .{
+ .api_style = api_style,
+ .base_url = base_url,
+ .model = model,
+ .reasoning = reasoning,
+ .thinking = thinking,
+ .effort = effort,
+ .thinking_budget_tokens = thinking_budget_tokens,
+ .thinking_interleaved = thinking_interleaved,
+ };
+}
+
+fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!StoredMessage {
+ const role_v = obj.get("role") orelse return error.MissingField;
+ if (role_v != .string) return error.MissingField;
+ const role = std.meta.stringToEnum(StoredMessageRole, 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: StoredSystemMode = blk: {
+ const mv = obj.get("mode") orelse break :blk .append;
+ if (mv != .string) break :blk .append;
+ break :blk std.meta.stringToEnum(StoredSystemMode, 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(StoredContentBlock).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 stop_reason: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "stopReason");
+ errdefer if (stop_reason) |s| allocator.free(s);
+ const metadata: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "metadata");
+ errdefer if (metadata) |m| allocator.free(m);
+
+ 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,
+ .stop_reason = stop_reason,
+ .usage = usage,
+ .metadata = metadata,
+ };
+}
+
+fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!StoredContentBlock {
+ 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![]StoredResultPart {
+ var list: std.ArrayList(StoredResultPart) = .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 `StoredContentBlock`. 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,
+) !StoredContentBlock {
+ 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(StoredResultPart) = .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 `StoredMessage.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 `StoredContentBlock` 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: StoredContentBlock,
+) !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"),
+ .metadata = try dupe(a, "{\"cwd\":\"/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.metadata.?, fe.header.metadata.?);
+}
+
+test "serialize/parse user message entry round-trip (with provider/model stamp)" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(StoredContentBlock, 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"),
+ },
+ .stamp = .{
+ .api_style = .openai_chat,
+ .base_url = try dupe(a, "https://api.openai.com/v1"),
+ .model = try dupe(a, "gpt-4o"),
+ .reasoning = .high,
+ },
+ .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.expectEqual(APIStyle.openai_chat, got.stamp.?.api_style);
+ try testing.expectEqualStrings("https://api.openai.com/v1", got.stamp.?.base_url);
+ try testing.expectEqualStrings("gpt-4o", got.stamp.?.model);
+ try testing.expectEqual(ReasoningEffort.high, got.stamp.?.reasoning);
+ try testing.expectEqual(StoredMessageRole.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(StoredContentBlock, 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"),
+ },
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-sonnet-4-20250514"),
+ },
+ .message = .{
+ .role = .assistant,
+ .content = content,
+ .stop_reason = try dupe(a, "toolUse"),
+ .usage = .{ .input = 1500, .output = 85 },
+ .metadata = try dupe(a, "{\"k\":1}"),
+ },
+ } };
+ 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(StoredMessageRole.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", @tagName(got.stamp.?.api_style)[0..9]);
+ try testing.expectEqualStrings("toolUse", got.message.stop_reason.?);
+ try testing.expectEqualStrings("{\"k\":1}", got.message.metadata.?);
+ 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(StoredContentBlock, 1);
+ var trp = try a.alloc(StoredResultPart, 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"),
+ },
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .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(StoredMessageRole.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.expectEqual(APIStyle.anthropic_messages, got.stamp.?.api_style);
+ // 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(StoredContentBlock, 1);
+ var trp = try a.alloc(StoredResultPart, 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"),
+ },
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .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(StoredContentBlock, 1);
+ var trp = try a.alloc(StoredResultPart, 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"),
+ },
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .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(StoredContentBlock, 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(StoredSystemMode.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(StoredSystemMode.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: StoredContentBlock = .{ .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(StoredContentBlock, 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,
+ .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(StoredContentBlock, 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: StoredContentBlock = .{ .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(StoredContentBlock, 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(StoredMessageRole.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);
+}
+
+test "WireStamp: Anthropic non-default thinking fields round-trip" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(StoredContentBlock, 1);
+ content[0] = .{ .text = .{ .text = try dupe(a, "hi") } };
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "aa000001"),
+ .parent_id = null,
+ .timestamp = try dupe(a, "2026-06-01T00:00:00Z"),
+ },
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-opus-4-8"),
+ .thinking = .adaptive,
+ .effort = .high,
+ .thinking_budget_tokens = null,
+ .thinking_interleaved = true,
+ },
+ .message = .{ .role = .user, .content = content },
+ } };
+ defer entry.deinit(a);
+
+ const line = try serializeEntry(a, entry);
+ defer a.free(line);
+
+ // Non-default fields must appear in the serialized line.
+ try testing.expect(std.mem.indexOf(u8, line, "\"thinking\":\"adaptive\"") != null);
+ try testing.expect(std.mem.indexOf(u8, line, "\"effort\":\"high\"") != null);
+ try testing.expect(std.mem.indexOf(u8, line, "\"thinkingBudgetTokens\":null") != null);
+ try testing.expect(std.mem.indexOf(u8, line, "\"thinkingInterleaved\":true") != null);
+
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ const got = fe.entry.message.stamp.?;
+ try testing.expectEqual(APIStyle.anthropic_messages, got.api_style);
+ try testing.expectEqual(Thinking.adaptive, got.thinking);
+ try testing.expectEqual(Effort.high, got.effort);
+ try testing.expectEqual(@as(?u32, null), got.thinking_budget_tokens);
+ try testing.expectEqual(true, got.thinking_interleaved);
+ // reasoning carries its default (unused for Anthropic)
+ try testing.expectEqual(ReasoningEffort.default, got.reasoning);
+}
+
+test "WireStamp: Anthropic stamp with all-default thinking fields omits non-essential keys" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(StoredContentBlock, 1);
+ content[0] = .{ .text = .{ .text = try dupe(a, "hi") } };
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "bb000002"),
+ .parent_id = null,
+ .timestamp = try dupe(a, "2026-06-01T00:00:00Z"),
+ },
+ .stamp = .{
+ .api_style = .anthropic_messages,
+ .base_url = try dupe(a, "https://api.anthropic.com"),
+ .model = try dupe(a, "claude-haiku-4-5"),
+ // All defaults: thinking=.enabled, effort=.medium,
+ // thinking_budget_tokens=32_000, thinking_interleaved=false
+ },
+ .message = .{ .role = .user, .content = content },
+ } };
+ defer entry.deinit(a);
+
+ const line = try serializeEntry(a, entry);
+ defer a.free(line);
+
+ // Default-valued fields should be omitted (keeps logs compact).
+ try testing.expect(std.mem.indexOf(u8, line, "thinking") == null);
+ try testing.expect(std.mem.indexOf(u8, line, "effort") == null);
+ try testing.expect(std.mem.indexOf(u8, line, "thinkingInterleaved") == null);
+ // thinkingBudgetTokens=32_000 is the default, should be omitted too.
+ try testing.expect(std.mem.indexOf(u8, line, "thinkingBudgetTokens") == null);
+
+ // Round-trip: all defaults parse back correctly.
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ const got = fe.entry.message.stamp.?;
+ try testing.expectEqual(Thinking.enabled, got.thinking);
+ try testing.expectEqual(Effort.medium, got.effort);
+ try testing.expectEqual(@as(?u32, 32_000), got.thinking_budget_tokens);
+ try testing.expectEqual(false, got.thinking_interleaved);
+}
+
+test "WireStamp: legacy Anthropic stamp (no thinking fields) parses with defaults" {
+ // Simulate a session log written before thinking fields were added.
+ const a = testing.allocator;
+ const line =
+ \\{"type":"message","id":"cc000003","parentId":null,"timestamp":"2026-06-01T00:00:00Z","apiStyle":"anthropic_messages","baseUrl":"https://api.anthropic.com","model":"claude-3-7-sonnet","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}
+ ;
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ const got = fe.entry.message.stamp.?;
+ try testing.expectEqual(APIStyle.anthropic_messages, got.api_style);
+ try testing.expectEqual(Thinking.enabled, got.thinking);
+ try testing.expectEqual(Effort.medium, got.effort);
+ try testing.expectEqual(@as(?u32, 32_000), got.thinking_budget_tokens);
+ try testing.expectEqual(false, got.thinking_interleaved);
+}
+
+test "WireStamp: OpenAI stamp is unchanged by Anthropic fields" {
+ const a = testing.allocator;
+
+ var content = try a.alloc(StoredContentBlock, 1);
+ content[0] = .{ .text = .{ .text = try dupe(a, "hi") } };
+
+ const entry: SessionEntry = .{ .message = .{
+ .base = .{
+ .id = try dupe(a, "dd000004"),
+ .parent_id = null,
+ .timestamp = try dupe(a, "2026-06-01T00:00:00Z"),
+ },
+ .stamp = .{
+ .api_style = .openai_chat,
+ .base_url = try dupe(a, "https://api.openai.com/v1"),
+ .model = try dupe(a, "gpt-4o"),
+ .reasoning = .high,
+ },
+ .message = .{ .role = .user, .content = content },
+ } };
+ defer entry.deinit(a);
+
+ const line = try serializeEntry(a, entry);
+ defer a.free(line);
+
+ // Anthropic fields should not appear for an OpenAI stamp.
+ try testing.expect(std.mem.indexOf(u8, line, "thinking") == null);
+ try testing.expect(std.mem.indexOf(u8, line, "effort") == null);
+ try testing.expect(std.mem.indexOf(u8, line, "thinkingBudget") == null);
+ try testing.expect(std.mem.indexOf(u8, line, "thinkingInterleaved") == null);
+ // reasoning=high should be present
+ try testing.expect(std.mem.indexOf(u8, line, "\"reasoning\":\"high\"") != null);
+
+ var fe = try parseLine(a, line);
+ defer fe.deinit(a);
+ const got = fe.entry.message.stamp.?;
+ try testing.expectEqual(APIStyle.openai_chat, got.api_style);
+ try testing.expectEqual(ReasoningEffort.high, got.reasoning);
+}