diff --git a/EVENTS.md b/EVENTS.md index 9b0f2cc..66e9b22 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -103,6 +103,8 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev A nest is a room where a team of crows works together. You create a nest and pick its core crows; when a job needs more crows than the core has, the Sentry borrows from the rest of the flock. The Sentry, Elder and Quill serve every nest and are never core members. Quill (assistant crow, CHATROOM_SPEC section 6) answers `request` messages addressed to it with one `deliver` from Quill to the requester. +A relay (D3, task #40): when the owner's request asks another named member crow to do work, Quill posts a `request` authored by Quill — `to` the named crow, `refers_to` the owner message it came from, `tagged_by: "harness"` — and nothing else; the addressed crow answers through the normal must-respond loop. If Quill cannot name a member of this nest, it posts one `say` back to the requester instead and the request rests there. No new fields; relay rides the existing record shape. + - The default nest is `todo`, with an empty core: its crows come from the whole flock, so nothing there is ever "borrowed". - Crows are picked core-first (rotating within the core), then borrowed from outside it (rotating), still capped by the flock guardrails. - Nests persist on the server (`.crows/nests.json`). diff --git a/relay_test.go b/relay_test.go new file mode 100644 index 0000000..2a62870 --- /dev/null +++ b/relay_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// relayBrain records every (system, prompt) pair and answers with scripted +// replies in call order, falling back to plain chatter once they run out. +type relayBrain struct { + mu sync.Mutex + pairs [][2]string + replies []string + calls int +} + +func (b *relayBrain) Name() string { return "relay-rec" } + +func (b *relayBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.pairs = append(b.pairs, [2]string{system, prompt}) + reply := "just chatter" + if b.calls < len(b.replies) { + reply = b.replies[b.calls] + } + b.calls++ + return reply, 10, nil +} + +func newRelayServer(t *testing.T, replies ...string) (*httptest.Server, *Server, *relayBrain) { + t.Helper() + ts, s := newTestNestServer(t) + rb := &relayBrain{replies: replies} + s.p.Flock.brain = rb + return ts, s, rb +} + +// askQuill posts the owner's request addressed to Quill and returns the +// stored record of that message. +func askQuill(t *testing.T, ts *httptest.Server, id, text string) map[string]any { + t.Helper() + code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"request","text":"`+text+`"}`) + if code != http.StatusCreated { + t.Fatalf("owner request: %d %v", code, req) + } + return req +} + +func countBy(t *testing.T, ts *httptest.Server, id, author string, kinds ...string) (total int, matched int) { + t.Helper() + for _, m := range nestMessages(t, ts, id) { + if m["author"] != author { + continue + } + total++ + for _, k := range kinds { + if m["kind"] == k { + matched++ + } + } + } + return +} + +func TestQuillRelayPostsRequest(t *testing.T) { + ts, _, _ := newRelayServer(t, + `RELAY {"crow":"Hugin","task":"draft the tagline"}`, + "KIND: deliver\nSo many crows, one banner.") + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + req := askQuill(t, ts, id, "get Hugin to draft the tagline") + msgs := awaitMessages(t, ts, id, 3) + + var relay map[string]any + for _, m := range msgs { + if m["author"] == "Quill" && m["kind"] == "request" { + relay = m + } + } + if relay == nil { + t.Fatalf("Quill never posted the relay request: %v", msgs) + } + if relay["to"] != "Hugin" { + t.Fatalf("relay must address Hugin, got %v", relay["to"]) + } + if relay["refers_to"] != req["msg_id"] { + t.Fatalf("relay must refer to the owner message %v, got %v", req["msg_id"], relay["refers_to"]) + } + if relay["text"] != "draft the tagline" { + t.Fatalf("relay text must carry the task, got %v", relay["text"]) + } + hugin := false + for _, m := range msgs { + if m["author"] == "Hugin" { + hugin = true + } + } + if !hugin { + t.Fatalf("the addressed crow must answer the relay through must-respond: %v", msgs) + } +} + +func TestQuillRelayUnknownCrowStalls(t *testing.T) { + ts, _, _ := newRelayServer(t, `RELAY {"crow":"Soot","task":"blacken the roof"}`) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + askQuill(t, ts, id, "get Soot to blacken the roof") + awaitMessages(t, ts, id, 2) + time.Sleep(200 * time.Millisecond) + msgs := nestMessages(t, ts, id) + + total, requests := countBy(t, ts, id, "Quill", "request") + if total != 1 || requests != 0 { + t.Fatalf("want exactly one Quill message and zero Quill requests, got %v in %v", msgs, total) + } + var stall map[string]any + for _, m := range msgs { + if m["author"] == "Quill" { + stall = m + } + } + if stall == nil || stall["kind"] != "say" { + t.Fatalf("the unknown-crow reply must be a Quill say, got %v", stall) + } + if stall["to"] != "owner" { + t.Fatalf("the say must go back to the requester, got %v", stall["to"]) + } +} + +func TestQuillRelayNotAnsweredTwice(t *testing.T) { + ts, _, _ := newRelayServer(t, + `RELAY {"crow":"Hugin","task":"draft the tagline"}`, + "KIND: deliver\nSo many crows, one banner.") + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + askQuill(t, ts, id, "get Hugin to draft the tagline") + awaitMessages(t, ts, id, 3) + time.Sleep(200 * time.Millisecond) + + total, delivers := countBy(t, ts, id, "Quill", "deliver") + if total != 1 { + t.Fatalf("a successful relay must leave Quill with exactly one message, got %d", total) + } + if delivers != 0 { + t.Fatalf("a relay must not also deliver, got %d delivers", delivers) + } +} + +func TestQuillRelayGoesThroughFlock(t *testing.T) { + ts, s := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + s.p.Flock.brain = &relayBrain{replies: []string{`RELAY {"crow":"Hugin","task":"draft the tagline"}`}} + s.p.Flock.sem = make(chan struct{}, 0) + askQuill(t, ts, id, "get Hugin to draft the tagline") + time.Sleep(300 * time.Millisecond) + if got := nestMessages(t, ts, id); len(got) != 1 { + t.Fatalf("a saturated flock means ask fails and Quill posts nothing: %v", got) + } +}