Files
goclaw/internal/agent/loop_compact_timeout_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

112 lines
3.0 KiB
Go

package agent
import (
"context"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
type deadlineCapturingProvider struct {
capturingProvider
deadline time.Time
hasDeadline bool
}
func (d *deadlineCapturingProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
d.deadline, d.hasDeadline = ctx.Deadline()
return d.capturingProvider.Chat(ctx, req)
}
func TestCompactMessagesInPlace_UsesDefaultTimeout(t *testing.T) {
provider := &deadlineCapturingProvider{
capturingProvider: capturingProvider{response: "Summary of conversation."},
}
loop := &Loop{
provider: provider,
model: "claude-3-5-sonnet",
contextWindow: 200_000,
}
start := time.Now()
result := loop.compactMessagesInPlace(context.Background(), compactableMessages())
if result == nil {
t.Fatal("compactMessagesInPlace returned nil; expected compaction to succeed")
}
assertDeadlineWithin(t, provider, start, 120*time.Second)
}
func TestCompactMessagesInPlace_UsesConfiguredTimeout(t *testing.T) {
provider := &deadlineCapturingProvider{
capturingProvider: capturingProvider{response: "Summary of conversation."},
}
loop := &Loop{
provider: provider,
model: "claude-3-5-sonnet",
contextWindow: 200_000,
compactionCfg: &config.CompactionConfig{
TimeoutSeconds: 45,
},
}
start := time.Now()
result := loop.compactMessagesInPlace(context.Background(), compactableMessages())
if result == nil {
t.Fatal("compactMessagesInPlace returned nil; expected compaction to succeed")
}
assertDeadlineWithin(t, provider, start, 45*time.Second)
}
func TestCompactMessagesInPlace_NonPositiveTimeoutFallsBackToDefault(t *testing.T) {
provider := &deadlineCapturingProvider{
capturingProvider: capturingProvider{response: "Summary of conversation."},
}
loop := &Loop{
provider: provider,
model: "claude-3-5-sonnet",
contextWindow: 200_000,
compactionCfg: &config.CompactionConfig{
TimeoutSeconds: -1,
},
}
start := time.Now()
result := loop.compactMessagesInPlace(context.Background(), compactableMessages())
if result == nil {
t.Fatal("compactMessagesInPlace returned nil; expected compaction to succeed")
}
assertDeadlineWithin(t, provider, start, 120*time.Second)
}
func compactableMessages() []providers.Message {
msgs := make([]providers.Message, 10)
for i := range msgs {
if i%2 == 0 {
msgs[i] = providers.Message{Role: "user", Content: "user message"}
} else {
msgs[i] = providers.Message{Role: "assistant", Content: "assistant reply"}
}
}
return msgs
}
func assertDeadlineWithin(t *testing.T, provider *deadlineCapturingProvider, start time.Time, want time.Duration) {
t.Helper()
if !provider.hasDeadline {
t.Fatal("provider context has no deadline")
}
got := provider.deadline.Sub(start)
lower := want - time.Second
upper := want + time.Second
if got < lower || got > upper {
t.Fatalf("deadline duration = %s, want within [%s, %s]", got, lower, upper)
}
}