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() }) }