summaryrefslogtreecommitdiff
path: root/libpanto/src/session.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:29:23 -0600
committert <t@tjp.lol>2026-07-07 12:15:28 -0600
commit621d7fee0ace729f8d44126032d2c6e13f72ee7f (patch)
treea2938b0a9e5c0930e8644721abbe94875df9ff08 /libpanto/src/session.zig
parent0fa6d4209ff9b4a95e7d1955887aa4c73ee3423c (diff)
Move libpanto projects to libpantograph dependencyHEADmain
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.
Diffstat (limited to 'libpanto/src/session.zig')
-rw-r--r--libpanto/src/session.zig1606
1 files changed, 0 insertions, 1606 deletions
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);
-}