diff options
| author | t <t@tjp.lol> | 2026-07-07 11:26:32 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 11:26:45 -0600 |
| commit | f83578fdc9264019a1a1cef8c5484a161167d3dd (patch) | |
| tree | 888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/session.zig | |
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/session.zig')
| -rw-r--r-- | src/session.zig | 1606 |
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); +} |
