返回 DeepSeek-Reasonix
compact_test.go
根目录 / internal / agent / compact_test.go
1 package agent
2
3 import (
4 "context"
5 "strings"
6 "testing"
7
8 "reasonix/internal/event"
9 "reasonix/internal/provider"
10 "reasonix/internal/tool"
11 )
12
13 // prepareForObservedUsage preserves the old synthetic-usage test ergonomics
14 // while production has only one mutating entry point: ContextManager.Prepare.
15 func prepareForObservedUsage(a *Agent, ctx context.Context, usage *provider.Usage) {
16 if a == nil || usage == nil || usage.LatestPromptTokens() <= 0 {
17 return
18 }
19 view := a.modelVisibleMessages()
20 a.setPromptTokenCalibration(usage.LatestPromptTokens(), a.requestCalibrationShape(provider.Request{Messages: view}))
21 _, _ = a.contextManager().Prepare(ctx, ContextPreparePolicy{
22 Trigger: CompactionTriggerPressure, ObservedInputTokens: usage.LatestPromptTokens(),
23 })
24 }
25
26 // fakeProvider returns a fixed reply and records the messages it was asked to
27 // complete, so tests can drive summarization without a network call.
28 type fakeProvider struct {
29 reply string
30 reasoningReply string // set (with empty reply) to emit ChunkReasoning: thinking-model shape
31 reasoningTool bool // with reasoningReply: also open a tool call, the shape that stays rejected
32 promptTokens int
33 got []provider.Message
34 streamErr error // when set, Stream emits a ChunkError instead of the reply
35 hang bool // when true, Stream returns a channel that never sends or closes
36 }
37
38 func (f *fakeProvider) Name() string { return "fake" }
39
40 func (f *fakeProvider) ContextBudgetPolicy() provider.ContextBudgetPolicy {
41 return provider.ContextBudgetPolicy{WindowMode: provider.ContextWindowIndependent}
42 }
43
44 func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
45 f.got = req.Messages
46 if f.hang {
47 return make(chan provider.Chunk), nil
48 }
49 ch := make(chan provider.Chunk, 3)
50 if f.streamErr != nil {
51 ch <- provider.Chunk{Type: provider.ChunkError, Err: f.streamErr}
52 close(ch)
53 return ch, nil
54 }
55 // Default stays byte-identical to the historical shape: always emit
56 // ChunkText (even empty). Only an explicit reasoningReply with no reply
57 // switches to the thinking-model reasoning-only shape.
58 if f.reply == "" && f.reasoningReply != "" {
59 ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: f.reasoningReply}
60 if f.reasoningTool {
61 ch <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file"}}
62 }
63 } else {
64 ch <- provider.Chunk{Type: provider.ChunkText, Text: f.reply}
65 }
66 if f.promptTokens > 0 {
67 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: f.promptTokens, TotalTokens: f.promptTokens}}
68 }
69 ch <- provider.Chunk{Type: provider.ChunkDone}
70 close(ch)
71 return ch, nil
72 }
73
74 // visibleContext returns the model-visible projection when present, else the
75 // canonical transcript. Compaction tests assert against this view.
76
77 func TestTailStart(t *testing.T) {
78 // 10-char content → with tokPerChar 1.0, each non-empty message costs 10
79 // "tokens"; tool-call messages carry name+args instead.
80 msg := func(role provider.Role, n int) provider.Message {
81 return provider.Message{Role: role, Content: strings.Repeat("x", n)}
82 }
83 u := func(n int) provider.Message { return msg(provider.RoleUser, n) }
84 as := func(n int) provider.Message { return msg(provider.RoleAssistant, n) }
85 ac := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "f", Arguments: "{}"}}}
86 to := func(n int) provider.Message {
87 return provider.Message{Role: provider.RoleTool, ToolCallID: "1", Name: "f", Content: strings.Repeat("x", n)}
88 }
89
90 sys := provider.Message{Role: provider.RoleSystem}
91 cases := []struct {
92 name string
93 msgs []provider.Message
94 head int
95 budget int
96 minKeep int
97 wantStr int
98 }{
99 // Budget 25 fits the two newest 10-char messages (20) but not a third (30);
100 // the tail stops at the third-from-last.
101 {"budget-bounds-tail", []provider.Message{u(10), as(10), u(10), as(10), u(10)}, 0, 25, 2, 3},
102 // A single huge recent message can't blow the budget below minKeep: the last
103 // two are kept regardless.
104 {"min-keep-floor", []provider.Message{u(10), as(10), u(10), as(10), to(9999)}, 0, 25, 2, 3},
105 // The boundary lands on an orphan tool result and must move back onto its
106 // assistant so the tail begins with the tool_calls.
107 {"align-off-tool", []provider.Message{sys, u(10), ac, to(10), ac, to(10)}, 1, 0, 1, 4},
108 // A generous budget keeps everything down to the first compactable message
109 // after the head.
110 {"budget-keeps-all", []provider.Message{sys, u(10), as(10), u(10)}, 1, 100000, 2, 2},
111 }
112
113 for _, tc := range cases {
114 t.Run(tc.name, func(t *testing.T) {
115 start := tailStart(tc.msgs, tc.head, tc.budget, 1.0, tc.minKeep)
116 if start != tc.wantStr {
117 t.Errorf("start = %d, want %d", start, tc.wantStr)
118 }
119 if tc.msgs[start].Role == provider.RoleTool {
120 t.Errorf("recent tail begins with orphan tool message at %d", start)
121 }
122 })
123 }
124 }
125
126 func TestTailStartSmallSession(t *testing.T) {
127 sys := provider.Message{Role: provider.RoleSystem}
128 usr := provider.Message{Role: provider.RoleUser, Content: "hi"}
129 for i, msgs := range [][]provider.Message{
130 {sys, usr}, // system + one message: nothing fits the tail; must not index msgs[len]
131 {sys},
132 {usr},
133 {},
134 } {
135 head := 0
136 if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem {
137 head = 1
138 }
139 start := tailStart(msgs, head, 16384, 0.25, 2)
140 if start < head || start > len(msgs) {
141 t.Errorf("case %d: start=%d out of bounds [%d,%d]", i, start, head, len(msgs))
142 }
143 }
144 }
145
146 func TestPinnedPrefixLen(t *testing.T) {
147 sys := provider.Message{Role: provider.RoleSystem}
148 small := provider.Message{Role: provider.RoleUser, Content: "do X with token T"}
149 big := provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 100000)}
150 sum := provider.Message{Role: provider.RoleUser, Content: summaryTagOpen + "\ndigest\n" + summaryTagClose}
151 as := provider.Message{Role: provider.RoleAssistant, Content: "a"}
152
153 newA := func(win int) *Agent {
154 return New(&fakeProvider{}, tool.NewRegistry(), &Session{}, Options{ContextWindow: win}, event.Discard)
155 }
156 cases := []struct {
157 name string
158 win int
159 msgs []provider.Message
160 want int
161 }{
162 {"pins-only-system-before-small-task", 0, []provider.Message{sys, small, as, as}, 1},
163 {"summaries-are-not-pinned-A1-merge", 0, []provider.Message{sys, small, sum, sum, as}, 1},
164 {"large-first-turn-stays-foldable", 0, []provider.Message{sys, big, as, as}, 1},
165 {"tiny-window-wont-pin", 10, []provider.Message{sys, small, as, as}, 1},
166 {"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 1},
167 }
168 for _, tc := range cases {
169 t.Run(tc.name, func(t *testing.T) {
170 if got := newA(tc.win).pinnedPrefixLen(tc.msgs); got != tc.want {
171 t.Errorf("pinnedPrefixLen = %d, want %d", got, tc.want)
172 }
173 })
174 }
175 }
176
177 // TestSummarizeRespectsContextCancel: a stalled stream (open but never closing)
178 // must unblock on context cancellation instead of pinning compaction forever.
179 func TestSummarizeRespectsContextCancel(t *testing.T) {
180 a := New(&fakeProvider{hang: true}, tool.NewRegistry(), &Session{}, Options{}, event.Discard)
181 ctx, cancel := context.WithCancel(context.Background())
182 cancel()
183 if _, _, err := a.summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil {
184 t.Fatal("summarize must return when ctx is cancelled, not hang")
185 }
186 }
187
188 // TestCompactEmitsEvents covers the card-driving signals: a CompactionStarted
189 // (before the summarizer runs) then a CompactionDone carrying the trigger,
190 // message count, and summary — in that order.
191 func TestCompactEmitsEvents(t *testing.T) {
192 prov := &fakeProvider{reply: "- goal: do X"}
193 sess := &Session{Messages: []provider.Message{
194 {Role: provider.RoleSystem, Content: "sys"},
195 {Role: provider.RoleUser, Content: "task"},
196 {Role: provider.RoleAssistant, Content: strings.Repeat("step one work ", 200)},
197 {Role: provider.RoleUser, Content: "more"},
198 {Role: provider.RoleAssistant, Content: strings.Repeat("step two work ", 200)},
199 {Role: provider.RoleUser, Content: "next"},
200 {Role: provider.RoleAssistant, Content: "ok"},
201 }}
202 var got []event.Event
203 sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
204 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 50_000, RecentKeep: 2}, sink)
205
206 if err := a.compact(context.Background(), "auto", "", true); err != nil {
207 t.Fatalf("compact: %v", err)
208 }
209
210 startedAt, doneAt := -1, -1
211 for i, e := range got {
212 switch e.Kind {
213 case event.CompactionStarted:
214 startedAt = i
215 if e.Compaction.Trigger != "auto" {
216 t.Errorf("started trigger = %q, want auto", e.Compaction.Trigger)
217 }
218 case event.CompactionDone:
219 doneAt = i
220 c := e.Compaction
221 if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "do X") {
222 t.Errorf("done event = %+v", c)
223 }
224 }
225 }
226 if startedAt < 0 {
227 t.Fatal("no CompactionStarted event emitted")
228 }
229 if doneAt < 0 {
230 t.Fatal("no CompactionDone event emitted")
231 }
232 if startedAt > doneAt {
233 t.Errorf("CompactionStarted (%d) must precede CompactionDone (%d)", startedAt, doneAt)
234 }
235 }
236
237 // TestCompactInjectsFocusAndPreCompactHook checks that /compact <focus> text and
238 // a PreCompact hook's output both reach the summarizer's system prompt.
239 func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) {
240 prov := &fakeProvider{reply: "- ok"}
241 big := strings.Repeat("step work detail ", 200)
242 sess := &Session{Messages: []provider.Message{
243 {Role: provider.RoleSystem, Content: "sys"},
244 {Role: provider.RoleUser, Content: "task"},
245 {Role: provider.RoleAssistant, Content: big},
246 {Role: provider.RoleUser, Content: "more"},
247 {Role: provider.RoleAssistant, Content: big},
248 {Role: provider.RoleUser, Content: "next"},
249 {Role: provider.RoleAssistant, Content: "ok"},
250 }}
251 a := New(prov, tool.NewRegistry(), sess, Options{
252 ContextWindow: 50_000, RecentKeep: 2,
253 Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"},
254 }, event.Discard)
255
256 if err := a.compact(context.Background(), "manual", "focus on the auth refactor", true); err != nil {
257 t.Fatalf("compact: %v", err)
258 }
259 if len(prov.got) == 0 || prov.got[0].Role != provider.RoleSystem {
260 t.Fatalf("summarizer wasn't asked with a system prompt: %+v", prov.got)
261 }
262 instruction := prov.got[len(prov.got)-1].Content
263 if !strings.Contains(instruction, "focus on the auth refactor") {
264 t.Errorf("final summary instruction missing the /compact focus text: %q", instruction)
265 }
266 if !strings.Contains(instruction, "KEEP-THE-MIGRATION-PLAN") {
267 t.Errorf("final summary instruction missing the PreCompact hook output: %q", instruction)
268 }
269 }
270
271 func TestCompactSkipsSingleSmallMessage(t *testing.T) {
272 prov := &fakeProvider{reply: "- should not be called"}
273 sess := &Session{Messages: []provider.Message{
274 {Role: provider.RoleSystem, Content: "sys"},
275 {Role: provider.RoleUser, Content: "tiny"},
276 {Role: provider.RoleUser, Content: "next"},
277 {Role: provider.RoleAssistant, Content: "ok"},
278 }}
279 a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
280
281 if err := a.compact(context.Background(), "auto", "", false); err != nil {
282 t.Fatalf("compact: %v", err)
283 }
284 if got := len(sess.Messages); got != 4 {
285 t.Fatalf("small single message should not compact, len = %d", got)
286 }
287 if len(prov.got) != 0 {
288 t.Fatalf("summarizer was called for tiny region: %+v", prov.got)
289 }
290 }
291
292 func TestMaybeCompactThreshold(t *testing.T) {
293 // compact_ratio is the sole trigger. Use a realistic window so hardInputCeiling
294 // (window−protocolReserve) stays above the fold trigger; tiny synthetic
295 // windows collapse hard to 1 and force every observation.
296 const window = 10_000
297 const ratio = 0.8 // fold trigger = 8000
298 newSess := func() *Session {
299 return &Session{Messages: []provider.Message{
300 {Role: provider.RoleSystem, Content: "sys"},
301 {Role: provider.RoleUser, Content: "task"},
302 {Role: provider.RoleAssistant, Content: strings.Repeat("a ", 5000)},
303 {Role: provider.RoleUser, Content: "c"},
304 {Role: provider.RoleAssistant, Content: strings.Repeat("b ", 5000)},
305 {Role: provider.RoleUser, Content: "e"},
306 {Role: provider.RoleAssistant, Content: "f"},
307 }}
308 }
309 opts := Options{ContextWindow: window, CompactRatio: ratio, RecentKeep: 2}
310
311 // Below compact_ratio: untouched, no summarizer call.
312 sess := newSess()
313 prov := &fakeProvider{reply: "s"}
314 a := New(prov, tool.NewRegistry(), sess, opts, event.Discard)
315 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 7000})
316 if len(sess.Messages) != 7 {
317 t.Errorf("below threshold should not compact, len = %d", len(sess.Messages))
318 }
319 if len(prov.got) != 0 {
320 t.Fatalf("below threshold called summarizer: %+v", prov.got)
321 }
322
323 // 60% is below the sole 80% trigger: still no maintenance.
324 sess = newSess()
325 prov = &fakeProvider{reply: "s"}
326 a = New(prov, tool.NewRegistry(), sess, opts, event.Discard)
327 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 6000})
328 if a.currentProjectionVersion() != 0 || len(prov.got) != 0 {
329 t.Fatalf("60%% should not maintain: version=%d calls=%d", a.currentProjectionVersion(), len(prov.got))
330 }
331
332 // At/above compact_ratio: one summary projection; canonical stays full.
333 sess = newSess()
334 a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, opts, event.Discard)
335 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500})
336 if !hasCompactionSummary(visibleContext(a)) {
337 t.Errorf("compact threshold should install a summary projection, got: %+v", visibleContext(a))
338 }
339 if len(sess.Messages) != 7 {
340 t.Errorf("canonical should stay full after projection compact, len=%d", len(sess.Messages))
341 }
342
343 // No context window: compaction disabled.
344 sess = newSess()
345 a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
346 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 1 << 30})
347 if len(sess.Messages) != 7 {
348 t.Errorf("no window should disable compaction, len = %d", len(sess.Messages))
349 }
350 }
351
352 func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
353 // Physical hard ceiling (window−reserve) forces a summary even when the fold
354 // is below the minFoldTokens economics floor — but the fold must still be
355 // large enough that the candidate lands under the compact_ratio trigger.
356 const window = 10_000
357 big := strings.Repeat("old analysis detail ", 400)
358 sess := &Session{Messages: []provider.Message{
359 {Role: provider.RoleSystem, Content: "sys"},
360 {Role: provider.RoleUser, Content: "task"},
361 {Role: provider.RoleAssistant, Content: big},
362 {Role: provider.RoleUser, Content: "next"},
363 {Role: provider.RoleAssistant, Content: "ok"},
364 }}
365 prov := &fakeProvider{reply: "forced summary"}
366 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
367
368 // hard = 10000-256 = 9744; observe just above it.
369 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 9800})
370 if got := len(sess.Messages); got != 5 {
371 t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages)
372 }
373 if sess.Messages[1].Content != "task" {
374 t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1])
375 }
376 proj := visibleContext(a)
377 if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "forced summary") {
378 t.Fatalf("forced compact did not install summary projection: %+v", proj)
379 }
380 if len(prov.got) == 0 {
381 t.Fatalf("summarizer was not called at force ceiling")
382 }
383 }
384
385 func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) {
386 // Above compact_ratio but below the physical hard ceiling: low-value folds
387 // are rejected by foldEconomics without calling the summarizer.
388 const window = 10_000
389 sess := &Session{Messages: []provider.Message{
390 {Role: provider.RoleSystem, Content: "sys"},
391 {Role: provider.RoleUser, Content: "small old request"},
392 {Role: provider.RoleAssistant, Content: "small old answer"},
393 {Role: provider.RoleUser, Content: "next"},
394 {Role: provider.RoleAssistant, Content: "ok"},
395 }}
396 prov := &fakeProvider{reply: "should not summarize"}
397 a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
398
399 // fold trigger = 8500; hard = 9744. Observe between them.
400 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8600})
401 if got := len(sess.Messages); got != 5 {
402 t.Fatalf("low-value region should not compact before force ceiling, len = %d", got)
403 }
404 if len(prov.got) != 0 {
405 t.Fatalf("summarizer was called for low-value non-forced region: %+v", prov.got)
406 }
407 if a.currentProjectionVersion() != 0 {
408 t.Fatalf("low-value region installed projection version %d", a.currentProjectionVersion())
409 }
410 }
411
412 func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) {
413 const window = 10_000
414 // Large assistant work (not the first user turn) so it is foldable, not pinned.
415 sess := &Session{Messages: []provider.Message{
416 {Role: provider.RoleSystem, Content: "sys"},
417 {Role: provider.RoleUser, Content: "task"},
418 {Role: provider.RoleAssistant, Content: strings.Repeat("large prompt chunk ", 500)},
419 {Role: provider.RoleUser, Content: "next"},
420 {Role: provider.RoleAssistant, Content: "ok"},
421 }}
422 a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{
423 ContextWindow: window, CompactRatio: 0.8, RecentKeep: 2,
424 }, event.Discard)
425
426 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500})
427 if got := len(sess.Messages); got != 5 {
428 t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages)
429 }
430 proj := visibleContext(a)
431 if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "single large summary") {
432 t.Fatalf("single large message was not compacted into projection: %+v", proj)
433 }
434 }
435
436 func TestRenderTranscriptRedactsToolCallArgs(t *testing.T) {
437 msgs := []provider.Message{
438 {Role: provider.RoleUser, Content: "Find me popular GitHub MCP projects"},
439 {
440 Role: provider.RoleAssistant,
441 Content: "I'll research that.",
442 ToolCalls: []provider.ToolCall{
443 {Name: "research", Arguments: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`},
444 },
445 },
446 {Role: provider.RoleTool, Name: "research", Content: "Found 5 projects."},
447 }
448
449 out := renderTranscript(msgs)
450
451 if strings.Contains(out, "Search for recently popular") {
452 t.Fatalf("renderTranscript leaked tool-call arguments into transcript:\n%s", out)
453 }
454 if !strings.Contains(out, "[assistant calls research]") {
455 t.Fatalf("renderTranscript missing tool-call label:\n%s", out)
456 }
457 if !strings.Contains(out, "task") {
458 t.Fatalf("renderTranscript missing key names:\n%s", out)
459 }
460 }
461
462 // Display-only output stays verbatim in the canonical transcript by construction
463 // (compaction only writes a projection); this pins the other half: it must never
464 // reach the summarizer or the model-visible projection.
465 func TestInterruptedDisplayStaysOutOfCompactionPromptAndProjection(t *testing.T) {
466 local := provider.Message{
467 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
468 LocalOnly: true, Content: "partial visible answer", ReasoningContent: "private partial reasoning",
469 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true},
470 }
471 a := &Agent{}
472 kept, fold, retention := a.partitionFoldForProjection([]provider.Message{local})
473 if len(kept) != 0 || len(fold) != 0 {
474 t.Fatalf("compaction partition kept=%+v fold=%+v, want display-only output in neither", kept, fold)
475 }
476 if retention.Kept != 0 || retention.Dropped != 0 {
477 t.Fatalf("retention = %+v, want display-only output counted as neither kept nor dropped", retention)
478 }
479 if transcript := renderTranscript([]provider.Message{local}); transcript != "" {
480 t.Fatalf("local interrupted output leaked into compaction prompt: %q", transcript)
481 }
482 }
483
484 func TestCompactKeepsActiveTurnVerbatim(t *testing.T) {
485 const currentCreatedAt int64 = 123456
486 call := provider.Message{
487 Role: provider.RoleAssistant,
488 ToolCalls: []provider.ToolCall{{
489 ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`,
490 }},
491 }
492 result := provider.Message{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"}
493 sess := &Session{Messages: []provider.Message{
494 {Role: provider.RoleSystem, Content: "sys"},
495 {Role: provider.RoleUser, Content: strings.Repeat("old request ", 200)},
496 {Role: provider.RoleAssistant, Content: strings.Repeat("old answer ", 200)},
497 {Role: provider.RoleUser, Content: "update a.txt", CreatedAt: currentCreatedAt},
498 call,
499 result,
500 }}
501 a := New(&fakeProvider{reply: "old work summary"}, tool.NewRegistry(), sess, Options{
502 ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 1,
503 }, event.Discard)
504 a.activeTurnCreatedAt.Store(currentCreatedAt)
505
506 if err := a.compact(context.Background(), "auto", "", true); err != nil {
507 t.Fatalf("compact: %v", err)
508 }
509 start := a.activeTurnStart(sess.Messages)
510 if start < 0 || len(sess.Messages)-start != 3 {
511 t.Fatalf("active turn boundary = %d in %+v, want three-message verbatim tail", start, sess.Messages)
512 }
513 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 {
514 t.Fatalf("active turn changed during compaction: %+v", sess.Messages[start:])
515 }
516 }
517
518 func TestSummarizeToolArgs(t *testing.T) {
519 tests := []struct {
520 name string
521 args string
522 want string
523 wantNot string
524 }{
525 {
526 name: "redacts long task prompt",
527 args: `{"task":"Search for recently popular GitHub projects that let AI use/control any software through MCP..."}`,
528 want: "task",
529 },
530 {
531 name: "empty args",
532 args: "",
533 want: "no arguments",
534 },
535 {
536 name: "invalid json",
537 args: "not json",
538 want: "bytes",
539 },
540 {
541 name: "multiple keys sorted",
542 args: `{"prompt":"do something","model":"gpt-4"}`,
543 want: "model, prompt",
544 },
545 }
546 for _, tt := range tests {
547 t.Run(tt.name, func(t *testing.T) {
548 got := summarizeToolArgs(tt.args)
549 if !strings.Contains(got, tt.want) {
550 t.Errorf("summarizeToolArgs(%q) = %q, want contains %q", tt.args, got, tt.want)
551 }
552 if tt.wantNot != "" && strings.Contains(got, tt.wantNot) {
553 t.Errorf("summarizeToolArgs(%q) = %q, should NOT contain %q", tt.args, got, tt.wantNot)
554 }
555 })
556 }
557 }
558
559 // TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger pins the documented
560 // contract that any turn under the compact trigger is "breathing room" that
561 // clears the stuck latch. The snip band ([snip, high)) is the regression: it
562 // returned before the reset ran, so a compaction that healthily settled the
563 // prompt at, say, 70% of the window left a stale consecutive-run count behind
564 // and the next compaction latched the session as "window too small" — silently
565 // disabling auto-compaction for the rest of the run.
566 func TestMaybeCompactClearsStuckLatchAnywhereBelowTrigger(t *testing.T) {
567 // contextWindow 20000 => soft 10000, snip 12000, high (trigger) 16000.
568 for _, tc := range []struct {
569 name string
570 prompt int
571 }{
572 {"below soft", 8000},
573 {"soft band", 11000},
574 {"snip band", 14000},
575 } {
576 t.Run(tc.name, func(t *testing.T) {
577 sess := NewSession("sys")
578 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
579 a := New(&fakeProvider{reply: "- summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 20000}, event.Discard)
580 a.sess.compaction.consecutive = 1
581 a.sess.compaction.stuck = true
582
583 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: tc.prompt})
584
585 if a.sess.compaction.consecutive != 0 || a.sess.compaction.stuck {
586 t.Fatalf("prompt %d sits under the trigger; want the latch cleared, got consecutiveCompacts=%d compactStuck=%v",
587 tc.prompt, a.sess.compaction.consecutive, a.sess.compaction.stuck)
588 }
589 })
590 }
591 }
592
593 // TestMaybeCompactDefersWhenOnlyActiveTurnRemains proves current-turn
594 // protection wins over a synthetic pressure observation.
595 func TestMaybeCompactDefersWhenOnlyActiveTurnRemains(t *testing.T) {
596 sess := NewSession("sys")
597 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
598 a := New(&fakeProvider{reply: "- summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 20000}, event.Discard)
599
600 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 17000})
601 if a.sess.compaction.stuck {
602 t.Fatalf("active turn should be deferred, not durably blocked: consecutiveCompacts=%d", a.sess.compaction.consecutive)
603 }
604 version := a.currentProjectionVersion()
605 prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 17000})
606 if got := a.currentProjectionVersion(); got != version {
607 t.Fatalf("blocked fingerprint retried: projection version %d -> %d", version, got)
608 }
609 }
610
611 func TestCompactTriggerIgnoresConfiguredOutputBudget(t *testing.T) {
612 a := &Agent{agentConfig: agentConfig{contextWindow: 100_000, maxOutputTokens: 20_000, compactRatio: 0.85}}
613 if got := a.compactTrigger(); got != 85_000 {
614 t.Fatalf("trigger = %d, want 85000 (output budget must not change it)", got)
615 }
616 if got := a.hardInputCeiling(); got != 100_000-protocolReserveTokens {
617 t.Fatalf("hard ceiling = %d, want window minus protocol reserve only", got)
618 }
619 }
620
621 func TestCompactRollsOldDigestsIntoNew(t *testing.T) {
622 // A1 rolling merge: prior digests enter the fold region and are merged into
623 // one new provider-visible summary. The canonical transcript stays intact.
624 oldDigest := summaryTagOpen + "\n" + strings.Repeat("old standing fact ", 60) + "\n" + summaryTagClose // >1500 chars → not pinnable
625 newestDigest := summaryTagOpen + "\nnewest digest\n" + summaryTagClose
626 big := strings.Repeat("work output ", 200)
627 sess := &Session{Messages: []provider.Message{
628 {Role: provider.RoleSystem, Content: "sys"},
629 {Role: provider.RoleUser, Content: "task"},
630 {Role: provider.RoleUser, Content: oldDigest}, // old digest, large → folds
631 {Role: provider.RoleUser, Content: newestDigest},
632 {Role: provider.RoleAssistant, Content: big},
633 {Role: provider.RoleUser, Content: "next"},
634 {Role: provider.RoleAssistant, Content: "ok"},
635 }}
636 a := New(&fakeProvider{reply: "merged digest"}, tool.NewRegistry(), sess,
637 Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
638
639 if err := a.compact(context.Background(), "manual", "", true); err != nil {
640 t.Fatalf("compact: %v", err)
641 }
642 canonical := sess.Snapshot()
643 var oldDigestRetained bool
644 for _, m := range canonical {
645 if m.Content == oldDigest {
646 oldDigestRetained = true
647 }
648 }
649 if !oldDigestRetained {
650 t.Fatalf("canonical transcript lost old digest: %+v", canonical)
651 }
652
653 projection := visibleContext(a)
654 var summaryCount int
655 var generatedSummaryPresent bool
656 for _, m := range projection {
657 if isCompactionSummary(m) {
658 summaryCount++
659 if strings.Contains(m.Content, "merged digest") {
660 generatedSummaryPresent = true
661 }
662 }
663 if m.Content == oldDigest {
664 t.Fatalf("old digest survived verbatim in projection: %+v", projection)
665 }
666 }
667 if summaryCount != 1 {
668 t.Fatalf("projection summaries = %d, want exactly 1: %+v", summaryCount, projection)
669 }
670 if !generatedSummaryPresent {
671 t.Fatalf("generated rolling summary missing from projection: %+v", projection)
672 }
673 }
674
674 lines GO