summaryrefslogtreecommitdiff
path: root/src/conversation.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/conversation.zig
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/conversation.zig')
-rw-r--r--src/conversation.zig730
1 files changed, 730 insertions, 0 deletions
diff --git a/src/conversation.zig b/src/conversation.zig
new file mode 100644
index 0000000..ea4309c
--- /dev/null
+++ b/src/conversation.zig
@@ -0,0 +1,730 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const config = @import("config.zig");
+
+/// A streaming text buffer used by content blocks.
+/// Thin alias over ArrayList(u8) — amortized O(1) appends,
+/// no O(n²) re-copying.
+pub const TextualBlock = std.ArrayList(u8);
+
+/// Create a TextualBlock with initial content (copies the slice).
+pub fn textualBlockFromSlice(alloc: Allocator, slice: []const u8) !TextualBlock {
+ var buf: TextualBlock = .empty;
+ try buf.appendSlice(alloc, slice);
+ return buf;
+}
+
+/// Provenance for a replayable thinking signature. Scoped conservatively to
+/// the exact provider endpoint + wire model that produced the enclosing
+/// assistant message.
+pub const SignatureOrigin = struct {
+ api_style: config.APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+
+ pub fn init(
+ alloc: Allocator,
+ api_style: config.APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+ ) !SignatureOrigin {
+ const burl = try alloc.dupe(u8, base_url);
+ errdefer alloc.free(burl);
+ const mdl = try alloc.dupe(u8, model);
+ return .{ .api_style = api_style, .base_url = burl, .model = mdl };
+ }
+
+ pub fn deinit(self: *SignatureOrigin, alloc: Allocator) void {
+ alloc.free(self.base_url);
+ alloc.free(self.model);
+ }
+
+ pub fn dupe(self: SignatureOrigin, alloc: Allocator) !SignatureOrigin {
+ return init(alloc, self.api_style, self.base_url, self.model);
+ }
+
+ pub fn matches(self: SignatureOrigin, api_style: config.APIStyle, base_url: []const u8, model: []const u8) bool {
+ return self.api_style == api_style and
+ std.mem.eql(u8, self.base_url, base_url) and
+ std.mem.eql(u8, self.model, model);
+ }
+};
+
+/// A reasoning/thinking block from the assistant.
+///
+/// `text` is the streamed reasoning content. `signature` is the opaque
+/// integrity token Anthropic emits with extended-thinking responses and
+/// requires echoed back verbatim on follow-up turns. It is optional because
+/// other providers (OpenAI-compatible APIs) do not produce it. When present,
+/// `signature_origin` records the exact provider/style/model that emitted the
+/// enclosing assistant message so serializers can decide whether that opaque
+/// signature is safe to replay on a later turn.
+pub const ThinkingBlock = struct {
+ text: TextualBlock = .empty,
+ signature: ?[]const u8 = null,
+ signature_origin: ?SignatureOrigin = null,
+
+ pub fn deinit(self: *ThinkingBlock, alloc: Allocator) void {
+ self.text.deinit(alloc);
+ if (self.signature) |sig| alloc.free(sig);
+ if (self.signature_origin) |*origin| origin.deinit(alloc);
+ }
+};
+
+pub const ToolUseBlock = struct {
+ id: []const u8,
+ name: []const u8,
+ input: TextualBlock = .empty,
+
+ pub fn deinit(self: *ToolUseBlock, alloc: Allocator) void {
+ alloc.free(self.id);
+ alloc.free(self.name);
+ self.input.deinit(alloc);
+ }
+};
+
+/// A binary attachment stored in a tool result. `media_type` is owned
+/// (e.g. "image/png"); `data` is the streaming text buffer holding
+/// base64-encoded bytes.
+pub const StoredMediaPart = struct {
+ media_type: []const u8,
+ data: TextualBlock = .empty,
+
+ pub fn deinit(self: *StoredMediaPart, alloc: Allocator) void {
+ alloc.free(self.media_type);
+ self.data.deinit(alloc);
+ }
+};
+
+/// One part of a tool result as stored in the conversation. Text uses the
+/// streaming `TextualBlock` form to preserve incremental-append semantics.
+pub const ResultPartStored = union(enum) {
+ text: TextualBlock,
+ media: StoredMediaPart,
+
+ pub fn deinit(self: *ResultPartStored, alloc: Allocator) void {
+ switch (self.*) {
+ .text => |*t| t.deinit(alloc),
+ .media => |*m| m.deinit(alloc),
+ }
+ }
+};
+
+pub const ToolResultBlock = struct {
+ tool_use_id: []const u8,
+ parts: std.ArrayList(ResultPartStored) = .empty,
+ /// True when this result reports a tool failure rather than success.
+ /// Serialized to providers that support it (Anthropic's `is_error`);
+ /// recorded but unserialized for OpenAI Chat (the error text in `parts`
+ /// carries the signal there). Defaults to false for backward
+ /// compatibility with sessions written before this field existed.
+ is_error: bool = false,
+
+ pub fn deinit(self: *ToolResultBlock, alloc: Allocator) void {
+ alloc.free(self.tool_use_id);
+ for (self.parts.items) |*p| p.deinit(alloc);
+ self.parts.deinit(alloc);
+ }
+
+ /// Concatenate all text parts into `out`. Media parts are skipped.
+ /// Used by callers (compaction, OpenAI fan-out) that want only the
+ /// textual portion.
+ pub fn appendTextInto(self: ToolResultBlock, alloc: Allocator, out: *TextualBlock) !void {
+ for (self.parts.items) |p| {
+ if (p == .text) try out.appendSlice(alloc, p.text.items);
+ }
+ }
+
+ /// True if any part is a media attachment.
+ pub fn hasMedia(self: ToolResultBlock) bool {
+ for (self.parts.items) |p| {
+ if (p == .media) return true;
+ }
+ return false;
+ }
+};
+
+/// How a `.system` content block combines with the system text collected
+/// before it. `append` adds to the running effective prompt; `replace`
+/// discards everything collected so far and starts fresh from this block.
+pub const SystemMode = enum { append, replace };
+
+/// A system-prompt content block. System prompts remain `.system`-role
+/// messages; this block records the mode that governs how its text folds
+/// into the effective system prompt (see `effectiveSystemBlocks`).
+pub const SystemBlock = struct {
+ text: TextualBlock = .empty,
+ mode: SystemMode = .append,
+
+ pub fn deinit(self: *SystemBlock, alloc: Allocator) void {
+ self.text.deinit(alloc);
+ }
+};
+
+/// A compaction summary block. Carries the synthetic seed text that
+/// stands in for a compacted conversation prefix. Like a `replace`-mode
+/// `.System` block, it changes replay semantics: when the effective
+/// conversation is rebuilt, a `.CompactionSummary` block resets all prior
+/// *conversation* turns (user/assistant), and only the latest summary
+/// plus the messages after it contribute to active context. System blocks
+/// are unaffected — they are derived separately by `effectiveSystemBlocks`
+/// and survive compaction untouched.
+///
+/// A `.CompactionSummary` block sits alone in a `user`-role message.
+pub const CompactionSummaryBlock = struct {
+ text: TextualBlock = .empty,
+
+ pub fn deinit(self: *CompactionSummaryBlock, alloc: Allocator) void {
+ self.text.deinit(alloc);
+ }
+};
+
+pub const ContentBlock = union(enum) {
+ Text: TextualBlock,
+ Thinking: ThinkingBlock,
+ ToolUse: ToolUseBlock,
+ ToolResult: ToolResultBlock,
+ System: SystemBlock,
+ CompactionSummary: CompactionSummaryBlock,
+
+ pub fn deinit(self: *ContentBlock, alloc: Allocator) void {
+ switch (self.*) {
+ .Text => |*b| b.deinit(alloc),
+ .Thinking => |*b| b.deinit(alloc),
+ .ToolUse => |*b| b.deinit(alloc),
+ .ToolResult => |*b| b.deinit(alloc),
+ .System => |*b| b.deinit(alloc),
+ .CompactionSummary => |*b| b.deinit(alloc),
+ }
+ }
+};
+
+pub const MessageRole = enum {
+ system,
+ user,
+ assistant,
+};
+
+/// Token usage reported by a provider for a single assistant turn.
+///
+/// All input categories sum to the total prompt tokens billed for the turn:
+/// `input + cache_read + cache_write` = total prompt tokens.
+///
+/// Crucially, `input` is *cumulative*: it is the entire prompt sent for the
+/// turn (the whole prior conversation), not the size of the turn's own
+/// message. Compaction relies on this to back out per-turn sizes from
+/// neighbor deltas (see `compaction.computeSplit`).
+///
+/// `reasoning` is a **subset** of `output`, not additive — the portion of
+/// the output 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.
+///
+/// Defined here (rather than in `session.zig`) so in-memory `Message`s can
+/// carry usage without a module cycle; `session.zig` re-exports it.
+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,
+};
+
+pub const Message = struct {
+ role: MessageRole,
+ content: std.ArrayList(ContentBlock) = .empty,
+ /// Provider-reported token usage for this message's turn. Set on
+ /// assistant messages (live, via the provider; replayed, from disk);
+ /// null on user/system messages and when the provider emitted no usage.
+ /// Used by compaction to size the retention window.
+ usage: ?Usage = null,
+ /// Opaque per-message metadata bag. `libpanto` never interprets these
+ /// bytes; the documented contract is that, when present, they are valid
+ /// JSON (so a store may keep them as a JSON column and tools may
+ /// deserialize them). Round-trips through persistence: set before a turn
+ /// commits, read back off the `Message` after `load`. Borrowed; owned by
+ /// whoever set it (the conversation's allocator on the replay path).
+ metadata: ?[]const u8 = null,
+ /// The wire-format provider identity (`api_style`/`base_url`/`model` and
+ /// the reasoning/thinking knobs) that actually produced this message.
+ /// Stamped once, when the message is first persisted, and from the disk
+ /// stamp on replay. Preserved verbatim through compaction's clone, so a
+ /// kept-verbatim turn keeps the identity of the model that generated it
+ /// rather than being re-stamped with the compaction model. This is the
+ /// per-message source of truth for thinking-signature replay — no need to
+ /// pin the identity onto each thinking block. When set, the `base_url`
+ /// and `model` slices are owned by the conversation's allocator.
+ identity: ?config.WireIdentity = null,
+
+ pub fn deinit(self: *Message, alloc: Allocator) void {
+ for (self.content.items) |*block| {
+ block.deinit(alloc);
+ }
+ self.content.deinit(alloc);
+ if (self.metadata) |m| alloc.free(m);
+ if (self.identity) |id| freeWireIdentity(alloc, id);
+ }
+};
+
+/// Duplicate a `WireIdentity`'s owned slices (`base_url`, `model`) into
+/// `alloc`; scalar fields are copied as-is. The result owns its strings and
+/// must be released with `freeWireIdentity`.
+pub fn dupeWireIdentity(alloc: Allocator, id: config.WireIdentity) !config.WireIdentity {
+ const burl = try alloc.dupe(u8, id.base_url);
+ errdefer alloc.free(burl);
+ const mdl = try alloc.dupe(u8, id.model);
+ var out = id;
+ out.base_url = burl;
+ out.model = mdl;
+ return out;
+}
+
+/// Free the owned slices of a `WireIdentity` produced by `dupeWireIdentity`.
+pub fn freeWireIdentity(alloc: Allocator, id: config.WireIdentity) void {
+ alloc.free(id.base_url);
+ alloc.free(id.model);
+}
+
+pub const Conversation = struct {
+ messages: std.ArrayList(Message) = .empty,
+ allocator: Allocator,
+
+ pub fn init(allocator: Allocator) Conversation {
+ return .{
+ .allocator = allocator,
+ };
+ }
+
+ /// Append a system message in `append` mode. Adds to the effective
+ /// system prompt. (Back-compatible: same external behavior as before
+ /// the `.System` block existed.)
+ pub fn addSystemMessage(self: *Conversation, text: []const u8) !void {
+ return self.appendSystemBlock(text, .append);
+ }
+
+ /// Append a system message in `replace` mode. When the effective
+ /// prompt is rebuilt (see `effectiveSystemBlocks`), this discards all
+ /// prior system text and starts fresh.
+ pub fn replaceSystemMessage(self: *Conversation, text: []const u8) !void {
+ return self.appendSystemBlock(text, .replace);
+ }
+
+ /// Append a `.system`-role message whose single content block is a
+ /// `.System` block carrying `mode`.
+ fn appendSystemBlock(self: *Conversation, text: []const u8, mode: SystemMode) !void {
+ const tb = try textualBlockFromSlice(self.allocator, text);
+ var content: std.ArrayList(ContentBlock) = .empty;
+ errdefer {
+ for (content.items) |*b| b.deinit(self.allocator);
+ content.deinit(self.allocator);
+ }
+ try content.append(self.allocator, .{ .System = .{ .text = tb, .mode = mode } });
+ try self.messages.append(self.allocator, .{
+ .role = .system,
+ .content = content,
+ });
+ }
+
+ /// Append a `user`-role message from a slice of content blocks.
+ /// Symmetric with `addAssistantMessage`: ownership of the blocks (and
+ /// every byte they reference) transfers to the conversation; the caller
+ /// must not deinit them after this call. This is the general user-side
+ /// builder — a user turn may carry plain text, multiple text blocks
+ /// (e.g. messages queued while the agent was mid-turn), one or more
+ /// `.ToolResult` blocks, or any mix. For the common single-text case,
+ /// build a one-element `.{ .Text = ... }` slice (see the `addUserText`
+ /// test helpers across the codebase).
+ ///
+ /// Blocks must use this conversation's allocator for any owned bytes,
+ /// since the conversation will free them with that allocator.
+ pub fn addUserMessage(self: *Conversation, blocks: []const ContentBlock) !void {
+ return self.addMessage(.user, blocks, null);
+ }
+
+ /// Append a `user`-role message carrying a single `.CompactionSummary`
+ /// block holding `text` (copied). This is the seed that stands in for
+ /// a compacted conversation prefix; see `CompactionSummaryBlock`.
+ pub fn addCompactionSummary(self: *Conversation, text: []const u8) !void {
+ const tb = try textualBlockFromSlice(self.allocator, text);
+ var content: std.ArrayList(ContentBlock) = .empty;
+ errdefer {
+ for (content.items) |*b| b.deinit(self.allocator);
+ content.deinit(self.allocator);
+ }
+ try content.append(self.allocator, .{ .CompactionSummary = .{ .text = tb } });
+ try self.messages.append(self.allocator, .{
+ .role = .user,
+ .content = content,
+ });
+ }
+
+ /// Append an assistant message, optionally tagged with its
+ /// provider-reported `usage` (pass `null` for none). Ownership of the
+ /// blocks is transferred to the conversation; the caller must not
+ /// deinit them after this call.
+ pub fn addAssistantMessage(
+ self: *Conversation,
+ blocks: []const ContentBlock,
+ usage: ?Usage,
+ ) !void {
+ return self.addMessage(.assistant, blocks, usage);
+ }
+
+ /// Append a message of any role from a slice of content blocks, with
+ /// optional usage. The general form behind `addUserMessage` /
+ /// `addAssistantMessage`; used to rebuild a persisted message (tool calls,
+ /// thinking, mixed blocks) losslessly. Ownership of the blocks transfers
+ /// to the conversation; the caller must not deinit them after this call.
+ pub fn addMessage(self: *Conversation, role: MessageRole, blocks: []const ContentBlock, usage: ?Usage) !void {
+ var content: std.ArrayList(ContentBlock) = .empty;
+ try content.ensureTotalCapacity(self.allocator, blocks.len);
+ for (blocks) |block| {
+ content.appendAssumeCapacity(block);
+ }
+ try self.messages.append(self.allocator, .{
+ .role = role,
+ .content = content,
+ .usage = usage,
+ });
+ }
+
+ pub fn deinit(self: *Conversation) void {
+ for (self.messages.items) |*msg| {
+ msg.deinit(self.allocator);
+ }
+ self.messages.deinit(self.allocator);
+ }
+};
+
+/// Whether a thinking block's opaque signature may be replayed to a request
+/// targeting `(api_style, base_url, model)`. A signature is portable only
+/// back to the exact endpoint/model that produced it; replaying it elsewhere
+/// is rejected (Anthropic) or meaningless (OpenAI encrypted reasoning).
+///
+/// Provenance is resolved per block first (`signature_origin`, set live by
+/// the producing provider), then falls back to the enclosing message's
+/// `identity` (the per-message source of truth that survives compaction). A
+/// block with no signature, or with no provenance from either source, is not
+/// replayable — sending a signature to an unverified endpoint is unsafe.
+pub fn thinkingSignatureMatches(
+ block: ThinkingBlock,
+ msg_identity: ?config.WireIdentity,
+ api_style: config.APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+) bool {
+ if (block.signature == null) return false;
+ if (block.signature_origin) |origin| {
+ return origin.matches(api_style, base_url, model);
+ }
+ if (msg_identity) |id| {
+ return id.api_style == api_style and
+ std.mem.eql(u8, id.base_url, base_url) and
+ std.mem.eql(u8, id.model, model);
+ }
+ return false;
+}
+
+pub fn setThinkingOrigins(
+ allocator: Allocator,
+ blocks: []ContentBlock,
+ api_style: config.APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+) !void {
+ for (blocks) |*block| {
+ if (block.* != .Thinking) continue;
+ const origin = try SignatureOrigin.init(allocator, api_style, base_url, model);
+ if (block.Thinking.signature_origin) |*old| old.deinit(allocator);
+ block.Thinking.signature_origin = origin;
+ }
+}
+
+/// Derive the effective ordered list of system-text blocks from a slice of
+/// messages. This is the single shared rule that governs both provider
+/// serialization and session rebuild.
+///
+/// Walk the messages in order; for each `.system` message's `.System`
+/// block:
+/// - `append`: add the block's text to the running list.
+/// - `replace`: clear the running list, then add this block's text.
+///
+/// The returned slices are **borrowed** from `messages` — valid only as
+/// long as the underlying conversation is unmodified. The caller owns the
+/// returned `ArrayList` itself and must `deinit` it (this frees the slice
+/// storage, not the borrowed text).
+///
+/// Running this walk over a *prefix* of the messages reconstructs the
+/// effective prompt as of that point — the `/tree` faithfulness property.
+pub fn effectiveSystemBlocks(
+ alloc: Allocator,
+ messages: []const Message,
+) !std.ArrayList([]const u8) {
+ var out: std.ArrayList([]const u8) = .empty;
+ errdefer out.deinit(alloc);
+ for (messages) |msg| {
+ if (msg.role != .system) continue;
+ for (msg.content.items) |block| {
+ switch (block) {
+ .System => |sb| {
+ if (sb.mode == .replace) out.clearRetainingCapacity();
+ try out.append(alloc, sb.text.items);
+ },
+ // Be tolerant of plain `.Text` blocks on a system message
+ // (e.g. hand-built test conversations): treat them as
+ // append-mode text.
+ .Text => |tb| try out.append(alloc, tb.items),
+ else => {},
+ }
+ }
+ }
+ return out;
+}
+
+/// Index of the message carrying the latest `.CompactionSummary` block,
+/// or null if the conversation has never been compacted.
+///
+/// Compaction replay is reset-like: only the latest compaction summary and
+/// the messages after it contribute to active conversation context. This
+/// returns the anchor message (the summary itself); active conversation is
+/// `messages[anchor..]`. System messages are unaffected and are derived
+/// independently via `effectiveSystemBlocks`.
+pub fn latestCompactionIndex(messages: []const Message) ?usize {
+ var anchor: ?usize = null;
+ for (messages, 0..) |msg, i| {
+ for (msg.content.items) |block| {
+ if (block == .CompactionSummary) {
+ anchor = i;
+ break;
+ }
+ }
+ }
+ return anchor;
+}
+
+/// The active (post-compaction) conversation message window that should be
+/// sent to a provider. If the conversation has been compacted, this is the
+/// latest compaction summary message plus everything after it; otherwise
+/// it is the whole message list. System messages within the window are
+/// still emitted/handled by the caller's own role filtering — this only
+/// trims the *prefix* superseded by compaction.
+///
+/// The returned slice borrows from `messages`.
+pub fn activeMessageWindow(messages: []const Message) []const Message {
+ if (latestCompactionIndex(messages)) |anchor| {
+ return messages[anchor..];
+ }
+ return messages;
+}
+
+/// Test helper: append a single-text user message. `addUserMessage` takes
+/// a block slice; this wraps the plain-text case the tests below use.
+fn addUserText(conv: *Conversation, text: []const u8) !void {
+ const tb = try textualBlockFromSlice(conv.allocator, text);
+ var block: ContentBlock = .{ .Text = tb };
+ errdefer block.deinit(conv.allocator);
+ try conv.addUserMessage(&.{block});
+}
+
+test "Conversation - add messages and verify content" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("You are a helpful assistant.");
+ try addUserText(&conv, "Hello!");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try textualBlockFromSlice(allocator, "Hi there!") },
+ }, null);
+
+ try std.testing.expectEqual(@as(usize, 3), conv.messages.items.len);
+
+ try std.testing.expectEqual(MessageRole.system, conv.messages.items[0].role);
+ try std.testing.expectEqualStrings(
+ "You are a helpful assistant.",
+ conv.messages.items[0].content.items[0].System.text.items,
+ );
+ try std.testing.expectEqual(
+ SystemMode.append,
+ conv.messages.items[0].content.items[0].System.mode,
+ );
+
+ try std.testing.expectEqual(MessageRole.user, conv.messages.items[1].role);
+ try std.testing.expectEqualStrings("Hello!", conv.messages.items[1].content.items[0].Text.items);
+
+ try std.testing.expectEqual(MessageRole.assistant, conv.messages.items[2].role);
+ try std.testing.expectEqualStrings("Hi there!", conv.messages.items[2].content.items[0].Text.items);
+}
+
+test "TextualBlock - incremental append" {
+ const allocator = std.testing.allocator;
+
+ var tb = TextualBlock.empty;
+ defer tb.deinit(allocator);
+
+ try tb.appendSlice(allocator, "Hello");
+ try tb.appendSlice(allocator, " world");
+ try std.testing.expectEqualStrings("Hello world", tb.items);
+}
+
+test "Conversation - deinit frees without leaks" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ try conv.addSystemMessage("system");
+ try addUserText(&conv, "user message");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try textualBlockFromSlice(allocator, "response") },
+ }, null);
+ conv.deinit();
+}
+
+test "ContentBlock - Thinking variant" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{ .text = try textualBlockFromSlice(allocator, "hmm...") } },
+ .{ .Text = try textualBlockFromSlice(allocator, "answer") },
+ }, null);
+
+ try std.testing.expectEqual(@as(usize, 2), conv.messages.items[0].content.items.len);
+ try std.testing.expectEqualStrings("hmm...", conv.messages.items[0].content.items[0].Thinking.text.items);
+ try std.testing.expectEqualStrings("answer", conv.messages.items[0].content.items[1].Text.items);
+}
+
+test "System block - addSystemMessage records append mode, replaceSystemMessage records replace mode" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("base");
+ try conv.replaceSystemMessage("fresh");
+
+ try std.testing.expectEqual(SystemMode.append, conv.messages.items[0].content.items[0].System.mode);
+ try std.testing.expectEqualStrings("base", conv.messages.items[0].content.items[0].System.text.items);
+ try std.testing.expectEqual(SystemMode.replace, conv.messages.items[1].content.items[0].System.mode);
+ try std.testing.expectEqualStrings("fresh", conv.messages.items[1].content.items[0].System.text.items);
+}
+
+test "effectiveSystemBlocks - append accumulates in order" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("a");
+ try conv.addSystemMessage("b");
+ try addUserText(&conv, "hi");
+ try conv.addSystemMessage("c");
+
+ var blocks = try effectiveSystemBlocks(allocator, conv.messages.items);
+ defer blocks.deinit(allocator);
+
+ try std.testing.expectEqual(@as(usize, 3), blocks.items.len);
+ try std.testing.expectEqualStrings("a", blocks.items[0]);
+ try std.testing.expectEqualStrings("b", blocks.items[1]);
+ try std.testing.expectEqualStrings("c", blocks.items[2]);
+}
+
+test "effectiveSystemBlocks - replace wipes everything collected so far" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("a");
+ try conv.addSystemMessage("b");
+ try conv.replaceSystemMessage("fresh");
+ try conv.addSystemMessage("after");
+
+ var blocks = try effectiveSystemBlocks(allocator, conv.messages.items);
+ defer blocks.deinit(allocator);
+
+ try std.testing.expectEqual(@as(usize, 2), blocks.items.len);
+ try std.testing.expectEqualStrings("fresh", blocks.items[0]);
+ try std.testing.expectEqualStrings("after", blocks.items[1]);
+}
+
+test "effectiveSystemBlocks - prefix reconstructs prompt as of that point" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("a");
+ try conv.replaceSystemMessage("fresh");
+ try conv.addSystemMessage("after");
+
+ // Truncate at position 1 (only the first `addSystemMessage`).
+ var blocks = try effectiveSystemBlocks(allocator, conv.messages.items[0..1]);
+ defer blocks.deinit(allocator);
+ try std.testing.expectEqual(@as(usize, 1), blocks.items.len);
+ try std.testing.expectEqualStrings("a", blocks.items[0]);
+}
+
+test "addCompactionSummary - sits alone in a user message" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try addUserText(&conv, "hi");
+ try conv.addCompactionSummary("summary of earlier history");
+
+ const m = conv.messages.items[1];
+ try std.testing.expectEqual(MessageRole.user, m.role);
+ try std.testing.expectEqual(@as(usize, 1), m.content.items.len);
+ try std.testing.expectEqualStrings(
+ "summary of earlier history",
+ m.content.items[0].CompactionSummary.text.items,
+ );
+}
+
+test "latestCompactionIndex - null when never compacted" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("sys");
+ try addUserText(&conv, "hi");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try textualBlockFromSlice(allocator, "hello") },
+ }, null);
+
+ try std.testing.expect(latestCompactionIndex(conv.messages.items) == null);
+}
+
+test "latestCompactionIndex - returns the latest summary anchor" {
+ const allocator = std.testing.allocator;
+
+ var conv = Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("sys");
+ try addUserText(&conv, "old");
+ try conv.addCompactionSummary("S1");
+ try addUserText(&conv, "mid");
+ try conv.addCompactionSummary("S2"); // index 4
+ try addUserText(&conv, "recent");
+
+ try std.testing.expectEqual(@as(?usize, 4), latestCompactionIndex(conv.messages.items));
+}