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