eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// End-to-end test of the conversational bot-home thread (internal/bot:
4// runtime.go triggered + brain.go charterHomeNote): in a member bot's own
5// chat the owner talks to the host WITHOUT an @mention, the brain receives
6// the charter (bot id included) as system context, and remove_bot retires
7// the bot from right there — while the bot's own persona-named editions
8// never trigger a reply.
9
10import (
11 "bytes"
12 "context"
13 "encoding/json"
14 "net/http"
15 "net/http/httptest"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "lchat/internal/bot"
22 "lchat/internal/e2ee"
23)
24
25// capturingAnthropic is scriptedAnthropic plus request capture, so a test can
26// assert what context actually reached the model.
27func capturingAnthropic(t *testing.T, responses ...[]map[string]any) (*httptest.Server, func() [][]byte) {
28 t.Helper()
29 var mu sync.Mutex
30 var bodies [][]byte
31 calls := 0
32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 var buf bytes.Buffer
34 buf.ReadFrom(r.Body)
35 mu.Lock()
36 bodies = append(bodies, buf.Bytes())
37 i := calls
38 calls++
39 mu.Unlock()
40 if i >= len(responses) {
41 i = len(responses) - 1
42 }
43 json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"})
44 }))
45 t.Cleanup(srv.Close)
46 return srv, func() [][]byte {
47 mu.Lock()
48 defer mu.Unlock()
49 return append([][]byte(nil), bodies...)
50 }
51}
52
53func TestCharterThreadConversation(t *testing.T) {
54 fakeAI, requests := capturingAnthropic(t,
55 // Turn 1 — the un-mentioned "Hello?": a plain conversational reply.
56 []map[string]any{{"type": "text", "text": "Hi! I look after Home News here — want me to change what it reads or when it runs?"}},
57 // Turn 2 — "remove this bot": the model uses the bot_id from its context.
58 []map[string]any{{"type": "tool_use", "name": "remove_bot", "input": map[string]any{"bot_id": "bot-home01"}}},
59 )
60 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
61
62 ts, s := newTestServer(t)
63 owner := provisionNamed(t, ts.URL, "Owner", false)
64 eleven := provisionNamed(t, ts.URL, "Eleven", true)
65 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
66 elevenUID, _ := s.db.deviceTokenUser(eleven.Token)
67 instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
68 threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
69
70 // A stored charter whose home is that thread. Its daily slot is far away
71 // and this test never sweeps the schedule queue, so no task run interferes.
72 farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04")
73 charter := map[string]any{
74 "v": 1, "name": "home-news", "title": "Home News", "glyph": "🏠",
75 "schedule": map[string]any{"at": farSlot},
76 "task": map[string]any{"prompt": "Write the household news."},
77 "thread": threadID, "owner": ownerUID,
78 }
79 blob, err := e2ee.EncryptJSON(instKey, charter)
80 if err != nil {
81 t.Fatal(err)
82 }
83 body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
84 req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-home01", bytes.NewReader(body))
85 req.Header.Set("Content-Type", "application/json")
86 if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
87 t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
88 }
89
90 dir := t.TempDir()
91 sessionPath := dir + "/eleven.session.json"
92 if err := eleven.Save(sessionPath); err != nil {
93 t.Fatal(err)
94 }
95 ready := make(chan struct{})
96 ctx, cancel := context.WithCancel(context.Background())
97 defer cancel()
98 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
99 Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
100 Ready: func() { close(ready) }, Logf: t.Logf}
101 go eng.Run(ctx)
102 awaitReady(t, ready)
103
104 // The bot's own editions post under the persona name with no uid — they
105 // must never trigger the host. Posted first: if this DID draw a reply, the
106 // count assertions below would catch the extra message.
107 edition, err := e2ee.EncryptJSON(threadKey, map[string]any{
108 "name": "Home News", "text": "Tonight's household news: all beds made.", "ts": time.Now().UnixMilli(),
109 })
110 if err != nil {
111 t.Fatal(err)
112 }
113 eb, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": edition})
114 if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(eb)); err != nil || resp.StatusCode != http.StatusOK {
115 t.Fatalf("post edition: %v %d", err, resp.StatusCode)
116 }
117
118 // The owner says hello — NO @mention. The host must reply anyway.
119 postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Hello?")
120 waitFor(t, "a reply to the un-mentioned hello", func() bool {
121 return len(botReplies(t, s, threadID, threadKey, "Eleven")) == 1
122 })
123 if got := botReplies(t, s, threadID, threadKey, "Eleven")[0].Text; !strings.Contains(got, "look after Home News") {
124 t.Fatalf("unexpected reply: %q", got)
125 }
126
127 // The brain got the charter as system context: a second system block
128 // carrying the real bot id, the persona, and the edit/retire tools.
129 reqs := requests()
130 if len(reqs) != 1 {
131 t.Fatalf("want 1 model call so far, got %d", len(reqs))
132 }
133 var call struct {
134 System []struct {
135 Text string `json:"text"`
136 } `json:"system"`
137 }
138 if err := json.Unmarshal(reqs[0], &call); err != nil {
139 t.Fatal(err)
140 }
141 if len(call.System) != 2 {
142 t.Fatalf("want the cached prompt + the charter note, got %d system blocks", len(call.System))
143 }
144 note := call.System[1].Text
145 for _, want := range []string{"bot-home01", "Home News", "create_bot", "remove_bot", "Write the household news."} {
146 if !strings.Contains(note, want) {
147 t.Fatalf("charter note missing %q:\n%s", want, note)
148 }
149 }
150
151 // "Remove this bot" — the model answers with remove_bot + the id from its
152 // context; the charter is deleted, its pending alarm dropped, and the
153 // thread (the member's history) stays.
154 if n := s.db.countScheduledMessages(selfThreadOf(t, s, elevenUID)); n != 1 {
155 t.Fatalf("want the seeded charter's 1 pending alarm before removal, got %d", n)
156 }
157 postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Please remove this bot.")
158 waitFor(t, "the retirement confirmation", func() bool {
159 replies := botReplies(t, s, threadID, threadKey, "Eleven")
160 return len(replies) == 2 && strings.Contains(replies[1].Text, "retired")
161 })
162 waitFor(t, "charter deleted", func() bool {
163 bots, _ := s.db.listMemberBots()
164 return len(bots) == 0
165 })
166 waitFor(t, "alarm dropped", func() bool {
167 return s.db.countScheduledMessages(selfThreadOf(t, s, elevenUID)) == 0
168 })
169}
170
171// selfThreadOf finds a bot identity's self-thread (where charter alarms live).
172func selfThreadOf(t *testing.T, s *Server, uid string) string {
173 t.Helper()
174 var selfID string
175 if err := s.db.sql.QueryRow(
176 `SELECT t.id FROM threads t JOIN thread_members m ON m.thread_id = t.id
177 WHERE t.kind = 'self' AND m.user_id = ?`, uid).Scan(&selfID); err != nil {
178 t.Fatalf("self thread: %v", err)
179 }
180 return selfID
181}
182