Files
goclaw/internal/agent/pruning_integration_test.go
viettranx 48335d7797 feat(pruning)!: faithful port of TS context pruning + backfill migration
Port goclaw context pruning to match upstream TS design in
openclaw/src/agents/pi-hooks/context-pruning/:

- Opt-in default: prune only when mode="cache-ttl" (was opt-out)
- Remove Pass 0 per-result 30% guard (duplicated Pass 1 with different
  suffix, caused wobble)
- Dedupe double prune call per iteration: PruneStage owns the single
  entry point; loop_history only runs limitHistoryTurns + sanitizeHistory
- Add cache-TTL gate for Anthropic prompt cache: skip prune while cache
  is live, scoped per-session via sync.Map
- Add context.pruned event emission for observability
- Configurable TTL as Go duration string ("5m", "30s")

BREAKING CHANGE: context pruning now opt-in. Add
contextPruning.mode: "cache-ttl" to config.agents.defaults to restore.

Migration 51 / SQLite v19 backfills mode="cache-ttl" for agents with
existing custom context_pruning config missing the mode field, so
previously-configured agents keep pruning after the opt-in flip.
NULL configs stay NULL (new opt-in default applies).

Web UI adds Cache TTL input + toggle wiring mode to cache-ttl/off.
2026-04-15 11:24:57 +07:00

63 lines
2.3 KiB
Go

//go:build !sqliteonly
package agent
import (
"sync/atomic"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// TestPruneStage_SingleEntryPoint verifies that after Phase 03 refactoring:
// - loop_history.go no longer calls pruneContextMessages
// - PruneStage owns the single entry point for pruning
// - SanitizeHistory is called after PruneMessages in PruneStage
//
// Full PruneStage tests are in internal/pipeline/stages_test.go.
func TestPruneStage_SingleEntryPoint(t *testing.T) {
// Verify loop_history's sanitizeHistory works on trimmed input directly.
// This proves pruneContextMessages is no longer called in that path.
history := []providers.Message{
{Role: "user", Content: "q1"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "read"}}},
{Role: "tool", ToolCallID: "tc1", Content: "result1"},
// Orphan tool result (no matching tool_use) - sanitizeHistory should drop it
{Role: "tool", ToolCallID: "tc_orphan", Content: "orphan result"},
{Role: "assistant", Content: "a1"},
}
// limitHistoryTurns doesn't drop the orphan, but sanitizeHistory does
trimmed := limitHistoryTurns(history, 100)
if len(trimmed) != len(history) {
t.Fatalf("limitHistoryTurns altered message count: got %d, want %d", len(trimmed), len(history))
}
sanitized, droppedCount := sanitizeHistory(trimmed)
if droppedCount != 1 {
t.Errorf("sanitizeHistory should drop 1 orphan, got droppedCount=%d", droppedCount)
}
if len(sanitized) != 4 {
t.Errorf("expected 4 messages after sanitize, got %d", len(sanitized))
}
}
// TestPruneCallback_CountsOnce demonstrates the callback structure supports counting.
// Actual single-call verification is in pipeline/stages_test.go.
func TestPruneCallback_CountsOnce(t *testing.T) {
var count int32
fakePrune := func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats) {
atomic.AddInt32(&count, 1)
return msgs, pipeline.PruneStats{}
}
// Simulate what PruneStage does: call PruneMessages once
msgs := []providers.Message{{Role: "user", Content: "test"}}
_, _ = fakePrune(msgs, 10000)
if got := atomic.LoadInt32(&count); got != 1 {
t.Errorf("expected prune called exactly once, got %d", got)
}
}