package main // End-to-end test of renaming a member bot conversationally (internal/bot: // botbuild.go + runtime.go renameCharterThread): a create_bot update with a // new title must rename the bot's dedicated thread (it was titled after the // bot at birth) β€” and an update that DOESN'T touch the persona must leave the // thread name alone, so a member's hand-picked group name survives a schedule // tweak. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) func TestCharterUpdateRenamesThread(t *testing.T) { t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/feed.xml" { w.Write([]byte(`All quiet`)) return } w.Header().Set("Content-Type", "text/html") w.Write([]byte(``)) })) defer site.Close() farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04") farSlot2 := time.Now().UTC().Add(13 * time.Hour).Format("15:04") dryRun := [][]map[string]any{ {{"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": site.URL + "/feed.xml"}}}, {{"type": "text", "text": "All quiet today."}}, } fakeAI := scriptedAnthropic(t, // Update 1: a RENAME (new title + glyph, same schedule). []map[string]any{{"type": "tool_use", "name": "create_bot", "input": map[string]any{ "bot_id": "bot-home01", "name": "home-news", "title": "Journal Daily", "glyph": "πŸ“°", "at": farSlot, "sources": []any{site.URL}, "prompt": "Summarise the feed.", }}}, dryRun[0], dryRun[1], // Update 2: schedule only β€” title/glyph unchanged. []map[string]any{{"type": "tool_use", "name": "create_bot", "input": map[string]any{ "bot_id": "bot-home01", "name": "home-news", "title": "Journal Daily", "glyph": "πŸ“°", "at": farSlot2, "sources": []any{site.URL}, "prompt": "Summarise the feed.", }}}, dryRun[0], dryRun[1], ) 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) 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) threadName := func() string { var nameBlob string if err := s.db.sql.QueryRow(`SELECT name_blob FROM threads WHERE id = ?`, threadID).Scan(&nameBlob); err != nil { t.Fatalf("thread name_blob: %v", err) } if nameBlob == "" { return "" } var n struct { Name string `json:"name"` } if err := e2ee.DecryptJSON(threadKey, nameBlob, &n); err != nil { return nameBlob // opaque (the hand-set marker below) } return n.Name } // Rename: the bot's chat follows the bot's new persona. postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Rename it to Journal Daily please.") waitFor(t, "the rename update to settle", func() bool { for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { if strings.Contains(rp.Text, "is updated") { return true } } return false }) if got := threadName(); got != "Journal Daily πŸ“°" { t.Fatalf("thread name after rename = %q, want %q", got, "Journal Daily πŸ“°") } // A schedule-only update must NOT touch the thread name β€” a member's own // group rename survives. if err := s.db.setThreadName(threadID, "HAND-SET-OPAQUE-BLOB"); err != nil { t.Fatal(err) } // Let the first build's goroutine release its one-build-at-a-time slot // (endBuild is deferred, a beat after the settle message lands). time.Sleep(200 * time.Millisecond) postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Make it an hour later.") // The second settle is recognisable by the NEW slot in its schedule // summary ("…the next arrives daily at UTC") β€” both settles // say "is updated", so the first line can't stand in for it. waitFor(t, "the schedule update to settle", func() bool { for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { if strings.Contains(rp.Text, farSlot2) { return true } } return false }) if got := threadName(); got != "HAND-SET-OPAQUE-BLOB" { t.Fatalf("schedule-only update touched the thread name: %q", got) } }