package main // End-to-end test of Eleven's memory (internal/bot/memory.go + // docs/eleven-memory.md) against the real router: a DM remember lands in the // thread's "memory" state slot (encrypted under the DM key — decrypted here // as the member's client would), the fact rides the NEXT call's system // context with its id, the reply carries the sidecar-attached open_memory // chip, and forget(all) wipes the slot. import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) func TestMemoryRememberRecallForget(t *testing.T) { var mu sync.Mutex var bodies []string calls := 0 fakeAI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) mu.Lock() bodies = append(bodies, string(b)) i := calls calls++ mu.Unlock() responses := [][]map[string]any{ // Turn 1: remember + a conversational ack. {{"type": "text", "text": "Noted!"}, {"type": "tool_use", "name": "remember", "input": map[string]any{"fact": "Runs the Eleven Messenger project"}}}, // Turn 2: a plain reply (the test only wants this call's request). {{"type": "text", "text": "You run the Eleven Messenger project."}}, // Turn 3: the member asked for a wipe. {{"type": "text", "text": "All forgotten."}, {"type": "tool_use", "name": "forget", "input": map[string]any{"all": true}}}, } if i >= len(responses) { i = len(responses) - 1 } json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"}) })) defer fakeAI.Close() t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) eleven := provisionNamed(t, ts.URL, "Eleven", true) ownerUID, _ := s.db.deviceTokenUser(owner.Token) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) dir := t.TempDir() sessionPath := dir + "/eleven.session.json" if err := eleven.Save(sessionPath); err != nil { t.Fatal(err) } ready := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf} go eng.Run(ctx) awaitReady(t, ready) // memorySlot reads the slot the way the member's scratchpad would. memorySlot := func() (map[string]any, bool) { resp, err := http.Get(ts.URL + "/api/threads/" + dmID + "/state?token=" + owner.Token + "&slot=memory") if err != nil { t.Fatal(err) } defer resp.Body.Close() var out struct { Blob string `json:"blob"` } if json.NewDecoder(resp.Body).Decode(&out) != nil || out.Blob == "" { return nil, false } var mem map[string]any if err := e2ee.DecryptJSON(dmKey, out.Blob, &mem); err != nil { t.Fatalf("memory slot undecryptable with the DM key: %v", err) } return mem, true } // Turn 1 — remember: the fact lands in the slot, and the reply carries the // sidecar-attached open_memory chip labeled as a note. postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "Please remember that I run the Eleven Messenger project.") waitFor(t, "memory slot written", func() bool { mem, ok := memorySlot() if !ok { return false } facts, _ := mem["facts"].([]any) return len(facts) == 1 }) mem, _ := memorySlot() fact := mem["facts"].([]any)[0].(map[string]any) if fact["text"] != "Runs the Eleven Messenger project" || fact["id"] == "" { t.Fatalf("stored fact: %+v", fact) } waitFor(t, "the open_memory chip on the reply", func() bool { for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") { for _, a := range rp.Actions { if a["type"] == "open_memory" { return true } } } return false }) // Turn 2 — recall: the next call's system context carries the fact + id. postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "What do I do?") waitFor(t, "the recall reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) >= 2 }) mu.Lock() secondReq := bodies[1] mu.Unlock() var call struct { System []struct { Text string `json:"text"` } `json:"system"` } if err := json.Unmarshal([]byte(secondReq), &call); err != nil { t.Fatal(err) } var note string for _, b := range call.System[1:] { // after the cached block if strings.Contains(b.Text, "MEMORY") { note = b.Text } } if !strings.Contains(note, "Runs the Eleven Messenger project") || !strings.Contains(note, fact["id"].(string)) { t.Fatalf("memory note missing the fact or its id:\n%s", note) } // Turn 3 — forget everything: the slot empties (the blob stays, sealed, // holding zero facts) and the wipe announces itself with the chip. postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "Forget everything about me please.") waitFor(t, "memory wiped", func() bool { mem, ok := memorySlot() if !ok { return false } facts, _ := mem["facts"].([]any) return len(facts) == 0 }) } // TestMemberScratchpadWriteWinsConflict pins the scratchpad's side of the // shared slot: a member's own write (the panel saving at the version it read) // bumps the version, and a stale second write 409s instead of clobbering. func TestMemberScratchpadWriteWinsConflict(t *testing.T) { ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) eleven := provisionNamed(t, ts.URL, "Eleven", true) _ = s dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) put := func(version int64) int { blob, err := e2ee.EncryptJSON(dmKey, map[string]any{"v": 1, "facts": []map[string]any{ {"id": "m1", "text": "Prefers short answers", "ts": time.Now().UnixMilli()}, }}) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{"token": owner.Token, "slot": "memory", "blob": blob, "version": version}) req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/threads/"+dmID+"/state", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } if code := put(0); code != http.StatusOK { t.Fatalf("first write: %d", code) } if code := put(0); code != http.StatusConflict { t.Fatalf("stale write should 409, got %d", code) } }