summaryrefslogtreecommitdiff
path: root/libpanto/src/compaction.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/compaction.zig')
-rw-r--r--libpanto/src/compaction.zig743
1 files changed, 743 insertions, 0 deletions
diff --git a/libpanto/src/compaction.zig b/libpanto/src/compaction.zig
new file mode 100644
index 0000000..254b876
--- /dev/null
+++ b/libpanto/src/compaction.zig
@@ -0,0 +1,743 @@
+//! Compaction sizing and retention policy.
+//!
+//! Compaction reduces context size by summarizing an older prefix of the
+//! conversation into a synthetic seed (a `.CompactionSummary` block) and
+//! keeping a recent suffix of whole turns verbatim. This module owns the
+//! *policy* half — deciding where to split prefix-to-summarize from
+//! suffix-to-keep — and the transcript serialization of the prefix. The
+//! *execution* half (running the compaction request, mutating the
+//! conversation) lives in `agent.zig`.
+//!
+//! ## Retention model
+//!
+//! The retention unit is a whole **turn**: a user message followed by the
+//! assistant message(s) it elicited (including any interleaved tool-result
+//! user messages that belong to the same request/response cycle). v1 never
+//! splits a turn.
+//!
+//! Splitting walks the active conversation backward, accumulating a token
+//! estimate per turn, and stops as soon as the running total *exceeds*
+//! `keep_verbatim`. The turn that crosses the threshold is the last turn
+//! folded into the summary; turns after it are kept verbatim. So
+//! `keep_verbatim` is an upper bound on the kept-suffix size, not a target.
+//!
+//! ## Sizing
+//!
+//! When provider-reported `Usage` is available for a message, its token
+//! total drives sizing. Otherwise we fall back to a cheap monotonic
+//! heuristic: word count scaled by `word_to_token_factor`. The fallback is
+//! not exact but is sufficient to choose a conservative recent suffix.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const conversation = @import("conversation.zig");
+const session_mod = @import("session.zig");
+
+pub const Message = conversation.Message;
+pub const Usage = session_mod.Usage;
+
+/// Multiplier applied to a word count to approximate token count when no
+/// provider usage data is available. Tokens-per-word runs ~1.3 for English
+/// prose and code on common BPE tokenizers (rough, deliberately so).
+pub const word_to_token_factor: f64 = 1.3;
+
+/// Estimate the token size of a single message.
+///
+/// If `usage` is non-null, its prompt+output token total is used directly.
+/// Otherwise the message's textual content is word-counted and scaled by
+/// `word_to_token_factor`.
+pub fn messageTokenEstimate(msg: Message, usage: ?Usage) u64 {
+ if (usage) |u| {
+ // Total tokens attributable to this message: all input categories
+ // plus output. (For a user message only inputs are typically set;
+ // for an assistant message output dominates.)
+ return u.input + u.cache_read + u.cache_write + u.output;
+ }
+ const words = countWords(msg);
+ const est = @as(f64, @floatFromInt(words)) * word_to_token_factor;
+ return @intFromFloat(@ceil(est));
+}
+
+/// The full prompt size (all input categories) reported by a turn's usage.
+/// Provider `input` may exclude cached tokens, which appear under
+/// `cache_read`/`cache_write`; summing them recovers the true context size
+/// that was sent to the model.
+fn promptTokens(u: Usage) u64 {
+ return u.input + u.cache_read + u.cache_write;
+}
+
+/// The cumulative context footprint at a turn's final assistant message:
+/// the full prompt it consumed plus the output it produced. This is the
+/// running total of *all* prior conversation tokens as of that turn.
+fn cumulativeAt(u: Usage) u64 {
+ return promptTokens(u) + u.output;
+}
+
+/// Estimate the incremental token cost of the turn spanning
+/// `messages[start..end)` using cumulative provider usage.
+///
+/// Provider `usage.input` is *cumulative*: it is the entire prompt sent for
+/// that turn (the whole prior conversation), not the size of one message.
+/// So a turn's own contribution is the delta between its final assistant's
+/// cumulative footprint and the previous turn's:
+///
+/// cumulativeAt(this_turn_last_assistant)
+/// - cumulativeAt(prev_turn_last_assistant)
+///
+/// This single delta captures the turn-start user prompt, every
+/// tool-result, every intermediate assistant output, and the final
+/// assistant output at once — no per-message decomposition needed. At
+/// session start there is no previous assistant, so `before` is zero and
+/// the first turn's cost is its own full cumulative footprint (which fairly
+/// includes the system prompt).
+///
+/// `prev_usage` is the usage of the assistant message immediately before
+/// `start` (the previous turn's final assistant), or null at session start.
+///
+/// Returns null when usage data is insufficient (no usage on this turn's
+/// assistant), signalling the caller to fall back to word counting.
+fn turnTokenEstimate(
+ messages: []const Message,
+ usages: []const ?Usage,
+ start: usize,
+ end: usize,
+ prev_usage: ?Usage,
+) ?u64 {
+ // Find this turn's final assistant usage (cumulative as of turn end).
+ var after: ?Usage = null;
+ var j = end;
+ while (j > start) {
+ j -= 1;
+ if (messages[j].role == .assistant) {
+ if (usages[j]) |u| {
+ after = u;
+ break;
+ }
+ }
+ }
+ const after_usage = after orelse return null;
+ const after_total = cumulativeAt(after_usage);
+ const before_total = if (prev_usage) |b| cumulativeAt(b) else 0;
+ // Saturating subtraction: usage is an estimate and can be non-monotone.
+ return if (after_total > before_total) after_total - before_total else 0;
+}
+
+/// Find the usage of the assistant message immediately before index
+/// `start`, scanning backward. Returns null if none precedes it.
+fn priorAssistantUsage(
+ messages: []const Message,
+ usages: []const ?Usage,
+ start: usize,
+) ?Usage {
+ var j = start;
+ while (j > 0) {
+ j -= 1;
+ if (messages[j].role == .assistant) {
+ if (usages[j]) |u| return u;
+ }
+ }
+ return null;
+}
+
+/// Count whitespace-delimited words across all textual content in a
+/// message. Tool-use input JSON and tool-result content count too — they
+/// occupy real context.
+fn countWords(msg: Message) u64 {
+ var total: u64 = 0;
+ for (msg.content.items) |block| {
+ const text: []const u8 = switch (block) {
+ .Text => |b| b.items,
+ .Thinking => |b| b.text.items,
+ .ToolUse => |b| b.input.items,
+ .ToolResult => |b| b.content.items,
+ .System => |b| b.text.items,
+ .CompactionSummary => |b| b.text.items,
+ };
+ total += countWordsIn(text);
+ }
+ return total;
+}
+
+fn countWordsIn(text: []const u8) u64 {
+ var count: u64 = 0;
+ var in_word = false;
+ for (text) |c| {
+ const is_space = c == ' ' or c == '\t' or c == '\n' or c == '\r';
+ if (is_space) {
+ in_word = false;
+ } else if (!in_word) {
+ in_word = true;
+ count += 1;
+ }
+ }
+ return count;
+}
+
+/// The result of a retention split over a conversation.
+pub const Split = struct {
+ /// Index into the *full* message list. Messages in `[0, prefix_end)`
+ /// are summarized; messages in `[prefix_end, len)` are kept verbatim.
+ /// A value equal to the message count means "summarize everything".
+ prefix_end: usize,
+ /// Number of whole turns kept verbatim.
+ kept_turns: usize,
+};
+
+/// Decide the prefix/suffix split for compaction.
+///
+/// `messages` is the conversation to compact. Only the active window
+/// (everything after the latest existing `.CompactionSummary`, if any) is
+/// considered — an earlier compacted prefix is already summarized and is
+/// never re-walked. `usages`, when provided, must be the same length as
+/// `messages` and supplies per-message provider usage (null entries fall
+/// back to word counting).
+///
+/// Leading system messages are never part of a turn and are always treated
+/// as belonging to the (kept) structural frame, not the summarized prefix:
+/// the split index is reported relative to the full list but the walk only
+/// accumulates user/assistant conversation turns.
+///
+/// Returns the split. When the entire active conversation fits within
+/// `keep_verbatim`, `prefix_end` points at the first active turn (nothing
+/// to summarize) — callers should treat that as a no-op.
+pub fn computeSplit(
+ messages: []const Message,
+ usages: ?[]const ?Usage,
+ keep_verbatim: u64,
+) Split {
+ if (usages) |u| std.debug.assert(u.len == messages.len);
+
+ const active_start = blk: {
+ if (conversation.latestCompactionIndex(messages)) |anchor| {
+ // The summary message itself anchors the window; active turns
+ // begin after it.
+ break :blk anchor + 1;
+ }
+ break :blk 0;
+ };
+
+ // Identify turn boundaries within the active window. A turn starts at a
+ // user message that is NOT purely tool-results (a fresh human/user
+ // prompt or compaction-follow user prompt) and runs until the next such
+ // boundary. Tool-result user messages and assistant messages attach to
+ // the current turn.
+ //
+ // We collect the start index of each turn, then walk turns backward.
+ var turn_starts_buf: [4096]usize = undefined;
+ var n_turns: usize = 0;
+ var i = active_start;
+ // Skip leading system messages (structural frame, not a turn).
+ while (i < messages.len and messages[i].role == .system) : (i += 1) {}
+ const first_turn_msg = i;
+ while (i < messages.len) : (i += 1) {
+ const msg = messages[i];
+ if (msg.role == .system) continue;
+ if (isTurnStart(msg)) {
+ if (n_turns < turn_starts_buf.len) {
+ turn_starts_buf[n_turns] = i;
+ }
+ n_turns += 1;
+ }
+ }
+ const turn_starts = turn_starts_buf[0..@min(n_turns, turn_starts_buf.len)];
+
+ if (turn_starts.len == 0) {
+ // No turns to keep or summarize.
+ return .{ .prefix_end = messages.len, .kept_turns = 0 };
+ }
+
+ // Walk turns backward, accumulating token totals. Stop as soon as the
+ // running total exceeds the budget: the crossing turn is summarized.
+ var running: u64 = 0;
+ var kept_turns: usize = 0;
+ var t = turn_starts.len;
+ while (t > 0) {
+ t -= 1;
+ const start = turn_starts[t];
+ const end = if (t + 1 < turn_starts.len) turn_starts[t + 1] else messages.len;
+ var turn_tokens: u64 = 0;
+ // Prefer cumulative-usage deltas: provider `input` is the whole
+ // prior conversation, so a turn's true cost is the delta between
+ // its final assistant footprint and the previous turn's. Fall back
+ // to per-message word counting when usage is unavailable.
+ const turn_estimate: ?u64 = if (usages) |u|
+ turnTokenEstimate(messages, u, start, end, priorAssistantUsage(messages, u, start))
+ else
+ null;
+ if (turn_estimate) |est| {
+ turn_tokens = est;
+ } else {
+ var j = start;
+ while (j < end) : (j += 1) {
+ const usage: ?Usage = if (usages) |u| u[j] else null;
+ turn_tokens += messageTokenEstimate(messages[j], usage);
+ }
+ }
+ if (running + turn_tokens > keep_verbatim) {
+ // This turn crosses the budget: it (and everything before it)
+ // is summarized. Kept suffix starts at the next turn.
+ const kept_start = if (t + 1 < turn_starts.len) turn_starts[t + 1] else messages.len;
+ return .{ .prefix_end = kept_start, .kept_turns = kept_turns };
+ }
+ running += turn_tokens;
+ kept_turns += 1;
+ }
+
+ // Everything fits: nothing to summarize. Prefix ends at the first turn.
+ return .{ .prefix_end = first_turn_msg, .kept_turns = kept_turns };
+}
+
+/// A user message starts a new turn unless it carries any tool-result
+/// block (in which case it's a continuation of the assistant's tool round
+/// within the current turn). System and assistant messages never start a
+/// turn.
+fn isTurnStart(msg: Message) bool {
+ if (msg.role != .user) return false;
+ for (msg.content.items) |block| {
+ if (block == .ToolResult) return false;
+ }
+ return true;
+}
+
+// =============================================================================
+// Transcript serialization
+// =============================================================================
+
+/// Serialize a range of conversation messages into a plain-text transcript
+/// artifact. The output marks message roles and structure so the
+/// compaction model treats it as material under analysis rather than live
+/// chat to continue. Caller owns the returned bytes.
+///
+/// Format (one labelled section per block, blank-line separated):
+/// [User]: ...
+/// [Assistant]: ...
+/// [Assistant thinking]: ...
+/// [Assistant tool call: <name> (<id>)]: <input>
+/// [Tool result (<id>)]: ...
+/// [Previous summary]: ...
+///
+/// System messages are skipped: the system prompt survives compaction and
+/// is not part of the material being summarized.
+pub fn serializeTranscript(
+ allocator: Allocator,
+ messages: []const Message,
+) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ const w = &out;
+
+ var first = true;
+ for (messages) |msg| {
+ if (msg.role == .system) continue;
+ for (msg.content.items) |block| {
+ if (!first) try w.appendSlice(allocator, "\n\n");
+ first = false;
+ switch (block) {
+ .Text => |b| {
+ const label = if (msg.role == .assistant) "[Assistant]: " else "[User]: ";
+ try w.appendSlice(allocator, label);
+ try w.appendSlice(allocator, b.items);
+ },
+ .Thinking => |b| {
+ try w.appendSlice(allocator, "[Assistant thinking]: ");
+ try w.appendSlice(allocator, b.text.items);
+ },
+ .ToolUse => |b| {
+ const line = try std.fmt.allocPrint(allocator, "[Assistant tool call: {s} ({s})]: {s}", .{
+ b.name, b.id, b.input.items,
+ });
+ defer allocator.free(line);
+ try w.appendSlice(allocator, line);
+ },
+ .ToolResult => |b| {
+ const line = try std.fmt.allocPrint(allocator, "[Tool result ({s})]: {s}", .{
+ b.tool_use_id, b.content.items,
+ });
+ defer allocator.free(line);
+ try w.appendSlice(allocator, line);
+ },
+ .CompactionSummary => |b| {
+ try w.appendSlice(allocator, "[Previous summary]: ");
+ try w.appendSlice(allocator, b.text.items);
+ },
+ .System => {},
+ }
+ }
+ }
+ return out.toOwnedSlice(allocator);
+}
+
+// =============================================================================
+// Compaction request user-prompt assembly
+// =============================================================================
+
+/// Build the user-prompt body for a compaction request: a brief framing,
+/// the optional previous summary in a `<previous-summary>` section, and the
+/// transcript of the prefix being summarized in a `<transcript>` section.
+///
+/// `transcript` is the serialized prefix (see `serializeTranscript`).
+/// `previous_summary`, when non-null, is the latest active compaction
+/// summary text carried forward (the chained-compaction invariant: at most
+/// one previous summary, never an accumulating stack). Caller owns the
+/// returned bytes.
+pub fn buildRequestBody(
+ allocator: Allocator,
+ transcript: []const u8,
+ previous_summary: ?[]const u8,
+) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+
+ if (previous_summary) |prev| {
+ try out.appendSlice(allocator,
+ "The previous compaction generated this summary:\n\n<previous-summary>\n");
+ try out.appendSlice(allocator, prev);
+ try out.appendSlice(allocator,
+ "\n</previous-summary>\n\nThe conversation since that previous compaction:\n\n<transcript>\n");
+ } else {
+ try out.appendSlice(allocator,
+ "The conversation to summarize:\n\n<transcript>\n");
+ }
+ try out.appendSlice(allocator, transcript);
+ try out.appendSlice(allocator, "\n</transcript>");
+ return out.toOwnedSlice(allocator);
+}
+
+/// The latest active compaction summary text in `messages`, or null if the
+/// conversation has never been compacted. Borrowed from `messages`.
+pub fn latestSummaryText(messages: []const Message) ?[]const u8 {
+ const anchor = conversation.latestCompactionIndex(messages) orelse return null;
+ for (messages[anchor].content.items) |block| {
+ if (block == .CompactionSummary) return block.CompactionSummary.text.items;
+ }
+ return null;
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+const testing = std.testing;
+
+fn userMsg(alloc: Allocator, text: []const u8) !Message {
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(alloc, .{ .Text = try conversation.textualBlockFromSlice(alloc, text) });
+ return .{ .role = .user, .content = content };
+}
+
+fn asstMsg(alloc: Allocator, text: []const u8) !Message {
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(alloc, .{ .Text = try conversation.textualBlockFromSlice(alloc, text) });
+ return .{ .role = .assistant, .content = content };
+}
+
+fn freeMsgs(alloc: Allocator, msgs: []Message) void {
+ for (msgs) |*m| m.deinit(alloc);
+}
+
+test "countWordsIn - basic word counting" {
+ try testing.expectEqual(@as(u64, 0), countWordsIn(""));
+ try testing.expectEqual(@as(u64, 0), countWordsIn(" \n\t "));
+ try testing.expectEqual(@as(u64, 1), countWordsIn("hello"));
+ try testing.expectEqual(@as(u64, 3), countWordsIn(" hello there world "));
+}
+
+test "messageTokenEstimate - usage wins when present" {
+ const a = testing.allocator;
+ var m = try userMsg(a, "one two three");
+ defer m.deinit(a);
+ const u: Usage = .{ .input = 100, .output = 7, .cache_read = 3 };
+ try testing.expectEqual(@as(u64, 110), messageTokenEstimate(m, u));
+}
+
+test "messageTokenEstimate - word-count fallback when usage absent" {
+ const a = testing.allocator;
+ var m = try userMsg(a, "one two three four"); // 4 words * 1.3 = 5.2 -> ceil 6
+ defer m.deinit(a);
+ try testing.expectEqual(@as(u64, 6), messageTokenEstimate(m, null));
+}
+
+test "computeSplit - everything fits keeps all turns, summarizes nothing" {
+ const a = testing.allocator;
+ var msgs = [_]Message{
+ try userMsg(a, "q1"),
+ try asstMsg(a, "a1"),
+ try userMsg(a, "q2"),
+ try asstMsg(a, "a2"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ const split = computeSplit(&msgs, null, 1_000_000);
+ try testing.expectEqual(@as(usize, 0), split.prefix_end);
+ try testing.expectEqual(@as(usize, 2), split.kept_turns);
+}
+
+test "computeSplit - tiny budget summarizes all but the last turn" {
+ const a = testing.allocator;
+ // Each message ~ a few words; usage forces deterministic sizes.
+ var msgs = [_]Message{
+ try userMsg(a, "q1"),
+ try asstMsg(a, "a1"),
+ try userMsg(a, "q2"),
+ try asstMsg(a, "a2"),
+ try userMsg(a, "q3"),
+ try asstMsg(a, "a3"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ // Each turn contributes ~200 incremental tokens (100 user + 100
+ // output). Provider `input` is cumulative: it is the whole prior
+ // conversation as of that assistant turn. So the assistant inputs grow
+ // 100, 300, 500 and the per-turn delta is a steady 200. Budget 250
+ // keeps only the last turn (200 <= 250); adding the prior turn (400)
+ // exceeds it.
+ const usages = [_]?Usage{
+ .{ .input = 0 }, .{ .input = 100, .output = 100 },
+ .{ .input = 0 }, .{ .input = 300, .output = 100 },
+ .{ .input = 0 }, .{ .input = 500, .output = 100 },
+ };
+ const split = computeSplit(&msgs, &usages, 250);
+ // Kept suffix = turn starting at index 4 (q3/a3).
+ try testing.expectEqual(@as(usize, 4), split.prefix_end);
+ try testing.expectEqual(@as(usize, 1), split.kept_turns);
+}
+
+test "computeSplit - crossing turn goes into the summary (upper-bound semantics)" {
+ const a = testing.allocator;
+ var msgs = [_]Message{
+ try userMsg(a, "q1"),
+ try asstMsg(a, "a1"),
+ try userMsg(a, "q2"),
+ try asstMsg(a, "a2"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ // 200 incremental tokens per turn via cumulative inputs (100, 300).
+ // Budget exactly 200: last turn (delta 200) does NOT exceed 200, so
+ // it's kept. Adding turn 1 => 400 > 200, so turn 1 summarized.
+ const usages = [_]?Usage{
+ .{ .input = 0 }, .{ .input = 100, .output = 100 },
+ .{ .input = 0 }, .{ .input = 300, .output = 100 },
+ };
+ const split = computeSplit(&msgs, &usages, 200);
+ try testing.expectEqual(@as(usize, 2), split.prefix_end);
+ try testing.expectEqual(@as(usize, 1), split.kept_turns);
+}
+
+test "computeSplit - tool-result user messages attach to the current turn" {
+ const a = testing.allocator;
+
+ // Turn 1: user q1 -> assistant(tool_use) -> user(tool_result) -> assistant a1
+ var tr_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try tr_content.append(a, .{ .ToolResult = .{
+ .tool_use_id = try a.dupe(u8, "t1"),
+ .content = try conversation.textualBlockFromSlice(a, "result"),
+ } });
+
+ var msgs = [_]Message{
+ try userMsg(a, "q1"),
+ try asstMsg(a, "calling tool"),
+ .{ .role = .user, .content = tr_content },
+ try asstMsg(a, "a1"),
+ try userMsg(a, "q2"),
+ try asstMsg(a, "a2"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ // Budget small enough to keep only the last turn. The tool-result
+ // user message must NOT be mistaken for a turn boundary. Turn 1's
+ // final assistant (index 3) carries the cumulative input; the inner
+ // tool-round assistant (index 1) is subsumed by it. Turn 1 delta =
+ // 300; turn 2 delta = cumAt(a2) - cumAt(a1) = 500 - 300 = 200.
+ const usages = [_]?Usage{
+ .{ .input = 0 }, .{ .input = 100, .output = 50 }, .{ .input = 0 }, .{ .input = 200, .output = 100 },
+ .{ .input = 0 }, .{ .input = 400, .output = 100 },
+ };
+ const split = computeSplit(&msgs, &usages, 250);
+ // Kept suffix = q2/a2 turn at index 4. The whole 4-message first turn
+ // is summarized.
+ try testing.expectEqual(@as(usize, 4), split.prefix_end);
+ try testing.expectEqual(@as(usize, 1), split.kept_turns);
+}
+
+test "computeSplit - active window starts after an existing compaction summary" {
+ const a = testing.allocator;
+ var msgs = [_]Message{
+ try userMsg(a, "old q"),
+ try asstMsg(a, "old a"),
+ undefined, // compaction summary, filled below
+ try userMsg(a, "new q"),
+ try asstMsg(a, "new a"),
+ };
+ var cs_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try cs_content.append(a, .{ .CompactionSummary = .{
+ .text = try conversation.textualBlockFromSlice(a, "S1"),
+ } });
+ msgs[2] = .{ .role = .user, .content = cs_content };
+ defer freeMsgs(a, &msgs);
+
+ // Large budget: the whole *active* window (new q / new a) fits, so
+ // nothing new is summarized. prefix_end points at first active turn.
+ const split = computeSplit(&msgs, null, 1_000_000);
+ try testing.expectEqual(@as(usize, 3), split.prefix_end);
+ try testing.expectEqual(@as(usize, 1), split.kept_turns);
+}
+
+test "computeSplit - leading system messages are not turns" {
+ const a = testing.allocator;
+ var sys_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try sys_content.append(a, .{ .System = .{
+ .text = try conversation.textualBlockFromSlice(a, "you are helpful"),
+ .mode = .append,
+ } });
+ var msgs = [_]Message{
+ .{ .role = .system, .content = sys_content },
+ try userMsg(a, "q1"),
+ try asstMsg(a, "a1"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ const split = computeSplit(&msgs, null, 1_000_000);
+ // First turn is index 1 (after the system message).
+ try testing.expectEqual(@as(usize, 1), split.prefix_end);
+ try testing.expectEqual(@as(usize, 1), split.kept_turns);
+}
+
+test "computeSplit - regression: real cumulative session overflows keep_verbatim" {
+ // Reproduces a real session whose final assistant reported
+ // usage.input=28098, usage.output=5477 (total context 33,575). Earlier
+ // code treated cumulative `input` as a per-message cost and so either
+ // double-counted or (when usages were absent) word-counted below the
+ // budget, yielding a spurious "nothing to compact". With the
+ // turn-delta model this conversation must split at keep_verbatim=20000.
+ const a = testing.allocator;
+ // 12 user/assistant turns. User messages carry null usage; assistant
+ // messages carry the cumulative inputs observed on disk.
+ var msgs = [_]Message{
+ try userMsg(a, "q1"), try asstMsg(a, "a1"),
+ try userMsg(a, "q2"), try asstMsg(a, "a2"),
+ try userMsg(a, "q3"), try asstMsg(a, "a3"),
+ try userMsg(a, "q4"), try asstMsg(a, "a4"),
+ try userMsg(a, "q5"), try asstMsg(a, "a5"),
+ try userMsg(a, "q6"), try asstMsg(a, "a6"),
+ try userMsg(a, "q7"), try asstMsg(a, "a7"),
+ try userMsg(a, "q8"), try asstMsg(a, "a8"),
+ try userMsg(a, "q9"), try asstMsg(a, "a9"),
+ try userMsg(a, "q10"), try asstMsg(a, "a10"),
+ try userMsg(a, "q11"), try asstMsg(a, "a11"),
+ try userMsg(a, "q12"), try asstMsg(a, "a12"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ const U = struct {
+ fn mk(input: u64, output: u64) ?Usage {
+ return .{ .input = input, .output = output };
+ }
+ };
+ const usages = [_]?Usage{
+ null, U.mk(1286, 118),
+ null, U.mk(2167, 62),
+ null, U.mk(2352, 146),
+ null, U.mk(9710, 153),
+ null, U.mk(10233, 180),
+ null, U.mk(12562, 127),
+ null, U.mk(13087, 110),
+ null, U.mk(14153, 68),
+ null, U.mk(16794, 838),
+ null, U.mk(17666, 228),
+ null, U.mk(24762, 203),
+ null, U.mk(28098, 5477),
+ };
+
+ const split = computeSplit(&msgs, &usages, 20_000);
+ // Something must be summarized: the split cannot sit at/before the
+ // first active turn (index 0).
+ try testing.expect(split.prefix_end > 0);
+ // The total context (33,575) exceeds the 20k budget, so at least the
+ // oldest turn(s) are shed and fewer than all 12 turns are kept.
+ try testing.expect(split.kept_turns < 12);
+}
+
+test "serializeTranscript - labels roles and skips system" {
+ const a = testing.allocator;
+ var sys_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try sys_content.append(a, .{ .System = .{
+ .text = try conversation.textualBlockFromSlice(a, "sys prompt"),
+ .mode = .append,
+ } });
+ var msgs = [_]Message{
+ .{ .role = .system, .content = sys_content },
+ try userMsg(a, "hello"),
+ try asstMsg(a, "hi there"),
+ };
+ defer freeMsgs(a, &msgs);
+
+ const t = try serializeTranscript(a, &msgs);
+ defer a.free(t);
+ try testing.expect(std.mem.indexOf(u8, t, "sys prompt") == null);
+ try testing.expect(std.mem.indexOf(u8, t, "[User]: hello") != null);
+ try testing.expect(std.mem.indexOf(u8, t, "[Assistant]: hi there") != null);
+}
+
+test "serializeTranscript - tool call and result framing" {
+ const a = testing.allocator;
+ var tu_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try tu_content.append(a, .{ .ToolUse = .{
+ .id = try a.dupe(u8, "tc1"),
+ .name = try a.dupe(u8, "bash"),
+ .input = try conversation.textualBlockFromSlice(a, "{\"cmd\":\"ls\"}"),
+ } });
+ var tr_content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try tr_content.append(a, .{ .ToolResult = .{
+ .tool_use_id = try a.dupe(u8, "tc1"),
+ .content = try conversation.textualBlockFromSlice(a, "file.txt"),
+ } });
+ var msgs = [_]Message{
+ .{ .role = .assistant, .content = tu_content },
+ .{ .role = .user, .content = tr_content },
+ };
+ defer freeMsgs(a, &msgs);
+
+ const t = try serializeTranscript(a, &msgs);
+ defer a.free(t);
+ try testing.expect(std.mem.indexOf(u8, t, "[Assistant tool call: bash (tc1)]: {\"cmd\":\"ls\"}") != null);
+ try testing.expect(std.mem.indexOf(u8, t, "[Tool result (tc1)]: file.txt") != null);
+}
+
+test "buildRequestBody - without previous summary" {
+ const a = testing.allocator;
+ const body = try buildRequestBody(a, "TRANSCRIPT", null);
+ defer a.free(body);
+ try testing.expect(std.mem.indexOf(u8, body, "previous-summary") == null);
+ try testing.expect(std.mem.indexOf(u8, body, "<transcript>\nTRANSCRIPT\n</transcript>") != null);
+}
+
+test "buildRequestBody - with previous summary" {
+ const a = testing.allocator;
+ const body = try buildRequestBody(a, "TRANSCRIPT", "PRIOR");
+ defer a.free(body);
+ try testing.expect(std.mem.indexOf(u8, body, "<previous-summary>\nPRIOR\n</previous-summary>") != null);
+ try testing.expect(std.mem.indexOf(u8, body, "<transcript>\nTRANSCRIPT\n</transcript>") != null);
+}
+
+test "latestSummaryText - returns latest, null when none" {
+ const a = testing.allocator;
+ {
+ var msgs = [_]Message{ try userMsg(a, "hi") };
+ defer freeMsgs(a, &msgs);
+ try testing.expect(latestSummaryText(&msgs) == null);
+ }
+ {
+ var cs: std.ArrayList(conversation.ContentBlock) = .empty;
+ try cs.append(a, .{ .CompactionSummary = .{
+ .text = try conversation.textualBlockFromSlice(a, "S2"),
+ } });
+ var msgs = [_]Message{
+ try userMsg(a, "hi"),
+ .{ .role = .user, .content = cs },
+ };
+ defer freeMsgs(a, &msgs);
+ try testing.expectEqualStrings("S2", latestSummaryText(&msgs).?);
+ }
+}