Files
goclaw/internal/tokencount/tiktoken_counter_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

163 lines
4.5 KiB
Go

package tokencount
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
func TestCount_CL100K(t *testing.T) {
c := NewTiktokenCounter()
// "Hello, world!" is 4 tokens in cl100k_base
count := c.Count("claude-sonnet-4-5-20250929", "Hello, world!")
if count < 3 || count > 6 {
t.Errorf("cl100k count = %d, expected ~4", count)
}
}
func TestCount_O200K(t *testing.T) {
c := NewTiktokenCounter()
count := c.Count("gpt-4o-mini", "Hello, world!")
if count < 3 || count > 6 {
t.Errorf("o200k count = %d, expected ~4", count)
}
}
func TestCount_UnknownModel(t *testing.T) {
c := NewTiktokenCounter()
// Unknown model falls back to rune/3
count := c.Count("unknown-model-xyz", "Hello, world!")
expected := NewFallbackCounter().Count("unknown-model-xyz", "Hello, world!")
if count != expected {
t.Errorf("fallback count = %d, want %d", count, expected)
}
}
func TestCountMessages_Cache(t *testing.T) {
c := NewTiktokenCounter()
msgs := []providers.Message{
{Role: "user", Content: "What is 2+2?"},
{Role: "assistant", Content: "The answer is 4."},
}
first := c.CountMessages("claude-sonnet-4-5-20250929", msgs)
second := c.CountMessages("claude-sonnet-4-5-20250929", msgs)
if first != second {
t.Errorf("cached count %d != first count %d", second, first)
}
if first <= 0 {
t.Errorf("count should be positive, got %d", first)
}
// Verify cache is populated
c.mu.RLock()
cacheLen := len(c.msgCache)
c.mu.RUnlock()
if cacheLen != 2 {
t.Errorf("cache has %d entries, want 2", cacheLen)
}
}
// TestCountMessages_CacheIsTokenizerScoped is the regression guard for the
// stale cross-tokenizer count bug: the same message counted under a cl100k
// model then an o200k model must re-encode (different tokenizer) rather than
// return the first tokenizer's cached count. This matters most on fallback
// candidates that switch tokenizer mid-run.
func TestCountMessages_CacheIsTokenizerScoped(t *testing.T) {
c := NewTiktokenCounter()
// A string whose cl100k and o200k token counts differ.
msgs := []providers.Message{
{Role: "user", Content: "internationalization tokenization pseudopseudohypoparathyroidism"},
}
cl := c.CountMessages("claude-sonnet-4-5-20250929", msgs) // cl100k_base
o2 := c.CountMessages("gpt-4o", msgs) // o200k_base
if cl <= 0 || o2 <= 0 {
t.Fatalf("counts must be positive: cl=%d o2=%d", cl, o2)
}
// The two tokenizers should produce different counts for this string; if they
// were sharing a cache entry, o2 would equal cl (the stale bug).
if cl == o2 {
t.Fatalf("cl100k and o200k returned identical count %d — cache is not tokenizer-scoped", cl)
}
// Two distinct tokenizers => two cache entries for the one message.
c.mu.RLock()
cacheLen := len(c.msgCache)
c.mu.RUnlock()
if cacheLen != 2 {
t.Errorf("cache has %d entries, want 2 (one per tokenizer)", cacheLen)
}
}
func TestCountMessages_Overhead(t *testing.T) {
c := NewTiktokenCounter()
msgs := []providers.Message{
{Role: "user", Content: "Hi"},
}
count := c.CountMessages("claude-sonnet-4-5-20250929", msgs)
// Should be > raw token count due to PerMessageOverhead
rawCount := c.Count("claude-sonnet-4-5-20250929", "Hi")
if count <= rawCount {
t.Errorf("messages count %d should exceed raw count %d (overhead)", count, rawCount)
}
}
func TestModelContextWindow(t *testing.T) {
c := NewTiktokenCounter()
tests := []struct {
model string
want int
}{
{"claude-sonnet-4-5-20250929", 200_000},
{"gpt-4o-mini", 128_000},
{"gpt-5.5", 1_050_000},
{"gpt-5.4", 1_000_000},
{"unknown-model", 200_000}, // conservative default
}
for _, tt := range tests {
got := c.ModelContextWindow(tt.model)
if got != tt.want {
t.Errorf("ModelContextWindow(%q) = %d, want %d", tt.model, got, tt.want)
}
}
}
func TestResetCache(t *testing.T) {
c := NewTiktokenCounter()
msgs := []providers.Message{{Role: "user", Content: "test"}}
c.CountMessages("claude-sonnet-4-5-20250929", msgs)
c.mu.RLock()
before := len(c.msgCache)
c.mu.RUnlock()
if before == 0 {
t.Fatal("cache should have entries before reset")
}
c.ResetCache()
c.mu.RLock()
after := len(c.msgCache)
c.mu.RUnlock()
if after != 0 {
t.Errorf("cache has %d entries after reset, want 0", after)
}
}
func TestNewTokenCounter_Factory(t *testing.T) {
fallback := NewTokenCounter(false)
if _, ok := fallback.(*FallbackCounter); !ok {
t.Errorf("NewTokenCounter(false) = %T, want *FallbackCounter", fallback)
}
tk := NewTokenCounter(true)
if _, ok := tk.(*tiktokenCounter); !ok {
t.Errorf("NewTokenCounter(true) = %T, want *tiktokenCounter", tk)
}
}