| new file mode 100644 |
| index 0000000..8bda68e |
| --- /dev/null |
| +++ b/bot_charterrename_test.go |
| @@ -0,0 +1,151 @@ |
| +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(`<rss><item><title>All quiet</title></item></rss>`)) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html") |
| + w.Write([]byte(`<html><head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head></html>`)) |
| + })) |
| + 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 <farSlot2> 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) |
| + } |
| +} |