Files
goclaw/internal/agent/loop_maybe_summarize_pressure_test.go
Clark Cant 5ca433b8ac fix: persist mid-loop compaction to stop re-compaction loop and revive episodic
The v3 pipeline compacts session history mid-loop (prune_stage +
final-request guard) but only mutates the run's message buffer, never the
session store. Each turn reloads full history and re-compacts from scratch:
message_tokens climb 129k->156k across turns while every turn compacts back
down to ~60k. The lossy compaction differs per run, degrading the agent.

The same missing persistence stalls episodic memory: the cumulative
compaction count never advances, so the episodic worker's idempotency key
(sessionKey:count) is pinned and every cycle after the first is skipped.
Observed on live traffic: 8 run.completed since deploy, 0 new episodic.

Fixes, all reusing existing machinery (no new store methods, no migrations):

- Bug A: emitSessionCompleted reads cumulative GetCompactionCount (matching
  the legacy v2 path) instead of the per-run counter that resets to 0.
- Bug B/anti-loop: finalize passes state.Prune.MidLoopCompacted into
  maybeSummarize; under pressure it lowers the trigger to a unit-aligned
  threshold (compactionInputCap - overhead, same MaxRequestShare the guard
  uses) so the compaction is PERSISTED via the existing TruncateHistory +
  IncrementCompaction path. Defensive floor prevents over-compaction on
  pathological config; tool-result-only bloat still skips (history-only).
- Bug C: SourceID embeds the count (sessionKey:count) so the eventbus dedup
  key advances per compaction cycle instead of swallowing rapid same-session
  turns within the 5m TTL.

Tests: episodic compaction, maybe_summarize pressure, request budget.
go build (PG + sqliteonly), go vet, go test -race all green.
2026-07-25 00:39:02 +07:00

259 lines
9.8 KiB
Go

