diff options
| author | t <t@tjp.lol> | 2026-07-07 11:29:23 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 12:15:28 -0600 |
| commit | 621d7fee0ace729f8d44126032d2c6e13f72ee7f (patch) | |
| tree | a2938b0a9e5c0930e8644721abbe94875df9ff08 /libpanto/src/conversation.zig | |
| parent | 0fa6d4209ff9b4a95e7d1955887aa4c73ee3423c (diff) | |
Remove the in-repo libpanto sources and binding projects from pantograph.
Consume libpantograph through the Zig package URL at
code.tjp.lol/libpantograph.git, including the Lua module artifact used by
CLI extensions.
Diffstat (limited to 'libpanto/src/conversation.zig')
| -rw-r--r-- | libpanto/src/conversation.zig | 730 |
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)); -} |
