507 lines
14 KiB
Go
507 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// bossBrain is a scripted brain that records how often it was called and hands
|
|
// back one canned reply per call.
|
|
type bossBrain struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
resp []string
|
|
}
|
|
|
|
func (b *bossBrain) Name() string { return "boss-script" }
|
|
func (b *bossBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
r := "no reply"
|
|
if b.calls < len(b.resp) {
|
|
r = b.resp[b.calls]
|
|
}
|
|
b.calls++
|
|
return r, 10, nil
|
|
}
|
|
|
|
func (b *bossBrain) count() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.calls
|
|
}
|
|
|
|
// newBossServer builds a whole server by hand (like newDispatchServer) so the
|
|
// Boss tests can attach a CrowStore at a temp path.
|
|
func newBossServer(t *testing.T, replies ...string) (*Server, *bossBrain) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
ledger, err := LoadLedger(filepath.Join(dir, "ledger.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
flog, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { flog.Close() })
|
|
nests, err := LoadNests(filepath.Join(dir, "nests.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msgs, err := LoadMessageStore(filepath.Join(dir, "messages"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
crows, err := LoadCrowStore(filepath.Join(dir, "crows.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
g := DefaultGuardrails()
|
|
bb := &bossBrain{resp: replies}
|
|
appr := NewWebApprover(time.Minute)
|
|
p := &Pipeline{Flock: NewFlock(bb, g, flog), Ledger: ledger, Log: flog, G: g, Approver: appr}
|
|
s := NewServer(ctx, p, appr, nests).WithMessages(msgs).WithCrows(crows)
|
|
return s, bb
|
|
}
|
|
|
|
func askBoss(t *testing.T, ts *httptest.Server, nestID, text string) (int, map[string]any) {
|
|
t.Helper()
|
|
code, out := post(t, ts.URL+"/api/nests/"+nestID+"/messages",
|
|
`{"author":"owner","kind":"request","to":"Boss","text":`+jsonString(text)+`}`)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("owner POST to Boss: want 201, got %d %v", code, out)
|
|
}
|
|
return code, out
|
|
}
|
|
|
|
func jsonString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|
|
func registryCrows(t *testing.T, ts *httptest.Server) []map[string]string {
|
|
t.Helper()
|
|
resp, err := http.Get(ts.URL + "/api/crows")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /api/crows: want 200, got %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Crows []map[string]string `json:"crows"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out.Crows
|
|
}
|
|
|
|
// bossSay waits for the nest to hold minTotal messages and returns the latest
|
|
// one from Boss.
|
|
func bossSay(t *testing.T, ts *httptest.Server, nestID string, minTotal int) map[string]any {
|
|
t.Helper()
|
|
var last map[string]any
|
|
for _, m := range awaitMessages(t, ts, nestID, minTotal) {
|
|
if m["author"] == "Boss" {
|
|
last = m
|
|
}
|
|
}
|
|
if last == nil {
|
|
t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID))
|
|
}
|
|
return last
|
|
}
|
|
|
|
func spunByName(crows []map[string]string, name string) map[string]string {
|
|
for _, c := range crows {
|
|
if c["name"] == name {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestBossSpinUpCreatesCrow(t *testing.T) {
|
|
s, bb := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "spin up a crow named Ash")
|
|
say := bossSay(t, ts, id, 2)
|
|
if say["kind"] != "say" || say["to"] != "owner" {
|
|
t.Fatalf("Boss must post a say back to the owner, got %v", say)
|
|
}
|
|
if !strings.Contains(say["text"].(string), "Ash") {
|
|
t.Fatalf("Boss say must name the new crow, got %v", say["text"])
|
|
}
|
|
|
|
got := spunByName(registryCrows(t, ts), "Ash")
|
|
if got == nil {
|
|
t.Fatalf("registry must contain Ash, got %v", registryCrows(t, ts))
|
|
}
|
|
if got["persona"] != "Soot" || got["trust"] != "stranger" {
|
|
t.Fatalf("want Ash on Soot at stranger, got %v", got)
|
|
}
|
|
|
|
rec := s.p.Ledger.Crows["Ash|other"]
|
|
if rec == nil || rec.Level != Stranger {
|
|
t.Fatalf("ledger must carry Ash|other at Stranger, got %+v", rec)
|
|
}
|
|
if bb.count() != 1 {
|
|
t.Fatalf("one spin-up is exactly one model call, got %d", bb.count())
|
|
}
|
|
}
|
|
|
|
func TestSpinUpCapRefused(t *testing.T) {
|
|
s, _ := newBossServer(t,
|
|
`SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`,
|
|
`SPIN {"name":"Beck","persona":"Jet","trust":"stranger"}`)
|
|
s.p.G.MaxSpunCrows = 1
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "spin up a crow named Ash")
|
|
bossSay(t, ts, id, 2)
|
|
if n := len(registryCrows(t, ts)); n != 1 {
|
|
t.Fatalf("first spin-up must land, registry has %d", n)
|
|
}
|
|
|
|
askBoss(t, ts, id, "spin up a crow named Beck")
|
|
say := bossSay(t, ts, id, 4)
|
|
text, _ := say["text"].(string)
|
|
if !strings.Contains(text, "cap") {
|
|
t.Fatalf("the refusal must name the cap, got %q", text)
|
|
}
|
|
if n := len(registryCrows(t, ts)); n != 1 {
|
|
t.Fatalf("a refused spin-up must not touch the registry, has %d", n)
|
|
}
|
|
if _, ok := s.p.Ledger.Crows["Beck|other"]; ok {
|
|
t.Fatal("a refused spin-up must not touch the ledger")
|
|
}
|
|
}
|
|
|
|
func TestSpinUpDuplicateNameRefused(t *testing.T) {
|
|
s, _ := newBossServer(t, `SPIN {"name":"Hugin","persona":"Soot","trust":"stranger"}`)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "spin up a crow named Hugin")
|
|
say := bossSay(t, ts, id, 2)
|
|
text, _ := say["text"].(string)
|
|
if !strings.Contains(text, "taken") {
|
|
t.Fatalf("the refusal must name the taken name, got %q", text)
|
|
}
|
|
if n := len(registryCrows(t, ts)); n != 0 {
|
|
t.Fatalf("registry must stay empty, has %d", n)
|
|
}
|
|
}
|
|
|
|
func TestSpinUpTrustGated(t *testing.T) {
|
|
s, _ := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"trusted"}`)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "spin up a crow named Ash, make it trusted")
|
|
bossSay(t, ts, id, 2)
|
|
|
|
got := spunByName(registryCrows(t, ts), "Ash")
|
|
if got == nil {
|
|
t.Fatal("Ash must be in the registry")
|
|
}
|
|
if got["trust"] != "regular" {
|
|
t.Fatalf("trusted must clamp to regular, got %q", got["trust"])
|
|
}
|
|
rec := s.p.Ledger.Crows["Ash|other"]
|
|
if rec == nil || rec.Level > Regular {
|
|
t.Fatalf("ledger level must be at most Regular, got %+v", rec)
|
|
}
|
|
}
|
|
|
|
func TestSpinUpGoesThroughFlock(t *testing.T) {
|
|
s, _ := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`)
|
|
s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "spin up a crow named Ash")
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
for _, m := range nestMessages(t, ts, id) {
|
|
if m["author"] == "Boss" {
|
|
t.Fatalf("a saturated flock must not produce a Boss message, got %v", m)
|
|
}
|
|
}
|
|
if n := len(registryCrows(t, ts)); n != 0 {
|
|
t.Fatalf("a saturated flock must not write the registry, has %d", n)
|
|
}
|
|
}
|
|
|
|
func TestBossPlainReply(t *testing.T) {
|
|
s, _ := newBossServer(t, "The schedule stands. Nothing needs whip.")
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "how goes the books")
|
|
say := bossSay(t, ts, id, 2)
|
|
if say["kind"] != "say" || say["text"] != "The schedule stands. Nothing needs whip." {
|
|
t.Fatalf("Boss posts its plain reply as a say, got %v", say)
|
|
}
|
|
if n := len(registryCrows(t, ts)); n != 0 {
|
|
t.Fatalf("a plain reply must not touch the registry, has %d", n)
|
|
}
|
|
}
|
|
|
|
func TestSpinUpGarbledRefused(t *testing.T) {
|
|
s, _ := newBossServer(t, `SPIN {"name": broken json`)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
askBoss(t, ts, id, "spin up a crow")
|
|
say := bossSay(t, ts, id, 2)
|
|
text, _ := say["text"].(string)
|
|
if text == "" {
|
|
t.Fatalf("an unreadable order must still get a refusal, got %v", say)
|
|
}
|
|
if n := len(registryCrows(t, ts)); n != 0 {
|
|
t.Fatalf("an unreadable order must not touch the registry, has %d", n)
|
|
}
|
|
}
|
|
|
|
func TestPersonasEndpointIncludesBoss(t *testing.T) {
|
|
ts, _ := newTestNestServer(t)
|
|
defer ts.Close()
|
|
resp, err := http.Get(ts.URL + "/api/personas")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /api/personas: want 200, got %d", resp.StatusCode)
|
|
}
|
|
var out []map[string]string
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := map[string]bool{}
|
|
for _, p := range out {
|
|
names[p["name"]] = true
|
|
}
|
|
for _, want := range []string{"Boss", "Quill", "Hugin"} {
|
|
if !names[want] {
|
|
t.Fatalf("/api/personas must list every persona crow (missing %s): %v", want, names)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrowsRegistryEmptyWithoutStore(t *testing.T) {
|
|
ts, _ := newTestNestServer(t) // no WithCrows: the registry reads as empty
|
|
defer ts.Close()
|
|
resp, err := http.Get(ts.URL + "/api/crows")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("want 200 with an empty list, got %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Crows []map[string]string `json:"crows"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out.Crows) != 0 {
|
|
t.Fatalf("want no crows, got %v", out.Crows)
|
|
}
|
|
}
|
|
|
|
// --- #43: Boss creates nests and assigns members ---
|
|
|
|
// listNests returns every nest from GET /api/nests.
|
|
func listNests(t *testing.T, ts *httptest.Server) []map[string]any {
|
|
t.Helper()
|
|
resp, err := http.Get(ts.URL + "/api/nests")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /api/nests: want 200, got %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
Nests []map[string]any `json:"nests"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out.Nests
|
|
}
|
|
|
|
func findNest(t *testing.T, ts *httptest.Server, name string) map[string]any {
|
|
t.Helper()
|
|
for _, n := range listNests(t, ts) {
|
|
if n["name"] == name {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nestCrowNames(nest map[string]any) []string {
|
|
raw, _ := nest["crows"].([]any)
|
|
out := make([]string, 0, len(raw))
|
|
for _, c := range raw {
|
|
out = append(out, c.(string))
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func TestBossCreatesNest(t *testing.T) {
|
|
s, bb := newBossServer(t,
|
|
`SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`,
|
|
`NEST {"name":"Launch","crows":["Hugin","Ash"]}`,
|
|
)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
labID := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
// Ash must exist as a registry crow: spin it through Boss first.
|
|
askBoss(t, ts, labID, "spin up a crow named Ash")
|
|
bossSay(t, ts, labID, 2)
|
|
|
|
askBoss(t, ts, labID, "make a nest Launch with Hugin and Ash")
|
|
|
|
var launch map[string]any
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for launch == nil && time.Now().Before(deadline) {
|
|
launch = findNest(t, ts, "Launch")
|
|
if launch == nil {
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}
|
|
if launch == nil {
|
|
t.Fatalf("Boss must create the nest Launch, nests: %v", listNests(t, ts))
|
|
}
|
|
if got := nestCrowNames(launch); !slices.Equal(got, []string{"Ash", "Hugin"}) {
|
|
t.Fatalf("Launch crows must be exactly [Ash Hugin], got %v", got)
|
|
}
|
|
|
|
// Boss announces membership in the NEW nest: a say to the owner.
|
|
announced := false
|
|
for _, m := range awaitMessages(t, ts, launch["nest_id"].(string), 1) {
|
|
if m["author"] == "Boss" && m["kind"] == "say" && m["to"] == "owner" {
|
|
announced = true
|
|
}
|
|
}
|
|
if !announced {
|
|
t.Fatalf("Boss must post a say to the owner in Launch, got %v",
|
|
nestMessages(t, ts, launch["nest_id"].(string)))
|
|
}
|
|
|
|
// And confirms in the requesting nest.
|
|
confirmed := false
|
|
for _, m := range awaitMessages(t, ts, labID, 4) {
|
|
if m["author"] == "Boss" && m["kind"] == "say" && strings.Contains(m["text"].(string), "Launch") {
|
|
confirmed = true
|
|
}
|
|
}
|
|
if !confirmed {
|
|
t.Fatalf("Boss must confirm the new nest in Lab, got %v", nestMessages(t, ts, labID))
|
|
}
|
|
if bb.count() != 2 {
|
|
t.Fatalf("two orders are two model calls, got %d", bb.count())
|
|
}
|
|
}
|
|
|
|
func TestBossNestUnknownMemberRefused(t *testing.T) {
|
|
s, _ := newBossServer(t, `NEST {"name":"Nope","crows":["Gandalf"]}`)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
labID := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
before := len(listNests(t, ts))
|
|
askBoss(t, ts, labID, "make a nest Nope with Gandalf")
|
|
|
|
say := bossSay(t, ts, labID, 2)
|
|
text, _ := say["text"].(string)
|
|
if strings.HasPrefix(text, "NEST") {
|
|
t.Fatalf("Boss must interpret the order, not echo it back: %q", text)
|
|
}
|
|
if !strings.Contains(text, "Gandalf") {
|
|
t.Fatalf("the refusal must name the unknown member, got %q", text)
|
|
}
|
|
if after := len(listNests(t, ts)); after != before {
|
|
t.Fatalf("an unknown member must refuse the whole create: %d before, %d after", before, after)
|
|
}
|
|
}
|
|
|
|
func TestBossNestCapRespected(t *testing.T) {
|
|
s, _ := newBossServer(t, `NEST {"name":"Overflow","crows":["Hugin"]}`)
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
labID := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
// Fill the store to its cap: todo + Lab + (maxNests-2) pads.
|
|
for i := 0; 2+i < maxNests; i++ {
|
|
if _, err := s.nests.Create(fmt.Sprintf("Pad %d", i), []string{"Hugin"}); err != nil {
|
|
t.Fatalf("padding nests: %v", err)
|
|
}
|
|
}
|
|
if n := len(listNests(t, ts)); n != maxNests {
|
|
t.Fatalf("test setup: want %d nests, got %d", maxNests, n)
|
|
}
|
|
|
|
askBoss(t, ts, labID, "make a nest Overflow with Hugin") // 201: no 500 at the front door
|
|
say := bossSay(t, ts, labID, 2)
|
|
text, _ := say["text"].(string)
|
|
if !strings.Contains(text, fmt.Sprintf("at most %d nests", maxNests)) {
|
|
t.Fatalf("Boss must report the store's cap refusal, got %q", text)
|
|
}
|
|
if findNest(t, ts, "Overflow") != nil {
|
|
t.Fatal("a capped create must not make a nest")
|
|
}
|
|
}
|
|
|
|
func TestBossNestGoesThroughFlock(t *testing.T) {
|
|
s, _ := newBossServer(t, `NEST {"name":"Launch","crows":["Hugin","Munin"]}`)
|
|
s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act
|
|
ts := serve(s)
|
|
defer ts.Close()
|
|
labID := newNest(t, ts, "Lab", `"Hugin"`)
|
|
|
|
before := len(listNests(t, ts))
|
|
askBoss(t, ts, labID, "make a nest Launch with Hugin and Munin")
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
if n := len(listNests(t, ts)); n != before {
|
|
t.Fatalf("a saturated flock must not create a nest, got %v", listNests(t, ts))
|
|
}
|
|
for _, m := range nestMessages(t, ts, labID) {
|
|
if m["author"] == "Boss" {
|
|
t.Fatalf("a saturated flock must not produce a Boss message, got %v", m)
|
|
}
|
|
}
|
|
}
|