1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
|
//! 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);
// The summary message itself anchors the window; active turns begin after it.
const active_start = if (conversation.latestCompactionIndex(messages)) |anchor| anchor + 1 else 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: <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| {
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 `<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);
}
/// 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, "<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).?);
}
}
|