vojo/apps/ai-bot/buffers_test.go

158 lines
6.9 KiB
Go

package main
import (
"fmt"
"testing"
)
// TestConvIDFlatCaseInvariant pins the load-bearing prompt-cache invariant of the
// per-(room,thread) refactor: the MAIN-timeline conv id (threadRoot "") must stay
// byte-identical to the pre-threading per-room value, so rooms that existed before
// threading keep their warm Grok prompt-cache routing; and a thread must get a DISTINCT
// id so divergent thread prefixes don't thrash one shared cache slot. Also asserts the
// flag-off path returns "" (no header) for both cases.
func TestConvIDFlatCaseInvariant(t *testing.T) {
b := &Bot{cfg: &Config{GrokPromptCache: true}}
flat := b.convID("!room:vojo.chat", "")
legacy := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat"))
if flat != legacy {
t.Fatalf("flat convID = %q, want legacy per-room value %q (prompt-cache continuity)", flat, legacy)
}
threaded := b.convID("!room:vojo.chat", "$root")
if threaded == flat {
t.Fatalf("threaded convID must differ from the flat/main-timeline id, both = %q", flat)
}
if want := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat|$root")); threaded != want {
t.Fatalf("threaded convID = %q, want %q", threaded, want)
}
off := &Bot{cfg: &Config{GrokPromptCache: false}}
if got := off.convID("!room:vojo.chat", ""); got != "" {
t.Fatalf("convID with GrokPromptCache off must be empty, got %q", got)
}
if got := off.convID("!room:vojo.chat", "$root"); got != "" {
t.Fatalf("threaded convID with GrokPromptCache off must be empty, got %q", got)
}
}
// TestSnapshotAppendBufNilPaths exercises the nested-map nil reads the per-(room,thread)
// keying introduced: a never-seen room, a known room but unknown thread, and the round-trip
// of a single append. A nil inner map / nil convBuf must read back as nil history (exactly
// what a fresh "new chat" wants), never panic.
func TestSnapshotAppendBufNilPaths(t *testing.T) {
b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
if got := b.snapshotBuf("!a", ""); got != nil {
t.Fatalf("snapshot of a never-seen room must be nil, got %v", got)
}
b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: "hi", isBot: false})
got := b.snapshotBuf("!a", "")
if len(got) != 1 || got[0].body != "hi" {
t.Fatalf("snapshot after one append = %v, want one message 'hi'", got)
}
// Same room, a DIFFERENT thread that was never appended to → nil (inner map exists, key absent).
if got := b.snapshotBuf("!a", "$other"); got != nil {
t.Fatalf("snapshot of an unknown thread in a known room must be nil, got %v", got)
}
// A different room entirely → nil (outer key absent).
if got := b.snapshotBuf("!b", ""); got != nil {
t.Fatalf("snapshot of a different room must be nil, got %v", got)
}
}
// TestAppendBufTrimsToLimit asserts a single conversation's buffer is bounded to
// MaxCtxEvent*2 (min 8) and keeps the most recent messages (FIFO drop of the oldest).
func TestAppendBufTrimsToLimit(t *testing.T) {
b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
const limit = 20 // MaxCtxEvent*2
for i := 0; i < limit+5; i++ {
b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: fmt.Sprintf("m%d", i)})
}
got := b.snapshotBuf("!a", "")
if len(got) != limit {
t.Fatalf("buffer length = %d, want capped at %d", len(got), limit)
}
// Oldest 5 dropped; the window starts at m5 and ends at m24.
if got[0].body != "m5" || got[len(got)-1].body != "m24" {
t.Fatalf("buffer window = [%s..%s], want [m5..m24]", got[0].body, got[len(got)-1].body)
}
}
// TestAppendBufLRUEviction proves the per-room conversation-buffer cap: once a room exceeds
// maxConvBuffersPerRoom, the LEAST-recently-touched conversation is evicted, and the
// just-touched conversation is never the victim.
func TestAppendBufLRUEviction(t *testing.T) {
b := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
// Touch exactly maxConvBuffersPerRoom+1 distinct threads, oldest-first.
for i := 0; i <= maxConvBuffersPerRoom; i++ {
b.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
}
if n := len(b.buf["!a"]); n != maxConvBuffersPerRoom {
t.Fatalf("room buffer count = %d, want capped at %d", n, maxConvBuffersPerRoom)
}
if got := b.snapshotBuf("!a", "$t0"); got != nil {
t.Fatalf("the coldest conversation ($t0) must have been evicted, got %v", got)
}
if got := b.snapshotBuf("!a", fmt.Sprintf("$t%d", maxConvBuffersPerRoom)); got == nil {
t.Fatal("the just-appended conversation must never be the eviction victim")
}
// Re-touch protection: fill to the cap, re-touch the OLDEST, then overflow by one.
// The re-touched conversation must survive; the new second-oldest becomes the victim.
b2 := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
for i := 0; i < maxConvBuffersPerRoom; i++ {
b2.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
}
b2.appendBuf("!a", "$t0", bufferedMsg{body: "x"}) // re-touch the oldest → now newest
b2.appendBuf("!a", "$overflow", bufferedMsg{body: "x"}) // overflow → evict the coldest
if got := b2.snapshotBuf("!a", "$t0"); got == nil {
t.Fatal("a re-touched conversation ($t0) must NOT be evicted")
}
if got := b2.snapshotBuf("!a", "$t1"); got != nil {
t.Fatalf("the new coldest conversation ($t1) must be the victim, got %v", got)
}
}
// TestTypingRefcount covers the per-room typing refcount the per-(room,thread) concurrency
// relies on (Matrix typing is room-scoped, so several thread generations share one
// indicator). Only the LAST release reports "last" (clears the indicator), and a release
// after forgetRoom dropped the key must self-heal without leaving a leaked negative entry.
func TestTypingRefcount(t *testing.T) {
b := &Bot{typingRefs: make(map[string]int)}
b.typingAcquire("!a")
b.typingAcquire("!a")
if b.typingRefs["!a"] != 2 {
t.Fatalf("typingRefs after two acquires = %d, want 2", b.typingRefs["!a"])
}
if last := b.typingRelease("!a"); last {
t.Fatal("first release of two must NOT be the last")
}
if last := b.typingRelease("!a"); !last {
t.Fatal("second release of two must be the last")
}
if _, ok := b.typingRefs["!a"]; ok {
t.Fatalf("the key must be deleted once the refcount hits 0, still present = %d", b.typingRefs["!a"])
}
// forgetRoom mid-flight: two acquires, then the room is forgotten (key deleted), then the
// in-flight generations release against a missing key. Each lands at -1 (<=0 → "last") and
// deletes the entry, so no persistent negative count leaks.
b.typingAcquire("!b")
b.typingAcquire("!b")
b.forgetRoom("!b")
if last := b.typingRelease("!b"); !last {
t.Fatal("a release after forgetRoom must report last (counter <= 0)")
}
if last := b.typingRelease("!b"); !last {
t.Fatal("a second stale release must also report last")
}
if _, ok := b.typingRefs["!b"]; ok {
t.Fatalf("no negative entry must leak after forgetRoom + stale releases, value = %d", b.typingRefs["!b"])
}
}