| 1 | package main |
| 2 | |
| 3 | // End-to-end test of renaming a member bot conversationally (internal/bot: |
| 4 | // botbuild.go + runtime.go renameCharterThread): a create_bot update with a |
| 5 | // new title must rename the bot's dedicated thread (it was titled after the |
| 6 | // bot at birth) β and an update that DOESN'T touch the persona must leave the |
| 7 | // thread name alone, so a member's hand-picked group name survives a schedule |
| 8 | // tweak. |
| 9 | |
| 10 | import ( |
| 11 | "bytes" |
| 12 | "context" |
| 13 | "encoding/json" |
| 14 | "net/http" |
| 15 | "net/http/httptest" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "lchat/internal/bot" |
| 21 | "lchat/internal/e2ee" |
| 22 | ) |
| 23 | |
| 24 | func TestCharterUpdateRenamesThread(t *testing.T) { |
| 25 | t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") |
| 26 | |
| 27 | site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 28 | if r.URL.Path == "/feed.xml" { |
| 29 | w.Write([]byte(`<rss><item><title>All quiet</title></item></rss>`)) |
| 30 | return |
| 31 | } |
| 32 | w.Header().Set("Content-Type", "text/html") |
| 33 | w.Write([]byte(`<html><head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head></html>`)) |
| 34 | })) |
| 35 | defer site.Close() |
| 36 | |
| 37 | farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04") |
| 38 | farSlot2 := time.Now().UTC().Add(13 * time.Hour).Format("15:04") |
| 39 | dryRun := [][]map[string]any{ |
| 40 | {{"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": site.URL + "/feed.xml"}}}, |
| 41 | {{"type": "text", "text": "All quiet today."}}, |
| 42 | } |
| 43 | fakeAI := scriptedAnthropic(t, |
| 44 | // Update 1: a RENAME (new title + glyph, same schedule). |
| 45 | []map[string]any{{"type": "tool_use", "name": "create_bot", "input": map[string]any{ |
| 46 | "bot_id": "bot-home01", "name": "home-news", "title": "Journal Daily", "glyph": "π°", |
| 47 | "at": farSlot, "sources": []any{site.URL}, "prompt": "Summarise the feed.", |
| 48 | }}}, |
| 49 | dryRun[0], dryRun[1], |
| 50 | // Update 2: schedule only β title/glyph unchanged. |
| 51 | []map[string]any{{"type": "tool_use", "name": "create_bot", "input": map[string]any{ |
| 52 | "bot_id": "bot-home01", "name": "home-news", "title": "Journal Daily", "glyph": "π°", |
| 53 | "at": farSlot2, "sources": []any{site.URL}, "prompt": "Summarise the feed.", |
| 54 | }}}, |
| 55 | dryRun[0], dryRun[1], |
| 56 | ) |
| 57 | t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) |
| 58 | |
| 59 | ts, s := newTestServer(t) |
| 60 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 61 | eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| 62 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 63 | instKey := grantInstanceKey(t, ts.URL, owner, eleven, s) |
| 64 | threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven) |
| 65 | |
| 66 | charter := map[string]any{ |
| 67 | "v": 1, "name": "home-news", "title": "Home News", "glyph": "π ", |
| 68 | "schedule": map[string]any{"at": farSlot}, |
| 69 | "task": map[string]any{"prompt": "Write the household news."}, |
| 70 | "thread": threadID, "owner": ownerUID, |
| 71 | } |
| 72 | blob, err := e2ee.EncryptJSON(instKey, charter) |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID}) |
| 77 | req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-home01", bytes.NewReader(body)) |
| 78 | req.Header.Set("Content-Type", "application/json") |
| 79 | if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK { |
| 80 | t.Fatalf("seed charter: %v %d", err, resp.StatusCode) |
| 81 | } |
| 82 | |
| 83 | dir := t.TempDir() |
| 84 | sessionPath := dir + "/eleven.session.json" |
| 85 | if err := eleven.Save(sessionPath); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | ready := make(chan struct{}) |
| 89 | ctx, cancel := context.WithCancel(context.Background()) |
| 90 | defer cancel() |
| 91 | eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, |
| 92 | Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true, |
| 93 | Ready: func() { close(ready) }, Logf: t.Logf} |
| 94 | go eng.Run(ctx) |
| 95 | awaitReady(t, ready) |
| 96 | |
| 97 | threadName := func() string { |
| 98 | var nameBlob string |
| 99 | if err := s.db.sql.QueryRow(`SELECT name_blob FROM threads WHERE id = ?`, threadID).Scan(&nameBlob); err != nil { |
| 100 | t.Fatalf("thread name_blob: %v", err) |
| 101 | } |
| 102 | if nameBlob == "" { |
| 103 | return "" |
| 104 | } |
| 105 | var n struct { |
| 106 | Name string `json:"name"` |
| 107 | } |
| 108 | if err := e2ee.DecryptJSON(threadKey, nameBlob, &n); err != nil { |
| 109 | return nameBlob // opaque (the hand-set marker below) |
| 110 | } |
| 111 | return n.Name |
| 112 | } |
| 113 | |
| 114 | // Rename: the bot's chat follows the bot's new persona. |
| 115 | postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Rename it to Journal Daily please.") |
| 116 | waitFor(t, "the rename update to settle", func() bool { |
| 117 | for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { |
| 118 | if strings.Contains(rp.Text, "is updated") { |
| 119 | return true |
| 120 | } |
| 121 | } |
| 122 | return false |
| 123 | }) |
| 124 | if got := threadName(); got != "Journal Daily π°" { |
| 125 | t.Fatalf("thread name after rename = %q, want %q", got, "Journal Daily π°") |
| 126 | } |
| 127 | |
| 128 | // A schedule-only update must NOT touch the thread name β a member's own |
| 129 | // group rename survives. |
| 130 | if err := s.db.setThreadName(threadID, "HAND-SET-OPAQUE-BLOB"); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | // Let the first build's goroutine release its one-build-at-a-time slot |
| 134 | // (endBuild is deferred, a beat after the settle message lands). |
| 135 | time.Sleep(200 * time.Millisecond) |
| 136 | postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Make it an hour later.") |
| 137 | // The second settle is recognisable by the NEW slot in its schedule |
| 138 | // summary ("β¦the next arrives daily at <farSlot2> UTC") β both settles |
| 139 | // say "is updated", so the first line can't stand in for it. |
| 140 | waitFor(t, "the schedule update to settle", func() bool { |
| 141 | for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { |
| 142 | if strings.Contains(rp.Text, farSlot2) { |
| 143 | return true |
| 144 | } |
| 145 | } |
| 146 | return false |
| 147 | }) |
| 148 | if got := threadName(); got != "HAND-SET-OPAQUE-BLOB" { |
| 149 | t.Fatalf("schedule-only update touched the thread name: %q", got) |
| 150 | } |
| 151 | } |
| 152 | |