| 1 | package 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 | |
| 11 | import ( |
| 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. |
| 27 | func 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 | |
| 49 | func 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 | |
| 63 | func 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. |
| 288 | func 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. |
| 318 | func 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. |
| 411 | func 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 | |