package main // End-to-end test of a FAILED conversational bot update (internal/bot: // botbuild.go): the owner asks, in the bot's own chat, to switch it to a // source that turns out to be unreadable. The dry-run gate must refuse the // change — and say so in update terms ("your bot is unchanged"), never the // create-path "nothing was created", which reads like the bot is gone. The // stored charter must be untouched. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) func TestCharterUpdateFailureLeavesBotUntouched(t *testing.T) { t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") // The would-be new source: unreadable (403 to everything). blocked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer blocked.Close() fakeAI := scriptedAnthropic(t, // Turn 1 — the owner's ask in the bot's chat: an update via create_bot // with the bot id from the brain's charter-home context. []map[string]any{ {"type": "text", "text": "Switching it over now."}, {"type": "tool_use", "name": "create_bot", "input": map[string]any{ "bot_id": "bot-home01", "name": "home-news", "title": "Home News", "glyph": "🏠", "every": "1h", "sources": []any{blocked.URL}, "prompt": "Fetch the new source and summarise it.", }}, }, // The dry run: one fetch (403s), then an apology. []map[string]any{{"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": blocked.URL}}}, []map[string]any{{"type": "text", "text": "I couldn't reach the new source."}}, ) 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) farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04") 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) postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Switch it to the blocked site please.") // The failure speaks update, not create: the member's bot still exists and // still runs — the copy must say so, and the breadcrumb must say "update". waitFor(t, "the update-failure explanation", func() bool { for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { if strings.Contains(rp.Text, "Your bot is unchanged and still running as before.") && strings.Contains(rp.Text, "I couldn't make that change") { return true } } return false }) var sawUpdateCrumb bool for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") { if strings.Contains(rp.Text, "Couldn't update Home News") { sawUpdateCrumb = true } if strings.Contains(rp.Text, "Nothing was created") || strings.Contains(rp.Text, "Couldn't set up") { t.Fatalf("update failure used create-path copy: %q", rp.Text) } } if !sawUpdateCrumb { t.Fatal("breadcrumb should read 'Couldn't update …'") } // The stored charter is byte-for-byte the seeded one: same single row, // original prompt, no schedule/source change. bots, _ := s.db.listMemberBots() if len(bots) != 1 { t.Fatalf("want the one original charter, got %d", len(bots)) } var c struct { Task struct { Prompt string `json:"prompt"` Fetch *struct { Allow []string `json:"allow"` } `json:"fetch"` } `json:"task"` } if err := e2ee.DecryptJSON(instKey, bots[0].Blob, &c); err != nil { t.Fatal(err) } if c.Task.Prompt != "Write the household news." || c.Task.Fetch != nil { t.Fatalf("charter changed by a failed update: %+v", c.Task) } }