diff options
Diffstat (limited to 'src/conversation.zig')
| -rw-r--r-- | src/conversation.zig | 730 |
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)); +} |
