//! 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| { switch (block) { .Text => |b| total += countWordsIn(b.items), .Thinking => |b| total += countWordsIn(b.text.items), .ToolUse => |b| total += countWordsIn(b.input.items), .ToolResult => |b| { // Text parts count by words. Media parts (base64 image / // PDF blobs) would wildly distort the retention window if // counted by length, so each is charged a fixed estimate // (matching the pi reference impl's ~4800-char image cost, // divided back out to words via `word_to_token_factor`). for (b.parts.items) |p| switch (p) { .text => |t| total += countWordsIn(t.items), .media => total += media_part_word_estimate, }; }, .System => |b| total += countWordsIn(b.text.items), .CompactionSummary => |b| total += countWordsIn(b.text.items), } } return total; } /// Fixed per-media-part word estimate. The pi reference impl sizes each /// inline image at ~4800 characters for compaction token math; expressed /// as words (≈4800/6 chars-per-word) this lands near 800. const media_part_word_estimate: u64 = 800; 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(messages, i)) { 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 }; } /// Whether the message at `index` begins a new turn. /// /// A turn starts at a user message that is both: /// (a) not a tool-result message — a user message carrying any tool-result /// block continues the assistant's current tool round, and /// (b) the *first* user message after a non-user message (or the start of /// the conversation). /// /// Condition (b) collapses a run of consecutive plain user messages into a /// single turn: only the first is a boundary. Without it, each bare user /// message would be its own turn, and the ones with no trailing assistant /// would fall to the per-message word-count fallback and be counted a second /// time on top of the turn's cumulative-usage delta (which already includes /// them). System and assistant messages never start a turn; preceding system /// messages are transparent for the "after a non-user message" test. fn isTurnStart(messages: []const Message, index: usize) bool { const msg = messages[index]; if (msg.role != .user) return false; for (msg.content.items) |block| { if (block == .ToolResult) return false; } // First user message after a non-user message, or the start of the // active window. Structural frame messages are transparent here: leading // system prompts and the compaction-summary anchor (a synthetic `.user` // message) are not conversational user turns, so a real user message // following one still opens a turn. var j = index; while (j > 0) { j -= 1; if (isStructuralFrame(messages[j])) continue; return messages[j].role != .user; } return true; } /// Whether a message is part of the structural frame rather than a /// conversational turn: a system message, or the synthetic `.user` message /// carrying a compaction summary that anchors an active window. fn isStructuralFrame(msg: Message) bool { if (msg.role == .system) return true; for (msg.content.items) |block| { if (block == .CompactionSummary) return true; } return false; } // ============================================================================= // 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: ()]: /// [Tool result ()]: ... /// [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| { var body: conversation.TextualBlock = .empty; defer body.deinit(allocator); for (b.parts.items) |p| switch (p) { .text => |t| try body.appendSlice(allocator, t.items), .media => |m| { const note = try std.fmt.allocPrint(allocator, "[{s} attachment]", .{m.media_type}); defer allocator.free(note); try body.appendSlice(allocator, note); }, }; const line = try std.fmt.allocPrint(allocator, "[Tool result ({s})]: {s}", .{ b.tool_use_id, body.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 `` section, and the /// transcript of the prefix being summarized in a `` 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\n"); try out.appendSlice(allocator, prev); try out.appendSlice(allocator, "\n\n\nThe conversation since that previous compaction:\n\n\n"); } else { try out.appendSlice(allocator, "The conversation to summarize:\n\n\n"); } try out.appendSlice(allocator, transcript); try out.appendSlice(allocator, "\n"); 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); } /// Build a `.ToolResult` content block with a single text part. fn textToolResult(alloc: Allocator, id: []const u8, text: []const u8) !conversation.ContentBlock { var parts: std.ArrayList(conversation.ResultPartStored) = .empty; try parts.append(alloc, .{ .text = try conversation.textualBlockFromSlice(alloc, text) }); return .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, id), .parts = parts, } }; } 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, try textToolResult(a, "t1", "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 - consecutive user messages collapse into a single turn" { const a = testing.allocator; // Three plain user messages in a row open ONE turn, not three: only the // first follows a non-user message; the others are continuations. var msgs = [_]Message{ try userMsg(a, "a"), try userMsg(a, "b"), try userMsg(a, "c"), try asstMsg(a, "a1"), try userMsg(a, "q2"), try asstMsg(a, "a2"), }; defer freeMsgs(a, &msgs); // Whole conversation fits: nothing is summarized and the leading run of // user messages counts as a single turn, so kept_turns == 2 (not 4). { 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); } // With usage, the first turn's cumulative delta already accounts for // a+b+c+a1, so the per-message fallback must not charge a/b a second // time. Turn deltas: turn 1 = cumAt(a1) - 0 = 400; turn 2 = cumAt(a2) - // cumAt(a1) = 600 - 400 = 200. Budget 250 keeps only turn 2 and // summarizes the entire collapsed first turn, so the kept suffix begins // at q2 (index 4). { const usages = [_]?Usage{ .{ .input = 0 }, .{ .input = 0 }, .{ .input = 0 }, .{ .input = 300, .output = 100 }, .{ .input = 0 }, .{ .input = 500, .output = 100 }, }; const split = computeSplit(&msgs, &usages, 250); 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, try textToolResult(a, "tc1", "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, "\nTRANSCRIPT\n") != 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, "\nPRIOR\n") != null); try testing.expect(std.mem.indexOf(u8, body, "\nTRANSCRIPT\n") != 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).?); } }