| 1 | package main |
| 2 | |
| 3 | // End-to-end test of a FAILED conversational bot update (internal/bot: |
| 4 | // botbuild.go): the owner asks, in the bot's own chat, to switch it to a |
| 5 | // source that turns out to be unreadable. The dry-run gate must refuse the |
| 6 | // change — and say so in update terms ("your bot is unchanged"), never the |
| 7 | // create-path "nothing was created", which reads like the bot is gone. The |
| 8 | // stored charter must be untouched. |
| 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 TestCharterUpdateFailureLeavesBotUntouched(t *testing.T) { |
| 25 | t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") |
| 26 | |
| 27 | // The would-be new source: unreadable (403 to everything). |
| 28 | blocked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 29 | w.WriteHeader(http.StatusForbidden) |
| 30 | })) |
| 31 | defer blocked.Close() |
| 32 | |
| 33 | fakeAI := scriptedAnthropic(t, |
| 34 | // Turn 1 — the owner's ask in the bot's chat: an update via create_bot |
| 35 | // with the bot id from the brain's charter-home context. |
| 36 | []map[string]any{ |
| 37 | {"type": "text", "text": "Switching it over now."}, |
| 38 | {"type": "tool_use", "name": "create_bot", "input": map[string]any{ |
| 39 | "bot_id": "bot-home01", "name": "home-news", "title": "Home News", "glyph": "🏠", |
| 40 | "every": "1h", |
| 41 | "sources": []any{blocked.URL}, |
| 42 | "prompt": "Fetch the new source and summarise it.", |
| 43 | }}, |
| 44 | }, |
| 45 | // The dry run: one fetch (403s), then an apology. |
| 46 | []map[string]any{{"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": blocked.URL}}}, |
| 47 | []map[string]any{{"type": "text", "text": "I couldn't reach the new source."}}, |
| 48 | ) |
| 49 | t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) |
| 50 | |
| 51 | ts, s := newTestServer(t) |
| 52 | owner := provisionNamed(t, ts.URL, "Owner", false) |
| 53 | eleven := provisionNamed(t, ts.URL, "Eleven", true) |
| 54 | ownerUID, _ := s.db.deviceTokenUser(owner.Token) |
| 55 | instKey := grantInstanceKey(t, ts.URL, owner, eleven, s) |
| 56 | threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven) |
| 57 | |
| 58 | farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04") |
| 59 | charter := map[string]any{ |
| 60 | "v": 1, "name": "home-news", "title": "Home News", "glyph": "🏠", |
| 61 | "schedule": map[string]any{"at": farSlot}, |
| 62 | "task": map[string]any{"prompt": "Write the household news."}, |
| 63 | "thread": threadID, "owner": ownerUID, |
| 64 | } |
| 65 | blob, err := e2ee.EncryptJSON(instKey, charter) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID}) |
| 70 | req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-home01", bytes.NewReader(body)) |
| 71 | req.Header.Set("Content-Type", "application/json") |
| 72 | if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK { |
| 73 | t.Fatalf("seed charter: %v %d", err, resp.StatusCode) |
| 74 | } |
| 75 | |
| 76 | dir := t.TempDir() |
| 77 | sessionPath := dir + "/eleven.session.json" |
| 78 | if err := eleven.Save(sessionPath); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | ready := make(chan struct{}) |
| 82 | ctx, cancel := context.WithCancel(context.Background()) |
| 83 | defer cancel() |
| 84 | eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, |
| 85 | Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true, |
| 86 | Ready: func() { close(ready) }, Logf: t.Logf} |
| 87 | go eng.Run(ctx) |
| 88 | awaitReady(t, ready) |
| 89 | |
| 90 | postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Switch it to the blocked site please.") |
| 91 | |
| 92 | // The failure speaks update, not create: the member's bot still exists and |
| 93 | // still runs — the copy must say so, and the breadcrumb must say "update". |
| 94 | waitFor(t, "the update-failure explanation", func() bool { |
| 95 | for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { |
| 96 | if strings.Contains(rp.Text, "Your bot is unchanged and still running as before.") && |
| 97 | strings.Contains(rp.Text, "I couldn't make that change") { |
| 98 | return true |
| 99 | } |
| 100 | } |
| 101 | return false |
| 102 | }) |
| 103 | var sawUpdateCrumb bool |
| 104 | for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { |
| 105 | if strings.Contains(rp.Text, "Couldn't update Home News") { |
| 106 | sawUpdateCrumb = true |
| 107 | } |
| 108 | if strings.Contains(rp.Text, "Nothing was created") || strings.Contains(rp.Text, "Couldn't set up") { |
| 109 | t.Fatalf("update failure used create-path copy: %q", rp.Text) |
| 110 | } |
| 111 | } |
| 112 | if !sawUpdateCrumb { |
| 113 | t.Fatal("breadcrumb should read 'Couldn't update …'") |
| 114 | } |
| 115 | |
| 116 | // The stored charter is byte-for-byte the seeded one: same single row, |
| 117 | // original prompt, no schedule/source change. |
| 118 | bots, _ := s.db.listMemberBots() |
| 119 | if len(bots) != 1 { |
| 120 | t.Fatalf("want the one original charter, got %d", len(bots)) |
| 121 | } |
| 122 | var c struct { |
| 123 | Task struct { |
| 124 | Prompt string `json:"prompt"` |
| 125 | Fetch *struct { |
| 126 | Allow []string `json:"allow"` |
| 127 | } `json:"fetch"` |
| 128 | } `json:"task"` |
| 129 | } |
| 130 | if err := e2ee.DecryptJSON(instKey, bots[0].Blob, &c); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if c.Task.Prompt != "Write the household news." || c.Task.Fetch != nil { |
| 134 | t.Fatalf("charter changed by a failed update: %+v", c.Task) |
| 135 | } |
| 136 | } |
| 137 | |