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 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
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 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