返回 DeepSeek-Reasonix
compact_test.go
根目录 / internal / agent / compact_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reasonix/internal/event"
9 "strings"
10 "testing"
11
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 // fakeProvider returns a fixed reply and records the messages it was asked to
17 // complete, so tests can drive summarization without a network call.
18 type fakeProvider struct {
19 reply string
20 promptTokens int
21 got []provider.Message
22 streamErr error // when set, Stream emits a ChunkError instead of the reply
23 hang bool // when true, Stream returns a channel that never sends or closes
24 }
25
26 func (f *fakeProvider) Name() string { return "fake" }
27
28 func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
29 f.got = req.Messages
30 if f.hang {
31 return make(chan provider.Chunk), nil
32 }
33 ch := make(chan provider.Chunk, 3)
34 if f.streamErr != nil {
35 ch <- provider.Chunk{Type: provider.ChunkError, Err: f.streamErr}
36 close(ch)
37 return ch, nil
38 }
39 ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply}
40 if f.promptTokens > 0 {
41 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: f.promptTokens, TotalTokens: f.promptTokens}}
42 }
43 ch <- provider.Chunk{Type: provider.ChunkDone}
44 close(ch)
45 return ch, nil
46 }
47
48 func TestTailStart(t *testing.T) {
49 // 10-char content → with tokPerChar 1.0, each non-empty message costs 10
50 // "tokens"; tool-call messages carry name+args instead.
51 msg := func(role provider.Role, n int) provider.Message {
52 return provider.Message{Role: role, Content: strings.Repeat("x", n)}
53 }
54 u := func(n int) provider.Message { return msg(provider.RoleUser, n) }
55 as := func(n int) provider.Message { return msg(provider.RoleAssistant, n) }
56 ac := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "f", Arguments: "{}"}}}
57 to := func(n int) provider.Message {
58 return provider.Message{Role: provider.RoleTool, ToolCallID: "1", Name: "f", Content: strings.Repeat("x", n)}
59 }
60
61 sys := provider.Message{Role: provider.RoleSystem}
62 cases := []struct {
63 name string
64 msgs []provider.Message
65 head int
66 budget int
67 minKeep int
68 wantStr int
69 }{
70 // Budget 25 fits the two newest 10-char messages (20) but not a third (30);
71 // the tail stops at the third-from-last.
72 {"budget-bounds-tail", []provider.Message{u(10), as(10), u(10), as(10), u(10)}, 0, 25, 2, 3},
73 // A single huge recent message can't blow the budget below minKeep: the last
74 // two are kept regardless.
75 {"min-keep-floor", []provider.Message{u(10), as(10), u(10), as(10), to(9999)}, 0, 25, 2, 3},
76 // The boundary lands on an orphan tool result and must move back onto its
77 // assistant so the tail begins with the tool_calls.
78 {"align-off-tool", []provider.Message{sys, u(10), ac, to(10), ac, to(10)}, 1, 0, 1, 4},
79 // A generous budget keeps everything down to the first compactable message
80 // after the head.
81 {"budget-keeps-all", []provider.Message{sys, u(10), as(10), u(10)}, 1, 100000, 2, 2},
82 }
83
84 for _, tc := range cases {
85 t.Run(tc.name, func(t *testing.T) {
86 start := tailStart(tc.msgs, tc.head, tc.budget, 1.0, tc.minKeep)
87 if start != tc.wantStr {
88 t.Errorf("start = %d, want %d", start, tc.wantStr)
89 }
90 if tc.msgs[start].Role == provider.RoleTool {
91 t.Errorf("recent tail begins with orphan tool message at %d", start)
92 }
93 })
94 }
95 }
96
97 func TestTailStartSmallSession(t *testing.T) {
98 sys := provider.Message{Role: provider.RoleSystem}
99 usr := provider.Message{Role: provider.RoleUser, Content: "hi"}
100 for i, msgs := range [][]provider.Message{
101 {sys, usr}, // system + one message: nothing fits the tail; must not index msgs[len]
102 {sys},
103 {usr},
104 {},
105 } {
106 head := 0
107 if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem {
108 head = 1
109 }
110 start := tailStart(msgs, head, 16384, 0.25, 2)
111 if start < head || start > len(msgs) {
112 t.Errorf("case %d: start=%d out of bounds [%d,%d]", i, start, head, len(msgs))
113 }
114 }
115 }
116
117 func TestPinnedPrefixLen(t *testing.T) {
118 sys := provider.Message{Role: provider.RoleSystem}
119 small := provider.Message{Role: provider.RoleUser, Content: "do X with token T"}
120 big := provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 100000)}
121 sum := provider.Message{Role: provider.RoleUser, Content: summaryTagOpen + "\ndigest\n" + summaryTagClose}
122 as := provider.Message{Role: provider.RoleAssistant, Content: "a"}
123
124 newA := func(win int) *Agent {
125 return New(&fakeProvider{}, tool.NewRegistry(), &Session{}, Options{ContextWindow: win}, event.Discard)
126 }
127 cases := []struct {
128 name string
129 win int
130 msgs []provider.Message
131 want int
132 }{
133 {"pins-system-and-small-task", 0, []provider.Message{sys, small, as, as}, 2},
134 {"also-pins-prior-summaries", 0, []provider.Message{sys, small, sum, sum, as}, 4},
135 {"large-first-turn-stays-foldable", 0, []provider.Message{sys, big, as, as}, 1},
136 {"tiny-window-wont-pin", 10, []provider.Message{sys, small, as, as}, 1},
137 {"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 2},
138 }
139 for _, tc := range cases {
140 t.Run(tc.name, func(t *testing.T) {
141 if got := newA(tc.win).pinnedPrefixLen(tc.msgs); got != tc.want {
142 t.Errorf("pinnedPrefixLen = %d, want %d", got, tc.want)
143 }
144 })
145 }
146 }
147
148 func TestCompactKeepsMidSessionUserTurns(t *testing.T) {
149 big := strings.Repeat("work output ", 100)
150 sess := &Session{Messages: []provider.Message{
151 {Role: provider.RoleSystem, Content: "sys"},
152 {Role: provider.RoleUser, Content: "first task"},
153 {Role: provider.RoleAssistant, Content: big},
154 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big},
155 {Role: provider.RoleUser, Content: "by the way, always use pnpm not npm"},
156 {Role: provider.RoleAssistant, Content: big},
157 {Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big},
158 {Role: provider.RoleUser, Content: "next"},
159 {Role: provider.RoleAssistant, Content: "ok"},
160 }}
161 a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess,
162 Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
163
164 if err := a.compact(context.Background(), "manual", "", true); err != nil {
165 t.Fatalf("compact: %v", err)
166 }
167
168 // Both the pinned first turn and the mid-session fact survive verbatim — not as
169 // summary text — while the assistant/tool work between them is folded.
170 var pinnedFirst, keptMid bool
171 for _, m := range sess.Snapshot() {
172 if isCompactionSummary(m) {
173 continue
174 }
175 if m.Role == provider.RoleUser && m.Content == "first task" {
176 pinnedFirst = true
177 }
178 if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") {
179 keptMid = true
180 }
181 }
182 if !pinnedFirst || !keptMid {
183 t.Fatalf("user turns not kept verbatim (first=%v mid=%v): %+v", pinnedFirst, keptMid, sess.Snapshot())
184 }
185 if strings.Contains(strings.Join(snapshotContents(sess), " "), big) {
186 t.Errorf("assistant/tool work was not folded")
187 }
188 }
189
190 func snapshotContents(s *Session) []string {
191 msgs := s.Snapshot()
192 out := make([]string, len(msgs))
193 for i, m := range msgs {
194 out[i] = m.Content
195 }
196 return out
197 }
198
199 func TestRunCompactsAfterFinalAnswer(t *testing.T) {
200 // A turn that ends with a final answer (no trailing tool batch) must still
201 // compact when the context is over the trigger; otherwise a large context
202 // carries into the next turn un-folded and overflows the model window.
203 big := strings.Repeat("old work ", 200)
204 sess := &Session{Messages: []provider.Message{
205 {Role: provider.RoleSystem, Content: "sys"},
206 {Role: provider.RoleUser, Content: "task"},
207 {Role: provider.RoleAssistant, Content: big},
208 {Role: provider.RoleAssistant, Content: big},
209 }}
210 a := New(&fakeProvider{reply: "done", promptTokens: 95}, tool.NewRegistry(), sess,
211 Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
212
213 if err := a.Run(context.Background(), "what's the status?"); err != nil {
214 t.Fatalf("run: %v", err)
215 }
216 if got := sess.RewriteVersion(); got != 1 {
217 t.Fatalf("final-answer turn over the trigger did not compact: rewrite version = %d, want 1", got)
218 }
219 }
220
221 func TestCompactKeepsPriorDigests(t *testing.T) {
222 // A prior digest anywhere in the folded region is kept verbatim, not
223 // re-summarized — so a fact it already captured is not lost to re-fold drift.
224 priorDigest := summaryTagOpen + "\n## Standing facts\n- db is orion_prod_42\n" + summaryTagClose
225 big := strings.Repeat("work output ", 200)
226 sess := &Session{Messages: []provider.Message{
227 {Role: provider.RoleSystem, Content: "sys"},
228 {Role: provider.RoleUser, Content: "task"},
229 {Role: provider.RoleAssistant, Content: big}, // breaks leading-summary contiguity
230 {Role: provider.RoleUser, Content: priorDigest},
231 {Role: provider.RoleAssistant, Content: big},
232 {Role: provider.RoleUser, Content: "next"},
233 {Role: provider.RoleAssistant, Content: "ok"},
234 }}
235 a := New(&fakeProvider{reply: "new digest"}, tool.NewRegistry(), sess,
236 Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
237
238 if err := a.compact(context.Background(), "manual", "", true); err != nil {
239 t.Fatalf("compact: %v", err)
240 }
241
242 // The fake summarizer returns "new digest" (no fact); the prior fact survives
243 // only because the prior digest was kept verbatim rather than re-folded.
244 var kept bool
245 for _, m := range sess.Snapshot() {
246 if strings.Contains(m.Content, "orion_prod_42") {
247 kept = true
248 }
249 }
250 if !kept {
251 t.Fatalf("prior digest re-summarized away: %+v", sess.Snapshot())
252 }
253 }
254
255 func TestCompactReplacesHistory(t *testing.T) {
256 prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"}
257 bigStep := strings.Repeat("important implementation detail ", 80)
258 sess := &Session{Messages: []provider.Message{
259 {Role: provider.RoleSystem, Content: "sys"},
260 {Role: provider.RoleUser, Content: "task " + bigStep},
261 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
262 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"},
263 {Role: provider.RoleAssistant, Content: "did a step"},
264 {Role: provider.RoleUser, Content: "next"},
265 {Role: provider.RoleAssistant, Content: "ok"},
266 }}
267 dir := t.TempDir()
268 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, event.Discard)
269
270 if err := a.compact(context.Background(), "manual", "", true); err != nil {
271 t.Fatalf("compact: %v", err)
272 }
273 if got := sess.RewriteVersion(); got != 1 {
274 t.Fatalf("rewrite version = %d, want 1", got)
275 }
276
277 // system + pinned first user turn + summary + last 2 verbatim.
278 if got := len(sess.Messages); got != 5 {
279 t.Fatalf("len = %d, want 5: %+v", got, sess.Messages)
280 }
281 if sess.Messages[0].Role != provider.RoleSystem {
282 t.Errorf("message 0 = %s, want system", sess.Messages[0].Role)
283 }
284 if task := sess.Messages[1]; task.Role != provider.RoleUser || !strings.HasPrefix(task.Content, "task ") {
285 t.Errorf("first user turn not pinned verbatim: %+v", task)
286 }
287 summary := sess.Messages[2]
288 if summary.Role != provider.RoleUser || !strings.Contains(summary.Content, "Summary of earlier") || !strings.Contains(summary.Content, "do X") {
289 t.Errorf("summary message = %+v", summary)
290 }
291 if sess.Messages[3].Content != "next" || sess.Messages[4].Content != "ok" {
292 t.Errorf("recent tail not preserved: %+v", sess.Messages[3:])
293 }
294
295 // The 3 dropped originals were archived, one JSON object per line (the task
296 // turn is pinned, not folded, so it is not among them).
297 entries, err := os.ReadDir(dir)
298 if err != nil || len(entries) != 1 {
299 t.Fatalf("archive dir: entries=%d err=%v", len(entries), err)
300 }
301 data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
302 if err != nil {
303 t.Fatalf("read archive: %v", err)
304 }
305 if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines != 3 {
306 t.Errorf("archived %d lines, want 3:\n%s", lines, data)
307 }
308 if !strings.HasSuffix(entries[0].Name(), ".jsonl") {
309 t.Errorf("archive name = %q, want .jsonl", entries[0].Name())
310 }
311 }
312
313 func TestCompactKeepsErrorMessages(t *testing.T) {
314 prov := &fakeProvider{reply: "- normal work summarized"}
315 sess := &Session{Messages: []provider.Message{
316 {Role: provider.RoleSystem, Content: "sys"},
317 {Role: provider.RoleUser, Content: "task"},
318 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}},
319 {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"},
320 {Role: provider.RoleUser, Content: "continue"},
321 {Role: provider.RoleAssistant, Content: "continued"},
322 {Role: provider.RoleUser, Content: "next"},
323 {Role: provider.RoleAssistant, Content: "ok"},
324 }}
325 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepErrors}, event.Discard)
326
327 if err := a.compact(context.Background(), "manual", "", true); err != nil {
328 t.Fatalf("compact: %v", err)
329 }
330 if len(sess.Messages) < 6 {
331 t.Fatalf("session unexpectedly short after compact: %+v", sess.Messages)
332 }
333 if sess.Messages[2].Role != provider.RoleAssistant || len(sess.Messages[2].ToolCalls) != 1 {
334 t.Fatalf("kept error lost its assistant tool call: %+v", sess.Messages)
335 }
336 if sess.Messages[3].Role != provider.RoleTool || sess.Messages[3].Content != "error: command failed" {
337 t.Fatalf("error tool result not kept verbatim: %+v", sess.Messages)
338 }
339 if strings.Contains(prov.got[1].Content, "error: command failed") {
340 t.Fatalf("kept error was still folded into summary input:\n%s", prov.got[1].Content)
341 }
342 }
343
344 func TestKeepIndexesKeepsSiblingToolResultsForKeptError(t *testing.T) {
345 region := []provider.Message{
346 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
347 {ID: "err", Name: "bash", Arguments: `{"cmd":"bad"}`},
348 {ID: "ok", Name: "read_file", Arguments: `{"path":"main.go"}`},
349 }},
350 {Role: provider.RoleTool, ToolCallID: "err", Name: "bash", Content: "error: command failed"},
351 {Role: provider.RoleTool, ToolCallID: "ok", Name: "read_file", Content: "package main"},
352 }
353
354 keep := keepIndexes(region, KeepErrors)
355 for i, kept := range keep {
356 if !kept {
357 t.Fatalf("keep[%d] = false, want all sibling tool-call messages kept: %v", i, keep)
358 }
359 }
360 }
361
362 func TestKeepIndexesScopesPolicyAfterLatestSummary(t *testing.T) {
363 priorSummary := provider.Message{Role: provider.RoleUser, Content: summaryTagOpen + "\nprior digest\n" + summaryTagClose}
364 region := []provider.Message{
365 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: "bash", Arguments: `{}`}}},
366 {Role: provider.RoleTool, ToolCallID: "old", Name: "bash", Content: "error: old failure"},
367 priorSummary,
368 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "new", Name: "bash", Arguments: `{}`}}},
369 {Role: provider.RoleTool, ToolCallID: "new", Name: "bash", Content: "error: new failure"},
370 }
371
372 keep := keepIndexes(region, KeepErrors)
373 want := []bool{false, false, false, true, true}
374 for i := range want {
375 if keep[i] != want[i] {
376 t.Fatalf("keep = %v, want %v", keep, want)
377 }
378 }
379 }
380
381 func TestCompactKeepsUserMarkedMessages(t *testing.T) {
382 prov := &fakeProvider{reply: "- unmarked work summarized"}
383 marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 200)
384 sess := &Session{Messages: []provider.Message{
385 {Role: provider.RoleSystem, Content: "sys"},
386 {Role: provider.RoleUser, Content: "task"},
387 {Role: provider.RoleUser, Content: marked},
388 {Role: provider.RoleAssistant, Content: "worked"},
389 {Role: provider.RoleUser, Content: "next"},
390 {Role: provider.RoleAssistant, Content: "ok"},
391 }}
392 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepUserMarked}, event.Discard)
393
394 if err := a.compact(context.Background(), "manual", "", true); err != nil {
395 t.Fatalf("compact: %v", err)
396 }
397 var kept bool
398 for _, m := range sess.Messages {
399 if m.Content == marked {
400 kept = true
401 break
402 }
403 }
404 if !kept {
405 t.Fatalf("marked message not kept verbatim: %+v", sess.Messages)
406 }
407 if strings.Contains(prov.got[1].Content, "exact requirement") {
408 t.Fatalf("marked message was still folded into summary input:\n%s", prov.got[1].Content)
409 }
410 }
411
412 func TestKeepUserMarkedRequiresUserPrefixMarker(t *testing.T) {
413 region := []provider.Message{
414 {Role: provider.RoleAssistant, Content: "[keep] assistant output"},
415 {Role: provider.RoleUser, Content: "ordinary prose mentioning [keep] later"},
416 {Role: provider.RoleUser, Content: " <keep> exact requirement"},
417 }
418
419 keep := keepIndexes(region, KeepUserMarked)
420 want := []bool{false, false, true}
421 for i := range want {
422 if keep[i] != want[i] {
423 t.Fatalf("keep = %v, want %v", keep, want)
424 }
425 }
426 }
427
428 // TestCompactFallsBackToMechanicalFoldWhenSummaryFails: when the summarizer is
429 // unreachable, /compact must still free context (fold mechanically) and surface a
430 // card, not hang or abort leaving a full window.
431 func TestCompactFallsBackToMechanicalFoldWhenSummaryFails(t *testing.T) {
432 prov := &fakeProvider{streamErr: errors.New("provider down")}
433 sess := &Session{Messages: []provider.Message{
434 {Role: provider.RoleSystem, Content: "sys"},
435 {Role: provider.RoleUser, Content: "task"},
436 {Role: provider.RoleAssistant, Content: "step one"},
437 {Role: provider.RoleUser, Content: "more"},
438 {Role: provider.RoleAssistant, Content: "step two"},
439 {Role: provider.RoleUser, Content: "next"},
440 {Role: provider.RoleAssistant, Content: "ok"},
441 }}
442 var got []event.Event
443 sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
444 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, sink)
445
446 before := len(sess.Messages)
447 if err := a.compact(context.Background(), "manual", "", true); err != nil {
448 t.Fatalf("compact should fall back, not error: %v", err)
449 }
450 if len(sess.Messages) >= before {
451 t.Fatalf("session not compacted on summarizer failure: %d -> %d", before, len(sess.Messages))
452 }
453 var done *event.Compaction
454 for i := range got {
455 if got[i].Kind == event.CompactionDone {
456 done = &got[i].Compaction
457 }
458 }
459 if done == nil || !strings.Contains(done.Summary, "summary was unavailable") {
460 t.Fatalf("CompactionDone = %+v, want a mechanical-fold summary", done)
461 }
462 }
463
464 // TestSummarizeRespectsContextCancel: a stalled stream (open but never closing)
465 // must unblock on context cancellation instead of pinning compaction forever.
466 func TestSummarizeRespectsContextCancel(t *testing.T) {
467 a := New(&fakeProvider{hang: true}, tool.NewRegistry(), &Session{}, Options{}, event.Discard)
468 ctx, cancel := context.WithCancel(context.Background())
469 cancel()
470 if _, err := a.summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil {
471 t.Fatal("summarize must return when ctx is cancelled, not hang")
472 }
473 }
474
475 // TestCompactEmitsEvents covers the card-driving signals: a CompactionStarted
476 // (before the summarizer runs) then a CompactionDone carrying the trigger,
477 // message count, and summary — in that order.
478 func TestCompactEmitsEvents(t *testing.T) {
479 prov := &fakeProvider{reply: "- goal: do X"}
480 sess := &Session{Messages: []provider.Message{
481 {Role: provider.RoleSystem, Content: "sys"},
482 {Role: provider.RoleUser, Content: "task"},
483 {Role: provider.RoleAssistant, Content: "step one"},
484 {Role: provider.RoleUser, Content: "more"},
485 {Role: provider.RoleAssistant, Content: "step two"},
486 {Role: provider.RoleUser, Content: "next"},
487 {Role: provider.RoleAssistant, Content: "ok"},
488 }}
489 var got []event.Event
490 sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
491 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, sink)
492
493 if err := a.compact(context.Background(), "auto", "", true); err != nil {
494 t.Fatalf("compact: %v", err)
495 }
496
497 startedAt, doneAt := -1, -1
498 for i, e := range got {
499 switch e.Kind {
500 case event.CompactionStarted:
501 startedAt = i
502 if e.Compaction.Trigger != "auto" {
503 t.Errorf("started trigger = %q, want auto", e.Compaction.Trigger)
504 }
505 case event.CompactionDone:
506 doneAt = i
507 c := e.Compaction
508 if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "do X") {
509 t.Errorf("done event = %+v", c)
510 }
511 }
512 }
513 if startedAt < 0 {
514 t.Fatal("no CompactionStarted event emitted")
515 }
516 if doneAt < 0 {
517 t.Fatal("no CompactionDone event emitted")
518 }
519 if startedAt > doneAt {
520 t.Errorf("CompactionStarted (%d) must precede CompactionDone (%d)", startedAt, doneAt)
521 }
522 }
523
524 // TestCompactInjectsFocusAndPreCompactHook checks that /compact <focus> text and
525 // a PreCompact hook's output both reach the summarizer's system prompt.
526 func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) {
527 prov := &fakeProvider{reply: "- ok"}
528 sess := &Session{Messages: []provider.Message{
529 {Role: provider.RoleSystem, Content: "sys"},
530 {Role: provider.RoleUser, Content: "task"},
531 {Role: provider.RoleAssistant, Content: "step one"},
532 {Role: provider.RoleUser, Content: "more"},
533 {Role: provider.RoleAssistant, Content: "step two"},
534 {Role: provider.RoleUser, Content: "next"},
535 {Role: provider.RoleAssistant, Content: "ok"},
536 }}
537 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"}}, event.Discard)
538
539 if err := a.compact(context.Background(), "manual", "focus on the auth refactor", true); err != nil {
540 t.Fatalf("compact: %v", err)
541 }
542 if len(prov.got) == 0 || prov.got[0].Role != provider.RoleSystem {
543 t.Fatalf("summarizer wasn't asked with a system prompt: %+v", prov.got)
544 }
545 sys := prov.got[0].Content
546 if !strings.Contains(sys, "focus on the auth refactor") {
547 t.Errorf("summary system prompt missing the /compact focus text: %q", sys)
548 }
549 if !strings.Contains(sys, "KEEP-THE-MIGRATION-PLAN") {
550 t.Errorf("summary system prompt missing the PreCompact hook output: %q", sys)
551 }
552 }
553
554 func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) {
555 prov := &fakeProvider{reply: "- summary"}
556 sess := &Session{Messages: []provider.Message{
557 {Role: provider.RoleSystem, Content: "sys"},
558 {Role: provider.RoleUser, Content: "a"},
559 {Role: provider.RoleAssistant, Content: "b"},
560 {Role: provider.RoleUser, Content: "c"},
561 {Role: provider.RoleAssistant, Content: "d"},
562 {Role: provider.RoleUser, Content: "e"},
563 {Role: provider.RoleAssistant, Content: "f"},
564 }}
565 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
566 before := CaptureShape("sys", nil, sess.RewriteVersion())
567
568 if err := a.compact(context.Background(), "auto", "", true); err != nil {
569 t.Fatalf("compact: %v", err)
570 }
571
572 after := CaptureShape("sys", nil, sess.RewriteVersion())
573 reasons := sess.DrainContentRewriteReasons()
574 diag := CompareShape(before, after, &provider.Usage{CacheMissTokens: 10}, reasons)
575 if !diag.PrefixChanged {
576 t.Fatalf("diagnostics should report prefix change: %+v", diag)
577 }
578 if len(diag.PrefixChangeReasons) != 1 || diag.PrefixChangeReasons[0] != "compact_auto" {
579 t.Fatalf("change reasons = %v, want [compact_auto]", diag.PrefixChangeReasons)
580 }
581 }
582
583 func TestCompactFoldsSingleLargeMessage(t *testing.T) {
584 prov := &fakeProvider{reply: "- captured the large file contents"}
585 sess := &Session{Messages: []provider.Message{
586 {Role: provider.RoleSystem, Content: "sys"},
587 {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("large output line\n", 500)},
588 {Role: provider.RoleUser, Content: "next"},
589 {Role: provider.RoleAssistant, Content: "ok"},
590 }}
591 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
592
593 if err := a.compact(context.Background(), "auto", "", false); err != nil {
594 t.Fatalf("compact: %v", err)
595 }
596 if got := len(sess.Messages); got != 4 {
597 t.Fatalf("len = %d, want 4: %+v", got, sess.Messages)
598 }
599 if !strings.Contains(sess.Messages[1].Content, "large file contents") {
600 t.Fatalf("single large message was not summarized: %+v", sess.Messages)
601 }
602 if len(prov.got) == 0 || !strings.Contains(prov.got[1].Content, "large output line") {
603 t.Fatalf("summarizer did not receive the large message: %+v", prov.got)
604 }
605 }
606
607 func TestCompactSkipsSingleSmallMessage(t *testing.T) {
608 prov := &fakeProvider{reply: "- should not be called"}
609 sess := &Session{Messages: []provider.Message{
610 {Role: provider.RoleSystem, Content: "sys"},
611 {Role: provider.RoleUser, Content: "tiny"},
612 {Role: provider.RoleUser, Content: "next"},
613 {Role: provider.RoleAssistant, Content: "ok"},
614 }}
615 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
616
617 if err := a.compact(context.Background(), "auto", "", false); err != nil {
618 t.Fatalf("compact: %v", err)
619 }
620 if got := len(sess.Messages); got != 4 {
621 t.Fatalf("small single message should not compact, len = %d", got)
622 }
623 if len(prov.got) != 0 {
624 t.Fatalf("summarizer was called for tiny region: %+v", prov.got)
625 }
626 }
627
628 func TestMaybeCompactThreshold(t *testing.T) {
629 // A large early user message gives the fold real value; with a 100-token window
630 // the soft (50%), trigger (80%), and force (90%) thresholds are easy to hit.
631 newSess := func() *Session {
632 return &Session{Messages: []provider.Message{
633 {Role: provider.RoleSystem, Content: "sys"},
634 {Role: provider.RoleUser, Content: strings.Repeat("a ", 500)},
635 {Role: provider.RoleAssistant, Content: "b"},
636 {Role: provider.RoleUser, Content: "c"},
637 {Role: provider.RoleAssistant, Content: "d"},
638 {Role: provider.RoleUser, Content: "e"},
639 {Role: provider.RoleAssistant, Content: "f"},
640 }}
641 }
642
643 // Below 50% of the window: untouched.
644 sess := newSess()
645 a := New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
646 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 49})
647 if len(sess.Messages) != 7 {
648 t.Errorf("below threshold should not compact, len = %d", len(sess.Messages))
649 }
650
651 // At/above 50% only emits a soft notice; it does not rewrite the cache prefix.
652 sess = newSess()
653 prov := &fakeProvider{reply: "s"}
654 var notices []event.Event
655 a = New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.FuncSink(func(e event.Event) {
656 if e.Kind == event.Notice {
657 notices = append(notices, e)
658 }
659 }))
660 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 50})
661 if len(sess.Messages) != 7 {
662 t.Errorf("soft threshold should not compact, len = %d", len(sess.Messages))
663 }
664 if len(prov.got) != 0 {
665 t.Fatalf("soft threshold called summarizer: %+v", prov.got)
666 }
667 if len(notices) != 1 || notices[0].Text != "Context is getting large; preserving cache until cleanup is needed." || !strings.Contains(notices[0].Detail, "context reached 50%") {
668 t.Fatalf("soft threshold notice = %+v", notices)
669 }
670 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 60})
671 if len(notices) != 1 {
672 t.Fatalf("soft threshold notice should only emit once, got %d", len(notices))
673 }
674
675 // At/above 80%: compacts when the fold is economically worthwhile. The
676 // token-budgeted tail keeps the small recent messages, so the large early
677 // message is the only foldable region — folding it installs a summary at
678 // index 1 (the count is unchanged because one message becomes one summary).
679 sess = newSess()
680 a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
681 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80})
682 if !strings.Contains(sess.Messages[1].Content, "Summary of earlier") {
683 t.Errorf("compact threshold should fold the large early message, got: %+v", sess.Messages[1])
684 }
685
686 // No context window: compaction disabled.
687 sess = newSess()
688 a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
689 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 1 << 30})
690 if len(sess.Messages) != 7 {
691 t.Errorf("no window should disable compaction, len = %d", len(sess.Messages))
692 }
693 }
694
695 func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
696 sess := &Session{Messages: []provider.Message{
697 {Role: provider.RoleSystem, Content: "sys"},
698 {Role: provider.RoleUser, Content: "small old request"},
699 {Role: provider.RoleAssistant, Content: "small old answer"},
700 {Role: provider.RoleUser, Content: "next"},
701 {Role: provider.RoleAssistant, Content: "ok"},
702 }}
703 prov := &fakeProvider{reply: "forced summary"}
704 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
705
706 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 90})
707 // The first user turn is pinned (index 1) and the token-budgeted tail keeps
708 // next, ok, so only "small old answer" folds — force bypasses the economics
709 // skip and installs a summary at index 2, leaving the count at 5.
710 if got := len(sess.Messages); got != 5 {
711 t.Fatalf("len = %d, want 5 after forced single-message fold: %+v", got, sess.Messages)
712 }
713 if sess.Messages[1].Content != "small old request" {
714 t.Fatalf("first user turn not pinned verbatim: %+v", sess.Messages[1])
715 }
716 if !strings.Contains(sess.Messages[2].Content, "forced summary") {
717 t.Fatalf("forced compact did not install summary: %+v", sess.Messages)
718 }
719 if len(prov.got) == 0 {
720 t.Fatalf("summarizer was not called at force ceiling")
721 }
722 }
723
724 func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) {
725 sess := &Session{Messages: []provider.Message{
726 {Role: provider.RoleSystem, Content: "sys"},
727 {Role: provider.RoleUser, Content: "small old request"},
728 {Role: provider.RoleAssistant, Content: "small old answer"},
729 {Role: provider.RoleUser, Content: "next"},
730 {Role: provider.RoleAssistant, Content: "ok"},
731 }}
732 prov := &fakeProvider{reply: "should not summarize"}
733 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
734
735 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80})
736 if got := len(sess.Messages); got != 5 {
737 t.Fatalf("low-value region should not compact before force ceiling, len = %d", got)
738 }
739 if len(prov.got) != 0 {
740 t.Fatalf("summarizer was called for low-value non-forced region: %+v", prov.got)
741 }
742 }
743
744 func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) {
745 sess := &Session{Messages: []provider.Message{
746 {Role: provider.RoleSystem, Content: "sys"},
747 {Role: provider.RoleUser, Content: strings.Repeat("large prompt chunk ", 500)},
748 {Role: provider.RoleUser, Content: "next"},
749 {Role: provider.RoleAssistant, Content: "ok"},
750 }}
751 a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
752
753 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 80})
754 if got := len(sess.Messages); got != 4 {
755 t.Fatalf("len = %d, want 4: %+v", got, sess.Messages)
756 }
757 if !strings.Contains(sess.Messages[1].Content, "single large summary") {
758 t.Fatalf("single large message was not compacted at threshold: %+v", sess.Messages)
759 }
760 }
761
762 func TestRenderTranscriptRedactsToolCallArgs(t *testing.T) {
763 msgs := []provider.Message{
764 {Role: provider.RoleUser, Content: "Find me popular GitHub MCP projects"},
765 {
766 Role: provider.RoleAssistant,
767 Content: "I'll research that.",
768 ToolCalls: []provider.ToolCall{
769 {Name: "research", Arguments: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`},
770 },
771 },
772 {Role: provider.RoleTool, Name: "research", Content: "Found 5 projects."},
773 }
774
775 out := renderTranscript(msgs)
776
777 if strings.Contains(out, "Search for recently popular") {
778 t.Fatalf("renderTranscript leaked tool-call arguments into transcript:\n%s", out)
779 }
780 if !strings.Contains(out, "[assistant calls research]") {
781 t.Fatalf("renderTranscript missing tool-call label:\n%s", out)
782 }
783 if !strings.Contains(out, "task") {
784 t.Fatalf("renderTranscript missing key names:\n%s", out)
785 }
786 }
787
788 func TestInterruptedDisplayStaysVerbatimAndOutOfCompactionPrompt(t *testing.T) {
789 local := provider.Message{
790 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
791 LocalOnly: true, Content: "partial visible answer", ReasoningContent: "private partial reasoning",
792 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true},
793 }
794 a := &Agent{}
795 kept, fold := a.partitionFold([]provider.Message{local})
796 if len(kept) != 1 || !kept[0].LocalOnly || len(fold) != 0 {
797 t.Fatalf("compaction partition kept=%+v fold=%+v, want local display kept verbatim", kept, fold)
798 }
799 if transcript := renderTranscript([]provider.Message{local}); transcript != "" {
800 t.Fatalf("local interrupted output leaked into compaction prompt: %q", transcript)
801 }
802 }
803
804 func TestCompactKeepsActiveTurnVerbatim(t *testing.T) {
805 const currentCreatedAt int64 = 123456
806 call := provider.Message{
807 Role: provider.RoleAssistant,
808 ToolCalls: []provider.ToolCall{{
809 ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`,
810 }},
811 }
812 result := provider.Message{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"}
813 sess := &Session{Messages: []provider.Message{
814 {Role: provider.RoleSystem, Content: "sys"},
815 {Role: provider.RoleUser, Content: strings.Repeat("old request ", 200)},
816 {Role: provider.RoleAssistant, Content: strings.Repeat("old answer ", 200)},
817 {Role: provider.RoleUser, Content: "update a.txt", CreatedAt: currentCreatedAt},
818 call,
819 result,
820 }}
821 a := New(&fakeProvider{reply: "old work summary"}, tool.NewRegistry(), sess, Options{
822 ContextWindow: 100, RecentKeep: 1, ArchiveDir: t.TempDir(),
823 }, event.Discard)
824 a.activeTurnCreatedAt.Store(currentCreatedAt)
825
826 if err := a.compact(context.Background(), "auto", "", true); err != nil {
827 t.Fatalf("compact: %v", err)
828 }
829 start := a.activeTurnStart(sess.Messages)
830 if start < 0 || len(sess.Messages)-start != 3 {
831 t.Fatalf("active turn boundary = %d in %+v, want three-message verbatim tail", start, sess.Messages)
832 }
833 if sess.Messages[start].Content != "update a.txt" || sess.Messages[start+1].ToolCalls[0].Arguments != call.ToolCalls[0].Arguments || sess.Messages[start+2].Content != result.Content {
834 t.Fatalf("active turn changed during compaction: %+v", sess.Messages[start:])
835 }
836 }
837
838 func TestSummarizeFromPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
839 archiveDir := t.TempDir()
840 local := provider.Message{
841 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
842 LocalOnly: true, Content: "visible interrupted output", ReasoningContent: "private interrupted reasoning",
843 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true, InterruptedTools: []string{"bash"}},
844 }
845 prov := &fakeProvider{reply: "later summary"}
846 sess := &Session{Messages: []provider.Message{
847 {Role: provider.RoleSystem, Content: "sys"},
848 {Role: provider.RoleUser, Content: "task"},
849 local,
850 {Role: provider.RoleAssistant, Content: "safe answer"},
851 }}
852 a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard)
853
854 if err := a.SummarizeFrom(context.Background(), 1); err != nil {
855 t.Fatalf("SummarizeFrom: %v", err)
856 }
857 if len(sess.Messages) != 3 || !sess.Messages[2].LocalOnly || sess.Messages[2].Content != local.Content || sess.Messages[2].ReasoningContent != local.ReasoningContent || sess.Messages[2].InterruptedTurn == nil || !sess.Messages[2].InterruptedTurn.Pending {
858 t.Fatalf("local-only message was not preserved verbatim: %+v", sess.Messages)
859 }
860 assertLocalOnlyAbsentFromSummaryAndArchive(t, prov, archiveDir, local)
861 }
862
863 func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
864 archiveDir := t.TempDir()
865 local := provider.Message{
866 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
867 LocalOnly: true, Content: "visible earlier interruption", ReasoningContent: "private earlier reasoning",
868 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true, InterruptedTools: []string{"read_file"}},
869 }
870 prov := &fakeProvider{reply: "earlier summary"}
871 sess := &Session{Messages: []provider.Message{
872 {Role: provider.RoleSystem, Content: "sys"},
873 {Role: provider.RoleUser, Content: "old task"},
874 local,
875 {Role: provider.RoleAssistant, Content: "old answer"},
876 {Role: provider.RoleUser, Content: "new task"},
877 {Role: provider.RoleAssistant, Content: "new answer"},
878 }}
879 a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard)
880
881 if err := a.SummarizeUpTo(context.Background(), 4); err != nil {
882 t.Fatalf("SummarizeUpTo: %v", err)
883 }
884 if len(sess.Messages) != 5 || !sess.Messages[2].LocalOnly || sess.Messages[2].Content != local.Content || sess.Messages[2].ReasoningContent != local.ReasoningContent || sess.Messages[3].Content != "new task" {
885 t.Fatalf("local-only message/tail ordering was not preserved: %+v", sess.Messages)
886 }
887 assertLocalOnlyAbsentFromSummaryAndArchive(t, prov, archiveDir, local)
888 }
889
890 func assertLocalOnlyAbsentFromSummaryAndArchive(t *testing.T, prov *fakeProvider, archiveDir string, local provider.Message) {
891 t.Helper()
892 if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) || strings.Contains(prov.got[1].Content, local.ReasoningContent) {
893 t.Fatalf("local-only output leaked into summarizer prompt: %+v", prov.got)
894 }
895 entries, err := os.ReadDir(archiveDir)
896 if err != nil {
897 t.Fatalf("ReadDir archive: %v", err)
898 }
899 if len(entries) != 1 {
900 t.Fatalf("archive entries = %d, want 1", len(entries))
901 }
902 b, err := os.ReadFile(filepath.Join(archiveDir, entries[0].Name()))
903 if err != nil {
904 t.Fatalf("ReadFile archive: %v", err)
905 }
906 if strings.Contains(string(b), local.Content) || strings.Contains(string(b), local.ReasoningContent) {
907 t.Fatalf("local-only output leaked into archive: %s", b)
908 }
909 }
910
911 func TestSummarizeToolArgs(t *testing.T) {
912 tests := []struct {
913 name string
914 args string
915 want string
916 wantNot string
917 }{
918 {
919 name: "redacts long task prompt",
920 args: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`,
921 want: "task",
922 },
923 {
924 name: "empty args",
925 args: "",
926 want: "no arguments",
927 },
928 {
929 name: "invalid json",
930 args: "not json",
931 want: "bytes",
932 },
933 {
934 name: "multiple keys sorted",
935 args: `{"prompt":"do something","model":"gpt-4"}`,
936 want: "model, prompt",
937 },
938 }
939 for _, tt := range tests {
940 t.Run(tt.name, func(t *testing.T) {
941 got := summarizeToolArgs(tt.args)
942 if !strings.Contains(got, tt.want) {
943 t.Errorf("summarizeToolArgs(%q) = %q, want contains %q", tt.args, got, tt.want)
944 }
945 if tt.wantNot != "" && strings.Contains(got, tt.wantNot) {
946 t.Errorf("summarizeToolArgs(%q) = %q, should NOT contain %q", tt.args, got, tt.wantNot)
947 }
948 })
949 }
950 }
951
952 // TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger pins the documented
953 // contract that any turn under the compact trigger is "breathing room" that
954 // clears the stuck latch. The snip band ([snip, high)) is the regression: it
955 // returned before the reset ran, so a compaction that healthily settled the
956 // prompt at, say, 70% of the window left a stale consecutive-run count behind
957 // and the next compaction latched the session as "window too small" — silently
958 // disabling auto-compaction for the rest of the run.
959 func TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger(t *testing.T) {
960 // contextWindow 20000 => soft 10000, snip 12000, high (trigger) 16000.
961 for _, tc := range []struct {
962 name string
963 prompt int
964 }{
965 {"below soft", 8000},
966 {"soft band", 11000},
967 {"snip band", 14000},
968 } {
969 t.Run(tc.name, func(t *testing.T) {
970 sess := NewSession("sys")
971 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
972 a := New(&fakeProvider{reply: "- summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 20000}, event.Discard)
973 a.consecutiveCompacts = 1
974 a.compactStuck = true
975
976 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: tc.prompt})
977
978 if a.consecutiveCompacts != 0 || a.compactStuck {
979 t.Fatalf("prompt %d sits under the trigger; want the latch cleared, got consecutiveCompacts=%d compactStuck=%v",
980 tc.prompt, a.consecutiveCompacts, a.compactStuck)
981 }
982 })
983 }
984 }
985
986 // TestMaybeCompactStillLatchesWhenPromptStaysAboveTrigger proves the safety
987 // valve survives the fix above: a genuinely too-small window (the prompt never
988 // drops under the trigger between compactions) must still pause auto-compaction.
989 func TestMaybeCompactStillLatchesWhenPromptStaysAboveTrigger(t *testing.T) {
990 sess := NewSession("sys")
991 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
992 a := New(&fakeProvider{reply: "- summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 20000}, event.Discard)
993
994 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 17000})
995 if a.compactStuck {
996 t.Fatalf("a single over-trigger compaction must not latch: consecutiveCompacts=%d", a.consecutiveCompacts)
997 }
998 a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 17000})
999 if !a.compactStuck {
1000 t.Fatalf("two consecutive over-trigger compactions must still latch: consecutiveCompacts=%d", a.consecutiveCompacts)
1001 }
1002 }
1003
1003 lines GO