package main
// End-to-end tests of the member-bot pipeline (internal/bot/botbuild.go +
// chartersched.go + memberbots.go): a member asks the charter host for a bot
// in a DM; the (fake) model answers with a create_bot call; the sidecar
// discovers the source's feed, DRY-RUNS the task (fetch → card → text) as the
// gate, stores the charter under the instance key, opens the bot's dedicated
// thread, arms the alarm in its self-thread, and delivers the dry run as the
// first edition — all against the real router, all ciphertext to the server.
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lchat/internal/bot"
"lchat/internal/e2ee"
)
// grantInstanceKey wraps a fresh instance key to the bot the way a member's
// client would, and returns it so the test can decrypt what the bot stores.
func grantInstanceKey(t *testing.T, tsURL string, owner, agent *bot.Session, s *Server) []byte {
t.Helper()
agentUID, _ := s.db.deviceTokenUser(agent.Token)
instKey, err := e2ee.GenerateKey()
if err != nil {
t.Fatal(err)
}
wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), agent.UserPub)
if err != nil {
t.Fatal(err)
}
grant, _ := json.Marshal(map[string]any{
"token": owner.Token,
"grants": []map[string]string{{"user_id": agentUID, "wrapped": wrapped}},
})
resp, err := http.Post(tsURL+"/api/instance-key", "application/json", bytes.NewReader(grant))
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("grant instance key: %v %d", err, resp.StatusCode)
}
return instKey
}
func postAsMember(t *testing.T, tsURL, threadID string, key []byte, sess *bot.Session, uid, text string) {
t.Helper()
blob, err := e2ee.EncryptJSON(key, map[string]any{
"name": sess.Name, "uid": uid, "text": text, "ts": time.Now().UnixMilli(),
})
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
if resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("post: %v %d", err, resp.StatusCode)
}
}
func TestBotBuildsMemberBot(t *testing.T) {
t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") // loopback site + feed
// The "publication": a homepage advertising its feed (discovery reads the
// ), and the feed itself.
site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/feed.xml" {
w.Write([]byte(`Budget talks resume`))
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`
`))
}))
defer site.Close()
fakeAI := scriptedAnthropic(t,
// Call 1 — the conversational turn: ack + the create_bot call.
[]map[string]any{
{"type": "text", "text": "Lovely — setting up Morning News now."},
{"type": "tool_use", "name": "create_bot", "input": map[string]any{
"name": "morning-news", "title": "Morning News", "glyph": "📰",
"at": "08:00", "tz": "Europe/Dublin",
"sources": []any{site.URL},
"prompt": "Fetch today's headlines from each source and write a short digest.",
}},
},
// Calls 2-4 — the DRY RUN's task loop: a thinking block rides along
// (as live models send under adaptive thinking — the loop must echo it
// back intact or the API 400s), then a fetch, then a card, then text.
[]map[string]any{
{"type": "thinking", "thinking": "fetch the feed first", "signature": "sig_abc123"},
{"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": site.URL + "/feed.xml"}},
},
[]map[string]any{
{"type": "tool_use", "id": "tu_2", "name": "card", "input": map[string]any{"card": map[string]any{
"schema": 1, "blocks": []map[string]any{
{"b": "heading", "text": "Morning news"},
{"b": "item", "text": "Budget talks resume", "source": "The Site"},
}}}},
},
[]map[string]any{
{"type": "text", "text": "Morning! One story today: budget talks resume."},
},
)
t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
ts, s := newTestServer(t)
owner := provisionNamed(t, ts.URL, "Owner", false)
eleven := provisionNamed(t, ts.URL, "Eleven", true)
dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
ownerUID, _ := s.db.deviceTokenUser(owner.Token)
elevenUID, _ := s.db.deviceTokenUser(eleven.Token)
instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
dir := t.TempDir()
sessionPath := dir + "/eleven.session.json"
if err := eleven.Save(sessionPath); err != nil {
t.Fatal(err)
}
ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
Ready: func() { close(ready) }, Logf: t.Logf}
go eng.Run(ctx)
awaitReady(t, ready)
postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID,
"Can you make me a bot that sends me a news summary every morning?")
// The charter lands in the store (the build runs on its own goroutine).
waitFor(t, "charter stored", func() bool {
bots, _ := s.db.listMemberBots()
return len(bots) == 1
})
bots, _ := s.db.listMemberBots()
if bots[0].AuthorID != elevenUID {
t.Fatalf("charter author should be the host bot: %+v", bots[0])
}
if bots[0].OwnerID != ownerUID {
t.Fatalf("charter owner should be the requester: %+v", bots[0])
}
if !strings.HasPrefix(bots[0].ID, "bot-") {
t.Fatalf("unexpected bot id %q", bots[0].ID)
}
// The build reported the charter wake-time (recomputeCharterDue), so a
// hibernating instance would wake for the new bot's first occurrence.
waitFor(t, "charter due reported after build", func() bool {
return s.db.charterNextDue() > time.Now().Unix()
})
// The blob decrypts (instance key) to the charter: persona, schedule, the
// DISCOVERED feed on the allowlist (the member only ever named the site),
// the dedicated thread.
var c struct {
V int `json:"v"`
Name string `json:"name"`
Title string `json:"title"`
Glyph string `json:"glyph"`
Schedule struct {
At string `json:"at"`
TZ string `json:"tz"`
} `json:"schedule"`
Task struct {
Prompt string `json:"prompt"`
Fetch struct {
Allow []string `json:"allow"`
} `json:"fetch"`
} `json:"task"`
Thread string `json:"thread"`
Owner string `json:"owner"`
By string `json:"by"`
}
if err := e2ee.DecryptJSON(instKey, bots[0].Blob, &c); err != nil {
t.Fatalf("decrypt charter: %v", err)
}
if c.V != 1 || c.Name != "morning-news" || c.Title != "Morning News" || c.Glyph != "📰" {
t.Fatalf("bad charter: %+v", c)
}
if c.Schedule.At != "08:00" || c.Schedule.TZ != "Europe/Dublin" {
t.Fatalf("bad schedule: %+v", c.Schedule)
}
if len(c.Task.Fetch.Allow) != 1 || c.Task.Fetch.Allow[0] != site.URL+"/feed.xml" {
t.Fatalf("allowlist should be the DISCOVERED feed: %v", c.Task.Fetch.Allow)
}
if c.Owner != ownerUID || c.By != "Owner" {
t.Fatalf("charter owner: %+v", c)
}
if c.Thread == "" {
t.Fatal("charter has no dedicated thread")
}
// The dedicated thread: a titled group of exactly (owner, host), whose
// first message is the dry run's output — posted under the PERSONA name,
// card riding along, text as the plain fallback.
if kind := s.db.threadKind(c.Thread); kind != "group" {
t.Fatalf("dedicated thread kind = %q", kind)
}
members, _ := s.db.memberUserIDs(c.Thread)
if len(members) != 2 {
t.Fatalf("dedicated thread members: %v", members)
}
msgs, err := s.db.messagesAfter(c.Thread, 0, 10)
if err != nil || len(msgs) != 1 {
t.Fatalf("first edition: %v (%d msgs)", err, len(msgs))
}
// The thread key is wrapped to the owner — unwrap as the owner's client would.
ownerWrapped := threadWrappedFor(t, ts.URL, owner, c.Thread)
keyB64, err := e2ee.UnwrapThreadKey(ownerWrapped, owner.UserPriv)
if err != nil {
t.Fatal(err)
}
tkey, err := e2ee.ImportKey(keyB64)
if err != nil {
t.Fatal(err)
}
var first struct {
Name string `json:"name"`
Text string `json:"text"`
Card map[string]any `json:"card"`
}
if err := e2ee.DecryptJSON(tkey, msgs[0].Blob, &first); err != nil {
t.Fatalf("decrypt first edition: %v", err)
}
if first.Name != "Morning News" {
t.Fatalf("first edition should post under the persona: %+v", first)
}
if !strings.Contains(first.Text, "budget talks resume") {
t.Fatalf("first edition text: %q", first.Text)
}
if first.Card == nil || first.Card["schema"] != float64(1) {
t.Fatalf("first edition should carry the composed card: %+v", first.Card)
}
// The alarm: exactly one pending tick in the host's self-thread.
var selfID string
err = s.db.sql.QueryRow(
`SELECT t.id FROM threads t JOIN thread_members m ON m.thread_id = t.id
WHERE t.kind = 'self' AND m.user_id = ?`, elevenUID).Scan(&selfID)
if err != nil {
t.Fatalf("host self-thread: %v", err)
}
if n := s.db.countScheduledMessages(selfID); n != 1 {
t.Fatalf("want 1 pending alarm, got %d", n)
}
// The DM settles for a PERSON: the confirmation is plain English with an
// "open the chat" chip pointing at the dedicated thread; the opaque id
// rides the muted breadcrumb line (where the brain can read it back for
// updates), never the message.
waitFor(t, "settle message carries the open-chat chip", func() bool {
for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") {
for _, a := range rp.Actions {
if a["type"] == "open_thread" && a["thread"] == c.Thread {
return true
}
}
}
return false
})
var breadcrumbHasID, messageHasID bool
for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") {
if strings.Contains(rp.Text, bots[0].ID) {
if len(rp.Actions) > 0 {
messageHasID = true
} else {
breadcrumbHasID = true
}
}
if strings.Contains(rp.Text, "://") {
t.Fatalf("settle text must carry no URLs (they drag link previews into the chat): %q", rp.Text)
}
}
if !breadcrumbHasID {
t.Fatal("the breadcrumb should carry the bot id for later updates")
}
if messageHasID {
t.Fatal("the member-facing confirmation should not carry the opaque id")
}
}
// threadWrappedFor reads a member's wrapped key for one thread off the
// ordinary threads endpoint.
func threadWrappedFor(t *testing.T, tsURL string, sess *bot.Session, threadID string) string {
t.Helper()
resp, err := http.Get(tsURL + "/api/threads?token=" + sess.Token)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var tl struct {
Threads []struct {
ID string `json:"id"`
Wrapped string `json:"wrapped"`
} `json:"threads"`
}
if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil {
t.Fatal(err)
}
for _, th := range tl.Threads {
if th.ID == threadID {
return th.Wrapped
}
}
t.Fatalf("no wrapped key for %s", threadID)
return ""
}
// TestCharterTickRunsTask drives the autonomous loop end to end: a stored
// charter, the host's connect-time reconcile arming a server-side alarm, the
// sweep delivering the encrypted bot.tick, the task running (scripted model),
// and the result landing in the charter's thread under the persona — then the
// alarm re-armed for the next occurrence.
func TestCharterTickRunsTask(t *testing.T) {
t.Setenv("LCHAT_BOT_MIN_EVERY", "1s") // watch the bot fire in test time
fakeAI := scriptedAnthropic(t,
[]map[string]any{{"type": "text", "text": "Your hourly note: all quiet."}},
)
t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
ts, s := newTestServer(t)
owner := provisionNamed(t, ts.URL, "Owner", false)
eleven := provisionNamed(t, ts.URL, "Eleven", true)
ownerUID, _ := s.db.deviceTokenUser(owner.Token)
instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
// The bot's dedicated thread (what the builder would have created).
threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
// Seed a stored charter directly — every 1s so the test can watch it fire.
charter := map[string]any{
"v": 1, "name": "quiet-note", "title": "Quiet Note", "glyph": "🕰️",
"schedule": map[string]any{"every": "1s"},
"task": map[string]any{"prompt": "Say something calm."},
"thread": threadID, "owner": ownerUID,
}
blob, err := e2ee.EncryptJSON(instKey, charter)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-quiet1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
}
dir := t.TempDir()
sessionPath := dir + "/eleven.session.json"
if err := eleven.Save(sessionPath); err != nil {
t.Fatal(err)
}
ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
Ready: func() { close(ready) }, Logf: t.Logf}
go eng.Run(ctx)
awaitReady(t, ready)
// Connect reconciled — and reported the charter wake-time, so a hibernating
// instance would be woken for this schedule (charter-wake registration).
waitFor(t, "charter due reported", func() bool {
return s.db.charterNextDue() > 0
})
// One pending alarm. The sweep delivers it; the task posts into the
// charter's thread under the persona name.
waitFor(t, "task result posted", func() bool {
s.sweepDueScheduled()
msgs, _ := s.db.messagesAfter(threadID, 0, 10)
return len(msgs) > 0
})
msgs, _ := s.db.messagesAfter(threadID, 0, 10)
var out struct {
Name string `json:"name"`
Text string `json:"text"`
}
if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &out); err != nil {
t.Fatalf("decrypt: %v", err)
}
if out.Name != "Quiet Note" || !strings.Contains(out.Text, "all quiet") {
t.Fatalf("bad task post: %+v", out)
}
// Firing re-armed the next occurrence.
uid, _ := s.db.deviceTokenUser(eleven.Token)
var selfID string
if err := s.db.sql.QueryRow(
`SELECT t.id FROM threads t JOIN thread_members m ON m.thread_id = t.id
WHERE t.kind = 'self' AND m.user_id = ?`, uid).Scan(&selfID); err != nil {
t.Fatalf("self thread: %v", err)
}
waitFor(t, "re-armed alarm", func() bool {
return s.db.countScheduledMessages(selfID) >= 1
})
}
// TestCharterMissedAlarmRunsAtConnect pins the due-wake boot path of
// charter-wake registration: a live charter whose alarm was consumed while no
// host was listening (the hibernation wake race — the instance's scheduler
// fires the tick before the co-spawned sidecar's cursor baseline) must run
// promptly at connect, not silently skip to its next occurrence. The seeded
// charter's daily slot is ~12 hours away, so a run inside the test window can
// only be the connect-time catch-up arm.
func TestCharterMissedAlarmRunsAtConnect(t *testing.T) {
fakeAI := scriptedAnthropic(t,
[]map[string]any{{"type": "text", "text": "Here is the edition you were owed."}},
)
t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
ts, s := newTestServer(t)
owner := provisionNamed(t, ts.URL, "Owner", false)
eleven := provisionNamed(t, ts.URL, "Eleven", true)
ownerUID, _ := s.db.deviceTokenUser(owner.Token)
instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
// A charter with NO pending alarm — exactly the state after a tick was
// delivered into a host-less self-thread. Its daily slot is far away.
farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04")
charter := map[string]any{
"v": 1, "name": "owed-note", "title": "Owed Note", "glyph": "📬",
"schedule": map[string]any{"at": farSlot},
"task": map[string]any{"prompt": "Say what was missed."},
"thread": threadID, "owner": ownerUID,
}
blob, err := e2ee.EncryptJSON(instKey, charter)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-owed01", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
}
dir := t.TempDir()
sessionPath := dir + "/eleven.session.json"
if err := eleven.Save(sessionPath); err != nil {
t.Fatal(err)
}
ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
Ready: func() { close(ready) }, Logf: t.Logf}
go eng.Run(ctx)
awaitReady(t, ready)
// The connect-time reconcile arms the owed run at NOW; the sweep delivers
// it and the task posts — hours before the schedule's next slot.
waitFor(t, "owed run posted at connect", func() bool {
s.sweepDueScheduled()
msgs, _ := s.db.messagesAfter(threadID, 0, 10)
return len(msgs) > 0
})
msgs, _ := s.db.messagesAfter(threadID, 0, 10)
var out struct {
Name string `json:"name"`
Text string `json:"text"`
}
if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &out); err != nil {
t.Fatalf("decrypt: %v", err)
}
if out.Name != "Owed Note" || !strings.Contains(out.Text, "owed") {
t.Fatalf("bad catch-up post: %+v", out)
}
// The run's re-arm reported the wake-time: the next occurrence (the far
// slot), so a hibernating instance would sleep until then, not loop.
waitFor(t, "wake-time reported", func() bool {
due := s.db.charterNextDue()
return due > time.Now().Add(time.Hour).Unix()
})
}