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 test of Eleven's memory (internal/bot/memory.go +
4// docs/eleven-memory.md) against the real router: a DM remember lands in the
5// thread's "memory" state slot (encrypted under the DM key — decrypted here
6// as the member's client would), the fact rides the NEXT call's system
7// context with its id, the reply carries the sidecar-attached open_memory
8// chip, and forget(all) wipes the slot.
9
10import (
11 "bytes"
12 "context"
13 "encoding/json"
14 "io"
15 "net/http"
16 "net/http/httptest"
17 "strings"
18 "sync"
19 "testing"
20 "time"
21
22 "lchat/internal/bot"
23 "lchat/internal/e2ee"
24)
25
26func TestMemoryRememberRecallForget(t *testing.T) {
27 var mu sync.Mutex
28 var bodies []string
29 calls := 0
30 fakeAI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 b, _ := io.ReadAll(r.Body)
32 mu.Lock()
33 bodies = append(bodies, string(b))
34 i := calls
35 calls++
36 mu.Unlock()
37 responses := [][]map[string]any{
38 // Turn 1: remember + a conversational ack.
39 {{"type": "text", "text": "Noted!"},
40 {"type": "tool_use", "name": "remember", "input": map[string]any{"fact": "Runs the Eleven Messenger project"}}},
41 // Turn 2: a plain reply (the test only wants this call's request).
42 {{"type": "text", "text": "You run the Eleven Messenger project."}},
43 // Turn 3: the member asked for a wipe.
44 {{"type": "text", "text": "All forgotten."},
45 {"type": "tool_use", "name": "forget", "input": map[string]any{"all": true}}},
46 }
47 if i >= len(responses) {
48 i = len(responses) - 1
49 }
50 json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"})
51 }))
52 defer fakeAI.Close()
53 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
54
55 ts, s := newTestServer(t)
56 owner := provisionNamed(t, ts.URL, "Owner", false)
57 eleven := provisionNamed(t, ts.URL, "Eleven", true)
58 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
59 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
60
61 dir := t.TempDir()
62 sessionPath := dir + "/eleven.session.json"
63 if err := eleven.Save(sessionPath); err != nil {
64 t.Fatal(err)
65 }
66 ready := make(chan struct{})
67 ctx, cancel := context.WithCancel(context.Background())
68 defer cancel()
69 eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
70 Recipe: bot.DefaultRecipe(), AIKey: "test-key",
71 Ready: func() { close(ready) }, Logf: t.Logf}
72 go eng.Run(ctx)
73 awaitReady(t, ready)
74
75 // memorySlot reads the slot the way the member's scratchpad would.
76 memorySlot := func() (map[string]any, bool) {
77 resp, err := http.Get(ts.URL + "/api/threads/" + dmID + "/state?token=" + owner.Token + "&slot=memory")
78 if err != nil {
79 t.Fatal(err)
80 }
81 defer resp.Body.Close()
82 var out struct {
83 Blob string `json:"blob"`
84 }
85 if json.NewDecoder(resp.Body).Decode(&out) != nil || out.Blob == "" {
86 return nil, false
87 }
88 var mem map[string]any
89 if err := e2ee.DecryptJSON(dmKey, out.Blob, &mem); err != nil {
90 t.Fatalf("memory slot undecryptable with the DM key: %v", err)
91 }
92 return mem, true
93 }
94
95 // Turn 1 — remember: the fact lands in the slot, and the reply carries the
96 // sidecar-attached open_memory chip labeled as a note.
97 postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "Please remember that I run the Eleven Messenger project.")
98 waitFor(t, "memory slot written", func() bool {
99 mem, ok := memorySlot()
100 if !ok {
101 return false
102 }
103 facts, _ := mem["facts"].([]any)
104 return len(facts) == 1
105 })
106 mem, _ := memorySlot()
107 fact := mem["facts"].([]any)[0].(map[string]any)
108 if fact["text"] != "Runs the Eleven Messenger project" || fact["id"] == "" {
109 t.Fatalf("stored fact: %+v", fact)
110 }
111 waitFor(t, "the open_memory chip on the reply", func() bool {
112 for _, rp := range botReplies(t, s, dmID, dmKey, "Eleven") {
113 for _, a := range rp.Actions {
114 if a["type"] == "open_memory" {
115 return true
116 }
117 }
118 }
119 return false
120 })
121
122 // Turn 2 — recall: the next call's system context carries the fact + id.
123 postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "What do I do?")
124 waitFor(t, "the recall reply", func() bool {
125 return len(botReplies(t, s, dmID, dmKey, "Eleven")) >= 2
126 })
127 mu.Lock()
128 secondReq := bodies[1]
129 mu.Unlock()
130 var call struct {
131 System []struct {
132 Text string `json:"text"`
133 } `json:"system"`
134 }
135 if err := json.Unmarshal([]byte(secondReq), &call); err != nil {
136 t.Fatal(err)
137 }
138 var note string
139 for _, b := range call.System[1:] { // after the cached block
140 if strings.Contains(b.Text, "MEMORY") {
141 note = b.Text
142 }
143 }
144 if !strings.Contains(note, "Runs the Eleven Messenger project") || !strings.Contains(note, fact["id"].(string)) {
145 t.Fatalf("memory note missing the fact or its id:\n%s", note)
146 }
147
148 // Turn 3 — forget everything: the slot empties (the blob stays, sealed,
149 // holding zero facts) and the wipe announces itself with the chip.
150 postAsMember(t, ts.URL, dmID, dmKey, owner, ownerUID, "Forget everything about me please.")
151 waitFor(t, "memory wiped", func() bool {
152 mem, ok := memorySlot()
153 if !ok {
154 return false
155 }
156 facts, _ := mem["facts"].([]any)
157 return len(facts) == 0
158 })
159}
160
161// TestMemberScratchpadWriteWinsConflict pins the scratchpad's side of the
162// shared slot: a member's own write (the panel saving at the version it read)
163// bumps the version, and a stale second write 409s instead of clobbering.
164func TestMemberScratchpadWriteWinsConflict(t *testing.T) {
165 ts, s := newTestServer(t)
166 owner := provisionNamed(t, ts.URL, "Owner", false)
167 eleven := provisionNamed(t, ts.URL, "Eleven", true)
168 _ = s
169 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven)
170
171 put := func(version int64) int {
172 blob, err := e2ee.EncryptJSON(dmKey, map[string]any{"v": 1, "facts": []map[string]any{
173 {"id": "m1", "text": "Prefers short answers", "ts": time.Now().UnixMilli()},
174 }})
175 if err != nil {
176 t.Fatal(err)
177 }
178 body, _ := json.Marshal(map[string]any{"token": owner.Token, "slot": "memory", "blob": blob, "version": version})
179 req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/threads/"+dmID+"/state", bytes.NewReader(body))
180 req.Header.Set("Content-Type", "application/json")
181 resp, err := http.DefaultClient.Do(req)
182 if err != nil {
183 t.Fatal(err)
184 }
185 resp.Body.Close()
186 return resp.StatusCode
187 }
188 if code := put(0); code != http.StatusOK {
189 t.Fatalf("first write: %d", code)
190 }
191 if code := put(0); code != http.StatusConflict {
192 t.Fatalf("stale write should 409, got %d", code)
193 }
194}
195