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 the bot FIXING an existing member app it didn't build
4// (docs/member-apps.md): the member deployed the app themselves (author =
5// owner = member, the App Studio shape), so nothing about it appears in the
6// conversation. The bot must discover it from the apps note in its context,
7// read its source via read_app_source, then update it in place via create_app
8// with the real app_id β€” landing as version 2 through the server's
9// agent-for-owner overwrite gate, with author/owner unchanged.
10
11import (
12 "bytes"
13 "context"
14 "encoding/json"
15 "io"
16 "net/http"
17 "net/http/httptest"
18 "strings"
19 "sync"
20 "testing"
21 "time"
22
23 "lchat/internal/bot"
24 "lchat/internal/e2ee"
25)
26
27// recordingAnthropic is scriptedAnthropic plus request capture β€” each call's
28// raw body is appended to reqs, so the test can assert what the model was
29// actually shown (the apps note, the tool_result source, the build prompt).
30func recordingAnthropic(t *testing.T, mu *sync.Mutex, reqs *[]string, responses ...[]map[string]any) *httptest.Server {
31 t.Helper()
32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 raw, _ := io.ReadAll(r.Body)
34 var body struct {
35 Stream bool `json:"stream"`
36 }
37 json.Unmarshal(raw, &body)
38 mu.Lock()
39 i := len(*reqs)
40 *reqs = append(*reqs, string(raw))
41 mu.Unlock()
42 if i >= len(responses) {
43 i = len(responses) - 1
44 }
45 if body.Stream {
46 writeAnthropicSSE(w, responses[i])
47 return
48 }
49 json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"})
50 }))
51 t.Cleanup(srv.Close)
52 return srv
53}
54
55var buggyNotesSource = `import { defineApp, addItem, removeItem } from "../apps.js";
56
57export const app = defineApp({
58 name: "notes",
59 title: "Notes",
60 glyph: "πŸ“",
61 initialState: () => ({ items: [] }),
62 events: {
63 "notes.add": (s, e) => ({ items: addItem(s.items, { id: e.id, text: "buggy-marker-7" }) }),
64 "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
65 },
66 render(state, el, ctx) {
67 for (const it of ((state && state.items) || [])) {
68 const row = document.createElement("div");
69 row.textContent = it.text;
70 el.appendChild(row);
71 }
72 },
73});
74`
75
76var fixedNotesApp = "```js\n" +
77 strings.Replace(buggyNotesSource, `"buggy-marker-7"`, "e.text /* fixed-marker-9 */", 1) +
78 "\n```"
79
80func TestBotReadsAndFixesExistingApp(t *testing.T) {
81 var mu sync.Mutex
82 var reqs []string
83 fakeAI := recordingAnthropic(t, &mu, &reqs,
84 // Call 1 β€” the conversational turn: the model reads the app it found
85 // in its context's apps note (the id appears nowhere in the thread).
86 []map[string]any{
87 {"type": "tool_use", "id": "tu-read", "name": "read_app_source",
88 "input": map[string]any{"app_id": "app-fixme01"}},
89 },
90 // Call 2 β€” after the tool_result carrying the source: diagnose + fix.
91 []map[string]any{
92 {"type": "text", "text": "Found it β€” the add handler drops your text. Fixing now."},
93 {"type": "tool_use", "id": "tu-build", "name": "create_app", "input": map[string]any{
94 "app_id": "app-fixme01",
95 "name": "notes", "title": "Notes", "glyph": "πŸ“",
96 "spec": "fix notes.add so the item keeps the text the member typed",
97 }},
98 },
99 // Call 3 β€” the dedicated build call (streamed): the corrected module.
100 []map[string]any{{"type": "text", "text": fixedNotesApp}},
101 )
102 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
103
104 ts, s := newTestServer(t)
105 owner := provisionNamed(t, ts.URL, "Owner", false)
106 claude := provisionNamed(t, ts.URL, "Claude", true)
107 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude)
108 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
109 claudeUID, _ := s.db.deviceTokenUser(claude.Token)
110
111 instKey, err := e2ee.GenerateKey()
112 if err != nil {
113 t.Fatal(err)
114 }
115 wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub)
116 if err != nil {
117 t.Fatal(err)
118 }
119 grant, _ := json.Marshal(map[string]any{
120 "token": owner.Token,
121 "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}},
122 })
123 if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK {
124 t.Fatalf("grant instance key: %v %d", err, resp.StatusCode)
125 }
126
127 // The member deploys the buggy app THEMSELVES (App Studio shape: author =
128 // owner = member), before the bot ever connects.
129 seedBlob, err := e2ee.EncryptJSON(instKey, map[string]any{
130 "source": buggyNotesSource,
131 "manifest": map[string]any{
132 "v": 1, "name": "notes", "title": "Notes", "glyph": "πŸ“",
133 "places": []string{"you"}, "types": []string{"notes.add", "notes.remove"},
134 "owner": ownerUID, "by": "Owner",
135 },
136 })
137 if err != nil {
138 t.Fatal(err)
139 }
140 seed, _ := json.Marshal(map[string]string{"token": owner.Token, "blob": seedBlob})
141 seedReq, _ := http.NewRequest("PUT", ts.URL+"/api/member-apps/app-fixme01", bytes.NewReader(seed))
142 seedReq.Header.Set("Content-Type", "application/json")
143 if resp, err := http.DefaultClient.Do(seedReq); err != nil || resp.StatusCode != http.StatusOK {
144 t.Fatalf("seed deploy: %v %d", err, resp.StatusCode)
145 }
146
147 dir := t.TempDir()
148 sessionPath := dir + "/claude.session.json"
149 if err := claude.Save(sessionPath); err != nil {
150 t.Fatal(err)
151 }
152 ready := make(chan struct{})
153 ctx, cancel := context.WithCancel(context.Background())
154 defer cancel()
155 eng := &bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath,
156 Recipe: bot.DefaultRecipe(), AIKey: "test-key",
157 Ready: func() { close(ready) }, Logf: t.Logf}
158 go eng.Run(ctx)
159 awaitReady(t, ready)
160
161 // The ask never mentions the app id β€” discovery is the point.
162 blob, err := e2ee.EncryptJSON(dmKey, map[string]any{
163 "name": owner.Name, "uid": ownerUID,
164 "text": "There's a bug in my notes app β€” items lose the text I type. Can you fix it?",
165 "ts": time.Now().UnixMilli(),
166 })
167 if err != nil {
168 t.Fatal(err)
169 }
170 body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob})
171 if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK {
172 t.Fatalf("post ask: %v %d", err, resp.StatusCode)
173 }
174
175 // The fix lands as version 2 of the SAME app β€” no second app, and the
176 // server's agent-for-owner gate let the bot overwrite a member-authored row.
177 waitFor(t, "app updated to version 2", func() bool {
178 app, ok, _ := s.db.getMemberApp("app-fixme01")
179 return ok && app.Version == 2
180 })
181 apps, _ := s.db.listMemberApps()
182 if len(apps) != 1 {
183 t.Fatalf("the fix must update in place, not add an app: %d apps", len(apps))
184 }
185 app, _, _ := s.db.getMemberApp("app-fixme01")
186 if app.AuthorID != ownerUID || app.OwnerID != ownerUID {
187 t.Fatalf("author/owner must not transfer on a bot fix: %+v", app)
188 }
189 var doc struct {
190 Source string `json:"source"`
191 Manifest struct {
192 Name string `json:"name"`
193 Owner string `json:"owner"`
194 BuiltBy string `json:"builtBy"`
195 } `json:"manifest"`
196 }
197 if err := e2ee.DecryptJSON(instKey, app.Blob, &doc); err != nil {
198 t.Fatalf("decrypt updated blob: %v", err)
199 }
200 if !strings.Contains(doc.Source, "fixed-marker-9") || strings.Contains(doc.Source, "buggy-marker-7") {
201 t.Fatalf("deployed source should be the fixed module:\n%s", doc.Source)
202 }
203 if doc.Manifest.Name != "notes" || doc.Manifest.Owner != ownerUID || doc.Manifest.BuiltBy != "Claude" {
204 t.Fatalf("bad updated manifest: %+v", doc.Manifest)
205 }
206
207 // The confirmation quotes the verified new version.
208 waitFor(t, "confirmation posts version 2", func() bool {
209 for _, rp := range botReplies(t, s, dmID, dmKey, "Claude") {
210 if strings.Contains(rp.Text, "version 2") {
211 return true
212 }
213 }
214 return false
215 })
216
217 // What the model was shown, call by call: the first call's context carried
218 // the apps note (id + ownership framing); the second carried the app's
219 // real source as the read's tool_result; the build call regenerated FROM
220 // that source (prevSource in the prompt).
221 mu.Lock()
222 defer mu.Unlock()
223 if len(reqs) < 3 {
224 t.Fatalf("expected 3 model calls, got %d", len(reqs))
225 }
226 if !strings.Contains(reqs[0], "app-fixme01") || !strings.Contains(reqs[0], "already owns") {
227 t.Errorf("first call should carry the apps note with the exact id")
228 }
229 if !strings.Contains(reqs[1], "buggy-marker-7") {
230 t.Errorf("second call should carry the app's source as the read result")
231 }
232 if !strings.Contains(reqs[2], "buggy-marker-7") {
233 t.Errorf("build call should regenerate from the current source")
234 }
235}
236