diff options
| author | T <t@tjp.lol> | 2026-05-27 12:45:20 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 16:21:52 -0600 |
| commit | 6545cdfd8f2bc865aa06a2b5515056daf58ba111 (patch) | |
| tree | 5393cefda42dad12eb90612c6b7ff3c50d8eb800 /libpanto/src/session.zig | |
| parent | b1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff) | |
session files
Diffstat (limited to 'libpanto/src/session.zig')
| -rw-r--r-- | libpanto/src/session.zig | 961 |
1 files changed, 961 insertions, 0 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig new file mode 100644 index 0000000..e77d121 --- /dev/null +++ b/libpanto/src/session.zig @@ -0,0 +1,961 @@ +//! On-disk session entry types and JSON serialization. +//! +//! These types are the wire format of pantograph's session log. They are +//! intentionally separate from the in-memory `Conversation`/`Message`/ +//! `ContentBlock` model: +//! +//! - The in-memory model holds only what providers need to serialize a +//! request (`role`, `content`). +//! - The on-disk model holds the full event-log story: provider/model +//! used per request, assistant stop reason and usage, timestamps, and +//! enough tree structure (`id`/`parent_id`) to allow future branching. +//! +//! Bridge functions at the bottom convert between the two. The bridge is +//! lossy by design: assistant metadata (provider/model/stop_reason/usage) +//! is recorded in entries but does NOT round-trip into the in-memory +//! conversation, because providers don't need it for request serialization. +//! +//! Format version: 1 (see `CURRENT_VERSION`). Migrations live in +//! `session_manager.zig`. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; + +const conversation = @import("conversation.zig"); + +/// Bumped whenever the on-disk format changes in a way that older readers +/// cannot tolerate. Older files are upgraded by `migrate()` on load and +/// the file is rewritten once. +pub const CURRENT_VERSION: u32 = 1; + +// ============================================================================= +// Header +// ============================================================================= + +/// First (and only) line of a session file. Metadata only — not part of +/// the entry tree (no id/parent_id). +pub const SessionHeader = struct { + version: u32, + id: []const u8, // UUIDv7 string, owned + timestamp: []const u8, // ISO 8601, owned + cwd: []const u8, // owned + + pub fn deinit(self: SessionHeader, alloc: Allocator) void { + alloc.free(self.id); + alloc.free(self.timestamp); + alloc.free(self.cwd); + } +}; + +// ============================================================================= +// Entries +// ============================================================================= + +/// Fields shared by every non-header entry. +pub const EntryBase = struct { + id: []const u8, // 8-char hex, owned + parent_id: ?[]const u8, // owned, null for first entry + timestamp: []const u8, // ISO 8601, owned + + pub fn deinit(self: EntryBase, alloc: Allocator) void { + alloc.free(self.id); + if (self.parent_id) |p| alloc.free(p); + alloc.free(self.timestamp); + } +}; + +pub const SessionEntry = union(enum) { + message: MessageEntry, + + pub fn base(self: SessionEntry) EntryBase { + return switch (self) { + .message => |m| m.base, + }; + } + + pub fn deinit(self: SessionEntry, alloc: Allocator) void { + switch (self) { + .message => |m| m.deinit(alloc), + } + } +}; + +pub const MessageEntry = struct { + base: EntryBase, + /// Recorded on user message entries (both human prompts and tool-result + /// messages). Both are submissions to a provider API; the stamp says + /// which one. Null on system and assistant entries. + provider: ?[]const u8 = null, // owned + model: ?[]const u8 = null, // owned + message: DiskMessage, + + pub fn deinit(self: MessageEntry, alloc: Allocator) void { + self.base.deinit(alloc); + if (self.provider) |p| alloc.free(p); + if (self.model) |m| alloc.free(m); + self.message.deinit(alloc); + } +}; + +pub const DiskMessageRole = enum { system, user, assistant }; + +pub const DiskMessage = struct { + role: DiskMessageRole, + content: []DiskContentBlock, // owned + // Assistant-only metadata. Null for system/user messages. + provider: ?[]const u8 = null, // owned + model: ?[]const u8 = null, // owned + stop_reason: ?[]const u8 = null, // owned + usage: ?Usage = null, + + pub fn deinit(self: DiskMessage, alloc: Allocator) void { + for (self.content) |block| block.deinit(alloc); + alloc.free(self.content); + if (self.provider) |p| alloc.free(p); + if (self.model) |m| alloc.free(m); + if (self.stop_reason) |s| alloc.free(s); + } +}; + +/// Token usage reported by a provider for a single assistant turn. +/// +/// All four input categories sum to the total prompt tokens that were +/// billed for the turn: +/// `input + cache_read + cache_write` = total prompt tokens. +/// +/// `reasoning` is a **subset** of `output`, not additive — it's the +/// portion of the output that the model spent on internal reasoning +/// (OpenAI o-series, Anthropic extended thinking). Cost is computed +/// from `output` only; `reasoning` is tracked for display. +/// +/// All fields are `u64` and default to 0. Providers that don't report a +/// given category leave it at 0. Future cache-tier breakdowns (e.g. +/// Anthropic's 5m vs 1h tiers) can be added as new fields without +/// breaking the format. +pub const Usage = struct { + /// Fresh input tokens billed at the model's base input rate. + input: u64 = 0, + /// Output tokens billed at the model's base output rate. + output: u64 = 0, + /// Input tokens served from cache (typ. 0.1× base input rate on + /// Anthropic, 0.5× on OpenAI). + cache_read: u64 = 0, + /// Input tokens written to a new cache entry (Anthropic only at + /// time of writing; typ. 1.25× base input rate). OpenAI's cache + /// hits don't bill a write premium, so this stays 0 there. + cache_write: u64 = 0, + /// Subset of `output` spent on reasoning. For OpenAI o-series + /// models and Anthropic extended thinking. Display-only — cost is + /// already accounted for via `output`. + reasoning: u64 = 0, +}; + +// ============================================================================= +// Content blocks +// ============================================================================= + +pub const DiskContentBlock = union(enum) { + text: DiskTextBlock, + thinking: DiskThinkingBlock, + tool_use: DiskToolUseBlock, + tool_result: DiskToolResultBlock, + + pub fn deinit(self: DiskContentBlock, alloc: Allocator) void { + switch (self) { + .text => |b| b.deinit(alloc), + .thinking => |b| b.deinit(alloc), + .tool_use => |b| b.deinit(alloc), + .tool_result => |b| b.deinit(alloc), + } + } +}; + +pub const DiskTextBlock = struct { + text: []const u8, // owned + pub fn deinit(self: DiskTextBlock, alloc: Allocator) void { + alloc.free(self.text); + } +}; + +pub const DiskThinkingBlock = struct { + thinking: []const u8, // owned + /// Anthropic's opaque integrity token. Other providers do not produce + /// one. Preserved here so resumed sessions can be sent back to + /// Anthropic with the original thinking block intact. + signature: ?[]const u8 = null, // owned + pub fn deinit(self: DiskThinkingBlock, alloc: Allocator) void { + alloc.free(self.thinking); + if (self.signature) |s| alloc.free(s); + } +}; + +pub const DiskToolUseBlock = struct { + id: []const u8, // owned + name: []const u8, // owned + input: []const u8, // raw JSON bytes, owned + pub fn deinit(self: DiskToolUseBlock, alloc: Allocator) void { + alloc.free(self.id); + alloc.free(self.name); + alloc.free(self.input); + } +}; + +pub const DiskToolResultBlock = struct { + tool_use_id: []const u8, // owned + content: []const u8, // owned + pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void { + alloc.free(self.tool_use_id); + alloc.free(self.content); + } +}; + +// ============================================================================= +// File entry (header or entry) +// ============================================================================= + +pub const FileEntry = union(enum) { + header: SessionHeader, + entry: SessionEntry, + + pub fn deinit(self: FileEntry, alloc: Allocator) void { + switch (self) { + .header => |h| h.deinit(alloc), + .entry => |e| e.deinit(alloc), + } + } +}; + +// ============================================================================= +// Serialization +// ============================================================================= + +/// Serialize the header as a single JSON line. Caller owns returned bytes. +/// The returned slice does NOT include a trailing newline. +pub fn serializeHeader(allocator: Allocator, header: SessionHeader) ![]u8 { + var aw: Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("type"); + try s.write("session"); + try s.objectField("version"); + try s.write(header.version); + try s.objectField("id"); + try s.write(header.id); + try s.objectField("timestamp"); + try s.write(header.timestamp); + try s.objectField("cwd"); + try s.write(header.cwd); + try s.endObject(); + + return try aw.toOwnedSlice(); +} + +/// Serialize an entry as a single JSON line. Caller owns returned bytes. +pub fn serializeEntry(allocator: Allocator, entry: SessionEntry) ![]u8 { + var aw: Writer.Allocating = .init(allocator); + errdefer aw.deinit(); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try writeEntry(&s, entry); + return try aw.toOwnedSlice(); +} + +fn writeEntry(s: *std.json.Stringify, entry: SessionEntry) !void { + switch (entry) { + .message => |m| try writeMessageEntry(s, m), + } +} + +fn writeMessageEntry(s: *std.json.Stringify, m: MessageEntry) !void { + try s.beginObject(); + try s.objectField("type"); + try s.write("message"); + try s.objectField("id"); + try s.write(m.base.id); + try s.objectField("parentId"); + if (m.base.parent_id) |p| try s.write(p) else try s.write(null); + try s.objectField("timestamp"); + try s.write(m.base.timestamp); + // Top-level provider/model on user message entries. + if (m.provider) |p| { + try s.objectField("provider"); + try s.write(p); + } + if (m.model) |mm| { + try s.objectField("model"); + try s.write(mm); + } + try s.objectField("message"); + try writeDiskMessage(s, m.message); + try s.endObject(); +} + +fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { + try s.beginObject(); + try s.objectField("role"); + try s.write(@tagName(msg.role)); + try s.objectField("content"); + try s.beginArray(); + for (msg.content) |block| { + try writeDiskBlock(s, block); + } + try s.endArray(); + if (msg.provider) |p| { + try s.objectField("provider"); + try s.write(p); + } + if (msg.model) |mm| { + try s.objectField("model"); + try s.write(mm); + } + if (msg.stop_reason) |sr| { + try s.objectField("stopReason"); + try s.write(sr); + } + if (msg.usage) |u| { + try s.objectField("usage"); + try s.beginObject(); + try s.objectField("input"); + try s.write(u.input); + try s.objectField("output"); + try s.write(u.output); + // Omit zero-valued auxiliary fields to keep older / unused + // sessions compact. Readers default missing fields to 0, so + // round-trip behavior is preserved. + if (u.cache_read != 0) { + try s.objectField("cacheRead"); + try s.write(u.cache_read); + } + if (u.cache_write != 0) { + try s.objectField("cacheWrite"); + try s.write(u.cache_write); + } + if (u.reasoning != 0) { + try s.objectField("reasoning"); + try s.write(u.reasoning); + } + try s.endObject(); + } + try s.endObject(); +} + +fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void { + switch (block) { + .text => |b| { + try s.beginObject(); + try s.objectField("type"); + try s.write("text"); + try s.objectField("text"); + try s.write(b.text); + try s.endObject(); + }, + .thinking => |b| { + try s.beginObject(); + try s.objectField("type"); + try s.write("thinking"); + try s.objectField("thinking"); + try s.write(b.thinking); + if (b.signature) |sig| { + try s.objectField("signature"); + try s.write(sig); + } + try s.endObject(); + }, + .tool_use => |b| { + try s.beginObject(); + try s.objectField("type"); + try s.write("toolUse"); + try s.objectField("id"); + try s.write(b.id); + try s.objectField("name"); + try s.write(b.name); + try s.objectField("input"); + try s.write(b.input); + try s.endObject(); + }, + .tool_result => |b| { + try s.beginObject(); + try s.objectField("type"); + try s.write("toolResult"); + try s.objectField("toolUseId"); + try s.write(b.tool_use_id); + try s.objectField("content"); + try s.write(b.content); + try s.endObject(); + }, + } +} + +// ============================================================================= +// Parsing +// ============================================================================= + +pub const ParseError = error{ + InvalidJson, + MissingField, + UnknownType, + UnknownRole, + UnknownBlockType, +} || Allocator.Error; + +/// Parse one JSON line into a `FileEntry`. Caller owns all bytes. +pub fn parseLine(allocator: Allocator, line: []const u8) ParseError!FileEntry { + var parsed = std.json.parseFromSlice(std.json.Value, allocator, line, .{}) catch { + return error.InvalidJson; + }; + defer parsed.deinit(); + return parseValue(allocator, parsed.value); +} + +fn parseValue(allocator: Allocator, v: std.json.Value) ParseError!FileEntry { + if (v != .object) return error.InvalidJson; + const type_v = v.object.get("type") orelse return error.MissingField; + if (type_v != .string) return error.MissingField; + const t = type_v.string; + if (std.mem.eql(u8, t, "session")) { + return .{ .header = try parseHeaderFromObject(allocator, v.object) }; + } else if (std.mem.eql(u8, t, "message")) { + return .{ .entry = .{ .message = try parseMessageEntry(allocator, v.object) } }; + } else { + return error.UnknownType; + } +} + +fn parseHeaderFromObject(allocator: Allocator, obj: std.json.ObjectMap) ParseError!SessionHeader { + const version: u32 = blk: { + if (obj.get("version")) |vv| { + if (vv == .integer) break :blk @intCast(vv.integer); + } + break :blk 1; + }; + const id = try dupeStringField(allocator, obj, "id"); + errdefer allocator.free(id); + const timestamp = try dupeStringField(allocator, obj, "timestamp"); + errdefer allocator.free(timestamp); + const cwd = try dupeStringField(allocator, obj, "cwd"); + errdefer allocator.free(cwd); + return .{ + .version = version, + .id = id, + .timestamp = timestamp, + .cwd = cwd, + }; +} + +fn parseMessageEntry(allocator: Allocator, obj: std.json.ObjectMap) ParseError!MessageEntry { + const id = try dupeStringField(allocator, obj, "id"); + errdefer allocator.free(id); + const timestamp = try dupeStringField(allocator, obj, "timestamp"); + errdefer allocator.free(timestamp); + const parent_id: ?[]const u8 = blk: { + const pv = obj.get("parentId") orelse break :blk null; + if (pv == .null) break :blk null; + if (pv != .string) return error.MissingField; + break :blk try allocator.dupe(u8, pv.string); + }; + errdefer if (parent_id) |p| allocator.free(p); + + const provider: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "provider"); + errdefer if (provider) |p| allocator.free(p); + const model: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "model"); + errdefer if (model) |m| allocator.free(m); + + const msg_v = obj.get("message") orelse return error.MissingField; + if (msg_v != .object) return error.MissingField; + const msg = try parseDiskMessage(allocator, msg_v.object); + + return .{ + .base = .{ .id = id, .parent_id = parent_id, .timestamp = timestamp }, + .provider = provider, + .model = model, + .message = msg, + }; +} + +fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskMessage { + const role_v = obj.get("role") orelse return error.MissingField; + if (role_v != .string) return error.MissingField; + const role = std.meta.stringToEnum(DiskMessageRole, role_v.string) orelse return error.UnknownRole; + + const content_v = obj.get("content") orelse return error.MissingField; + if (content_v != .array) return error.MissingField; + var content_list = try std.ArrayList(DiskContentBlock).initCapacity(allocator, content_v.array.items.len); + errdefer { + for (content_list.items) |b| b.deinit(allocator); + content_list.deinit(allocator); + } + for (content_v.array.items) |item| { + if (item != .object) return error.UnknownBlockType; + const block = try parseDiskBlock(allocator, item.object); + try content_list.append(allocator, block); + } + const content = try content_list.toOwnedSlice(allocator); + errdefer { + for (content) |b| b.deinit(allocator); + allocator.free(content); + } + + const provider: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "provider"); + errdefer if (provider) |p| allocator.free(p); + const model: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "model"); + errdefer if (model) |m| allocator.free(m); + const stop_reason: ?[]const u8 = try dupeOptionalStringField(allocator, obj, "stopReason"); + errdefer if (stop_reason) |s| allocator.free(s); + + var usage: ?Usage = null; + if (obj.get("usage")) |uv| { + if (uv == .object) { + usage = .{ + .input = readU64(uv.object, "input"), + .output = readU64(uv.object, "output"), + .cache_read = readU64(uv.object, "cacheRead"), + .cache_write = readU64(uv.object, "cacheWrite"), + .reasoning = readU64(uv.object, "reasoning"), + }; + } + } + + return .{ + .role = role, + .content = content, + .provider = provider, + .model = model, + .stop_reason = stop_reason, + .usage = usage, + }; +} + +fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskContentBlock { + const type_v = obj.get("type") orelse return error.MissingField; + if (type_v != .string) return error.MissingField; + const t = type_v.string; + if (std.mem.eql(u8, t, "text")) { + const text = try dupeStringField(allocator, obj, "text"); + return .{ .text = .{ .text = text } }; + } else if (std.mem.eql(u8, t, "thinking")) { + const text = try dupeStringField(allocator, obj, "thinking"); + errdefer allocator.free(text); + const sig = try dupeOptionalStringField(allocator, obj, "signature"); + return .{ .thinking = .{ .thinking = text, .signature = sig } }; + } else if (std.mem.eql(u8, t, "toolUse")) { + const id = try dupeStringField(allocator, obj, "id"); + errdefer allocator.free(id); + const name = try dupeStringField(allocator, obj, "name"); + errdefer allocator.free(name); + const input = try dupeStringField(allocator, obj, "input"); + return .{ .tool_use = .{ .id = id, .name = name, .input = input } }; + } else if (std.mem.eql(u8, t, "toolResult")) { + const tuid = try dupeStringField(allocator, obj, "toolUseId"); + errdefer allocator.free(tuid); + const content = try dupeStringField(allocator, obj, "content"); + return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; + } else { + return error.UnknownBlockType; + } +} + +fn readU64(obj: std.json.ObjectMap, name: []const u8) u64 { + const v = obj.get(name) orelse return 0; + if (v != .integer) return 0; + if (v.integer < 0) return 0; + return @intCast(v.integer); +} + +fn dupeStringField(allocator: Allocator, obj: std.json.ObjectMap, name: []const u8) ParseError![]const u8 { + const v = obj.get(name) orelse return error.MissingField; + if (v != .string) return error.MissingField; + return try allocator.dupe(u8, v.string); +} + +fn dupeOptionalStringField(allocator: Allocator, obj: std.json.ObjectMap, name: []const u8) ParseError!?[]const u8 { + const v = obj.get(name) orelse return null; + if (v == .null) return null; + if (v != .string) return error.MissingField; + return try allocator.dupe(u8, v.string); +} + +// ============================================================================= +// Bridge between in-memory and on-disk content blocks +// ============================================================================= + +/// Convert an in-memory `ContentBlock` to a `DiskContentBlock`. All strings +/// are duplicated; the source block remains untouched and the resulting +/// disk block is independently owned. +pub fn contentBlockToDisk( + allocator: Allocator, + block: conversation.ContentBlock, +) !DiskContentBlock { + switch (block) { + .Text => |tb| { + const text = try allocator.dupe(u8, tb.items); + return .{ .text = .{ .text = text } }; + }, + .Thinking => |tb| { + const text = try allocator.dupe(u8, tb.text.items); + errdefer allocator.free(text); + const sig: ?[]const u8 = if (tb.signature) |s| try allocator.dupe(u8, s) else null; + return .{ .thinking = .{ .thinking = text, .signature = sig } }; + }, + .ToolUse => |tu| { + const id = try allocator.dupe(u8, tu.id); + errdefer allocator.free(id); + const name = try allocator.dupe(u8, tu.name); + errdefer allocator.free(name); + const input = try allocator.dupe(u8, tu.input.items); + return .{ .tool_use = .{ .id = id, .name = name, .input = input } }; + }, + .ToolResult => |tr| { + const tuid = try allocator.dupe(u8, tr.tool_use_id); + errdefer allocator.free(tuid); + const content = try allocator.dupe(u8, tr.content.items); + return .{ .tool_result = .{ .tool_use_id = tuid, .content = content } }; + }, + } +} + +/// Convert a `DiskContentBlock` to an in-memory `ContentBlock`. Allocates +/// fresh owned buffers for every string field. The returned block is +/// independently owned. +pub fn diskContentBlockToInternal( + allocator: Allocator, + block: DiskContentBlock, +) !conversation.ContentBlock { + switch (block) { + .text => |b| { + const tb = try conversation.textualBlockFromSlice(allocator, b.text); + return .{ .Text = tb }; + }, + .thinking => |b| { + const tb = try conversation.textualBlockFromSlice(allocator, b.thinking); + errdefer { + var mut = tb; + mut.deinit(allocator); + } + const sig: ?[]const u8 = if (b.signature) |s| try allocator.dupe(u8, s) else null; + return .{ .Thinking = .{ .text = tb, .signature = sig } }; + }, + .tool_use => |b| { + const id = try allocator.dupe(u8, b.id); + errdefer allocator.free(id); + const name = try allocator.dupe(u8, b.name); + errdefer allocator.free(name); + const input = try conversation.textualBlockFromSlice(allocator, b.input); + return .{ .ToolUse = .{ .id = id, .name = name, .input = input } }; + }, + .tool_result => |b| { + const tuid = try allocator.dupe(u8, b.tool_use_id); + errdefer allocator.free(tuid); + const content = try conversation.textualBlockFromSlice(allocator, b.content); + return .{ .ToolResult = .{ .tool_use_id = tuid, .content = content } }; + }, + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +fn dupe(allocator: Allocator, s: []const u8) ![]const u8 { + return try allocator.dupe(u8, s); +} + +test "serialize/parse header round-trip" { + const a = testing.allocator; + const header: SessionHeader = .{ + .version = 1, + .id = try dupe(a, "019dc5ba-53f6-71a5-ab8f-b1f8709c2572"), + .timestamp = try dupe(a, "2026-04-25T17:40:15.990Z"), + .cwd = try dupe(a, "/Users/travis/Code/pantograph"), + }; + defer header.deinit(a); + + const line = try serializeHeader(a, header); + defer a.free(line); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expect(fe == .header); + try testing.expectEqual(@as(u32, 1), fe.header.version); + try testing.expectEqualStrings(header.id, fe.header.id); + try testing.expectEqualStrings(header.cwd, fe.header.cwd); +} + +test "serialize/parse user message entry round-trip (with provider/model stamp)" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hello world") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "a1b2c3d4"), + .parent_id = try dupe(a, "00000000"), + .timestamp = try dupe(a, "2026-04-25T17:40:16.000Z"), + }, + .provider = try dupe(a, "openai"), + .model = try dupe(a, "gpt-4o"), + .message = .{ + .role = .user, + .content = content, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expect(fe == .entry); + const got = fe.entry.message; + try testing.expectEqualStrings("a1b2c3d4", got.base.id); + try testing.expectEqualStrings("00000000", got.base.parent_id.?); + try testing.expectEqualStrings("openai", got.provider.?); + try testing.expectEqualStrings("gpt-4o", got.model.?); + try testing.expectEqual(DiskMessageRole.user, got.message.role); + try testing.expectEqual(@as(usize, 1), got.message.content.len); + try testing.expectEqualStrings("hello world", got.message.content[0].text.text); +} + +test "serialize/parse assistant message entry with metadata" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 3); + content[0] = .{ .thinking = .{ + .thinking = try dupe(a, "let me think"), + .signature = try dupe(a, "sig-xyz"), + } }; + content[1] = .{ .text = .{ .text = try dupe(a, "I'll check.") } }; + content[2] = .{ .tool_use = .{ + .id = try dupe(a, "tool_abc"), + .name = try dupe(a, "bash"), + .input = try dupe(a, "{\"command\":\"ls\"}"), + } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "b2c3d4e5"), + .parent_id = try dupe(a, "a1b2c3d4"), + .timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"), + }, + .message = .{ + .role = .assistant, + .content = content, + .provider = try dupe(a, "anthropic"), + .model = try dupe(a, "claude-sonnet-4-20250514"), + .stop_reason = try dupe(a, "toolUse"), + .usage = .{ .input = 1500, .output = 85 }, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message; + try testing.expectEqual(DiskMessageRole.assistant, got.message.role); + try testing.expectEqual(@as(usize, 3), got.message.content.len); + try testing.expectEqualStrings("let me think", got.message.content[0].thinking.thinking); + try testing.expectEqualStrings("sig-xyz", got.message.content[0].thinking.signature.?); + try testing.expectEqualStrings("bash", got.message.content[2].tool_use.name); + try testing.expectEqualStrings("{\"command\":\"ls\"}", got.message.content[2].tool_use.input); + try testing.expectEqualStrings("anthropic", got.message.provider.?); + try testing.expectEqualStrings("toolUse", got.message.stop_reason.?); + try testing.expect(got.message.usage != null); + try testing.expectEqual(@as(u64, 1500), got.message.usage.?.input); + try testing.expectEqual(@as(u64, 85), got.message.usage.?.output); +} + +test "serialize/parse tool result message entry" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .tool_result = .{ + .tool_use_id = try dupe(a, "tool_abc"), + .content = try dupe(a, "file1.txt\nfile2.txt"), + } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "c3d4e5f6"), + .parent_id = try dupe(a, "b2c3d4e5"), + .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), + }, + .provider = try dupe(a, "anthropic"), + .model = try dupe(a, "claude-sonnet-4-20250514"), + .message = .{ + .role = .user, + .content = content, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message; + try testing.expectEqual(DiskMessageRole.user, got.message.role); + try testing.expectEqualStrings("tool_abc", got.message.content[0].tool_result.tool_use_id); + try testing.expectEqualStrings("file1.txt\nfile2.txt", got.message.content[0].tool_result.content); + try testing.expectEqualStrings("anthropic", got.provider.?); +} + +test "parse: null parentId is handled" { + const a = testing.allocator; + const line = + \\{"type":"message","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z","message":{"role":"system","content":[{"type":"text","text":"hi"}]}} + ; + var fe = try parseLine(a, line); + defer fe.deinit(a); + try testing.expect(fe.entry.message.base.parent_id == null); +} + +test "parse: malformed JSON is reported" { + const a = testing.allocator; + try testing.expectError(error.InvalidJson, parseLine(a, "not json")); + try testing.expectError(error.InvalidJson, parseLine(a, "{\"type\":\"message\"")); +} + +test "parse: unknown entry type is reported" { + const a = testing.allocator; + const line = + \\{"type":"future_entry","id":"abcdefab","parentId":null,"timestamp":"2026-04-25T17:40:00Z"} + ; + try testing.expectError(error.UnknownType, parseLine(a, line)); +} + +test "contentBlockToDisk: Text round-trips via in-memory" { + const a = testing.allocator; + + var tb = try conversation.textualBlockFromSlice(a, "hello"); + defer tb.deinit(a); + const block: conversation.ContentBlock = .{ .Text = tb }; + + const disk = try contentBlockToDisk(a, block); + defer disk.deinit(a); + try testing.expectEqualStrings("hello", disk.text.text); +} + +test "diskContentBlockToInternal: ToolUse preserves id/name/input" { + const a = testing.allocator; + + const disk: DiskContentBlock = .{ .tool_use = .{ + .id = try a.dupe(u8, "tu_1"), + .name = try a.dupe(u8, "bash"), + .input = try a.dupe(u8, "{\"command\":\"ls\"}"), + } }; + defer disk.deinit(a); + + var inmem = try diskContentBlockToInternal(a, disk); + defer inmem.deinit(a); + try testing.expectEqualStrings("tu_1", inmem.ToolUse.id); + try testing.expectEqualStrings("bash", inmem.ToolUse.name); + try testing.expectEqualStrings("{\"command\":\"ls\"}", inmem.ToolUse.input.items); +} + +test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "deadbeef"), + .parent_id = null, + .timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"), + }, + .message = .{ + .role = .assistant, + .content = content, + .provider = try dupe(a, "anthropic"), + .model = try dupe(a, "claude-sonnet-4-20250514"), + .stop_reason = try dupe(a, "stop"), + .usage = .{ + .input = 100, + .output = 50, + .cache_read = 800, + .cache_write = 200, + .reasoning = 30, + }, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Every non-zero field should appear in the serialized JSON. + try testing.expect(std.mem.indexOf(u8, line, "\"input\":100") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"output\":50") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"cacheRead\":800") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"cacheWrite\":200") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"reasoning\":30") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const u = fe.entry.message.message.usage.?; + try testing.expectEqual(@as(u64, 100), u.input); + try testing.expectEqual(@as(u64, 50), u.output); + try testing.expectEqual(@as(u64, 800), u.cache_read); + try testing.expectEqual(@as(u64, 200), u.cache_write); + try testing.expectEqual(@as(u64, 30), u.reasoning); +} + +test "Usage: zero-valued auxiliary fields are omitted but parse back as 0" { + const a = testing.allocator; + + var content = try a.alloc(DiskContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "deadbeef"), + .parent_id = null, + .timestamp = try dupe(a, "2026-04-25T17:40:17.000Z"), + }, + .message = .{ + .role = .assistant, + .content = content, + .usage = .{ .input = 100, .output = 50 }, + }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + try testing.expect(std.mem.indexOf(u8, line, "cacheRead") == null); + try testing.expect(std.mem.indexOf(u8, line, "cacheWrite") == null); + try testing.expect(std.mem.indexOf(u8, line, "reasoning") == null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const u = fe.entry.message.message.usage.?; + try testing.expectEqual(@as(u64, 0), u.cache_read); + try testing.expectEqual(@as(u64, 0), u.cache_write); + try testing.expectEqual(@as(u64, 0), u.reasoning); +} + +test "diskContentBlockToInternal: Thinking preserves signature" { + const a = testing.allocator; + + const disk: DiskContentBlock = .{ .thinking = .{ + .thinking = try a.dupe(u8, "reasoning..."), + .signature = try a.dupe(u8, "sig123"), + } }; + defer disk.deinit(a); + + var inmem = try diskContentBlockToInternal(a, disk); + defer inmem.deinit(a); + try testing.expectEqualStrings("reasoning...", inmem.Thinking.text.items); + try testing.expectEqualStrings("sig123", inmem.Thinking.signature.?); +} |