package agent
import (
"context"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// Fixture math for these tests (contextWindow=200000, maxTokens=8192, nil cfg):
//
// effectiveMaxTokens = 8192
// compactionInputCap = min(200000-8192, int(200000*0.85)-8192) = min(191808, 161808) = 161808
// baseline threshold = int(200000*0.85) = 170000
// overhead (no calib) = min(int(200000*0.2), 40000) = 40000
// pressureThreshold = max(161808-40000, 0) = 121808
// defensive floor = 170000/2 = 85000 (< 121808, so unused)
//
// Token estimate (no calibration) = sum(runes(content)/3) over all history messages.
// Bands used below:
//
// ~100k tokens → below BOTH thresholds → skip even under mid-loop pressure
// ~150k tokens → between pressure(121808) and baseline(170000) → persist ONLY under pressure
const (
pressureContextWindow = 200000
pressureMaxTokens = 8192
)
// buildTokenHistory returns n alternating user/assistant messages whose combined
// rune/3 token estimate is approximately targetTokens. Each message carries
// targetTokens/n * 3 runes so EstimateTokens(history) ≈ targetTokens.
func buildTokenHistory(n, targetTokens int) []providers.Message {
perMsgTokens := targetTokens / n
perMsgRunes := perMsgTokens * 3
content := makeLongString(perMsgRunes)
history := make([]providers.Message, n)
for i := range history {
if i%2 == 0 {
history[i] = providers.Message{Role: "user", Content: content}
} else {
history[i] = providers.Message{Role: "assistant", Content: content}
}
}
return history
}
// waitForCount polls get() until it reaches want or the deadline elapses.
func waitForCount(t *testing.T, get func() int, want int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if get() >= want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for count to reach %d, last=%d", want, get())
}
func newPressureLoop(sessions *nopSessionStore, provider providers.Provider) *Loop {
return &Loop{
id: "test-agent",
provider: provider,
model: "claude-3-5-sonnet",
contextWindow: pressureContextWindow,
maxTokens: pressureMaxTokens,
sessions: sessions,
hasMemory: false,
compactionCfg: nil,
}
}
// V1-B / V2 core: a run that compacted mid-loop lowers the threshold to
// pressureThreshold (121808). History at ~150k tokens is BELOW the baseline
// (170000) but ABOVE the pressure threshold → summarize must run and PERSIST
// (TruncateHistory + IncrementCompaction each exactly once).
func TestMaybeSummarize_MidLoopPressurePersists(t *testing.T) {
sessions := &nopSessionStore{
history: buildTokenHistory(20, 150000), // ~150k tokens
}
loop := newPressureLoop(sessions, &capturingProvider{response: "compaction summary"})
loop.maybeSummarize(context.Background(), "sess-1", true)
waitForCount(t, sessions.incrementCount, 1)
if got := sessions.incrementCount(); got != 1 {
t.Errorf("IncrementCompaction called %d times, want 1", got)
}
if got := sessions.truncateCount(); got != 1 {
t.Errorf("TruncateHistory called %d times, want 1", got)
}
sessions.mu.Lock()
keepLast := sessions.truncateKeepLast
setSummary := sessions.setSummaryCalls
sessions.mu.Unlock()
if keepLast != 4 {
t.Errorf("TruncateHistory keepLast = %d, want 4", keepLast)
}
if setSummary != 1 {
t.Errorf("SetSummary called %d times, want 1", setSummary)
}
}
// V2 no-over-compaction (baseline case): the SAME ~150k history without mid-loop
// pressure must SKIP (150000 < 170000 baseline). No summarize, no persist.
func TestMaybeSummarize_NonMidLoopSkipsUnderBaseline(t *testing.T) {
provider := &capturingProvider{response: "should not be called"}
sessions := &nopSessionStore{
history: buildTokenHistory(20, 150000), // ~150k tokens
}
loop := newPressureLoop(sessions, provider)
loop.maybeSummarize(context.Background(), "sess-1", false)
// Skip returns synchronously before spawning the goroutine; give any
// (erroneously spawned) goroutine a brief window to prove it didn't run.
time.Sleep(50 * time.Millisecond)
if got := len(provider.captured); got != 0 {
t.Errorf("provider.Chat called %d times, want 0 (under baseline, no pressure)", got)
}
if got := sessions.incrementCount(); got != 0 {
t.Errorf("IncrementCompaction called %d times, want 0", got)
}
if got := sessions.truncateCount(); got != 0 {
t.Errorf("TruncateHistory called %d times, want 0", got)
}
}
// V2 no-over-compaction (the KEY case that distinguishes "lower threshold" from
// "skip threshold"): mid-loop pressure is set, but the real history is small
// (~100k tokens, below pressureThreshold=121808). The mid-loop compaction was
// driven by transient tool-result bloat, not history — so we must STILL SKIP and
// NOT truncate the session. This proves we lowered the threshold rather than
// bypassing it.
func TestMaybeSummarize_MidLoopSkipsWhenHistorySmall(t *testing.T) {
provider := &capturingProvider{response: "should not be called"}
sessions := &nopSessionStore{
history: buildTokenHistory(20, 100000), // ~100k tokens < pressureThreshold
}
loop := newPressureLoop(sessions, provider)
loop.maybeSummarize(context.Background(), "sess-1", true)
time.Sleep(50 * time.Millisecond)
if got := len(provider.captured); got != 0 {
t.Errorf("provider.Chat called %d times, want 0 (history below pressure threshold)", got)
}
if got := sessions.incrementCount(); got != 0 {
t.Errorf("IncrementCompaction called %d times, want 0 (no over-compaction)", got)
}
if got := sessions.truncateCount(); got != 0 {
t.Errorf("TruncateHistory called %d times, want 0 (no over-compaction)", got)
}
}
// V2 threshold-sync boundary: with one fixed history between the pressure
// threshold (121808) and the baseline (170000), the ONLY thing that flips the
// decision is the mid-loop flag. Persist when true, skip when false — proving
// the two thresholds bracket this history exactly as designed.
func TestMaybeSummarize_ThresholdSyncBoundary(t *testing.T) {
history := buildTokenHistory(20, 150000) // between 121808 and 170000
// mid-loop = false → skip
skipProvider := &capturingProvider{response: "unused"}
skipSessions := &nopSessionStore{history: cloneMessages(history)}
newPressureLoop(skipSessions, skipProvider).maybeSummarize(context.Background(), "sess-1", false)
time.Sleep(50 * time.Millisecond)
if got := skipSessions.incrementCount(); got != 0 {
t.Errorf("non-mid-loop: IncrementCompaction = %d, want 0 (skip above pressure, below baseline)", got)
}
// mid-loop = true → persist
triggerSessions := &nopSessionStore{history: cloneMessages(history)}
newPressureLoop(triggerSessions, &capturingProvider{response: "compaction summary"}).
maybeSummarize(context.Background(), "sess-1", true)
waitForCount(t, triggerSessions.incrementCount, 1)
if got := triggerSessions.incrementCount(); got != 1 {
t.Errorf("mid-loop: IncrementCompaction = %d, want 1 (persist under pressure)", got)
}
}
// V2 anti-loop (characterization): turn 1 persists the mid-loop compaction,
// shrinking the session history. Turn 2 loads the now-small history, so even
// with mid-loop pressure still set it stays below the pressure threshold and
// does NOT re-compact — the unbounded re-compaction loop is broken.
func TestMaybeSummarize_AntiLoop_SecondTurnDoesNotRecompact(t *testing.T) {
sessions := &nopSessionStore{
history: buildTokenHistory(20, 150000),
}
loop := newPressureLoop(sessions, &capturingProvider{response: "compaction summary"})
// Turn 1: pressure present, history large → persist (truncate to keepLast=4).
loop.maybeSummarize(context.Background(), "sess-1", true)
waitForCount(t, sessions.incrementCount, 1)
sessions.mu.Lock()
remaining := len(sessions.history)
sessions.mu.Unlock()
if remaining > 4 {
t.Fatalf("after turn 1 persist, history len = %d, want <= 4 (keepLast)", remaining)
}
// Turn 2: history is now tiny (4 messages). Even with pressure set, the
// history-only estimate is far below pressureThreshold → skip.
loop.maybeSummarize(context.Background(), "sess-1", true)
time.Sleep(50 * time.Millisecond)
if got := sessions.incrementCount(); got != 1 {
t.Errorf("after turn 2, IncrementCompaction total = %d, want 1 (no re-compaction)", got)
}
if got := sessions.truncateCount(); got != 1 {
t.Errorf("after turn 2, TruncateHistory total = %d, want 1 (no re-compaction)", got)
}
}
// Media preservation regression: MediaRefs on messages about to be truncated
// must be carried onto the first kept message so the agent doesn't lose track of
// shared files across a pressure-driven compaction.
func TestMaybeSummarize_MidLoopPreservesMediaRefs(t *testing.T) {
history := buildTokenHistory(20, 150000)
// Attach a media ref to an early message (will be in the to-summarize slice).
history[0].MediaRefs = []providers.MediaRef{
{ID: "img-1", MimeType: "image/png", Kind: "image", Path: "/tmp/img-1.png"},
}
sessions := &nopSessionStore{history: history}
loop := newPressureLoop(sessions, &capturingProvider{response: "compaction summary"})
loop.maybeSummarize(context.Background(), "sess-1", true)
waitForCount(t, sessions.incrementCount, 1)
sessions.mu.Lock()
defer sessions.mu.Unlock()
if len(sessions.history) == 0 {
t.Fatal("history empty after truncation")
}
var found bool
for _, ref := range sessions.history[0].MediaRefs {
if ref.ID == "img-1" {
found = true
break
}
}
if !found {
t.Errorf("preserved media ref img-1 not found on first kept message; refs=%+v", sessions.history[0].MediaRefs)
}
}
// cloneMessages returns a shallow copy of a message slice so two Loops don't
// share the same backing array (TruncateHistory mutates it in the mock).
func cloneMessages(msgs []providers.Message) []providers.Message {
out := make([]providers.Message, len(msgs))
copy(out, msgs)
return out
}