From 621d7fee0ace729f8d44126032d2c6e13f72ee7f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 7 Jul 2026 11:29:23 -0600 Subject: Move libpanto projects to libpantograph dependency Remove the in-repo libpanto sources and binding projects from pantograph. Consume libpantograph through the Zig package URL at code.tjp.lol/libpantograph.git, including the Lua module artifact used by CLI extensions. --- libpanto/src/session.zig | 1606 ---------------------------------------------- 1 file changed, 1606 deletions(-) delete mode 100644 libpanto/src/session.zig (limited to 'libpanto/src/session.zig') diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig deleted file mode 100644 index 35fd09b..0000000 --- a/libpanto/src/session.zig +++ /dev/null @@ -1,1606 +0,0 @@ -//! 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); -} -- cgit v1.3