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 tests of the member-bot pipeline (internal/bot/botbuild.go +
4// chartersched.go + memberbots.go): a member asks the charter host for a bot
5// in a DM; the (fake) model answers with a create_bot call; the sidecar
6// discovers the source's feed, DRY-RUNS the task (fetch → card → text) as the
7// gate, stores the charter under the instance key, opens the bot's dedicated
8// thread, arms the alarm in its self-thread, and delivers the dry run as the
9// first edition — all against the real router, all ciphertext to the server.
10
11import (
12 "bytes"
13 "context"
14 "encoding/json"
15 "net/http"
16 "net/http/httptest"
17 "strings"
18 "testing"
19 "time"
20
21 "lchat/internal/bot"
22 "lchat/internal/e2ee"
23)
24
25// grantInstanceKey wraps a fresh instance key to the bot the way a member's
26// client would, and returns it so the test can decrypt what the bot stores.
27func grantInstanceKey(t *testing.T, tsURL string, owner, agent *bot.Session, s *Server) []byte {
28 t.Helper()
29 agentUID, _ := s.db.deviceTokenUser(agent.Token)
30 instKey, err := e2ee.GenerateKey()
31 if err != nil {
32 t.Fatal(err)
33 }
34 wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), agent.UserPub)
35 if err != nil {
36 t.Fatal(err)
37 }
38 grant, _ := json.Marshal(map[string]any{
39 "token": owner.Token,
40 "grants": []map[string]string{{"user_id": agentUID, "wrapped": wrapped}},
41 })
42 resp, err := http.Post(tsURL+"/api/instance-key", "application/json", bytes.NewReader(grant))
43 if err != nil || resp.StatusCode != http.StatusOK {
44 t.Fatalf("grant instance key: %v %d", err, resp.StatusCode)
45 }
46 return instKey
47}
48
49func postAsMember(t *testing.T, tsURL, threadID string, key []byte, sess *bot.Session, uid, text string) {
50 t.Helper()
51 blob, err := e2ee.EncryptJSON(key, map[string]any{
52 "name": sess.Name, "uid": uid, "text": text, "ts": time.Now().UnixMilli(),
53 })
54 if err != nil {
55 t.Fatal(err)
56 }
57 body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob})
58 if resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK {
59 t.Fatalf("post: %v %d", err, resp.StatusCode)
60 }
61}
62
63func TestBotBuildsMemberBot(t *testing.T) {
64 t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1") // loopback site + feed
65
66 // The "publication": a homepage advertising its feed (discovery reads the
67 // <link rel=alternate>), and the feed itself.
68 site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69 if r.URL.Path == "/feed.xml" {
70 w.Write([]byte(`<rss><item><title>Budget talks resume</title></item></rss>`))
71 return
72 }
73 w.Header().Set("Content-Type", "text/html")
74 w.Write([]byte(`<html><head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head></html>`))
75 }))
76 defer site.Close()
77
78 fakeAI := scriptedAnthropic(t,
79 // Call 1 — the conversational turn: ack + the create_bot call.
80 []map[string]any{
81 {"type": "text", "text": "Lovely — setting up Morning News now."},
82 {"type": "tool_use", "name": "create_bot", "input": map[string]any{
83 "name": "morning-news", "title": "Morning News", "glyph": "📰",
84 "at": "08:00", "tz": "Europe/Dublin",
85 "sources": []any{site.URL},
86 "prompt": "Fetch today's headlines from each source and write a short digest.",
87 }},
88 },
89 // Calls 2-4 — the DRY RUN's task loop: a thinking block rides along
90 // (as live models send under adaptive thinking — the loop must echo it
91 // back intact or the API 400s), then a fetch, then a card, then text.
92 []map[string]any{
93 {"type": "thinking", "thinking": "fetch the feed first", "signature": "sig_abc123"},
94 {"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": site.URL + "/feed.xml"}},
95 },
96 []map[string]any{
97 {"type": "tool_use", "id": "tu_2", "name": "card", "input": map[string]any{"card": map[string]any{
98 "schema": 1, "blocks": []map[string]any{
99 {"b": "heading", "text": "Morning news"},
100 {"b": "item", "text": "Budget talks resume", "source": "The Site"},
101 }}}},
102 },
103 []map[string]any{
104 {"type": "text", "text": "Morning! One story today: budget talks resume."},
105 },
106 )
107 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
108
109 ts, s := newTestServer(t)
110 owner := provisionNamed(t, ts.URL, "Owner", false)
111 eleven := provisionNamed(t, ts.URL, "Eleven", true)
112 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
113 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
114 elevenUID, _ := s.db.deviceTokenUser(eleven.Token)
115 instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
116
117 dir := t.TempDir()
118 sessionPath := dir + "/eleven.session.json"
119 if err := eleven.Save(sessionPath); err != nil {
120 t.Fatal(err)
121 }
122 ready := make(chan struct{})
123 ctx, cancel := context.WithCancel(context.Background())
124 defer cancel()
125 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
126 Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
127 Ready: func() { close(ready) }, Logf: t.Logf}
128 go eng.Run(ctx)
129 awaitReady(t, ready)
130
131 postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID,
132 "Can you make me a bot that sends me a news summary every morning?")
133
134 // The charter lands in the store (the build runs on its own goroutine).
135 waitFor(t, "charter stored", func() bool {
136 bots, _ := s.db.listMemberBots()
137 return len(bots) == 1
138 })
139 bots, _ := s.db.listMemberBots()
140 if bots[0].AuthorID != elevenUID {
141 t.Fatalf("charter author should be the host bot: %+v", bots[0])
142 }
143 if bots[0].OwnerID != ownerUID {
144 t.Fatalf("charter owner should be the requester: %+v", bots[0])
145 }
146 if !strings.HasPrefix(bots[0].ID, "bot-") {
147 t.Fatalf("unexpected bot id %q", bots[0].ID)
148 }
149
150 // The build reported the charter wake-time (recomputeCharterDue), so a
151 // hibernating instance would wake for the new bot's first occurrence.
152 waitFor(t, "charter due reported after build", func() bool {
153 return s.db.charterNextDue() > time.Now().Unix()
154 })
155
156 // The blob decrypts (instance key) to the charter: persona, schedule, the
157 // DISCOVERED feed on the allowlist (the member only ever named the site),
158 // the dedicated thread.
159 var c struct {
160 V int `json:"v"`
161 Name string `json:"name"`
162 Title string `json:"title"`
163 Glyph string `json:"glyph"`
164 Schedule struct {
165 At string `json:"at"`
166 TZ string `json:"tz"`
167 } `json:"schedule"`
168 Task struct {
169 Prompt string `json:"prompt"`
170 Fetch struct {
171 Allow []string `json:"allow"`
172 } `json:"fetch"`
173 } `json:"task"`
174 Thread string `json:"thread"`
175 Owner string `json:"owner"`
176 By string `json:"by"`
177 }
178 if err := e2ee.DecryptJSON(instKey, bots[0].Blob, &c); err != nil {
179 t.Fatalf("decrypt charter: %v", err)
180 }
181 if c.V != 1 || c.Name != "morning-news" || c.Title != "Morning News" || c.Glyph != "📰" {
182 t.Fatalf("bad charter: %+v", c)
183 }
184 if c.Schedule.At != "08:00" || c.Schedule.TZ != "Europe/Dublin" {
185 t.Fatalf("bad schedule: %+v", c.Schedule)
186 }
187 if len(c.Task.Fetch.Allow) != 1 || c.Task.Fetch.Allow[0] != site.URL+"/feed.xml" {
188 t.Fatalf("allowlist should be the DISCOVERED feed: %v", c.Task.Fetch.Allow)
189 }
190 if c.Owner != ownerUID || c.By != "Owner" {
191 t.Fatalf("charter owner: %+v", c)
192 }
193 if c.Thread == "" {
194 t.Fatal("charter has no dedicated thread")
195 }
196
197 // The dedicated thread: a titled group of exactly (owner, host), whose
198 // first message is the dry run's output — posted under the PERSONA name,
199 // card riding along, text as the plain fallback.
200 if kind := s.db.threadKind(c.Thread); kind != "group" {
201 t.Fatalf("dedicated thread kind = %q", kind)
202 }
203 members, _ := s.db.memberUserIDs(c.Thread)
204 if len(members) != 2 {
205 t.Fatalf("dedicated thread members: %v", members)
206 }
207 msgs, err := s.db.messagesAfter(c.Thread, 0, 10)
208 if err != nil || len(msgs) != 1 {
209 t.Fatalf("first edition: %v (%d msgs)", err, len(msgs))
210 }
211 // The thread key is wrapped to the owner — unwrap as the owner's client would.
212 ownerWrapped := threadWrappedFor(t, ts.URL, owner, c.Thread)
213 keyB64, err := e2ee.UnwrapThreadKey(ownerWrapped, owner.UserPriv)
214 if err != nil {
215 t.Fatal(err)
216 }
217 tkey, err := e2ee.ImportKey(keyB64)
218 if err != nil {
219 t.Fatal(err)
220 }
221 var first struct {
222 Name string `json:"name"`
223 Text string `json:"text"`
224 Card map[string]any `json:"card"`
225 }
226 if err := e2ee.DecryptJSON(tkey, msgs[0].Blob, &first); err != nil {
227 t.Fatalf("decrypt first edition: %v", err)
228 }
229 if first.Name != "Morning News" {
230 t.Fatalf("first edition should post under the persona: %+v", first)
231 }
232 if !strings.Contains(first.Text, "budget talks resume") {
233 t.Fatalf("first edition text: %q", first.Text)
234 }
235 if first.Card == nil || first.Card["schema"] != float64(1) {
236 t.Fatalf("first edition should carry the composed card: %+v", first.Card)
237 }
238
239 // The alarm: exactly one pending tick in the host's self-thread.
240 var selfID string
241 err = s.db.sql.QueryRow(
242 `SELECT t.id FROM threads t JOIN thread_members m ON m.thread_id = t.id
243 WHERE t.kind = 'self' AND m.user_id = ?`, elevenUID).Scan(&selfID)
244 if err != nil {
245 t.Fatalf("host self-thread: %v", err)
246 }
247 if n := s.db.countScheduledMessages(selfID); n != 1 {
248 t.Fatalf("want 1 pending alarm, got %d", n)
249 }
250
251 // The DM settles for a PERSON: the confirmation is plain English with an
252 // "open the chat" chip pointing at the dedicated thread; the opaque id
253 // rides the muted breadcrumb line (where the brain can read it back for
254 // updates), never the message.
255 waitFor(t, "settle message carries the open-chat chip", func() bool {
256 for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") {
257 for _, a := range rp.Actions {
258 if a["type"] == "open_thread" && a["thread"] == c.Thread {
259 return true
260 }
261 }
262 }
263 return false
264 })
265 var breadcrumbHasID, messageHasID bool
266 for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") {
267 if strings.Contains(rp.Text, bots[0].ID) {
268 if len(rp.Actions) > 0 {
269 messageHasID = true
270 } else {
271 breadcrumbHasID = true
272 }
273 }
274 if strings.Contains(rp.Text, "://") {
275 t.Fatalf("settle text must carry no URLs (they drag link previews into the chat): %q", rp.Text)
276 }
277 }
278 if !breadcrumbHasID {
279 t.Fatal("the breadcrumb should carry the bot id for later updates")
280 }
281 if messageHasID {
282 t.Fatal("the member-facing confirmation should not carry the opaque id")
283 }
284}
285
286// threadWrappedFor reads a member's wrapped key for one thread off the
287// ordinary threads endpoint.
288func threadWrappedFor(t *testing.T, tsURL string, sess *bot.Session, threadID string) string {
289 t.Helper()
290 resp, err := http.Get(tsURL + "/api/threads?token=" + sess.Token)
291 if err != nil {
292 t.Fatal(err)
293 }
294 defer resp.Body.Close()
295 var tl struct {
296 Threads []struct {
297 ID string `json:"id"`
298 Wrapped string `json:"wrapped"`
299 } `json:"threads"`
300 }
301 if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil {
302 t.Fatal(err)
303 }
304 for _, th := range tl.Threads {
305 if th.ID == threadID {
306 return th.Wrapped
307 }
308 }
309 t.Fatalf("no wrapped key for %s", threadID)
310 return ""
311}
312
313// TestCharterTickRunsTask drives the autonomous loop end to end: a stored
314// charter, the host's connect-time reconcile arming a server-side alarm, the
315// sweep delivering the encrypted bot.tick, the task running (scripted model),
316// and the result landing in the charter's thread under the persona — then the
317// alarm re-armed for the next occurrence.
318func TestCharterTickRunsTask(t *testing.T) {
319 t.Setenv("LCHAT_BOT_MIN_EVERY", "1s") // watch the bot fire in test time
320 fakeAI := scriptedAnthropic(t,
321 []map[string]any{{"type": "text", "text": "Your hourly note: all quiet."}},
322 )
323 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
324
325 ts, s := newTestServer(t)
326 owner := provisionNamed(t, ts.URL, "Owner", false)
327 eleven := provisionNamed(t, ts.URL, "Eleven", true)
328 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
329 instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
330
331 // The bot's dedicated thread (what the builder would have created).
332 threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
333
334 // Seed a stored charter directly — every 1s so the test can watch it fire.
335 charter := map[string]any{
336 "v": 1, "name": "quiet-note", "title": "Quiet Note", "glyph": "🕰️",
337 "schedule": map[string]any{"every": "1s"},
338 "task": map[string]any{"prompt": "Say something calm."},
339 "thread": threadID, "owner": ownerUID,
340 }
341 blob, err := e2ee.EncryptJSON(instKey, charter)
342 if err != nil {
343 t.Fatal(err)
344 }
345 body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
346 req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-quiet1", bytes.NewReader(body))
347 req.Header.Set("Content-Type", "application/json")
348 if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
349 t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
350 }
351
352 dir := t.TempDir()
353 sessionPath := dir + "/eleven.session.json"
354 if err := eleven.Save(sessionPath); err != nil {
355 t.Fatal(err)
356 }
357 ready := make(chan struct{})
358 ctx, cancel := context.WithCancel(context.Background())
359 defer cancel()
360 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
361 Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
362 Ready: func() { close(ready) }, Logf: t.Logf}
363 go eng.Run(ctx)
364 awaitReady(t, ready)
365
366 // Connect reconciled — and reported the charter wake-time, so a hibernating
367 // instance would be woken for this schedule (charter-wake registration).
368 waitFor(t, "charter due reported", func() bool {
369 return s.db.charterNextDue() > 0
370 })
371
372 // One pending alarm. The sweep delivers it; the task posts into the
373 // charter's thread under the persona name.
374 waitFor(t, "task result posted", func() bool {
375 s.sweepDueScheduled()
376 msgs, _ := s.db.messagesAfter(threadID, 0, 10)
377 return len(msgs) > 0
378 })
379 msgs, _ := s.db.messagesAfter(threadID, 0, 10)
380 var out struct {
381 Name string `json:"name"`
382 Text string `json:"text"`
383 }
384 if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &out); err != nil {
385 t.Fatalf("decrypt: %v", err)
386 }
387 if out.Name != "Quiet Note" || !strings.Contains(out.Text, "all quiet") {
388 t.Fatalf("bad task post: %+v", out)
389 }
390
391 // Firing re-armed the next occurrence.
392 uid, _ := s.db.deviceTokenUser(eleven.Token)
393 var selfID string
394 if err := s.db.sql.QueryRow(
395 `SELECT t.id FROM threads t JOIN thread_members m ON m.thread_id = t.id
396 WHERE t.kind = 'self' AND m.user_id = ?`, uid).Scan(&selfID); err != nil {
397 t.Fatalf("self thread: %v", err)
398 }
399 waitFor(t, "re-armed alarm", func() bool {
400 return s.db.countScheduledMessages(selfID) >= 1
401 })
402}
403
404// TestCharterMissedAlarmRunsAtConnect pins the due-wake boot path of
405// charter-wake registration: a live charter whose alarm was consumed while no
406// host was listening (the hibernation wake race — the instance's scheduler
407// fires the tick before the co-spawned sidecar's cursor baseline) must run
408// promptly at connect, not silently skip to its next occurrence. The seeded
409// charter's daily slot is ~12 hours away, so a run inside the test window can
410// only be the connect-time catch-up arm.
411func TestCharterMissedAlarmRunsAtConnect(t *testing.T) {
412 fakeAI := scriptedAnthropic(t,
413 []map[string]any{{"type": "text", "text": "Here is the edition you were owed."}},
414 )
415 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
416
417 ts, s := newTestServer(t)
418 owner := provisionNamed(t, ts.URL, "Owner", false)
419 eleven := provisionNamed(t, ts.URL, "Eleven", true)
420 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
421 instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
422 threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
423
424 // A charter with NO pending alarm — exactly the state after a tick was
425 // delivered into a host-less self-thread. Its daily slot is far away.
426 farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04")
427 charter := map[string]any{
428 "v": 1, "name": "owed-note", "title": "Owed Note", "glyph": "📬",
429 "schedule": map[string]any{"at": farSlot},
430 "task": map[string]any{"prompt": "Say what was missed."},
431 "thread": threadID, "owner": ownerUID,
432 }
433 blob, err := e2ee.EncryptJSON(instKey, charter)
434 if err != nil {
435 t.Fatal(err)
436 }
437 body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
438 req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-owed01", bytes.NewReader(body))
439 req.Header.Set("Content-Type", "application/json")
440 if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
441 t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
442 }
443
444 dir := t.TempDir()
445 sessionPath := dir + "/eleven.session.json"
446 if err := eleven.Save(sessionPath); err != nil {
447 t.Fatal(err)
448 }
449 ready := make(chan struct{})
450 ctx, cancel := context.WithCancel(context.Background())
451 defer cancel()
452 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
453 Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
454 Ready: func() { close(ready) }, Logf: t.Logf}
455 go eng.Run(ctx)
456 awaitReady(t, ready)
457
458 // The connect-time reconcile arms the owed run at NOW; the sweep delivers
459 // it and the task posts — hours before the schedule's next slot.
460 waitFor(t, "owed run posted at connect", func() bool {
461 s.sweepDueScheduled()
462 msgs, _ := s.db.messagesAfter(threadID, 0, 10)
463 return len(msgs) > 0
464 })
465 msgs, _ := s.db.messagesAfter(threadID, 0, 10)
466 var out struct {
467 Name string `json:"name"`
468 Text string `json:"text"`
469 }
470 if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &out); err != nil {
471 t.Fatalf("decrypt: %v", err)
472 }
473 if out.Name != "Owed Note" || !strings.Contains(out.Text, "owed") {
474 t.Fatalf("bad catch-up post: %+v", out)
475 }
476
477 // The run's re-arm reported the wake-time: the next occurrence (the far
478 // slot), so a hibernating instance would sleep until then, not loop.
479 waitFor(t, "wake-time reported", func() bool {
480 due := s.db.charterNextDue()
481 return due > time.Now().Add(time.Hour).Unix()
482 })
483}
484