- personas.go: backstory, prompt policy and output policy per crow; Hugin and Munin grounded in Grimnismal 20 and Gylfaginning 38; every working crow also follows a common output policy - Personas go into each crow's instructions; Sentry keeps JSON-only - banter.go: retrieve backstory, relationships and recent flock memory, then the speaking crow generates one line in character; max 2 per list against the list budget, never blocks work, unusable lines dropped - crow.banter event, GET /api/crows, `crows personas` -> PERSONAS.md - UI: epithets on tiles and messages, banter as quiet asides Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
114 lines
3.5 KiB
Go
114 lines
3.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type recordEmitter struct {
|
|
mu sync.Mutex
|
|
events []map[string]any
|
|
}
|
|
|
|
func (r *recordEmitter) Emit(event string, fields map[string]any) {
|
|
if event != "crow.banter" {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.events = append(r.events, fields)
|
|
}
|
|
|
|
// promptBrain records the banter prompts it receives.
|
|
type promptBrain struct {
|
|
mu sync.Mutex
|
|
prompts []string
|
|
reply string
|
|
}
|
|
|
|
func (b *promptBrain) Name() string { return "prompt" }
|
|
func (b *promptBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
|
|
b.mu.Lock()
|
|
b.prompts = append(b.prompts, system+"\n"+prompt)
|
|
b.mu.Unlock()
|
|
return b.reply, 5, nil
|
|
}
|
|
|
|
func newChatter(b Brain, em Emitter, mem *flockMemory) *chatter {
|
|
return &chatter{
|
|
f: NewFlock(b, DefaultGuardrails(), nil), mem: mem, em: em,
|
|
budget: &Budget{name: "list", limit: 100_000}, roll: func() float64 { return 0 },
|
|
}
|
|
}
|
|
|
|
func TestRetrieveUsesBackstoryRelationshipAndMemory(t *testing.T) {
|
|
mem := &flockMemory{}
|
|
mem.add("Hugin's answer to \"ravens\" was rejected by the human")
|
|
mem.add("Pepper answered \"names\"")
|
|
lore := strings.Join(retrieve(mem, "Munin", "Hugin"), "\n")
|
|
for _, want := range []string{"Hugin (Thought)", "Munin (Memory)", "Between them:", "Recently: Hugin's answer"} {
|
|
if !strings.Contains(lore, want) {
|
|
t.Errorf("lore missing %q:\n%s", want, lore)
|
|
}
|
|
}
|
|
if strings.Contains(lore, "Pepper") {
|
|
t.Errorf("retrieved an unrelated memory:\n%s", lore)
|
|
}
|
|
}
|
|
|
|
func TestBanterIsGeneratedFromRetrievedLoreAndCapped(t *testing.T) {
|
|
b := &promptBrain{reply: `Munin: "Bold as ever, Hugin."`}
|
|
em := &recordEmitter{}
|
|
c := newChatter(b, em, &flockMemory{})
|
|
for i := 0; i < 5; i++ {
|
|
c.moment(context.Background(), "L-1", "Hugin", "Munin", "The human rejected Hugin's answer.")
|
|
}
|
|
c.wait(5 * time.Second)
|
|
if len(em.events) != MaxBanterPerList {
|
|
t.Fatalf("got %d banter lines, want the cap of %d", len(em.events), MaxBanterPerList)
|
|
}
|
|
if got := em.events[0]["text"]; got != "Bold as ever, Hugin." {
|
|
t.Fatalf("line not cleaned: %q", got)
|
|
}
|
|
if !strings.Contains(b.prompts[0], "Odin's two ravens") || !strings.Contains(b.prompts[0], markerBanter) {
|
|
t.Fatalf("prompt missing retrieved lore or banter instructions:\n%s", b.prompts[0])
|
|
}
|
|
}
|
|
|
|
func TestBanterPicksARelatedSpeaker(t *testing.T) {
|
|
b := &promptBrain{reply: "Caw."}
|
|
em := &recordEmitter{}
|
|
c := newChatter(b, em, &flockMemory{})
|
|
c.moment(context.Background(), "L-1", "Corvina", "", "Corvina leveled up.")
|
|
c.wait(5 * time.Second)
|
|
if len(em.events) != 1 || em.events[0]["crow"] != "Edgar" {
|
|
t.Fatalf("want Edgar (Corvina's only relationship) to speak, got %v", em.events)
|
|
}
|
|
}
|
|
|
|
func TestCleanBanterRejectsBadLines(t *testing.T) {
|
|
for _, bad := range []string{"", strings.Repeat("caw ", 80), "see https://example.com", "visit www.crows.biz"} {
|
|
if _, ok := cleanBanter(bad, "Soot"); ok {
|
|
t.Errorf("accepted %q", bad)
|
|
}
|
|
}
|
|
if got, ok := cleanBanter("Soot: “Nice try.”\nsecond line", "Soot"); !ok || got != "Nice try." {
|
|
t.Errorf("got %q %v", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestWorkPromptsCarryPersonaAndPolicies(t *testing.T) {
|
|
s := systemFor("Munin", crowSystem, true)
|
|
for _, want := range []string{"You are Munin, Memory", "Mark anything uncertain", "Never invent sources"} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("Munin's prompt missing %q", want)
|
|
}
|
|
}
|
|
if !strings.HasPrefix(systemFor("Sentry", sentrySystem, false), markerSentry) {
|
|
t.Error("Sentry prompt must still start with its marker")
|
|
}
|
|
}
|