summaryrefslogtreecommitdiff
path: root/libpanto/src/conversation.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/conversation.zig')
-rw-r--r--libpanto/src/conversation.zig730
1 files changed, 0 insertions, 730 deletions
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
deleted file mode 100644
index ea4309c..0000000
--- a/libpanto/src/conversation.zig
+++ /dev/null
@@ -1,730 +0,0 @@
-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));
-}