diff options
Diffstat (limited to 'libpanto/src/session.zig')
| -rw-r--r-- | libpanto/src/session.zig | 302 |
1 files changed, 181 insertions, 121 deletions
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index 0491157..ca7046c 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -23,6 +23,38 @@ 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; + +/// 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 + reasoning: ReasoningEffort = .default, + + 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, + }; + } +}; /// Bumped whenever the on-disk format changes in a way that older readers /// cannot tolerate. Older files are upgraded by `migrate()` on load and @@ -83,47 +115,45 @@ pub const SessionEntry = union(enum) { 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, + /// 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.provider) |p| alloc.free(p); - if (self.model) |m| alloc.free(m); + if (self.stamp) |s| s.deinit(alloc); self.message.deinit(alloc); } }; -pub const DiskMessageRole = enum { system, user, assistant }; +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 DiskSystemMode = enum { append, replace }; +pub const StoredSystemMode = enum { append, replace }; -pub const DiskMessage = struct { - role: DiskMessageRole, - content: []DiskContentBlock, // owned +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: DiskSystemMode = .append, - // Assistant-only metadata. Null for system/user messages. - provider: ?[]const u8 = null, // owned - model: ?[]const u8 = null, // owned + 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: DiskMessage, alloc: Allocator) void { + pub fn deinit(self: StoredMessage, 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); + if (self.metadata) |m| alloc.free(m); } }; @@ -138,14 +168,14 @@ pub const Usage = conversation.Usage; // Content blocks // ============================================================================= -pub const DiskContentBlock = union(enum) { - text: DiskTextBlock, - thinking: DiskThinkingBlock, - tool_use: DiskToolUseBlock, - tool_result: DiskToolResultBlock, - compaction_summary: DiskCompactionSummaryBlock, +pub const StoredContentBlock = union(enum) { + text: StoredTextBlock, + thinking: StoredThinkingBlock, + tool_use: StoredToolUseBlock, + tool_result: StoredToolResultBlock, + compaction_summary: StoredCompactionSummaryBlock, - pub fn deinit(self: DiskContentBlock, alloc: Allocator) void { + pub fn deinit(self: StoredContentBlock, alloc: Allocator) void { switch (self) { .text => |b| b.deinit(alloc), .thinking => |b| b.deinit(alloc), @@ -156,30 +186,30 @@ pub const DiskContentBlock = union(enum) { } }; -pub const DiskTextBlock = struct { +pub const StoredTextBlock = struct { text: []const u8, // owned - pub fn deinit(self: DiskTextBlock, alloc: Allocator) void { + pub fn deinit(self: StoredTextBlock, alloc: Allocator) void { alloc.free(self.text); } }; -pub const DiskThinkingBlock = struct { +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: DiskThinkingBlock, alloc: Allocator) void { + pub fn deinit(self: StoredThinkingBlock, alloc: Allocator) void { alloc.free(self.thinking); if (self.signature) |s| alloc.free(s); } }; -pub const DiskToolUseBlock = struct { +pub const StoredToolUseBlock = struct { id: []const u8, // owned name: []const u8, // owned input: []const u8, // raw JSON bytes, owned - pub fn deinit(self: DiskToolUseBlock, alloc: Allocator) void { + pub fn deinit(self: StoredToolUseBlock, alloc: Allocator) void { alloc.free(self.id); alloc.free(self.name); alloc.free(self.input); @@ -188,13 +218,13 @@ pub const DiskToolUseBlock = struct { /// One on-disk tool-result part: either text or an inline base64 media /// attachment (no sidecar files). -pub const DiskResultPart = union(enum) { +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: DiskResultPart, alloc: Allocator) void { + pub fn deinit(self: StoredResultPart, alloc: Allocator) void { switch (self) { .text => |t| alloc.free(t), .media => |m| { @@ -205,11 +235,11 @@ pub const DiskResultPart = union(enum) { } }; -pub const DiskToolResultBlock = struct { +pub const StoredToolResultBlock = struct { tool_use_id: []const u8, // owned - parts: []DiskResultPart, // owned + parts: []StoredResultPart, // owned is_error: bool = false, - pub fn deinit(self: DiskToolResultBlock, alloc: Allocator) void { + 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); @@ -219,9 +249,9 @@ pub const DiskToolResultBlock = struct { /// 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 DiskCompactionSummaryBlock = struct { +pub const StoredCompactionSummaryBlock = struct { text: []const u8, // owned - pub fn deinit(self: DiskCompactionSummaryBlock, alloc: Allocator) void { + pub fn deinit(self: StoredCompactionSummaryBlock, alloc: Allocator) void { alloc.free(self.text); } }; @@ -294,21 +324,25 @@ fn writeMessageEntry(s: *std.json.Stringify, m: MessageEntry) !void { 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); - } + // 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 writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { +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); + try s.objectField("reasoning"); + try s.write(@tagName(st.reasoning)); +} + +fn writeDiskMessage(s: *std.json.Stringify, msg: StoredMessage) !void { try s.beginObject(); try s.objectField("role"); try s.write(@tagName(msg.role)); @@ -324,18 +358,14 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { 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.metadata) |md| { + try s.objectField("metadata"); + try s.write(md); + } if (msg.usage) |u| { try s.objectField("usage"); try s.beginObject(); @@ -363,7 +393,7 @@ fn writeDiskMessage(s: *std.json.Stringify, msg: DiskMessage) !void { try s.endObject(); } -fn writeDiskBlock(s: *std.json.Stringify, block: DiskContentBlock) !void { +fn writeDiskBlock(s: *std.json.Stringify, block: StoredContentBlock) !void { switch (block) { .text => |b| { try s.beginObject(); @@ -518,10 +548,8 @@ fn parseMessageEntry(allocator: Allocator, obj: std.json.ObjectMap) ParseError!M }; 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 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; @@ -529,28 +557,45 @@ fn parseMessageEntry(allocator: Allocator, obj: std.json.ObjectMap) ParseError!M return .{ .base = .{ .id = id, .parent_id = parent_id, .timestamp = timestamp }, - .provider = provider, - .model = model, + .stamp = stamp, .message = msg, }; } -fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskMessage { +/// 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); + 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; + }; + return .{ .api_style = api_style, .base_url = base_url, .model = model, .reasoning = reasoning }; +} + +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(DiskMessageRole, role_v.string) orelse return error.UnknownRole; + 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: DiskSystemMode = blk: { + const mode: StoredSystemMode = blk: { const mv = obj.get("mode") orelse break :blk .append; if (mv != .string) break :blk .append; - break :blk std.meta.stringToEnum(DiskSystemMode, mv.string) orelse .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(DiskContentBlock).initCapacity(allocator, content_v.array.items.len); + 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); @@ -566,12 +611,10 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di 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); + 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| { @@ -590,14 +633,13 @@ fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Di .role = role, .content = content, .mode = mode, - .provider = provider, - .model = model, .stop_reason = stop_reason, .usage = usage, + .metadata = metadata, }; } -fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!DiskContentBlock { +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; @@ -634,8 +676,8 @@ fn parseDiskBlock(allocator: Allocator, obj: std.json.ObjectMap) ParseError!Disk /// 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![]DiskResultPart { - var list: std.ArrayList(DiskResultPart) = .empty; +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); @@ -698,13 +740,13 @@ fn dupeOptionalStringField(allocator: Allocator, obj: std.json.ObjectMap, name: // Bridge between in-memory and on-disk content blocks // ============================================================================= -/// Convert an in-memory `ContentBlock` to a `DiskContentBlock`. All strings +/// 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, -) !DiskContentBlock { +) !StoredContentBlock { switch (block) { .Text => |tb| { const text = try allocator.dupe(u8, tb.items); @@ -727,7 +769,7 @@ pub fn contentBlockToDisk( .ToolResult => |tr| { const tuid = try allocator.dupe(u8, tr.tool_use_id); errdefer allocator.free(tuid); - var parts: std.ArrayList(DiskResultPart) = .empty; + var parts: std.ArrayList(StoredResultPart) = .empty; errdefer { for (parts.items) |p| p.deinit(allocator); parts.deinit(allocator); @@ -751,7 +793,7 @@ pub fn contentBlockToDisk( } }; }, // A `.System` block becomes a disk text block; its mode rides on - // the enclosing `DiskMessage.mode` (set by the session manager), + // 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); @@ -764,12 +806,12 @@ pub fn contentBlockToDisk( } } -/// Convert a `DiskContentBlock` to an in-memory `ContentBlock`. Allocates +/// 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: DiskContentBlock, + block: StoredContentBlock, ) !conversation.ContentBlock { switch (block) { .text => |b| { @@ -856,7 +898,7 @@ test "serialize/parse header round-trip" { test "serialize/parse user message entry round-trip (with provider/model stamp)" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); + var content = try a.alloc(StoredContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "hello world") } }; const entry: SessionEntry = .{ .message = .{ @@ -865,8 +907,12 @@ test "serialize/parse user message entry round-trip (with provider/model stamp)" .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"), + .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, @@ -883,9 +929,11 @@ test "serialize/parse user message entry round-trip (with provider/model stamp)" 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(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); } @@ -893,7 +941,7 @@ test "serialize/parse user message entry round-trip (with provider/model stamp)" test "serialize/parse assistant message entry with metadata" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 3); + var content = try a.alloc(StoredContentBlock, 3); content[0] = .{ .thinking = .{ .thinking = try dupe(a, "let me think"), .signature = try dupe(a, "sig-xyz"), @@ -911,13 +959,17 @@ test "serialize/parse assistant message entry with metadata" { .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, - .provider = try dupe(a, "anthropic"), - .model = try dupe(a, "claude-sonnet-4-20250514"), .stop_reason = try dupe(a, "toolUse"), .usage = .{ .input = 1500, .output = 85 }, + .metadata = try dupe(a, "{\"k\":1}"), }, } }; defer entry.deinit(a); @@ -928,14 +980,15 @@ test "serialize/parse assistant message entry with metadata" { 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(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", got.message.provider.?); + 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); @@ -944,8 +997,8 @@ test "serialize/parse assistant message entry with metadata" { test "serialize/parse tool result message entry" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); - var trp = try a.alloc(DiskResultPart, 1); + 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"), @@ -958,8 +1011,11 @@ test "serialize/parse tool result message entry" { .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"), + .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, @@ -973,11 +1029,11 @@ test "serialize/parse tool result message entry" { 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.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.expectEqualStrings("anthropic", got.provider.?); + 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); @@ -986,8 +1042,8 @@ test "serialize/parse tool result message entry" { test "serialize/parse tool result preserves is_error = true" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); - var trp = try a.alloc(DiskResultPart, 1); + 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"), @@ -1001,8 +1057,11 @@ test "serialize/parse tool result preserves is_error = true" { .parent_id = try dupe(a, "e0"), .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), }, - .provider = try dupe(a, "anthropic"), - .model = try dupe(a, "claude-sonnet-4-20250514"), + .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); @@ -1030,8 +1089,8 @@ test "parse tool result without isError defaults to false" { test "serialize/parse tool result with text + image part round-trips" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); - var trp = try a.alloc(DiskResultPart, 2); + 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"), @@ -1048,8 +1107,11 @@ test "serialize/parse tool result with text + image part round-trips" { .parent_id = try dupe(a, "img00000"), .timestamp = try dupe(a, "2026-04-25T17:40:18.000Z"), }, - .provider = try dupe(a, "anthropic"), - .model = try dupe(a, "claude-sonnet-4-20250514"), + .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); @@ -1072,7 +1134,7 @@ test "system message mode round-trips; absent mode defaults to append" { // replace-mode system entry round-trips. { - var content = try a.alloc(DiskContentBlock, 1); + var content = try a.alloc(StoredContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "fresh seed") } }; const entry: SessionEntry = .{ .message = .{ .base = .{ @@ -1094,7 +1156,7 @@ test "system message mode round-trips; absent mode defaults to append" { var fe = try parseLine(a, line); defer fe.deinit(a); - try testing.expectEqual(DiskSystemMode.replace, fe.entry.message.message.mode); + try testing.expectEqual(StoredSystemMode.replace, fe.entry.message.message.mode); } // A legacy system entry with no `mode` parses back as append. @@ -1104,7 +1166,7 @@ test "system message mode round-trips; absent mode defaults to append" { ; var fe = try parseLine(a, line); defer fe.deinit(a); - try testing.expectEqual(DiskSystemMode.append, fe.entry.message.message.mode); + try testing.expectEqual(StoredSystemMode.append, fe.entry.message.message.mode); } } @@ -1147,7 +1209,7 @@ test "contentBlockToDisk: Text round-trips via in-memory" { test "diskContentBlockToInternal: ToolUse preserves id/name/input" { const a = testing.allocator; - const disk: DiskContentBlock = .{ .tool_use = .{ + 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\"}"), @@ -1164,7 +1226,7 @@ test "diskContentBlockToInternal: ToolUse preserves id/name/input" { test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); + var content = try a.alloc(StoredContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; const entry: SessionEntry = .{ .message = .{ @@ -1176,8 +1238,6 @@ test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" { .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, @@ -1213,7 +1273,7 @@ test "Usage: all five fields round-trip; zero-valued fields omitted from JSON" { test "Usage: zero-valued auxiliary fields are omitted but parse back as 0" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); + var content = try a.alloc(StoredContentBlock, 1); content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; const entry: SessionEntry = .{ .message = .{ @@ -1248,7 +1308,7 @@ test "Usage: zero-valued auxiliary fields are omitted but parse back as 0" { test "diskContentBlockToInternal: Thinking preserves signature" { const a = testing.allocator; - const disk: DiskContentBlock = .{ .thinking = .{ + const disk: StoredContentBlock = .{ .thinking = .{ .thinking = try a.dupe(u8, "reasoning..."), .signature = try a.dupe(u8, "sig123"), } }; @@ -1263,7 +1323,7 @@ test "diskContentBlockToInternal: Thinking preserves signature" { test "compactionSummary block round-trips through serialize/parse" { const a = testing.allocator; - var content = try a.alloc(DiskContentBlock, 1); + var content = try a.alloc(StoredContentBlock, 1); content[0] = .{ .compaction_summary = .{ .text = try dupe(a, "earlier history summary") } }; const entry: SessionEntry = .{ .message = .{ @@ -1283,7 +1343,7 @@ test "compactionSummary block round-trips through serialize/parse" { 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.expectEqual(StoredMessageRole.user, got.message.role); try testing.expectEqualStrings("earlier history summary", got.message.content[0].compaction_summary.text); } |
