package main // End-to-end test of the conversational bot-home thread (internal/bot: // runtime.go triggered + brain.go charterHomeNote): in a member bot's own // chat the owner talks to the host WITHOUT an @mention, the brain receives // the charter (bot id included) as system context, and remove_bot retires // the bot from right there — while the bot's own persona-named editions // never trigger a reply. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) // capturingAnthropic is scriptedAnthropic plus request capture, so a test can // assert what context actually reached the model. func capturingAnthropic(t *testing.T, responses ...[]map[string]any) (*httptest.Server, func() [][]byte) { t.Helper() var mu sync.Mutex var bodies [][]byte calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var buf bytes.Buffer buf.ReadFrom(r.Body) mu.Lock() bodies = append(bodies, buf.Bytes()) i := calls calls++ mu.Unlock() if i >= len(responses) { i = len(responses) - 1 } json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"}) })) t.Cleanup(srv.Close) return srv, func() [][]byte { mu.Lock() defer mu.Unlock() return append([][]byte(nil), bodies...) } } func TestCharterThreadConversation(t *testing.T) { fakeAI, requests := capturingAnthropic(t, // Turn 1 — the un-mentioned "Hello?": a plain conversational reply. []map[string]any{{"type": "text", "text": "Hi! I look after Home News here — want me to change what it reads or when it runs?"}}, // Turn 2 — "remove this bot": the model uses the bot_id from its context. []map[string]any{{"type": "tool_use", "name": "remove_bot", "input": map[string]any{"bot_id": "bot-home01"}}}, ) 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) elevenUID, _ := s.db.deviceTokenUser(eleven.Token) instKey := grantInstanceKey(t, ts.URL, owner, eleven, s) threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven) // A stored charter whose home is that thread. Its daily slot is far away // and this test never sweeps the schedule queue, so no task run interferes. farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04") charter := map[string]any{ "v": 1, "name": "home-news", "title": "Home News", "glyph": "🏠", "schedule": map[string]any{"at": farSlot}, "task": map[string]any{"prompt": "Write the household news."}, "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-home01", 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 bot's own editions post under the persona name with no uid — they // must never trigger the host. Posted first: if this DID draw a reply, the // count assertions below would catch the extra message. edition, err := e2ee.EncryptJSON(threadKey, map[string]any{ "name": "Home News", "text": "Tonight's household news: all beds made.", "ts": time.Now().UnixMilli(), }) if err != nil { t.Fatal(err) } eb, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": edition}) if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(eb)); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("post edition: %v %d", err, resp.StatusCode) } // The owner says hello — NO @mention. The host must reply anyway. postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Hello?") waitFor(t, "a reply to the un-mentioned hello", func() bool { return len(botReplies(t, s, threadID, threadKey, "Eleven")) == 1 }) if got := botReplies(t, s, threadID, threadKey, "Eleven")[0].Text; !strings.Contains(got, "look after Home News") { t.Fatalf("unexpected reply: %q", got) } // The brain got the charter as system context: a second system block // carrying the real bot id, the persona, and the edit/retire tools. reqs := requests() if len(reqs) != 1 { t.Fatalf("want 1 model call so far, got %d", len(reqs)) } var call struct { System []struct { Text string `json:"text"` } `json:"system"` } if err := json.Unmarshal(reqs[0], &call); err != nil { t.Fatal(err) } if len(call.System) != 2 { t.Fatalf("want the cached prompt + the charter note, got %d system blocks", len(call.System)) } note := call.System[1].Text for _, want := range []string{"bot-home01", "Home News", "create_bot", "remove_bot", "Write the household news."} { if !strings.Contains(note, want) { t.Fatalf("charter note missing %q:\n%s", want, note) } } // "Remove this bot" — the model answers with remove_bot + the id from its // context; the charter is deleted, its pending alarm dropped, and the // thread (the member's history) stays. if n := s.db.countScheduledMessages(selfThreadOf(t, s, elevenUID)); n != 1 { t.Fatalf("want the seeded charter's 1 pending alarm before removal, got %d", n) } postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Please remove this bot.") waitFor(t, "the retirement confirmation", func() bool { replies := botReplies(t, s, threadID, threadKey, "Eleven") return len(replies) == 2 && strings.Contains(replies[1].Text, "retired") }) waitFor(t, "charter deleted", func() bool { bots, _ := s.db.listMemberBots() return len(bots) == 0 }) waitFor(t, "alarm dropped", func() bool { return s.db.countScheduledMessages(selfThreadOf(t, s, elevenUID)) == 0 }) } // selfThreadOf finds a bot identity's self-thread (where charter alarms live). func selfThreadOf(t *testing.T, s *Server, uid string) string { t.Helper() 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) } return selfID }