eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
10import (
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
24func 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