b0b393517e
internal/context/: - Tracker: monitors token usage with OK/Warning/Critical states (thresholds from CC: 20K warning buffer, 13K autocompact buffer) - TruncateStrategy: drops oldest messages, preserves system prompt + recent N turns, adds compaction boundary marker - Window: manages message history with auto-compaction trigger, circuit breaker after 3 consecutive failures Engine integration: - Context window tracks usage per turn - Auto-compacts when critical threshold reached - History syncs with context window after compaction TUI status bar: - Token count with percentage (tokens: 1234 (5%)) - Color-coded: green=ok, yellow=warning, red=critical Session Status extended: TokensMax, TokenPercent, TokenState. 7 context tests.
200 lines
4.9 KiB
Go
200 lines
4.9 KiB
Go
package context
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
)
|
|
|
|
// --- Tracker ---
|
|
|
|
func TestTracker_States(t *testing.T) {
|
|
tr := NewTracker(200_000) // 200K context window
|
|
|
|
// Initially OK
|
|
if tr.State() != TokensOK {
|
|
t.Errorf("initial state = %s, want ok", tr.State())
|
|
}
|
|
|
|
// Add usage below warning threshold
|
|
tr.Add(message.Usage{InputTokens: 100_000, OutputTokens: 50_000})
|
|
if tr.State() != TokensOK {
|
|
t.Errorf("150K of 200K = %s, want ok", tr.State())
|
|
}
|
|
|
|
// Add more to hit warning (200K - 20K = 180K threshold)
|
|
tr.Add(message.Usage{InputTokens: 20_000, OutputTokens: 10_000})
|
|
if tr.State() != TokensWarning {
|
|
t.Errorf("180K of 200K = %s, want warning", tr.State())
|
|
}
|
|
|
|
// Add more to hit critical (200K - 13K = 187K threshold)
|
|
tr.Add(message.Usage{InputTokens: 5_000, OutputTokens: 3_000})
|
|
if tr.State() != TokensCritical {
|
|
t.Errorf("188K of 200K = %s, want critical", tr.State())
|
|
}
|
|
|
|
if !tr.ShouldCompact() {
|
|
t.Error("should compact at critical")
|
|
}
|
|
}
|
|
|
|
func TestTracker_PercentUsed(t *testing.T) {
|
|
tr := NewTracker(100_000)
|
|
tr.Add(message.Usage{InputTokens: 25_000, OutputTokens: 25_000})
|
|
|
|
if tr.PercentUsed() != 50 {
|
|
t.Errorf("PercentUsed = %d, want 50", tr.PercentUsed())
|
|
}
|
|
}
|
|
|
|
func TestTracker_Remaining(t *testing.T) {
|
|
tr := NewTracker(100_000)
|
|
tr.Add(message.Usage{InputTokens: 60_000})
|
|
|
|
if tr.Remaining() != 40_000 {
|
|
t.Errorf("Remaining = %d, want 40000", tr.Remaining())
|
|
}
|
|
}
|
|
|
|
func TestTracker_Reset(t *testing.T) {
|
|
tr := NewTracker(100_000)
|
|
tr.Add(message.Usage{InputTokens: 50_000})
|
|
tr.Reset()
|
|
|
|
if tr.Used() != 0 {
|
|
t.Errorf("Used after reset = %d", tr.Used())
|
|
}
|
|
}
|
|
|
|
// --- TruncateStrategy ---
|
|
|
|
func TestTruncateStrategy_KeepsRecent(t *testing.T) {
|
|
s := &TruncateStrategy{KeepRecent: 4}
|
|
|
|
msgs := []message.Message{
|
|
message.NewSystemText("system prompt"),
|
|
message.NewUserText("old message 1"),
|
|
message.NewAssistantText("old reply 1"),
|
|
message.NewUserText("old message 2"),
|
|
message.NewAssistantText("old reply 2"),
|
|
message.NewUserText("recent 1"),
|
|
message.NewAssistantText("recent reply 1"),
|
|
message.NewUserText("recent 2"),
|
|
message.NewAssistantText("recent reply 2"),
|
|
}
|
|
|
|
result, err := s.Compact(msgs, 50_000)
|
|
if err != nil {
|
|
t.Fatalf("Compact: %v", err)
|
|
}
|
|
|
|
// System + marker + ack + 4 recent = 7
|
|
if len(result) != 7 {
|
|
t.Errorf("len = %d, want 7 (system + marker + ack + 4 recent)", len(result))
|
|
for i, m := range result {
|
|
t.Logf(" [%d] %s: %s", i, m.Role, m.TextContent())
|
|
}
|
|
}
|
|
|
|
// First message should be system
|
|
if result[0].Role != message.RoleSystem {
|
|
t.Errorf("result[0].Role = %q, want system", result[0].Role)
|
|
}
|
|
|
|
// Marker message
|
|
if result[1].Role != message.RoleUser {
|
|
t.Errorf("result[1] should be compaction marker")
|
|
}
|
|
|
|
// Last message should be the most recent
|
|
last := result[len(result)-1]
|
|
if last.TextContent() != "recent reply 2" {
|
|
t.Errorf("last message = %q, want 'recent reply 2'", last.TextContent())
|
|
}
|
|
}
|
|
|
|
func TestTruncateStrategy_NoopWhenSmall(t *testing.T) {
|
|
s := &TruncateStrategy{KeepRecent: 10}
|
|
|
|
msgs := []message.Message{
|
|
message.NewUserText("hello"),
|
|
message.NewAssistantText("hi"),
|
|
}
|
|
|
|
result, err := s.Compact(msgs, 50_000)
|
|
if err != nil {
|
|
t.Fatalf("Compact: %v", err)
|
|
}
|
|
|
|
if len(result) != 2 {
|
|
t.Errorf("small history should not be compacted, got %d messages", len(result))
|
|
}
|
|
}
|
|
|
|
// --- Window ---
|
|
|
|
func TestWindow_CompactIfNeeded(t *testing.T) {
|
|
w := NewWindow(WindowConfig{
|
|
MaxTokens: 100_000,
|
|
Strategy: &TruncateStrategy{KeepRecent: 2},
|
|
})
|
|
|
|
// Add enough messages and usage to trigger compaction
|
|
for i := 0; i < 20; i++ {
|
|
w.Append(message.NewUserText("message"), message.Usage{InputTokens: 5000})
|
|
w.Append(message.NewAssistantText("reply"), message.Usage{OutputTokens: 5000})
|
|
}
|
|
|
|
// Should be at critical
|
|
if w.Tracker().State() != TokensCritical {
|
|
t.Skipf("not at critical (used: %d, max: %d), skipping", w.Tracker().Used(), w.Tracker().MaxTokens())
|
|
}
|
|
|
|
compacted, err := w.CompactIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("CompactIfNeeded: %v", err)
|
|
}
|
|
if !compacted {
|
|
t.Error("should have compacted")
|
|
}
|
|
|
|
// Messages should be reduced
|
|
if len(w.Messages()) >= 40 {
|
|
t.Errorf("messages not reduced: %d", len(w.Messages()))
|
|
}
|
|
}
|
|
|
|
func TestWindow_CircuitBreaker(t *testing.T) {
|
|
// Strategy that always fails
|
|
failStrategy := &failingStrategy{}
|
|
w := NewWindow(WindowConfig{
|
|
MaxTokens: 1000,
|
|
Strategy: failStrategy,
|
|
})
|
|
|
|
// Push past critical
|
|
w.Append(message.NewUserText("x"), message.Usage{InputTokens: 990})
|
|
|
|
// Try to compact — should fail 3 times then stop
|
|
for i := 0; i < 5; i++ {
|
|
w.CompactIfNeeded()
|
|
}
|
|
|
|
if failStrategy.calls > 3 {
|
|
t.Errorf("circuit breaker should stop after 3 failures, got %d calls", failStrategy.calls)
|
|
}
|
|
}
|
|
|
|
type failingStrategy struct {
|
|
calls int
|
|
}
|
|
|
|
func (s *failingStrategy) Compact(msgs []message.Message, budget int64) ([]message.Message, error) {
|
|
s.calls++
|
|
return nil, fmt.Errorf("always fails")
|
|
}
|
|
|
|
var _ Strategy = (*failingStrategy)(nil)
|