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 app-builder loop (internal/bot/appbuild.go +
4// memberapps.go): a member asks the bot for an app in a DM; the (fake) model
5// answers with a create_app tool call, the sidecar generates the module on a
6// second call, validates it, encrypts { source, manifest } under the instance
7// key, and deploys it via PUT /api/member-apps β€” all against the real router.
8
9import (
10 "bytes"
11 "context"
12 "encoding/json"
13 "fmt"
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// scriptedAnthropic returns a different canned response per call, in order;
26// the last one repeats. Each entry is a content list.
27func scriptedAnthropic(t *testing.T, responses ...[]map[string]any) *httptest.Server {
28 t.Helper()
29 var mu sync.Mutex
30 calls := 0
31 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32 var body struct {
33 Stream bool `json:"stream"`
34 }
35 json.NewDecoder(r.Body).Decode(&body)
36 mu.Lock()
37 i := calls
38 calls++
39 mu.Unlock()
40 if i >= len(responses) {
41 i = len(responses) - 1
42 }
43 // Honour stream:true the way the real API does β€” the build generation call
44 // streams (internal/bot: createStream), the conversational turn doesn't.
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
55// writeAnthropicSSE serialises a canned content list as the Messages API's SSE
56// event stream, so a client using createStream reassembles the same response.
57func writeAnthropicSSE(w http.ResponseWriter, blocks []map[string]any) {
58 w.Header().Set("Content-Type", "text/event-stream")
59 ev := func(typ string, data map[string]any) {
60 data["type"] = typ
61 b, _ := json.Marshal(data)
62 fmt.Fprintf(w, "event: %s\ndata: %s\n\n", typ, b)
63 }
64 ev("message_start", map[string]any{"message": map[string]any{}})
65 for i, blk := range blocks {
66 typ, _ := blk["type"].(string)
67 if typ == "tool_use" {
68 ev("content_block_start", map[string]any{"index": i, "content_block": map[string]any{"type": "tool_use", "id": "t", "name": blk["name"], "input": map[string]any{}}})
69 pj, _ := json.Marshal(blk["input"])
70 ev("content_block_delta", map[string]any{"index": i, "delta": map[string]any{"type": "input_json_delta", "partial_json": string(pj)}})
71 } else {
72 text, _ := blk["text"].(string)
73 ev("content_block_start", map[string]any{"index": i, "content_block": map[string]any{"type": "text", "text": ""}})
74 ev("content_block_delta", map[string]any{"index": i, "delta": map[string]any{"type": "text_delta", "text": text}})
75 }
76 ev("content_block_stop", map[string]any{"index": i})
77 }
78 ev("message_delta", map[string]any{"delta": map[string]any{"stop_reason": "end_turn"}})
79 ev("message_stop", map[string]any{})
80}
81
82const builtNotesApp = "```js\n" + `import { defineApp, addItem, removeItem } from "../apps.js";
83
84export const app = defineApp({
85 name: "notes",
86 title: "Quick Notes",
87 glyph: "πŸ“",
88 initialState: () => ({ items: [] }),
89 events: {
90 "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s),
91 "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
92 },
93 render(state, el, ctx) {
94 for (const it of ((state && state.items) || [])) {
95 const row = document.createElement("div");
96 row.textContent = it.text;
97 el.appendChild(row);
98 }
99 },
100});
101` + "\n```"
102
103func TestBotBuildsAndDeploysMemberApp(t *testing.T) {
104 fakeAI := scriptedAnthropic(t,
105 // Call 1 β€” the conversational turn: ack text + the create_app call.
106 []map[string]any{
107 {"type": "text", "text": "On it β€” building Quick Notes now."},
108 {"type": "tool_use", "name": "create_app", "input": map[string]any{
109 "name": "notes", "title": "Quick Notes", "glyph": "πŸ“",
110 "spec": "a personal notes list: add a note via a form, remove with an Γ— button",
111 }},
112 },
113 // Call 2 β€” the dedicated build call: the module, fenced.
114 []map[string]any{{"type": "text", "text": builtNotesApp}},
115 )
116 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
117
118 ts, s := newTestServer(t)
119 owner := provisionNamed(t, ts.URL, "Owner", false)
120 claude := provisionNamed(t, ts.URL, "Claude", true)
121 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude)
122 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
123 claudeUID, _ := s.db.deviceTokenUser(claude.Token)
124
125 // Grant the instance key to the bot the way a member's client would:
126 // wrapped to the bot's roster pubkey, posted with the member's token.
127 instKey, err := e2ee.GenerateKey()
128 if err != nil {
129 t.Fatal(err)
130 }
131 wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub)
132 if err != nil {
133 t.Fatal(err)
134 }
135 grant, _ := json.Marshal(map[string]any{
136 "token": owner.Token,
137 "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}},
138 })
139 if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK {
140 t.Fatalf("grant instance key: %v %d", err, resp.StatusCode)
141 }
142
143 dir := t.TempDir()
144 sessionPath := dir + "/claude.session.json"
145 if err := claude.Save(sessionPath); err != nil {
146 t.Fatal(err)
147 }
148 ready := make(chan struct{})
149 ctx, cancel := context.WithCancel(context.Background())
150 defer cancel()
151 eng := &bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath,
152 Recipe: bot.DefaultRecipe(), AIKey: "test-key",
153 Ready: func() { close(ready) }, Logf: t.Logf}
154 go eng.Run(ctx)
155 awaitReady(t, ready)
156
157 // Ask for the app β€” with the sender uid the live client injects, so the
158 // deployed manifest records the requester as its owner.
159 blob, err := e2ee.EncryptJSON(dmKey, map[string]any{
160 "name": owner.Name, "uid": ownerUID, "text": "Can you build me a little notes app?", "ts": time.Now().UnixMilli(),
161 })
162 if err != nil {
163 t.Fatal(err)
164 }
165 body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob})
166 if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK {
167 t.Fatalf("post ask: %v %d", err, resp.StatusCode)
168 }
169
170 // The deploy lands (the build runs on its own goroutine).
171 waitFor(t, "member app deployed", func() bool {
172 apps, _ := s.db.listMemberApps()
173 return len(apps) == 1
174 })
175 apps, _ := s.db.listMemberApps()
176 if apps[0].AuthorID != claudeUID {
177 t.Fatalf("app author should be the bot: %+v", apps[0])
178 }
179 if apps[0].OwnerID != ownerUID {
180 t.Fatalf("app owner should be the requester (so it's theirs to manage): %+v", apps[0])
181 }
182 if !strings.HasPrefix(apps[0].ID, "app-") {
183 t.Fatalf("unexpected app id %q", apps[0].ID)
184 }
185
186 // The blob decrypts (with the instance key) to the module + a manifest
187 // naming the requester as owner.
188 var doc struct {
189 Source string `json:"source"`
190 Manifest struct {
191 V int `json:"v"`
192 Name string `json:"name"`
193 Title string `json:"title"`
194 Glyph string `json:"glyph"`
195 Places []string `json:"places"`
196 Types []string `json:"types"`
197 Owner string `json:"owner"`
198 By string `json:"by"`
199 } `json:"manifest"`
200 }
201 if err := e2ee.DecryptJSON(instKey, apps[0].Blob, &doc); err != nil {
202 t.Fatalf("decrypt deployed blob: %v", err)
203 }
204 if !strings.Contains(doc.Source, "defineApp") {
205 t.Fatal("deployed source isn't the module")
206 }
207 m := doc.Manifest
208 // The bot builds personal apps: the manifest now speaks the places
209 // vocabulary (places-first), not the retired scope enum.
210 if m.V != 1 || m.Name != "notes" || m.Title != "Quick Notes" || m.Glyph != "πŸ“" || fmt.Sprint(m.Places) != "[you]" {
211 t.Fatalf("bad manifest: %+v", m)
212 }
213 if fmt.Sprint(m.Types) != "[notes.add notes.remove]" {
214 t.Fatalf("bad manifest types: %v", m.Types)
215 }
216 if m.Owner != ownerUID || m.By != "Owner" {
217 t.Fatalf("manifest owner should be the requester: %+v", m)
218 }
219
220 // The thread gets the model's ack and the deploy confirmation β€” the latter is
221 // the status line, edited in place to carry the app id. They race to post
222 // (the status line goes up as the build starts, alongside the ack), so don't
223 // assume an order: wait for the id to land, then check both are present.
224 waitFor(t, "confirmation carries the app id", func() bool {
225 for _, rp := range botReplies(t, s, dmID, dmKey, "Claude") {
226 if strings.Contains(rp.Text, apps[0].ID) {
227 return true
228 }
229 }
230 return false
231 })
232 replies := botReplies(t, s, dmID, dmKey, "Claude")
233 var haveAck, haveID, haveVersion bool
234 for _, rp := range replies {
235 if strings.Contains(rp.Text, "On it") {
236 haveAck = true
237 }
238 if strings.Contains(rp.Text, apps[0].ID) {
239 haveID = true
240 }
241 // The confirmation quotes the VERIFIED deployed version (read back from
242 // the server after the PUT), so the member can check it in App Studio.
243 if strings.Contains(rp.Text, "version 1") {
244 haveVersion = true
245 }
246 }
247 if !haveAck {
248 t.Fatalf("expected the model's ack among the replies: %+v", replies)
249 }
250 if !haveID {
251 t.Fatalf("expected the deploy confirmation to carry the app id: %+v", replies)
252 }
253 if !haveVersion {
254 t.Fatalf("expected the deploy confirmation to carry the verified version: %+v", replies)
255 }
256 if apps[0].Version != 1 {
257 t.Fatalf("first deploy should be version 1: %+v", apps[0])
258 }
259}
260
261// The model sometimes invents an app_id for a BRAND-NEW app (it did this for
262// "let me build a hello button" β†’ app_id "hello-button"). That id doesn't
263// exist, so the builder must fall through to creating a new app rather than
264// failing with "no app with id …".
265func TestBotBuildsWithBogusAppId(t *testing.T) {
266 fakeAI := scriptedAnthropic(t,
267 []map[string]any{
268 {"type": "text", "text": "On it."},
269 {"type": "tool_use", "name": "create_app", "input": map[string]any{
270 "app_id": "hello-button", // does not exist β€” a model slip
271 "name": "notes", "title": "Quick Notes", "glyph": "πŸ“",
272 "spec": "notes list",
273 }},
274 },
275 []map[string]any{{"type": "text", "text": builtNotesApp}},
276 )
277 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
278
279 ts, s := newTestServer(t)
280 owner := provisionNamed(t, ts.URL, "Owner", false)
281 claude := provisionNamed(t, ts.URL, "Claude", true)
282 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude)
283 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
284 claudeUID, _ := s.db.deviceTokenUser(claude.Token)
285
286 instKey, _ := e2ee.GenerateKey()
287 wrapped, _ := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub)
288 grant, _ := json.Marshal(map[string]any{"token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}})
289 if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK {
290 t.Fatalf("grant: %v %d", err, resp.StatusCode)
291 }
292
293 dir := t.TempDir()
294 sessionPath := dir + "/claude.session.json"
295 if err := claude.Save(sessionPath); err != nil {
296 t.Fatal(err)
297 }
298 ready := make(chan struct{})
299 ctx, cancel := context.WithCancel(context.Background())
300 defer cancel()
301 go (&bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf}).Run(ctx)
302 awaitReady(t, ready)
303
304 blob, _ := e2ee.EncryptJSON(dmKey, map[string]any{"name": owner.Name, "uid": ownerUID, "text": "a hello button please", "ts": time.Now().UnixMilli()})
305 body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob})
306 http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body))
307
308 // Despite the bogus app_id, a NEW app is created (fresh app- id), not failed.
309 waitFor(t, "app created despite bogus id", func() bool {
310 apps, _ := s.db.listMemberApps()
311 return len(apps) == 1
312 })
313 apps, _ := s.db.listMemberApps()
314 if !strings.HasPrefix(apps[0].ID, "app-") || apps[0].ID == "hello-button" {
315 t.Fatalf("should have minted a fresh id, got %q", apps[0].ID)
316 }
317 replies := botReplies(t, s, dmID, dmKey, "Claude")
318 for _, r := range replies {
319 if strings.Contains(r.Text, "couldn't") || strings.Contains(r.Text, "no app with id") {
320 t.Fatalf("build should not have reported a failure: %q", r.Text)
321 }
322 }
323}
324
325const builtCompanionApp = "```js\n" + `import { defineApp, addItem } from "../apps.js";
326import { formatBytes } from "../format.js";
327
328export const app = defineApp({
329 name: "habit",
330 title: "Habits",
331 glyph: "🌱",
332 initialState: () => ({ items: [] }),
333 events: {
334 "habit.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s),
335 "habit.tick": (s, e) => ({ items: s.items.map((h) => (h.id === e.id ? { ...h, ticked: true } : h)) }),
336 },
337 chatMessage: (e) => (e.type === "habit.add" ? { text: "tracking " + e.text, action: true } : null),
338 commands: {
339 "/habit": (args, ctx) => {
340 const t = args.trim();
341 if (!t) return ctx.reply({ text: "Usage: /habit <name>" });
342 ctx.emit({ type: "habit.add", id: ctx.id(), text: t });
343 ctx.reply({ text: "Tracking " + t });
344 },
345 },
346 render(state, el, ctx) {
347 for (const it of ((state && state.items) || [])) {
348 const row = document.createElement("div");
349 row.textContent = it.text;
350 el.appendChild(row);
351 }
352 },
353});
354` + "\n```"
355
356// A companion app with a slash command and a format.js import deploys with a
357// manifest that reflects all three: chat:true (from the source's chatMessage),
358// the declared command, and it passes validation (format import allowed).
359func TestBotBuildsCompanionCommandApp(t *testing.T) {
360 fakeAI := scriptedAnthropic(t,
361 []map[string]any{
362 {"type": "text", "text": "Building it."},
363 {"type": "tool_use", "name": "create_app", "input": map[string]any{
364 "name": "habit", "title": "Habits", "glyph": "🌱",
365 "spec": "a habit tracker; /habit adds one; the thread reads as a conversation",
366 "commands": []any{map[string]any{"name": "/habit", "desc": "Track a habit", "args": "<name>"}},
367 }},
368 },
369 []map[string]any{{"type": "text", "text": builtCompanionApp}},
370 )
371 t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
372
373 ts, s := newTestServer(t)
374 owner := provisionNamed(t, ts.URL, "Owner", false)
375 claude := provisionNamed(t, ts.URL, "Claude", true)
376 dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude)
377 ownerUID, _ := s.db.deviceTokenUser(owner.Token)
378 claudeUID, _ := s.db.deviceTokenUser(claude.Token)
379
380 instKey, _ := e2ee.GenerateKey()
381 wrapped, _ := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub)
382 grant, _ := json.Marshal(map[string]any{"token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}})
383 if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK {
384 t.Fatalf("grant instance key: %v %d", err, resp.StatusCode)
385 }
386
387 dir := t.TempDir()
388 sessionPath := dir + "/claude.session.json"
389 if err := claude.Save(sessionPath); err != nil {
390 t.Fatal(err)
391 }
392 ready := make(chan struct{})
393 ctx, cancel := context.WithCancel(context.Background())
394 defer cancel()
395 eng := &bot.Engine{Base: ts.URL, Session: claude, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf}
396 go eng.Run(ctx)
397 awaitReady(t, ready)
398
399 blob, _ := e2ee.EncryptJSON(dmKey, map[string]any{"name": owner.Name, "uid": ownerUID, "text": "make me a habit tracker with a /habit command and a chat view", "ts": time.Now().UnixMilli()})
400 body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob})
401 if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK {
402 t.Fatalf("post ask: %v %d", err, resp.StatusCode)
403 }
404
405 waitFor(t, "companion app deployed", func() bool {
406 apps, _ := s.db.listMemberApps()
407 return len(apps) == 1
408 })
409 apps, _ := s.db.listMemberApps()
410 var doc struct {
411 Source string `json:"source"`
412 Manifest struct {
413 Name string `json:"name"`
414 Types []string
415 Chat bool `json:"chat"`
416 Commands []struct {
417 Name string `json:"name"`
418 Desc string `json:"desc"`
419 Args string `json:"args"`
420 } `json:"commands"`
421 } `json:"manifest"`
422 }
423 if err := e2ee.DecryptJSON(instKey, apps[0].Blob, &doc); err != nil {
424 t.Fatalf("decrypt: %v", err)
425 }
426 if !doc.Manifest.Chat {
427 t.Error("manifest.chat should be true (source has chatMessage)")
428 }
429 if len(doc.Manifest.Commands) != 1 || doc.Manifest.Commands[0].Name != "/habit" || doc.Manifest.Commands[0].Desc != "Track a habit" {
430 t.Fatalf("bad manifest commands: %+v", doc.Manifest.Commands)
431 }
432 if fmt.Sprint(doc.Manifest.Types) != "[habit.add habit.tick]" {
433 t.Fatalf("bad types: %v", doc.Manifest.Types)
434 }
435 if !strings.Contains(doc.Source, "formatBytes") {
436 t.Error("source should keep the format.js import (allowed)")
437 }
438}
439