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 | |
| parent | b1a155273662d7dc2ea1fe0cfc43c1741ed30b6d (diff) | |
session files
Diffstat (limited to 'libpanto/src')
| -rw-r--r-- | libpanto/src/agent.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 52 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 64 | ||||
| -rw-r--r-- | libpanto/src/pricing.zig | 303 | ||||
| -rw-r--r-- | libpanto/src/provider.zig | 16 | ||||
| -rw-r--r-- | libpanto/src/provider_anthropic_messages.zig | 126 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 129 | ||||
| -rw-r--r-- | libpanto/src/root.zig | 3 | ||||
| -rw-r--r-- | libpanto/src/session.zig | 961 | ||||
| -rw-r--r-- | libpanto/src/session_manager.zig | 1509 |
10 files changed, 3141 insertions, 24 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig index bbcc54c..92c5a3a 100644 --- a/libpanto/src/agent.zig +++ b/libpanto/src/agent.zig @@ -591,7 +591,7 @@ const NoopReceiver = struct { fn noopToolDetails(_: *anyopaque, _: usize, _: []const u8, _: []const u8) anyerror!void {} fn noop3(_: *anyopaque, _: usize, _: []const u8) anyerror!void {} fn noop4(_: *anyopaque, _: usize, _: conversation.ContentBlock) anyerror!void {} - fn noop5(_: *anyopaque, _: conversation.Message) anyerror!void {} + fn noop5(_: *anyopaque, _: conversation.Message, _: ?provider_mod.Usage) anyerror!void {} fn noop6(_: *anyopaque, _: anyerror) void {} }; diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index 708ede5..46ec17c 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -232,9 +232,31 @@ pub const StreamEventTag = enum { pub const ContentBlockKind = enum { text, thinking, tool_use, unknown }; +/// Partial token-count snapshot emitted in `message_start` and +/// `message_delta`. Field semantics follow Anthropic's wire format: +/// +/// - `input_tokens`: prompt tokens billed at the base rate. +/// - `cache_creation_input_tokens`: prompt tokens written to a new +/// cache entry (1.25× base). +/// - `cache_read_input_tokens`: prompt tokens served from cache +/// (0.1× base). +/// - `output_tokens`: response tokens billed at the output rate. +/// +/// `message_start.usage` carries the initial input-side counts (output +/// will be 0 or absent); `message_delta.usage` carries the final +/// `output_tokens` and may repeat the input-side counts. The provider +/// merges them — "missing" means "unchanged," not "reset to zero." +pub const StreamUsage = struct { + input_tokens: ?u64 = null, + output_tokens: ?u64 = null, + cache_creation_input_tokens: ?u64 = null, + cache_read_input_tokens: ?u64 = null, +}; + pub const StreamEvent = union(StreamEventTag) { message_start: struct { role: ?[]const u8 = null, + usage: StreamUsage = .{}, }, content_block_start: struct { index: usize, @@ -255,6 +277,7 @@ pub const StreamEvent = union(StreamEventTag) { }, message_delta: struct { stop_reason: ?[]const u8 = null, + usage: StreamUsage = .{}, }, message_stop: void, ping: void, @@ -287,14 +310,18 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStream const ty = type_v.string; if (std.mem.eql(u8, ty, "message_start")) { var role: ?[]const u8 = null; + var usage: StreamUsage = .{}; if (root.object.get("message")) |m| { if (m == .object) { if (m.object.get("role")) |r| { if (r == .string) role = r.string; } + if (m.object.get("usage")) |u| { + if (u == .object) usage = parseStreamUsage(u.object); + } } } - return .{ .parsed = parsed, .event = .{ .message_start = .{ .role = role } } }; + return .{ .parsed = parsed, .event = .{ .message_start = .{ .role = role, .usage = usage } } }; } if (std.mem.eql(u8, ty, "content_block_start")) { @@ -380,6 +407,7 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStream if (std.mem.eql(u8, ty, "message_delta")) { var stop_reason: ?[]const u8 = null; + var usage: StreamUsage = .{}; if (root.object.get("delta")) |d| { if (d == .object) { if (d.object.get("stop_reason")) |sr| { @@ -387,7 +415,11 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStream } } } - return .{ .parsed = parsed, .event = .{ .message_delta = .{ .stop_reason = stop_reason } } }; + // `usage` is on the message_delta event itself, not under `delta`. + if (root.object.get("usage")) |u| { + if (u == .object) usage = parseStreamUsage(u.object); + } + return .{ .parsed = parsed, .event = .{ .message_delta = .{ .stop_reason = stop_reason, .usage = usage } } }; } if (std.mem.eql(u8, ty, "message_stop")) { @@ -417,6 +449,22 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStream return .{ .parsed = parsed, .event = .unknown }; } +fn parseStreamUsage(obj: std.json.ObjectMap) StreamUsage { + return .{ + .input_tokens = readOptionalU64(obj, "input_tokens"), + .output_tokens = readOptionalU64(obj, "output_tokens"), + .cache_creation_input_tokens = readOptionalU64(obj, "cache_creation_input_tokens"), + .cache_read_input_tokens = readOptionalU64(obj, "cache_read_input_tokens"), + }; +} + +fn readOptionalU64(obj: std.json.ObjectMap, name: []const u8) ?u64 { + const v = obj.get(name) orelse return null; + if (v != .integer) return null; + if (v.integer < 0) return null; + return @intCast(v.integer); +} + fn readIndex(root: std.json.Value) ?usize { const v = root.object.get("index") orelse return null; if (v != .integer) return null; diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 9531131..37d347b 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -31,6 +31,31 @@ pub const StreamDelta = struct { /// treat the turn as failed. error_message: ?[]const u8 = null, error_type: ?[]const u8 = null, + /// Token usage from the final chunk's top-level `usage` block. + /// Only present on the final chunk when the request was sent with + /// `stream_options.include_usage: true`. Earlier chunks have null. + usage: ?StreamUsage = null, +}; + +/// Token usage payload from OpenAI's terminating SSE chunk. Field +/// semantics: +/// +/// - `prompt_tokens`: total input tokens, **including** cached tokens. +/// - `completion_tokens`: output tokens (including reasoning tokens). +/// - `cached_prompt_tokens`: subset of `prompt_tokens` served from +/// the prompt cache (billed at a discount). +/// - `reasoning_tokens`: subset of `completion_tokens` spent on +/// internal reasoning (o-series models). +/// +/// To map to `Usage`: `input = prompt_tokens - cached_prompt_tokens`, +/// `cache_read = cached_prompt_tokens`, `output = completion_tokens`, +/// `reasoning = reasoning_tokens`, `cache_write = 0` (OpenAI doesn't +/// bill a cache-write premium). +pub const StreamUsage = struct { + prompt_tokens: ?u64 = null, + completion_tokens: ?u64 = null, + cached_prompt_tokens: ?u64 = null, + reasoning_tokens: ?u64 = null, }; /// A single entry from a streaming `tool_calls` array. Multiple parallel @@ -66,6 +91,17 @@ pub fn serializeRequest( try s.objectField("stream"); try s.write(true); + // Ask for the final-chunk usage block. Without this the server + // sends `usage: null` and we can't stamp token counts on the + // session log. Most OpenAI-compatible proxies accept this; ones + // that don't will either ignore it or 400 — in the latter case + // the user has bigger problems than missing token counts. + try s.objectField("stream_options"); + try s.beginObject(); + try s.objectField("include_usage"); + try s.write(true); + try s.endObject(); + switch (cfg.reasoning) { .default => {}, .off => { @@ -285,6 +321,27 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta // Top-level `error` field. Some providers (and OpenAI itself on rare // mid-stream failures) emit `data: {"error":{"message":...,"type":...}}` // with HTTP 200, so we look for this BEFORE the choices array. + // Top-level `usage` field on the terminating chunk. Independent of + // the (often empty) choices array. + if (root.object.get("usage")) |u| { + if (u == .object) { + var su: StreamUsage = .{}; + su.prompt_tokens = readOptU64(u.object, "prompt_tokens"); + su.completion_tokens = readOptU64(u.object, "completion_tokens"); + if (u.object.get("prompt_tokens_details")) |ptd| { + if (ptd == .object) { + su.cached_prompt_tokens = readOptU64(ptd.object, "cached_tokens"); + } + } + if (u.object.get("completion_tokens_details")) |ctd| { + if (ctd == .object) { + su.reasoning_tokens = readOptU64(ctd.object, "reasoning_tokens"); + } + } + delta.usage = su; + } + } + if (root.object.get("error")) |e| { switch (e) { .object => |obj| { @@ -375,6 +432,13 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta }; } +fn readOptU64(obj: std.json.ObjectMap, name: []const u8) ?u64 { + const v = obj.get(name) orelse return null; + if (v != .integer) return null; + if (v.integer < 0) return null; + return @intCast(v.integer); +} + // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- diff --git a/libpanto/src/pricing.zig b/libpanto/src/pricing.zig new file mode 100644 index 0000000..7d4e1f8 --- /dev/null +++ b/libpanto/src/pricing.zig @@ -0,0 +1,303 @@ +//! Per-(provider, model) token pricing and cost calculation. +//! +//! Prices are stored as integers — specifically, *micro-cents per token* +//! (1/1,000,000 of a cent), equivalently picodollars (10^-12 USD) per +//! token. Reasons: +//! +//! - All commonly-quoted "USD per million tokens" prices land on round +//! integers under the conversion: $3.00 / 1M tokens = 300 +//! micro-cents per token. +//! - Per-message token counts (10^2 to 10^5) multiplied by per-token +//! prices (~10^2) stay well inside `u64` for entire long-running +//! sessions — a session worth ~$1 (10^14 micro-cents) is nowhere +//! near `u64.max` (~1.8 × 10^19). +//! - Summing per-turn costs across a session is exact: no +//! floating-point drift. +//! +//! The TOML on-disk format lets users write `input = 3.0` for $3/Mtok, +//! which is the natural unit for humans. The loader multiplies by 100 +//! and rounds to the nearest integer; the rounding handles parse-time +//! float-precision wobble. +//! +//! `costMicroCents` does the integer arithmetic. Each `Pricing` field +//! is `?u64`: `null` means "we don't know the price for this token +//! category," which is distinct from a price of zero (e.g. OpenAI does +//! charge nothing for cache writes — that's a known 0, not unknown). +//! `costMicroCents` returns `?u64`: if any usage category with nonzero +//! count maps to a `null` price, the whole turn cost goes to `null` +//! ("unknown"), and any session-level sum that includes that turn must +//! likewise degenerate to `null`. This prevents silently treating +//! unknown costs as free. +//! +//! The display layer lives in the CLI; libpanto only computes. + +const std = @import("std"); + +const session_mod = @import("session.zig"); +pub const Usage = session_mod.Usage; + +// ============================================================================= +// Pricing struct +// ============================================================================= + +/// Per-token pricing for a single (provider, model) pair. +/// +/// Units: micro-cents per token (1/1,000,000 of a cent per token), aka +/// picodollars (10^-12 USD) per token. +/// +/// Conversion from "USD per million tokens": +/// +/// micro_cents_per_token = round(USD_per_Mtok * 100) +/// +/// So $3.00/Mtok → 300 micro-cents/token. +pub const Pricing = struct { + /// Per fresh (uncached, non-cache-written) input token. `null` = + /// unknown (e.g. field omitted from `models.toml`). + input: ?u64 = null, + /// Per output token. `null` = unknown. + output: ?u64 = null, + /// Per cache-read input token. Typically a fraction of `input` + /// (0.1× on Anthropic, 0.5× on OpenAI for cached prompt tokens). + /// `null` = unknown. + cache_read: ?u64 = null, + /// Per cache-write input token. Anthropic charges a premium + /// (1.25× `input`); OpenAI doesn't bill a cache-write rate (a + /// known 0, which should be written `cache_write = 0` rather than + /// omitted). `null` = unknown. + cache_write: ?u64 = null, + + /// Convert a USD-per-million-tokens float (the human-friendly unit) + /// to the internal integer representation. Rounds to nearest. + /// + /// `dollars_per_mtok * 1_000_000 cents/dollar / 1_000_000 tokens` = + /// cents-per-token, then * 1_000_000 micro-cents/cent = + /// micro-cents-per-token. The 1_000_000s cancel, leaving + /// `dollars_per_mtok * 100`. + /// + /// Non-finite inputs round to 0. Negative inputs are clamped to 0 + /// (treated as a known free price, not unknown). + pub fn fromDollarsPerMtok(dollars_per_mtok: f64) u64 { + if (!std.math.isFinite(dollars_per_mtok) or dollars_per_mtok <= 0) return 0; + const scaled = dollars_per_mtok * 100.0; + const r = @round(scaled); + if (r >= @as(f64, @floatFromInt(std.math.maxInt(u64)))) return std.math.maxInt(u64); + return @intFromFloat(r); + } +}; + +// ============================================================================= +// Cost calculation +// ============================================================================= + +/// Compute the cost of a single turn's `usage` under the given `pricing`, +/// in micro-cents. Returns `null` if any usage category with a nonzero +/// token count maps to a `null` price — i.e. "we used some cache reads +/// but we don't know the cache-read price" poisons the whole turn cost +/// to "unknown". Categories with zero tokens are ignored regardless of +/// whether their price is known, so e.g. a model with `cache_write = +/// null` still produces a known cost on turns that never write to +/// cache. +/// +/// `reasoning` does NOT contribute separately — it's already counted +/// inside `output`. +pub fn costMicroCents(usage: Usage, pricing: Pricing) ?u64 { + var total: u64 = 0; + total +%= component(usage.input, pricing.input) orelse return null; + total +%= component(usage.output, pricing.output) orelse return null; + total +%= component(usage.cache_read, pricing.cache_read) orelse return null; + total +%= component(usage.cache_write, pricing.cache_write) orelse return null; + return total; +} + +/// Cost contribution (in micro-cents) from a single (tokens, price) +/// pair. Returns `0` when `tokens == 0` regardless of whether `price` +/// is known — so unknown prices don't poison turns that never used +/// that category. Returns `null` when tokens are nonzero but the +/// price is unknown; callers convert that to a `null` total. +fn component(tokens: u64, price: ?u64) ?u64 { + if (tokens == 0) return 0; + const p = price orelse return null; + return tokens *% p; +} + +// ============================================================================= +// Registry +// ============================================================================= + +/// In-memory registry of `(provider, model) -> Pricing`. Lookups are by +/// exact match of both fields. +/// +/// The registry owns the (provider, model) key strings; entries are +/// pushed via `set` (which duplicates the inputs). +pub const Registry = struct { + allocator: std.mem.Allocator, + entries: std.ArrayList(Entry), + + pub const Entry = struct { + provider: []u8, + model: []u8, + pricing: Pricing, + }; + + pub fn init(allocator: std.mem.Allocator) Registry { + return .{ .allocator = allocator, .entries = .empty }; + } + + pub fn deinit(self: *Registry) void { + for (self.entries.items) |e| { + self.allocator.free(e.provider); + self.allocator.free(e.model); + } + self.entries.deinit(self.allocator); + } + + /// Look up pricing for (provider, model). Returns null if no entry + /// matches — distinct from "price is zero," which is a valid entry. + pub fn get(self: *const Registry, provider: []const u8, model: []const u8) ?Pricing { + for (self.entries.items) |e| { + if (std.mem.eql(u8, e.provider, provider) and std.mem.eql(u8, e.model, model)) { + return e.pricing; + } + } + return null; + } + + /// Insert or replace pricing for (provider, model). Duplicates the + /// key strings. + pub fn set( + self: *Registry, + provider: []const u8, + model: []const u8, + pricing: Pricing, + ) !void { + for (self.entries.items) |*e| { + if (std.mem.eql(u8, e.provider, provider) and std.mem.eql(u8, e.model, model)) { + e.pricing = pricing; + return; + } + } + const provider_copy = try self.allocator.dupe(u8, provider); + errdefer self.allocator.free(provider_copy); + const model_copy = try self.allocator.dupe(u8, model); + errdefer self.allocator.free(model_copy); + try self.entries.append(self.allocator, .{ + .provider = provider_copy, + .model = model_copy, + .pricing = pricing, + }); + } + + pub fn count(self: *const Registry) usize { + return self.entries.items.len; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +test "Pricing.fromDollarsPerMtok: $3.00/Mtok -> 300 micro-cents/token" { + try testing.expectEqual(@as(u64, 300), Pricing.fromDollarsPerMtok(3.0)); + try testing.expectEqual(@as(u64, 1500), Pricing.fromDollarsPerMtok(15.0)); + try testing.expectEqual(@as(u64, 30), Pricing.fromDollarsPerMtok(0.3)); + try testing.expectEqual(@as(u64, 0), Pricing.fromDollarsPerMtok(0)); + try testing.expectEqual(@as(u64, 0), Pricing.fromDollarsPerMtok(-1)); +} + +test "Pricing.fromDollarsPerMtok: rounds float noise to clean integer" { + // 0.1 * 3 = 0.30000000000000004 in IEEE 754. Verify rounding + // recovers the clean integer. + const v = 0.1 * 3.0; + try testing.expectEqual(@as(u64, 30), Pricing.fromDollarsPerMtok(v)); +} + +test "costMicroCents: standard mixed input/output" { + const pricing: Pricing = .{ + .input = 300, // $3/Mtok + .output = 1500, // $15/Mtok + }; + const usage: Usage = .{ .input = 1000, .output = 200 }; + // 1000*300 + 200*1500 = 300_000 + 300_000 = 600_000 micro-cents. + // = 6 cents = $0.06. + try testing.expectEqual(@as(?u64, 600_000), costMicroCents(usage, pricing)); +} + +test "costMicroCents: cache_read and cache_write discounts are honored" { + const pricing: Pricing = .{ + .input = 300, + .output = 1500, + .cache_read = 30, // 0.1x input + .cache_write = 375, // 1.25x input + }; + const usage: Usage = .{ + .input = 1000, + .output = 200, + .cache_read = 5000, + .cache_write = 500, + }; + // 1000*300 + 200*1500 + 5000*30 + 500*375 + // = 300_000 + 300_000 + 150_000 + 187_500 = 937_500. + try testing.expectEqual(@as(?u64, 937_500), costMicroCents(usage, pricing)); +} + +test "costMicroCents: reasoning tokens do not double-count" { + const pricing: Pricing = .{ .input = 0, .output = 1500 }; + const usage: Usage = .{ .output = 100, .reasoning = 60 }; + // Cost is from `output` alone; reasoning is a subset of output. + try testing.expectEqual(@as(?u64, 150_000), costMicroCents(usage, pricing)); +} + +test "costMicroCents: unknown price + nonzero usage poisons to null" { + // gpt-4o written with only input/output set; cache fields default + // to null (unknown). A turn that uses any cache reads should + // surface as unknown cost, not silently free. + const pricing: Pricing = .{ + .input = 250, + .output = 1000, + // cache_read, cache_write left null. + }; + const usage: Usage = .{ .input = 1000, .output = 200, .cache_read = 500 }; + try testing.expectEqual(@as(?u64, null), costMicroCents(usage, pricing)); +} + +test "costMicroCents: unknown price + zero usage stays known" { + // Same partially-specified pricing, but the turn never touched + // cache. Cost should remain known. + const pricing: Pricing = .{ .input = 250, .output = 1000 }; + const usage: Usage = .{ .input = 1000, .output = 200 }; + try testing.expectEqual(@as(?u64, 450_000), costMicroCents(usage, pricing)); +} + +test "Registry: set, get, replace" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + + try testing.expect(reg.get("anthropic", "claude-sonnet-4") == null); + + try reg.set("anthropic", "claude-sonnet-4", .{ .input = 300, .output = 1500 }); + try testing.expectEqual(@as(usize, 1), reg.count()); + + const p = reg.get("anthropic", "claude-sonnet-4").?; + try testing.expectEqual(@as(?u64, 300), p.input); + try testing.expectEqual(@as(?u64, 1500), p.output); + + // Replace existing. + try reg.set("anthropic", "claude-sonnet-4", .{ .input = 400, .output = 1600 }); + try testing.expectEqual(@as(usize, 1), reg.count()); + const p2 = reg.get("anthropic", "claude-sonnet-4").?; + try testing.expectEqual(@as(?u64, 400), p2.input); +} + +test "Registry: distinct (provider, model) pairs" { + var reg = Registry.init(testing.allocator); + defer reg.deinit(); + try reg.set("openai", "gpt-4o", .{ .input = 250 }); + try reg.set("openai", "gpt-4o-mini", .{ .input = 15 }); + try reg.set("anthropic", "claude-sonnet-4", .{ .input = 300 }); + try testing.expectEqual(@as(usize, 3), reg.count()); + try testing.expectEqual(@as(?u64, 250), reg.get("openai", "gpt-4o").?.input); + try testing.expectEqual(@as(?u64, 15), reg.get("openai", "gpt-4o-mini").?.input); + try testing.expectEqual(@as(?u64, 300), reg.get("anthropic", "claude-sonnet-4").?.input); +} diff --git a/libpanto/src/provider.zig b/libpanto/src/provider.zig index bc39026..ab60678 100644 --- a/libpanto/src/provider.zig +++ b/libpanto/src/provider.zig @@ -3,7 +3,9 @@ const std = @import("std"); const config_mod = @import("config.zig"); const conversation = @import("conversation.zig"); const tool_registry_mod = @import("tool_registry.zig"); +const session_mod = @import("session.zig"); pub const ToolRegistry = tool_registry_mod.ToolRegistry; +pub const Usage = session_mod.Usage; pub const ContentBlockType = enum { Text, @@ -35,13 +37,21 @@ pub const ContentBlockType = enum { /// or in the Provider (HTTP/parse/stream failure). It is the last callback the /// receiver will see for that turn. `onError` itself cannot fail; receivers /// must swallow secondary failures during cleanup. +/// +/// `onMessageComplete`'s `usage` argument carries the wire-reported token +/// counts for the just-finished assistant turn. Providers fire this exactly +/// once per successful turn. `usage` is `null` only when the wire genuinely +/// did not deliver any usage information — chiefly OpenAI-compatible proxies +/// (OpenRouter, vLLM, some self-hosted backends) that ignore +/// `stream_options.include_usage`. Receivers that compute cost should record +/// the null case explicitly ("unknown") rather than treating it as zero. pub const ReceiverVTable = struct { onMessageStart: *const fn (*anyopaque, conversation.MessageRole) anyerror!void, onBlockStart: *const fn (*anyopaque, ContentBlockType, usize) anyerror!void, onToolDetails: *const fn (*anyopaque, usize, []const u8, []const u8) anyerror!void, onContentDelta: *const fn (*anyopaque, usize, []const u8) anyerror!void, onBlockComplete: *const fn (*anyopaque, usize, conversation.ContentBlock) anyerror!void, - onMessageComplete: *const fn (*anyopaque, conversation.Message) anyerror!void, + onMessageComplete: *const fn (*anyopaque, conversation.Message, ?Usage) anyerror!void, onError: *const fn (*anyopaque, anyerror) void, }; @@ -69,8 +79,8 @@ pub const Receiver = struct { try self.vtable.onBlockComplete(self.ptr, block_index, block); } - pub fn onMessageComplete(self: Receiver, message: conversation.Message) !void { - try self.vtable.onMessageComplete(self.ptr, message); + pub fn onMessageComplete(self: Receiver, message: conversation.Message, usage: ?Usage) !void { + try self.vtable.onMessageComplete(self.ptr, message, usage); } pub fn onError(self: Receiver, err: anyerror) void { diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index 96649b6..5d5520a 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -216,6 +216,15 @@ const StreamState = struct { /// Assembled blocks for the final message, in stream order. blocks: std.ArrayList(conversation.ContentBlock) = .empty, + /// Accumulated token counts. Anthropic reports the input-side counts + /// on `message_start.usage` and the final `output_tokens` (plus + /// possibly updated input-side counts) on `message_delta.usage`. + /// `usage_seen` distinguishes "genuinely all zero" from "never + /// reported" — the former stamps a real `Usage` on + /// `onMessageComplete`, the latter stamps `null`. + usage: provider_mod.Usage = .{}, + usage_seen: bool = false, + const ActiveBlock = struct { /// Index reported on the wire (Anthropic's content-array index). wire_index: usize, @@ -252,6 +261,22 @@ const StreamState = struct { try receiver.onMessageStart(.assistant); } + /// Merge a wire-level usage snapshot into the accumulated counts. + /// Missing fields mean "unchanged," not "reset to zero." Marks + /// `usage_seen` so `finalize` delivers a non-null `Usage` to + /// `onMessageComplete`. + fn mergeUsage(self: *StreamState, partial: json_mod.StreamUsage) void { + if (partial.input_tokens) |v| self.usage.input = v; + if (partial.output_tokens) |v| self.usage.output = v; + if (partial.cache_creation_input_tokens) |v| self.usage.cache_write = v; + if (partial.cache_read_input_tokens) |v| self.usage.cache_read = v; + if (partial.input_tokens != null or partial.output_tokens != null or + partial.cache_creation_input_tokens != null or partial.cache_read_input_tokens != null) + { + self.usage_seen = true; + } + } + fn openBlock( self: *StreamState, receiver: *provider_mod.Receiver, @@ -402,7 +427,8 @@ const StreamState = struct { try conv.addAssistantMessage(moved_blocks); const msg = conv.messages.items[conv.messages.items.len - 1]; - try receiver.onMessageComplete(msg); + const usage: ?provider_mod.Usage = if (self.usage_seen) self.usage else null; + try receiver.onMessageComplete(msg, usage); } }; @@ -416,8 +442,9 @@ fn handleEvent( defer parsed.deinit(); switch (parsed.event) { - .message_start => { + .message_start => |s| { try state.ensureStarted(receiver); + state.mergeUsage(s.usage); }, .content_block_start => |s| { try state.ensureStarted(receiver); @@ -438,9 +465,10 @@ fn handleEvent( .content_block_stop => { try state.closeBlock(receiver); }, - .message_delta => { + .message_delta => |d| { // We don't act on stop_reason directly; message_stop is the // authoritative end-of-stream signal. + state.mergeUsage(d.usage); }, .message_stop => { state.end_of_stream = true; @@ -486,7 +514,7 @@ const RecordingReceiver = struct { text: []const u8, // owned copy signature: ?[]const u8 = null, // owned copy when present }, - message_complete, + message_complete: ?provider_mod.Usage, err: anyerror, }; @@ -578,9 +606,9 @@ const RecordingReceiver = struct { else => {}, } } - fn onMessageComplete(ptr: *anyopaque, _: conversation.Message) anyerror!void { + fn onMessageComplete(ptr: *anyopaque, _: conversation.Message, usage: ?provider_mod.Usage) anyerror!void { const self: *RecordingReceiver = @ptrCast(@alignCast(ptr)); - try self.events.append(self.allocator, .message_complete); + try self.events.append(self.allocator, .{ .message_complete = usage }); } fn onError(ptr: *anyopaque, err: anyerror) void { const self: *RecordingReceiver = @ptrCast(@alignCast(ptr)); @@ -650,7 +678,91 @@ test "streams a text-only turn end-to-end" { try testing.expectEqualStrings("Hello", rec.events.items[2].delta.bytes); try testing.expectEqualStrings("!", rec.events.items[3].delta.bytes); try testing.expectEqualStrings("Hello!", rec.events.items[4].block_complete.text); - try testing.expectEqual(@as(@TypeOf(rec.events.items[5]), .message_complete), rec.events.items[5]); + try testing.expect(rec.events.items[5] == .message_complete); + // No usage on the wire — the assertion is structural. + try testing.expect(rec.events.items[5].message_complete == null); +} + +test "anthropic: captures usage from message_start and message_delta on message_complete" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var rec = RecordingReceiver.init(allocator); + defer rec.deinit(); + var recv = rec.receiver(); + + const events = [_][]const u8{ + // Initial input-side counts on message_start. + \\{"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"claude","usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":200}}} + , + \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + , + \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} + , + \\{"type":"content_block_stop","index":0} + , + // Final output count on message_delta. + \\{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":42}} + , + \\{"type":"message_stop"} + , + }; + + try runStreamedTurn(allocator, &conv, &recv, &events); + + // Find the message_complete event and check its usage payload. + var found: ?provider_mod.Usage = null; + for (rec.events.items) |ev| { + if (ev == .message_complete) found = ev.message_complete; + } + try testing.expect(found != null); + const u = found.?; + try testing.expectEqual(@as(u64, 100), u.input); + try testing.expectEqual(@as(u64, 42), u.output); + try testing.expectEqual(@as(u64, 200), u.cache_read); + try testing.expectEqual(@as(u64, 50), u.cache_write); + try testing.expectEqual(@as(u64, 0), u.reasoning); // Anthropic doesn't split reasoning separately. +} + +test "anthropic: message_complete carries null usage when wire omits it" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var rec = RecordingReceiver.init(allocator); + defer rec.deinit(); + var recv = rec.receiver(); + + const events = [_][]const u8{ + \\{"type":"message_start","message":{"id":"m","role":"assistant","content":[],"model":"claude"}} + , + \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + , + \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + , + \\{"type":"content_block_stop","index":0} + , + \\{"type":"message_delta","delta":{"stop_reason":"end_turn"}} + , + \\{"type":"message_stop"} + , + }; + + try runStreamedTurn(allocator, &conv, &recv, &events); + + var saw_complete = false; + for (rec.events.items) |ev| { + if (ev == .message_complete) { + saw_complete = true; + try testing.expect(ev.message_complete == null); + } + } + try testing.expect(saw_complete); } test "captures thinking signature for round-trip" { diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig index ca8fa4c..fcbc0bb 100644 --- a/libpanto/src/provider_openai_chat.zig +++ b/libpanto/src/provider_openai_chat.zig @@ -207,14 +207,17 @@ pub const OpenAIChatProvider = struct { return; } try handleEvent(self.allocator, ev_payload, &state, receiver); - if (state.end_of_stream) { - try state.finalize(receiver, conv); - return; - } + // Note: we do NOT bail when state.end_of_stream is set. + // OpenAI emits the terminating `usage` chunk *after* the + // chunk carrying finish_reason, then sends `[DONE]`. If + // we returned on finish_reason we'd never capture usage. + // `[DONE]` is the authoritative end-of-stream marker. } } - // Stream ended without [DONE] or finish_reason. Finalize anyway. + // Stream ended without [DONE]. Some servers and proxies omit it + // (or drop the trailing usage chunk). Finalize with whatever we've + // got — usage will be null in that case, which is fine. try state.finalize(receiver, conv); } }; @@ -266,6 +269,11 @@ const StreamState = struct { /// whose block we've already emitted. closed_tool_indices: std.AutoHashMap(usize, void), + /// Token counts from the terminating chunk's `usage` block. Only + /// populated when the server sent `usage` (i.e. the request used + /// `stream_options.include_usage: true` AND the server honored it). + usage: ?provider_mod.Usage = null, + const ToolUseInProgress = struct { /// Block index emitted to the receiver for this tool call's /// onBlockStart / onContentDelta / onBlockComplete callbacks. @@ -542,7 +550,7 @@ const StreamState = struct { try conv.addAssistantMessage(moved_blocks); const msg = conv.messages.items[conv.messages.items.len - 1]; - try receiver.onMessageComplete(msg); + try receiver.onMessageComplete(msg, self.usage); } }; @@ -556,6 +564,24 @@ fn handleEvent( defer parsed.deinit(); const d = parsed.delta; + // Usage block arrives in the terminating chunk (after finish_reason, + // with an empty `choices` array). Capture it; `finalize` delivers it + // as part of `onMessageComplete`. OpenAI bills `prompt_tokens` as + // the *total* input including cached tokens; we split them so + // callers don't have to. + if (d.usage) |u| { + const prompt: u64 = u.prompt_tokens orelse 0; + const cached: u64 = u.cached_prompt_tokens orelse 0; + const fresh: u64 = if (cached > prompt) 0 else prompt - cached; + state.usage = .{ + .input = fresh, + .output = u.completion_tokens orelse 0, + .cache_read = cached, + .cache_write = 0, // OpenAI doesn't bill a cache-write premium. + .reasoning = u.reasoning_tokens orelse 0, + }; + } + // Mid-stream provider error: some OpenAI-compatible endpoints (and // OpenAI itself on rare transient failures) return HTTP 200 with an // error embedded in the SSE stream. Treat the turn as failed. @@ -631,7 +657,7 @@ const NoopReceiver = struct { fn noopToolDetails(_: *anyopaque, _: usize, _: []const u8, _: []const u8) anyerror!void {} fn noopDelta(_: *anyopaque, _: usize, _: []const u8) anyerror!void {} fn noopBlockComplete(_: *anyopaque, _: usize, _: conversation.ContentBlock) anyerror!void {} - fn noopMsgComplete(_: *anyopaque, _: conversation.Message) anyerror!void {} + fn noopMsgComplete(_: *anyopaque, _: conversation.Message, _: ?provider_mod.Usage) anyerror!void {} fn noopErr(_: *anyopaque, _: anyerror) void {} }; @@ -648,8 +674,10 @@ fn runStreamedTurn( for (events) |payload| { if (std.mem.eql(u8, payload, "[DONE]")) break; + // Process every chunk through to [DONE], including the + // post-finish_reason usage chunk. Mirrors the production loop + // in OpenAIChatProvider.streamStep. try handleEvent(allocator, payload, &state, receiver); - if (state.end_of_stream) break; } try state.finalize(receiver, conv); } @@ -712,6 +740,76 @@ test "two streamed turns persist assistant replies in the conversation" { ); } +test "openai_chat: terminating usage chunk lands on message_complete with split cache_read" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var rec = RecordingReceiver{ .allocator = allocator }; + defer { + for (rec.events.items) |s| allocator.free(s); + rec.events.deinit(allocator); + } + var recv = rec.receiver(); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"content":"hi"}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"stop"}]} + , + // OpenAI's terminating chunk: empty choices, top-level usage. + \\{"choices":[],"usage":{"prompt_tokens":150,"completion_tokens":42,"prompt_tokens_details":{"cached_tokens":120},"completion_tokens_details":{"reasoning_tokens":18}}} + , + "[DONE]", + }; + try runStreamedTurn(allocator, &conv, &recv, &events); + + var found: ?[]const u8 = null; + for (rec.events.items) |s| { + if (std.mem.startsWith(u8, s, "msg_complete[")) found = s; + } + try testing.expect(found != null); + // 150 prompt - 120 cached = 30 fresh input. + try testing.expectEqualStrings("msg_complete[usage:in=30,out=42,cr=120,cw=0,rsn=18]", found.?); +} + +test "openai_chat: omitted stream usage yields null on message_complete" { + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var rec = RecordingReceiver{ .allocator = allocator }; + defer { + for (rec.events.items) |s| allocator.free(s); + rec.events.deinit(allocator); + } + var recv = rec.receiver(); + + const events = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"content":"hi"}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"stop"}]} + , + "[DONE]", + }; + try runStreamedTurn(allocator, &conv, &recv, &events); + + var found: ?[]const u8 = null; + for (rec.events.items) |s| { + if (std.mem.startsWith(u8, s, "msg_complete")) found = s; + } + try testing.expect(found != null); + try testing.expectEqualStrings("msg_complete[usage:null]", found.?); +} + test "fragmented tool_call id and name are reassembled" { // Lenient OpenAI-compatible providers occasionally split `id` and // `function.name` across multiple deltas instead of sending them whole @@ -817,9 +915,16 @@ const RecordingReceiver = struct { const self: *RecordingReceiver = @ptrCast(@alignCast(ptr)); try self.recordFmt("block_complete[{d}]", .{idx}); } - fn onMessageComplete(ptr: *anyopaque, _: conversation.Message) anyerror!void { + fn onMessageComplete(ptr: *anyopaque, _: conversation.Message, usage: ?provider_mod.Usage) anyerror!void { const self: *RecordingReceiver = @ptrCast(@alignCast(ptr)); - try self.record("msg_complete"); + if (usage) |u| { + try self.recordFmt( + "msg_complete[usage:in={d},out={d},cr={d},cw={d},rsn={d}]", + .{ u.input, u.output, u.cache_read, u.cache_write, u.reasoning }, + ); + } else { + try self.record("msg_complete[usage:null]"); + } } fn onError(_: *anyopaque, _: anyerror) void {} }; @@ -878,7 +983,9 @@ test "parallel tool_calls emit one complete start/delta/complete cycle per block "tool_details[3]:c3:ping", "delta[3]:{\"host\":\"d\"}", "block_complete[3]", - "msg_complete", + // No usage chunk in this fixture (older test data) — record + // shows null. + "msg_complete[usage:null]", }; // Identity arrives in the assembled ContentBlock at completion time. diff --git a/libpanto/src/root.zig b/libpanto/src/root.zig index ff02d4d..de62cfa 100644 --- a/libpanto/src/root.zig +++ b/libpanto/src/root.zig @@ -8,6 +8,9 @@ pub const sse = @import("sse.zig"); pub const tool = @import("tool.zig"); pub const tool_source = @import("tool_source.zig"); pub const tool_registry = @import("tool_registry.zig"); +pub const session = @import("session.zig"); +pub const session_manager = @import("session_manager.zig"); +pub const pricing = @import("pricing.zig"); // Re-exports for ergonomic embedder use. pub const Tool = tool.Tool; 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.?); +} diff --git a/libpanto/src/session_manager.zig b/libpanto/src/session_manager.zig new file mode 100644 index 0000000..bf9585d --- /dev/null +++ b/libpanto/src/session_manager.zig @@ -0,0 +1,1509 @@ +//! Session lifecycle: create, open, replay, append. +//! +//! Backed by an append-only JSONL file on disk. The on-disk types live in +//! `session.zig`. This module owns: +//! +//! - Path resolution (sessions dir is supplied by the caller; we own the +//! filename and writes within it). +//! - The in-memory entry index (`by_id` map + leaf pointer). +//! - Deferred file creation: the file is not written until the first +//! assistant message persists. Until that point, all entries are +//! buffered in memory. +//! - Append semantics: once flushed, every completed entry is written +//! and synced to disk immediately. +//! - Crash recovery: on open, the file is parsed line-by-line; the first +//! line that fails to parse causes everything from that line onward to +//! be truncated from the file. +//! - One-time format migration when a future version reads a v1 file +//! (currently a no-op; the hook is in place). +//! - Rebuilding a `Conversation` from the entry tree, plus determining +//! the active provider/model. +//! +//! The library-vs-CLI boundary: callers pass an absolute path to the +//! per-cwd sessions directory. We compute the per-session filename +//! ourselves (`<uuidv7>.jsonl`) and lazily mkdir the directory on the +//! first flush. The CLI owns XDG resolution, encoded-cwd grouping, and +//! the `--resume` flag plumbing. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const session_mod = @import("session.zig"); +const conversation_mod = @import("conversation.zig"); + +pub const SessionHeader = session_mod.SessionHeader; +pub const SessionEntry = session_mod.SessionEntry; +pub const MessageEntry = session_mod.MessageEntry; +pub const DiskMessage = session_mod.DiskMessage; +pub const DiskMessageRole = session_mod.DiskMessageRole; +pub const DiskContentBlock = session_mod.DiskContentBlock; +pub const Usage = session_mod.Usage; +pub const CURRENT_VERSION = session_mod.CURRENT_VERSION; + +// ============================================================================= +// IDs and timestamps +// ============================================================================= + +/// Generate a UUIDv7 (RFC 9562 §5.7). Returns a 36-character canonical +/// hex string with hyphens. Caller owns. +/// +/// Layout: +/// - 48 bits: unix_ts_ms (big-endian) +/// - 4 bits: version (7) +/// - 12 bits: random +/// - 2 bits: variant (10) +/// - 62 bits: random +pub fn newUuidV7(allocator: Allocator, io: Io) ![]u8 { + const ts = Io.Timestamp.now(io, .real); + const now_ms: u64 = @intCast(@max(ts.toMilliseconds(), 0)); + + var rand_bytes: [10]u8 = undefined; + io.random(&rand_bytes); + + var b: [16]u8 = undefined; + // Timestamp (48 bits, big-endian). + b[0] = @intCast((now_ms >> 40) & 0xFF); + b[1] = @intCast((now_ms >> 32) & 0xFF); + b[2] = @intCast((now_ms >> 24) & 0xFF); + b[3] = @intCast((now_ms >> 16) & 0xFF); + b[4] = @intCast((now_ms >> 8) & 0xFF); + b[5] = @intCast(now_ms & 0xFF); + // Version (4 high bits = 0x7) + 12 bits random. + b[6] = 0x70 | (rand_bytes[0] & 0x0F); + b[7] = rand_bytes[1]; + // Variant (2 high bits = 10) + 62 bits random. + b[8] = 0x80 | (rand_bytes[2] & 0x3F); + b[9] = rand_bytes[3]; + b[10] = rand_bytes[4]; + b[11] = rand_bytes[5]; + b[12] = rand_bytes[6]; + b[13] = rand_bytes[7]; + b[14] = rand_bytes[8]; + b[15] = rand_bytes[9]; + + return try std.fmt.allocPrint( + allocator, + "{x:0>2}{x:0>2}{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}-{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}{x:0>2}", + .{ b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] }, + ); +} + +/// Generate a fresh 8-character hex entry id. Caller owns. +fn newEntryIdInto(buf: []u8, io: Io) void { + std.debug.assert(buf.len == 8); + var bytes: [4]u8 = undefined; + io.random(&bytes); + _ = std.fmt.bufPrint(buf, "{x:0>2}{x:0>2}{x:0>2}{x:0>2}", .{ bytes[0], bytes[1], bytes[2], bytes[3] }) catch unreachable; +} + +/// Format `now` as an ISO 8601 UTC string with millisecond precision. +/// Example: `2026-04-25T17:40:15.990Z`. Caller owns. +pub fn isoTimestamp(allocator: Allocator, io: Io) ![]u8 { + const ts = Io.Timestamp.now(io, .real); + const ms_total: i64 = ts.toMilliseconds(); + const seconds_total: i64 = @divTrunc(ms_total, 1000); + const ms: u64 = @intCast(@mod(ms_total, 1000)); + + const epoch_secs = std.time.epoch.EpochSeconds{ .secs = @intCast(seconds_total) }; + const epoch_day = epoch_secs.getEpochDay(); + const day_secs = epoch_secs.getDaySeconds(); + const year_day = epoch_day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + + return try std.fmt.allocPrint( + allocator, + "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", + .{ + @as(u32, year_day.year), + month_day.month.numeric(), + @as(u32, month_day.day_index) + 1, + day_secs.getHoursIntoDay(), + day_secs.getMinutesIntoHour(), + day_secs.getSecondsIntoMinute(), + ms, + }, + ); +} + +// ============================================================================= +// SessionInfo (listing) +// ============================================================================= + +pub const SessionInfo = struct { + path: []u8, + id: []u8, + cwd: []u8, + created: []u8, // ISO 8601 from header timestamp + modified: []u8, // ISO 8601 from last user/assistant entry, falling back to header, then file mtime + message_count: usize, + + pub fn deinit(self: SessionInfo, alloc: Allocator) void { + alloc.free(self.path); + alloc.free(self.id); + alloc.free(self.cwd); + alloc.free(self.created); + alloc.free(self.modified); + } +}; + +pub fn freeSessionInfos(alloc: Allocator, infos: []SessionInfo) void { + for (infos) |info| info.deinit(alloc); + alloc.free(infos); +} + +// ============================================================================= +// SessionManager +// ============================================================================= + +pub const Error = error{ + NoSessionsFound, + AmbiguousSessionId, + SessionNotFound, + InvalidSessionFile, +} || Allocator.Error || Io.Cancelable; + +pub const SessionManager = struct { + allocator: Allocator, + io: Io, + + /// Absolute path to the per-cwd sessions directory. Lazily created. + session_dir: []u8, + /// Absolute path to the file we *will* write to (computed at init). + /// May not yet exist on disk if `flushed = false`. + session_file: []u8, + + /// Header. Allocated at init for new sessions; reloaded from the file + /// on resume. + header: SessionHeader, + + /// Entries indexed in insertion order. The first entry's `parent_id` + /// is null; each subsequent entry's `parent_id` points to its parent + /// (currently always the previous entry). + entries: std.ArrayList(SessionEntry), + /// id → entry index in `entries`. Used both for parent-id lookups + /// and for collision detection in `newEntryId`. + by_id: std.StringHashMap(usize), + /// id of the most recently appended entry, or null if no entries yet. + /// Borrowed from the entry; do not free. + leaf_id: ?[]const u8, + + /// True once the file exists on disk. False during the "buffered" + /// pre-assistant phase. See module-level docs. + flushed: bool, + /// Number of bytes written to `session_file` so far. Used as the + /// offset for the next positional write. Only meaningful when + /// `flushed = true`. + written_bytes: u64, + + // ---------- Construction ---------- + + /// Create a new session in memory. Allocates a UUIDv7, computes the + /// file path, but does NOT touch the filesystem. The file is created + /// on the first assistant-message flush. + /// + /// `session_dir` is duplicated; the caller retains ownership of the + /// passed slice. + pub fn init( + allocator: Allocator, + io: Io, + session_dir: []const u8, + cwd: []const u8, + ) !SessionManager { + const dir = try allocator.dupe(u8, session_dir); + errdefer allocator.free(dir); + + const id = try newUuidV7(allocator, io); + errdefer allocator.free(id); + + const timestamp = try isoTimestamp(allocator, io); + errdefer allocator.free(timestamp); + + const cwd_copy = try allocator.dupe(u8, cwd); + errdefer allocator.free(cwd_copy); + + const filename = try std.fmt.allocPrint(allocator, "{s}.jsonl", .{id}); + defer allocator.free(filename); + const file_path = try std.fs.path.join(allocator, &.{ dir, filename }); + errdefer allocator.free(file_path); + + return .{ + .allocator = allocator, + .io = io, + .session_dir = dir, + .session_file = file_path, + .header = .{ + .version = CURRENT_VERSION, + .id = id, + .timestamp = timestamp, + .cwd = cwd_copy, + }, + .entries = .empty, + .by_id = std.StringHashMap(usize).init(allocator), + .leaf_id = null, + .flushed = false, + .written_bytes = 0, + }; + } + + /// Open and replay an existing session file. Truncates from the first + /// corrupted line. Runs format migration if needed and rewrites the + /// file once. + pub fn open( + allocator: Allocator, + io: Io, + file_path: []const u8, + ) !SessionManager { + const path_copy = try allocator.dupe(u8, file_path); + errdefer allocator.free(path_copy); + + // The session dir is the file's parent directory. + const dir_path = std.fs.path.dirname(path_copy) orelse "."; + const dir = try allocator.dupe(u8, dir_path); + errdefer allocator.free(dir); + + const bytes = try readWholeFile(allocator, io, path_copy); + defer allocator.free(bytes); + + // Walk line-by-line. The first failure causes a truncation back to + // the start of that line. + var entries: std.ArrayList(SessionEntry) = .empty; + errdefer { + for (entries.items) |e| e.deinit(allocator); + entries.deinit(allocator); + } + var by_id = std.StringHashMap(usize).init(allocator); + errdefer by_id.deinit(); + + var header_opt: ?SessionHeader = null; + errdefer if (header_opt) |h| h.deinit(allocator); + + var cursor: usize = 0; + var valid_bytes: u64 = 0; // length of the file prefix that parses cleanly + var saw_corruption: bool = false; + + while (cursor < bytes.len) { + // Find the next newline (or EOF). + const rest = bytes[cursor..]; + const nl_rel = std.mem.indexOfScalar(u8, rest, '\n'); + const line_end_excl: usize = if (nl_rel) |n| cursor + n else bytes.len; + const line = bytes[cursor..line_end_excl]; + const next_cursor: usize = if (nl_rel != null) line_end_excl + 1 else bytes.len; + + // Allow blank lines silently (just whitespace), but a non-empty + // trimmed line that won't parse triggers truncation. + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) { + if (nl_rel == null) break; + cursor = next_cursor; + valid_bytes = cursor; + continue; + } + + // If the final line has no trailing newline AND we hit EOF, it + // is presumed truncated mid-write. Treat as corruption. + if (nl_rel == null) { + saw_corruption = true; + break; + } + + const fe = session_mod.parseLine(allocator, line) catch { + saw_corruption = true; + break; + }; + + switch (fe) { + .header => |h| { + if (header_opt != null) { + // Two headers — treat as corruption from this line on. + h.deinit(allocator); + saw_corruption = true; + break; + } + if (entries.items.len != 0) { + // Header arrived after entries — malformed. + h.deinit(allocator); + saw_corruption = true; + break; + } + header_opt = h; + }, + .entry => |e| { + const idx = entries.items.len; + entries.append(allocator, e) catch |err| { + e.deinit(allocator); + return err; + }; + by_id.put(e.base().id, idx) catch |err| { + // Rolling back the append is awkward; in practice + // OOM here is fatal anyway. + return err; + }; + }, + } + + cursor = next_cursor; + valid_bytes = cursor; + } + + // No header at all — refuse to load. + const header = header_opt orelse return error.InvalidSessionFile; + + // Truncate the file if anything beyond `valid_bytes` is corrupt. + if (saw_corruption and valid_bytes < bytes.len) { + try truncateFileTo(io, path_copy, valid_bytes); + } + + // Run format migration. Currently a no-op for version 1, but the + // hook exists so future versions can rewrite the file. + var migrated_header = header; + const did_migrate = migrate(allocator, &migrated_header, &entries); + // We didn't reassign by_id during migration; rebuild if needed. + if (did_migrate) { + by_id.clearRetainingCapacity(); + for (entries.items, 0..) |e, i| { + try by_id.put(e.base().id, i); + } + try rewriteFile(allocator, io, path_copy, migrated_header, entries.items); + } + + const leaf_id: ?[]const u8 = if (entries.items.len > 0) + entries.items[entries.items.len - 1].base().id + else + null; + + // Compute final file length on disk so future appends use the + // correct offset. + const stat = try statFileForLength(io, path_copy); + + return .{ + .allocator = allocator, + .io = io, + .session_dir = dir, + .session_file = path_copy, + .header = migrated_header, + .entries = entries, + .by_id = by_id, + .leaf_id = leaf_id, + .flushed = true, + .written_bytes = stat, + }; + } + + pub fn deinit(self: *SessionManager) void { + self.header.deinit(self.allocator); + for (self.entries.items) |e| e.deinit(self.allocator); + self.entries.deinit(self.allocator); + self.by_id.deinit(); + self.allocator.free(self.session_dir); + self.allocator.free(self.session_file); + } + + // ---------- Accessors ---------- + + pub fn getCwd(self: *const SessionManager) []const u8 { + return self.header.cwd; + } + + pub fn getSessionId(self: *const SessionManager) []const u8 { + return self.header.id; + } + + pub fn getSessionFile(self: *const SessionManager) []const u8 { + return self.session_file; + } + + pub fn getSessionDir(self: *const SessionManager) []const u8 { + return self.session_dir; + } + + pub fn getLeafId(self: *const SessionManager) ?[]const u8 { + return self.leaf_id; + } + + pub fn getEntry(self: *const SessionManager, id: []const u8) ?*const SessionEntry { + const idx = self.by_id.get(id) orelse return null; + return &self.entries.items[idx]; + } + + pub fn getEntries(self: *const SessionManager) []const SessionEntry { + return self.entries.items; + } + + pub fn isFlushed(self: *const SessionManager) bool { + return self.flushed; + } + + // ---------- Active model resolution ---------- + + /// Determine the active provider/model by walking entries leaf→root + /// and finding the last user-message entry with provider/model + /// stamped. Returns null only when no user message has been appended + /// yet, which is only reachable on a freshly-`init`'d session before + /// the first user prompt (and therefore before any disk flush). + /// + /// Returns borrowed slices owned by the manager; do not free. + pub fn activeModel(self: *const SessionManager) ?struct { provider: []const u8, model: []const u8 } { + var i = self.entries.items.len; + while (i > 0) : (i -= 1) { + const e = self.entries.items[i - 1]; + switch (e) { + .message => |m| { + if (m.message.role == .user) { + if (m.provider) |p| { + if (m.model) |mo| { + return .{ .provider = p, .model = mo }; + } + } + } + }, + } + } + return null; + } + + // ---------- Appending ---------- + + /// Append a message entry. `msg` is consumed (ownership transferred) + /// regardless of success — on error, the message is deinit'd before + /// the error is returned. + /// + /// If `flushed`: writes the new line immediately. + /// If not flushed and `msg.role == .assistant`: writes the header + + /// all buffered entries + the new entry, then sets `flushed`. + /// Otherwise: buffers in memory only. + pub fn appendMessage( + self: *SessionManager, + msg: DiskMessage, + // Top-level stamps on the entry (vs. inside the message). + // Stamped only on user messages. + provider: ?[]const u8, + model: ?[]const u8, + ) ![]const u8 { + // Build the entry up-front, taking ownership of the inputs. + var msg_local = msg; + errdefer msg_local.deinit(self.allocator); + + const id_buf = try self.newEntryId(); + errdefer self.allocator.free(id_buf); + + const timestamp = try isoTimestamp(self.allocator, self.io); + errdefer self.allocator.free(timestamp); + + const parent_id_copy: ?[]const u8 = if (self.leaf_id) |l| try self.allocator.dupe(u8, l) else null; + errdefer if (parent_id_copy) |p| self.allocator.free(p); + + const provider_copy: ?[]const u8 = if (provider) |p| try self.allocator.dupe(u8, p) else null; + errdefer if (provider_copy) |p| self.allocator.free(p); + + const model_copy: ?[]const u8 = if (model) |m| try self.allocator.dupe(u8, m) else null; + errdefer if (model_copy) |m| self.allocator.free(m); + + const entry: SessionEntry = .{ .message = .{ + .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp }, + .provider = provider_copy, + .model = model_copy, + .message = msg_local, + } }; + + // The entry now owns msg_local + id_buf + timestamp + parent_id_copy + + // provider/model copies. Cancel the errdefers individually. + // (Zig's errdefer behavior: they only run on error returns; pushing the + // entry into entries.items before any further fallible step means an + // error in by_id.put() would double-free. Instead, do the put first + // against a not-yet-stored id.) + + const idx = self.entries.items.len; + + // Ensure capacity before touching anything. + try self.entries.ensureUnusedCapacity(self.allocator, 1); + try self.by_id.ensureUnusedCapacity(1); + + // Persist BEFORE inserting into the in-memory structures, so that on + // I/O failure we don't have a dangling in-memory entry the caller + // thinks was saved. (Failure leaves the file unchanged for an + // unflushed session, and unchanged-except-for-EOF for a flushed one.) + const is_assistant = entry.message.message.role == .assistant; + if (self.flushed) { + try self.persistEntry(entry); + } else if (is_assistant) { + try self.flushBuffered(entry); + } + // If not flushed and not assistant: nothing to do; the entry will be + // flushed alongside the eventual first assistant entry. + + // Now insert into in-memory structures. All allocations are kept. + self.entries.appendAssumeCapacity(entry); + self.by_id.putAssumeCapacity(entry.base().id, idx); + self.leaf_id = entry.base().id; + return entry.base().id; + } + + /// Returns a freshly allocated 8-character hex id, guaranteed not to + /// collide with any existing entry id in this session. + fn newEntryId(self: *SessionManager) ![]u8 { + const max_tries = 100; + var i: usize = 0; + while (i < max_tries) : (i += 1) { + const buf = try self.allocator.alloc(u8, 8); + errdefer self.allocator.free(buf); + newEntryIdInto(buf[0..8], self.io); + if (!self.by_id.contains(buf)) { + return buf; + } + self.allocator.free(buf); + } + // Fall back to a UUID prefix if 100 retries all collided. With 4 + // random bytes per id and a session with <<2^16 entries, the + // probability of getting here is effectively zero, but we want a + // hard guarantee. + const long = try newUuidV7(self.allocator, self.io); + defer self.allocator.free(long); + const buf = try self.allocator.alloc(u8, 8); + @memcpy(buf, long[0..8]); + return buf; + } + + // ---------- Persistence ---------- + + /// Write the header + all currently-buffered entries + `final_entry` + /// to the file as a single batch. Creates the directory and file. + /// Called only when `flushed = false` and we just got an assistant + /// message. + fn flushBuffered(self: *SessionManager, final_entry: SessionEntry) !void { + // Ensure the directory exists. + try mkdirP(self.io, self.session_dir); + + // Open (create exclusively isn't required; if a stale file with the + // same UUIDv7 existed we'd just overwrite — UUIDv7s are unique). + const file = try Io.Dir.cwd().createFile(self.io, self.session_file, .{ + .truncate = true, + .read = false, + }); + defer file.close(self.io); + + var offset: u64 = 0; + // Header + const header_line = try session_mod.serializeHeader(self.allocator, self.header); + defer self.allocator.free(header_line); + try file.writePositionalAll(self.io, header_line, offset); + offset += header_line.len; + try file.writePositionalAll(self.io, "\n", offset); + offset += 1; + + // Buffered entries. + for (self.entries.items) |e| { + const line = try session_mod.serializeEntry(self.allocator, e); + defer self.allocator.free(line); + try file.writePositionalAll(self.io, line, offset); + offset += line.len; + try file.writePositionalAll(self.io, "\n", offset); + offset += 1; + } + + // Final (the assistant message we just got). + const final_line = try session_mod.serializeEntry(self.allocator, final_entry); + defer self.allocator.free(final_line); + try file.writePositionalAll(self.io, final_line, offset); + offset += final_line.len; + try file.writePositionalAll(self.io, "\n", offset); + offset += 1; + + // Best-effort fsync — the directory itself doesn't need syncing + // for our purposes (we don't unlink/rename); the file body does. + file.sync(self.io) catch {}; + + self.flushed = true; + self.written_bytes = offset; + } + + /// Append a single line for `entry` to the open session file. Caller + /// must have already verified `flushed`. + fn persistEntry(self: *SessionManager, entry: SessionEntry) !void { + const file = try Io.Dir.cwd().openFile(self.io, self.session_file, .{ + .mode = .write_only, + }); + defer file.close(self.io); + + const line = try session_mod.serializeEntry(self.allocator, entry); + defer self.allocator.free(line); + + try file.writePositionalAll(self.io, line, self.written_bytes); + self.written_bytes += line.len; + try file.writePositionalAll(self.io, "\n", self.written_bytes); + self.written_bytes += 1; + file.sync(self.io) catch {}; + } + + // ============================================================================= + // Conversation rebuild + // ============================================================================= + + /// Build a fresh `Conversation` from the entry log. Caller owns the + /// returned conversation (call `deinit`). + pub fn rebuildConversation(self: *const SessionManager) !conversation_mod.Conversation { + var conv = conversation_mod.Conversation.init(self.allocator); + errdefer conv.deinit(); + + for (self.entries.items) |entry| { + switch (entry) { + .message => |me| try appendMessageToConv(&conv, self.allocator, me.message), + } + } + return conv; + } +}; + +fn appendMessageToConv( + conv: *conversation_mod.Conversation, + allocator: Allocator, + disk_msg: DiskMessage, +) !void { + var content: std.ArrayList(conversation_mod.ContentBlock) = .empty; + errdefer { + for (content.items) |*b| { + var mut = b.*; + mut.deinit(allocator); + } + content.deinit(allocator); + } + try content.ensureTotalCapacity(allocator, disk_msg.content.len); + for (disk_msg.content) |db| { + const block = try session_mod.diskContentBlockToInternal(allocator, db); + content.appendAssumeCapacity(block); + } + const role: conversation_mod.MessageRole = switch (disk_msg.role) { + .system => .system, + .user => .user, + .assistant => .assistant, + }; + try conv.messages.append(allocator, .{ .role = role, .content = content }); +} + +// ============================================================================= +// Migration +// ============================================================================= + +/// Future format migrations land here. Returns true if anything changed +/// (which triggers a one-time file rewrite). +fn migrate( + allocator: Allocator, + header: *SessionHeader, + entries: *std.ArrayList(SessionEntry), +) bool { + _ = allocator; + _ = entries; + if (header.version >= CURRENT_VERSION) return false; + // No earlier versions exist yet. When v2 lands, transform v1 entries + // here and bump `header.version`. + return false; +} + +// ============================================================================= +// File utilities +// ============================================================================= + +fn readWholeFile(allocator: Allocator, io: Io, path: []const u8) ![]u8 { + const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) { + error.FileNotFound => return error.InvalidSessionFile, + else => return err, + }; + defer file.close(io); + + const len = file.length(io) catch { + // Fall back to a streaming read of a reasonable upper bound. + // Sessions over ~10 MB are out of scope for phase 4. + var list: std.ArrayList(u8) = .empty; + defer list.deinit(allocator); + var chunk: [4096]u8 = undefined; + while (true) { + const n = file.readStreaming(io, &.{&chunk}) catch break; + if (n == 0) break; + try list.appendSlice(allocator, chunk[0..n]); + } + return try list.toOwnedSlice(allocator); + }; + + const buf = try allocator.alloc(u8, @intCast(len)); + errdefer allocator.free(buf); + _ = try file.readPositionalAll(io, buf, 0); + return buf; +} + +fn statFileForLength(io: Io, path: []const u8) !u64 { + const file = try Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }); + defer file.close(io); + return try file.length(io); +} + +fn truncateFileTo(io: Io, path: []const u8, new_length: u64) !void { + const file = try Io.Dir.cwd().openFile(io, path, .{ .mode = .write_only }); + defer file.close(io); + try file.setLength(io, new_length); + file.sync(io) catch {}; +} + +/// Write a fresh file containing `header` followed by `entries`. Truncates +/// any existing content. Used after a migration rewrites the format. +fn rewriteFile( + allocator: Allocator, + io: Io, + path: []const u8, + header: SessionHeader, + entries: []const SessionEntry, +) !void { + const file = try Io.Dir.cwd().createFile(io, path, .{ + .truncate = true, + .read = false, + }); + defer file.close(io); + + var offset: u64 = 0; + const header_line = try session_mod.serializeHeader(allocator, header); + defer allocator.free(header_line); + try file.writePositionalAll(io, header_line, offset); + offset += header_line.len; + try file.writePositionalAll(io, "\n", offset); + offset += 1; + for (entries) |e| { + const line = try session_mod.serializeEntry(allocator, e); + defer allocator.free(line); + try file.writePositionalAll(io, line, offset); + offset += line.len; + try file.writePositionalAll(io, "\n", offset); + offset += 1; + } + file.sync(io) catch {}; +} + +fn mkdirP(io: Io, path: []const u8) !void { + Io.Dir.cwd().createDirPath(io, path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; +} + +// ============================================================================= +// Listing +// ============================================================================= + +/// List sessions in `session_dir`. Returns a slice of `SessionInfo`s +/// sorted by `modified` descending (most recent first). Caller owns the +/// slice and each `SessionInfo`. +/// +/// If the directory does not exist, returns an empty slice (no error). +/// +/// If `on_progress` is non-null, it is invoked after each file is parsed. +pub fn listSessions( + allocator: Allocator, + io: Io, + session_dir: []const u8, + on_progress: ?*const fn (loaded: usize, total: usize) void, +) ![]SessionInfo { + var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) { + error.FileNotFound => return try allocator.alloc(SessionInfo, 0), + else => return err, + }; + defer dir.close(io); + + var names: std.ArrayList([]u8) = .empty; + defer { + for (names.items) |n| allocator.free(n); + names.deinit(allocator); + } + + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; + const copy = try allocator.dupe(u8, entry.name); + errdefer allocator.free(copy); + try names.append(allocator, copy); + } + + var infos: std.ArrayList(SessionInfo) = .empty; + errdefer { + for (infos.items) |i| i.deinit(allocator); + infos.deinit(allocator); + } + try infos.ensureTotalCapacity(allocator, names.items.len); + + var loaded: usize = 0; + for (names.items) |name| { + const full = try std.fs.path.join(allocator, &.{ session_dir, name }); + defer allocator.free(full); + const info_opt = buildSessionInfo(allocator, io, full) catch null; + if (info_opt) |info| { + infos.appendAssumeCapacity(info); + } + loaded += 1; + if (on_progress) |cb| cb(loaded, names.items.len); + } + + const slice = try infos.toOwnedSlice(allocator); + std.sort.pdq(SessionInfo, slice, {}, sessionInfoNewerFirst); + return slice; +} + +fn sessionInfoNewerFirst(_: void, a: SessionInfo, b: SessionInfo) bool { + return std.mem.order(u8, a.modified, b.modified) == .gt; +} + +fn buildSessionInfo( + allocator: Allocator, + io: Io, + file_path: []const u8, +) !?SessionInfo { + const bytes = readWholeFile(allocator, io, file_path) catch return null; + defer allocator.free(bytes); + + var header_opt: ?SessionHeader = null; + defer if (header_opt) |h| h.deinit(allocator); + + var message_count: usize = 0; + var last_activity: ?[]u8 = null; + defer if (last_activity) |la| allocator.free(la); + + var lines = std.mem.splitScalar(u8, bytes, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) continue; + const fe = session_mod.parseLine(allocator, trimmed) catch break; + switch (fe) { + .header => |h| { + if (header_opt != null) { + h.deinit(allocator); + } else { + header_opt = h; + } + }, + .entry => |e| { + defer e.deinit(allocator); + switch (e) { + .message => |m| { + if (m.message.role == .user or m.message.role == .assistant) { + message_count += 1; + if (last_activity) |la| allocator.free(la); + last_activity = try allocator.dupe(u8, m.base.timestamp); + } + }, + } + }, + } + } + + const header = header_opt orelse return null; + // We will keep header alive through the deinit defer, and dupe its + // fields into the result. Cheap, avoids any ownership shuffling. + + const path = try allocator.dupe(u8, file_path); + errdefer allocator.free(path); + const id = try allocator.dupe(u8, header.id); + errdefer allocator.free(id); + const cwd = try allocator.dupe(u8, header.cwd); + errdefer allocator.free(cwd); + const created = try allocator.dupe(u8, header.timestamp); + errdefer allocator.free(created); + const modified = if (last_activity) |la| blk: { + last_activity = null; + break :blk la; + } else try allocator.dupe(u8, header.timestamp); + + return .{ + .path = path, + .id = id, + .cwd = cwd, + .created = created, + .modified = modified, + .message_count = message_count, + }; +} + +// ============================================================================= +// Recent / resume helpers +// ============================================================================= + +/// Find the most recent session file in `session_dir`. Returns null if +/// none exist. Caller owns the returned path. +pub fn findMostRecentSession(allocator: Allocator, io: Io, session_dir: []const u8) !?[]u8 { + var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer dir.close(io); + + var best_name: ?[]u8 = null; + errdefer if (best_name) |b| allocator.free(b); + + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; + if (best_name) |b| { + // Lexicographic compare. UUIDv7 filenames sort chronologically. + if (std.mem.order(u8, entry.name, b) == .gt) { + allocator.free(b); + best_name = try allocator.dupe(u8, entry.name); + } + } else { + best_name = try allocator.dupe(u8, entry.name); + } + } + + const name = best_name orelse return null; + defer allocator.free(name); + best_name = null; + return try std.fs.path.join(allocator, &.{ session_dir, name }); +} + +/// Resolve a (possibly abbreviated) session id to a session file path +/// within `session_dir`. Errors if no match or ambiguous prefix. +pub fn resolveSessionId( + allocator: Allocator, + io: Io, + session_dir: []const u8, + id_or_prefix: []const u8, +) ![]u8 { + var dir = Io.Dir.cwd().openDir(io, session_dir, .{}) catch |err| switch (err) { + error.FileNotFound => return error.SessionNotFound, + else => return err, + }; + defer dir.close(io); + + var match: ?[]u8 = null; + errdefer if (match) |m| allocator.free(m); + + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; + // Strip `.jsonl` for the prefix match. + const stem = entry.name[0 .. entry.name.len - ".jsonl".len]; + if (!std.mem.startsWith(u8, stem, id_or_prefix)) continue; + if (match != null) return error.AmbiguousSessionId; + match = try allocator.dupe(u8, entry.name); + } + + const name = match orelse return error.SessionNotFound; + defer allocator.free(name); + match = null; + return try std.fs.path.join(allocator, &.{ session_dir, name }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +const testing = std.testing; + +test "newUuidV7: produces 36-char hyphenated string with version 7" { + const io = testing.io; + const id = try newUuidV7(testing.allocator, io); + defer testing.allocator.free(id); + try testing.expectEqual(@as(usize, 36), id.len); + // Position 14 is the version nibble — should be '7'. + try testing.expectEqual(@as(u8, '7'), id[14]); + // Hyphens at canonical positions. + try testing.expectEqual(@as(u8, '-'), id[8]); + try testing.expectEqual(@as(u8, '-'), id[13]); + try testing.expectEqual(@as(u8, '-'), id[18]); + try testing.expectEqual(@as(u8, '-'), id[23]); +} + +test "isoTimestamp: well-formed ISO 8601 with millisecond precision" { + const ts = try isoTimestamp(testing.allocator, testing.io); + defer testing.allocator.free(ts); + try testing.expectEqual(@as(usize, 24), ts.len); + try testing.expectEqual(@as(u8, '-'), ts[4]); + try testing.expectEqual(@as(u8, 'T'), ts[10]); + try testing.expectEqual(@as(u8, '.'), ts[19]); + try testing.expectEqual(@as(u8, 'Z'), ts[23]); +} + +// ---- In-memory + filesystem tests (use a tmp dir) ---- + +const TmpSessionDir = struct { + parent: std.testing.TmpDir, + abs_path: []u8, + + fn init(allocator: Allocator) !TmpSessionDir { + var parent = std.testing.tmpDir(.{}); + errdefer parent.cleanup(); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const n = try parent.dir.realPath(testing.io, &path_buf); + const abs = try allocator.dupe(u8, path_buf[0..n]); + return .{ .parent = parent, .abs_path = abs }; + } + + fn deinit(self: *TmpSessionDir, allocator: Allocator) void { + allocator.free(self.abs_path); + self.parent.cleanup(); + } +}; + +test "SessionManager.init: does not create file yet" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + + // Use a non-existent subdirectory inside the tmp dir to also exercise + // lazy directory creation. + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + var mgr = try SessionManager.init( + testing.allocator, + io, + sessions, + "/some/cwd", + ); + defer mgr.deinit(); + + try testing.expect(!mgr.isFlushed()); + + // The directory should not exist yet. + const stat_err = Io.Dir.cwd().openDir(io, sessions, .{}); + try testing.expectError(error.FileNotFound, stat_err); +} + +test "SessionManager: full flow — buffer, flush on assistant, append, resume" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + const session_file: []u8 = blk: { + var mgr = try SessionManager.init( + testing.allocator, + io, + sessions, + "/proj/foo", + ); + defer mgr.deinit(); + + // System message — non-assistant, should NOT trigger flush. + const sys_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } }; + _ = try mgr.appendMessage( + .{ .role = .system, .content = sys_blocks }, + null, + null, + ); + try testing.expect(!mgr.isFlushed()); + + // User message — also doesn't flush. + const usr_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } }; + _ = try mgr.appendMessage( + .{ .role = .user, .content = usr_blocks }, + "openai", + "gpt-4o", + ); + try testing.expect(!mgr.isFlushed()); + + // Assistant message — triggers flush. + const a_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; + _ = try mgr.appendMessage( + .{ + .role = .assistant, + .content = a_blocks, + .provider = try testing.allocator.dupe(u8, "openai"), + .model = try testing.allocator.dupe(u8, "gpt-4o"), + .stop_reason = try testing.allocator.dupe(u8, "stop"), + }, + null, + null, + ); + try testing.expect(mgr.isFlushed()); + + // Append another user/assistant round. + const u_two = try testing.allocator.alloc(DiskContentBlock, 1); + u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "what's 2+2?") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, "openai", "gpt-4o"); + + const a2 = try testing.allocator.alloc(DiskContentBlock, 1); + a2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "4") } }; + _ = try mgr.appendMessage( + .{ .role = .assistant, .content = a2, .stop_reason = try testing.allocator.dupe(u8, "stop") }, + null, + null, + ); + + try testing.expectEqual(@as(usize, 5), mgr.getEntries().len); + + break :blk try testing.allocator.dupe(u8, mgr.getSessionFile()); + }; + defer testing.allocator.free(session_file); + + // Verify the file exists and is well-formed. + { + const bytes = try readWholeFile(testing.allocator, io, session_file); + defer testing.allocator.free(bytes); + // 1 header + 5 entries + trailing \n on each = 6 newlines. + var nl_count: usize = 0; + for (bytes) |b| if (b == '\n') { + nl_count += 1; + }; + try testing.expectEqual(@as(usize, 6), nl_count); + } + + // Resume. + var resumed = try SessionManager.open(testing.allocator, io, session_file); + defer resumed.deinit(); + try testing.expect(resumed.isFlushed()); + try testing.expectEqual(@as(usize, 5), resumed.getEntries().len); + try testing.expectEqualStrings("/proj/foo", resumed.getCwd()); + + // Continue the conversation. + const u_three = try testing.allocator.alloc(DiskContentBlock, 1); + u_three[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "thanks") } }; + _ = try resumed.appendMessage(.{ .role = .user, .content = u_three }, "openai", "gpt-4o"); + try testing.expectEqual(@as(usize, 6), resumed.getEntries().len); +} + +test "SessionManager: assistant message tags the message metadata and the entry leaf id is the assistant entry" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const u_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } }; + const user_id = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, "openai", "gpt-4o"); + + const a_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } }; + const asst_id = try mgr.appendMessage(.{ .role = .assistant, .content = a_blocks }, null, null); + + // Leaf is the assistant entry. + try testing.expectEqualStrings(asst_id, mgr.getLeafId().?); + // Parent of assistant is the user entry. + const assistant_entry = mgr.getEntry(asst_id).?; + try testing.expectEqualStrings(user_id, assistant_entry.base().parent_id.?); + // User entry's parent is null (no system). + const user_entry = mgr.getEntry(user_id).?; + try testing.expect(user_entry.base().parent_id == null); +} + +test "SessionManager: activeModel is null before any user message, then tracks the latest user stamp" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + // No user messages yet — there is no "active" model on disk yet. + try testing.expect(mgr.activeModel() == null); + + // Stamp a user message with anthropic. + const u_blocks = try testing.allocator.alloc(DiskContentBlock, 1); + u_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, "anthropic", "claude-sonnet-4-20250514"); + + { + const am = mgr.activeModel().?; + try testing.expectEqualStrings("anthropic", am.provider); + try testing.expectEqualStrings("claude-sonnet-4-20250514", am.model); + } +} + +test "SessionManager: rebuildConversation reconstructs system/user/assistant turn" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const sys = try testing.allocator.alloc(DiskContentBlock, 1); + sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "you are helpful") } }; + _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null); + + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi!") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null); + + var conv = try mgr.rebuildConversation(); + defer conv.deinit(); + try testing.expectEqual(@as(usize, 3), conv.messages.items.len); + try testing.expectEqual(conversation_mod.MessageRole.system, conv.messages.items[0].role); + try testing.expectEqualStrings("you are helpful", conv.messages.items[0].content.items[0].Text.items); + try testing.expectEqual(conversation_mod.MessageRole.user, conv.messages.items[1].role); + try testing.expectEqualStrings("hello", conv.messages.items[1].content.items[0].Text.items); + try testing.expectEqual(conversation_mod.MessageRole.assistant, conv.messages.items[2].role); + try testing.expectEqualStrings("hi!", conv.messages.items[2].content.items[0].Text.items); +} + +test "SessionManager: crash recovery truncates corrupted trailing line" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + // Build a valid session first. + const session_file: []u8 = blk: { + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "ping") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "pong") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null); + break :blk try testing.allocator.dupe(u8, mgr.getSessionFile()); + }; + defer testing.allocator.free(session_file); + + // Corrupt the file: append a partial JSON line at the end. + const garbage = "{\"type\":\"message\",\"id\":\"deadbeef\",\"parent"; + { + const file = try Io.Dir.cwd().openFile(io, session_file, .{ .mode = .write_only }); + defer file.close(io); + const len = try file.length(io); + try file.writePositionalAll(io, garbage, len); + } + // Confirm the file got bigger. + { + const f = try Io.Dir.cwd().openFile(io, session_file, .{ .mode = .read_only }); + defer f.close(io); + const corrupted_len = try f.length(io); + try testing.expect(corrupted_len > garbage.len); + } + + // Now resume — the partial line should be truncated. + var resumed = try SessionManager.open(testing.allocator, io, session_file); + defer resumed.deinit(); + try testing.expectEqual(@as(usize, 2), resumed.getEntries().len); + + // And the file on disk should match. + { + const bytes = try readWholeFile(testing.allocator, io, session_file); + defer testing.allocator.free(bytes); + try testing.expect(!std.mem.endsWith(u8, bytes, "parent")); + // Should end with a newline after the assistant entry. + try testing.expectEqual(@as(u8, '\n'), bytes[bytes.len - 1]); + } +} + +test "listSessions: returns most recent first, with counts" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + // Create two sessions. + for (0..2) |i| { + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null); + // Small sleep so UUIDv7 timestamps differ. + io.sleep(.fromMilliseconds(2), .real) catch {}; + _ = i; + } + + const infos = try listSessions(testing.allocator, io, sessions, null); + defer freeSessionInfos(testing.allocator, infos); + try testing.expectEqual(@as(usize, 2), infos.len); + try testing.expectEqual(@as(usize, 2), infos[0].message_count); + try testing.expect(std.mem.order(u8, infos[0].modified, infos[1].modified) != .lt); +} + +test "findMostRecentSession: picks lexicographically greatest" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + // Pre-resolution before any sessions exist → null. + try testing.expect((try findMostRecentSession(testing.allocator, io, sessions)) == null); + + // Create two. + var second_file: ?[]u8 = null; + defer if (second_file) |s| testing.allocator.free(s); + + for (0..2) |i| { + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null); + if (i == 1) second_file = try testing.allocator.dupe(u8, mgr.getSessionFile()); + io.sleep(.fromMilliseconds(2), .real) catch {}; + } + + const found = (try findMostRecentSession(testing.allocator, io, sessions)).?; + defer testing.allocator.free(found); + try testing.expectEqualStrings(second_file.?, found); +} + +test "SessionManager: tool-use round-trip — assistant w/ ToolUse, user w/ ToolResult, assistant" { + const io = testing.io; + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + const session_file: []u8 = blk: { + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "list files") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + + // Assistant emits a ToolUse. + const a1 = try testing.allocator.alloc(DiskContentBlock, 2); + a1[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "checking...") } }; + a1[1] = .{ .tool_use = .{ + .id = try testing.allocator.dupe(u8, "tool_abc"), + .name = try testing.allocator.dupe(u8, "bash"), + .input = try testing.allocator.dupe(u8, "{\"command\":\"ls\"}"), + } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a1 }, null, null); + + // Tool-result user message. + const tr = try testing.allocator.alloc(DiskContentBlock, 1); + tr[0] = .{ .tool_result = .{ + .tool_use_id = try testing.allocator.dupe(u8, "tool_abc"), + .content = try testing.allocator.dupe(u8, "a.txt\nb.txt"), + } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = tr }, "openai", "gpt-4o"); + + // Final assistant reply. + const a2 = try testing.allocator.alloc(DiskContentBlock, 1); + a2[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "two files: a.txt and b.txt") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a2 }, null, null); + + break :blk try testing.allocator.dupe(u8, mgr.getSessionFile()); + }; + defer testing.allocator.free(session_file); + + // Reopen and verify content blocks survive. + var resumed = try SessionManager.open(testing.allocator, io, session_file); + defer resumed.deinit(); + const entries = resumed.getEntries(); + try testing.expectEqual(@as(usize, 4), entries.len); + + // [1] = assistant with ToolUse + try testing.expectEqual(DiskMessageRole.assistant, entries[1].message.message.role); + try testing.expectEqual(@as(usize, 2), entries[1].message.message.content.len); + try testing.expect(entries[1].message.message.content[1] == .tool_use); + try testing.expectEqualStrings("bash", entries[1].message.message.content[1].tool_use.name); + try testing.expectEqualStrings("{\"command\":\"ls\"}", entries[1].message.message.content[1].tool_use.input); + + // [2] = user with ToolResult, stamped with provider/model. + try testing.expectEqual(DiskMessageRole.user, entries[2].message.message.role); + try testing.expectEqualStrings("openai", entries[2].message.provider.?); + try testing.expect(entries[2].message.message.content[0] == .tool_result); + try testing.expectEqualStrings("tool_abc", entries[2].message.message.content[0].tool_result.tool_use_id); + try testing.expectEqualStrings("a.txt\nb.txt", entries[2].message.message.content[0].tool_result.content); + + // Conversation rebuild yields the same shape. + var conv = try resumed.rebuildConversation(); + defer conv.deinit(); + try testing.expectEqual(@as(usize, 4), conv.messages.items.len); + try testing.expect(conv.messages.items[1].content.items[1] == .ToolUse); + try testing.expect(conv.messages.items[2].content.items[0] == .ToolResult); +} + +test "SessionManager: linear chain — each entry's parent_id is the previous entry's id" { + const io = testing.io; + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + + // Three rounds: sys, user, asst, user, asst. + const sys = try testing.allocator.alloc(DiskContentBlock, 1); + sys[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "sys") } }; + _ = try mgr.appendMessage(.{ .role = .system, .content = sys }, null, null); + + const u_one = try testing.allocator.alloc(DiskContentBlock, 1); + u_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u1") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_one }, "openai", "gpt-4o"); + + const a_one = try testing.allocator.alloc(DiskContentBlock, 1); + a_one[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a1") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_one }, null, null); + + const u_two = try testing.allocator.alloc(DiskContentBlock, 1); + u_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u2") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u_two }, "openai", "gpt-4o"); + + const a_two = try testing.allocator.alloc(DiskContentBlock, 1); + a_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a2") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a_two }, null, null); + + const entries = mgr.getEntries(); + try testing.expectEqual(@as(usize, 5), entries.len); + try testing.expect(entries[0].base().parent_id == null); + for (entries[1..], 1..) |e, i| { + try testing.expectEqualStrings(entries[i - 1].base().id, e.base().parent_id.?); + } +} + +test "resolveSessionId: unique prefix → match, ambiguous → error" { + const io = testing.io; + + var td = try TmpSessionDir.init(testing.allocator); + defer td.deinit(testing.allocator); + const sessions = try std.fs.path.join(testing.allocator, &.{ td.abs_path, "sessions" }); + defer testing.allocator.free(sessions); + + // Create one session. + var mgr = try SessionManager.init(testing.allocator, io, sessions, "/c"); + defer mgr.deinit(); + const u = try testing.allocator.alloc(DiskContentBlock, 1); + u[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "u") } }; + _ = try mgr.appendMessage(.{ .role = .user, .content = u }, "openai", "gpt-4o"); + const a = try testing.allocator.alloc(DiskContentBlock, 1); + a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } }; + _ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null, null); + + const id = mgr.getSessionId(); + const prefix = id[0..8]; + + const resolved = try resolveSessionId(testing.allocator, io, sessions, prefix); + defer testing.allocator.free(resolved); + try testing.expectEqualStrings(mgr.getSessionFile(), resolved); + + try testing.expectError(error.SessionNotFound, resolveSessionId(testing.allocator, io, sessions, "ffffffff")); +} |
