| 1 | package 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 | |
| 10 | import ( |
| 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. |
| 27 | func 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 | |
| 53 | func 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). |
| 172 | func 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 | |