package main // End-to-end test of the app-builder loop (internal/bot/appbuild.go + // memberapps.go): a member asks the bot for an app in a DM; the (fake) model // answers with a create_app tool call, the sidecar generates the module on a // second call, validates it, encrypts { source, manifest } under the instance // key, and deploys it via PUT /api/member-apps — all against the real router. import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) // scriptedAnthropic returns a different canned response per call, in order; // the last one repeats. Each entry is a content list. func scriptedAnthropic(t *testing.T, responses ...[]map[string]any) *httptest.Server { t.Helper() var mu sync.Mutex calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Stream bool `json:"stream"` } json.NewDecoder(r.Body).Decode(&body) mu.Lock() i := calls calls++ mu.Unlock() if i >= len(responses) { i = len(responses) - 1 } // Honour stream:true the way the real API does — the build generation call // streams (internal/bot: createStream), the conversational turn doesn't. if body.Stream { writeAnthropicSSE(w, responses[i]) return } json.NewEncoder(w).Encode(map[string]any{"content": responses[i], "stop_reason": "end_turn"}) })) t.Cleanup(srv.Close) return srv } // writeAnthropicSSE serialises a canned content list as the Messages API's SSE // event stream, so a client using createStream reassembles the same response. func writeAnthropicSSE(w http.ResponseWriter, blocks []map[string]any) { w.Header().Set("Content-Type", "text/event-stream") ev := func(typ string, data map[string]any) { data["type"] = typ b, _ := json.Marshal(data) fmt.Fprintf(w, "event: %s\ndata: %s\n\n", typ, b) } ev("message_start", map[string]any{"message": map[string]any{}}) for i, blk := range blocks { typ, _ := blk["type"].(string) if typ == "tool_use" { 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{}}}) pj, _ := json.Marshal(blk["input"]) ev("content_block_delta", map[string]any{"index": i, "delta": map[string]any{"type": "input_json_delta", "partial_json": string(pj)}}) } else { text, _ := blk["text"].(string) ev("content_block_start", map[string]any{"index": i, "content_block": map[string]any{"type": "text", "text": ""}}) ev("content_block_delta", map[string]any{"index": i, "delta": map[string]any{"type": "text_delta", "text": text}}) } ev("content_block_stop", map[string]any{"index": i}) } ev("message_delta", map[string]any{"delta": map[string]any{"stop_reason": "end_turn"}}) ev("message_stop", map[string]any{}) } const builtNotesApp = "```js\n" + `import { defineApp, addItem, removeItem } from "../apps.js"; export const app = defineApp({ name: "notes", title: "Quick Notes", glyph: "📝", initialState: () => ({ items: [] }), events: { "notes.add": (s, e) => (e.id && e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), "notes.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), }, render(state, el, ctx) { for (const it of ((state && state.items) || [])) { const row = document.createElement("div"); row.textContent = it.text; el.appendChild(row); } }, }); ` + "\n```" func TestBotBuildsAndDeploysMemberApp(t *testing.T) { fakeAI := scriptedAnthropic(t, // Call 1 — the conversational turn: ack text + the create_app call. []map[string]any{ {"type": "text", "text": "On it — building Quick Notes now."}, {"type": "tool_use", "name": "create_app", "input": map[string]any{ "name": "notes", "title": "Quick Notes", "glyph": "📝", "spec": "a personal notes list: add a note via a form, remove with an × button", }}, }, // Call 2 — the dedicated build call: the module, fenced. []map[string]any{{"type": "text", "text": builtNotesApp}}, ) t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) claude := provisionNamed(t, ts.URL, "Claude", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude) ownerUID, _ := s.db.deviceTokenUser(owner.Token) claudeUID, _ := s.db.deviceTokenUser(claude.Token) // Grant the instance key to the bot the way a member's client would: // wrapped to the bot's roster pubkey, posted with the member's token. instKey, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub) if err != nil { t.Fatal(err) } grant, _ := json.Marshal(map[string]any{ "token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}, }) if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("grant instance key: %v %d", err, resp.StatusCode) } dir := t.TempDir() sessionPath := dir + "/claude.session.json" if err := claude.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: claude, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf} go eng.Run(ctx) awaitReady(t, ready) // Ask for the app — with the sender uid the live client injects, so the // deployed manifest records the requester as its owner. blob, err := e2ee.EncryptJSON(dmKey, map[string]any{ "name": owner.Name, "uid": ownerUID, "text": "Can you build me a little notes app?", "ts": time.Now().UnixMilli(), }) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob}) if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("post ask: %v %d", err, resp.StatusCode) } // The deploy lands (the build runs on its own goroutine). waitFor(t, "member app deployed", func() bool { apps, _ := s.db.listMemberApps() return len(apps) == 1 }) apps, _ := s.db.listMemberApps() if apps[0].AuthorID != claudeUID { t.Fatalf("app author should be the bot: %+v", apps[0]) } if apps[0].OwnerID != ownerUID { t.Fatalf("app owner should be the requester (so it's theirs to manage): %+v", apps[0]) } if !strings.HasPrefix(apps[0].ID, "app-") { t.Fatalf("unexpected app id %q", apps[0].ID) } // The blob decrypts (with the instance key) to the module + a manifest // naming the requester as owner. var doc struct { Source string `json:"source"` Manifest struct { V int `json:"v"` Name string `json:"name"` Title string `json:"title"` Glyph string `json:"glyph"` Places []string `json:"places"` Types []string `json:"types"` Owner string `json:"owner"` By string `json:"by"` } `json:"manifest"` } if err := e2ee.DecryptJSON(instKey, apps[0].Blob, &doc); err != nil { t.Fatalf("decrypt deployed blob: %v", err) } if !strings.Contains(doc.Source, "defineApp") { t.Fatal("deployed source isn't the module") } m := doc.Manifest // The bot builds personal apps: the manifest now speaks the places // vocabulary (places-first), not the retired scope enum. if m.V != 1 || m.Name != "notes" || m.Title != "Quick Notes" || m.Glyph != "📝" || fmt.Sprint(m.Places) != "[you]" { t.Fatalf("bad manifest: %+v", m) } if fmt.Sprint(m.Types) != "[notes.add notes.remove]" { t.Fatalf("bad manifest types: %v", m.Types) } if m.Owner != ownerUID || m.By != "Owner" { t.Fatalf("manifest owner should be the requester: %+v", m) } // The thread gets the model's ack and the deploy confirmation — the latter is // the status line, edited in place to carry the app id. They race to post // (the status line goes up as the build starts, alongside the ack), so don't // assume an order: wait for the id to land, then check both are present. waitFor(t, "confirmation carries the app id", func() bool { for _, rp := range botReplies(t, s, dmID, dmKey, "Claude") { if strings.Contains(rp.Text, apps[0].ID) { return true } } return false }) replies := botReplies(t, s, dmID, dmKey, "Claude") var haveAck, haveID, haveVersion bool for _, rp := range replies { if strings.Contains(rp.Text, "On it") { haveAck = true } if strings.Contains(rp.Text, apps[0].ID) { haveID = true } // The confirmation quotes the VERIFIED deployed version (read back from // the server after the PUT), so the member can check it in App Studio. if strings.Contains(rp.Text, "version 1") { haveVersion = true } } if !haveAck { t.Fatalf("expected the model's ack among the replies: %+v", replies) } if !haveID { t.Fatalf("expected the deploy confirmation to carry the app id: %+v", replies) } if !haveVersion { t.Fatalf("expected the deploy confirmation to carry the verified version: %+v", replies) } if apps[0].Version != 1 { t.Fatalf("first deploy should be version 1: %+v", apps[0]) } } // The model sometimes invents an app_id for a BRAND-NEW app (it did this for // "let me build a hello button" → app_id "hello-button"). That id doesn't // exist, so the builder must fall through to creating a new app rather than // failing with "no app with id …". func TestBotBuildsWithBogusAppId(t *testing.T) { fakeAI := scriptedAnthropic(t, []map[string]any{ {"type": "text", "text": "On it."}, {"type": "tool_use", "name": "create_app", "input": map[string]any{ "app_id": "hello-button", // does not exist — a model slip "name": "notes", "title": "Quick Notes", "glyph": "📝", "spec": "notes list", }}, }, []map[string]any{{"type": "text", "text": builtNotesApp}}, ) t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) claude := provisionNamed(t, ts.URL, "Claude", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude) ownerUID, _ := s.db.deviceTokenUser(owner.Token) claudeUID, _ := s.db.deviceTokenUser(claude.Token) instKey, _ := e2ee.GenerateKey() wrapped, _ := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub) grant, _ := json.Marshal(map[string]any{"token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}}) if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("grant: %v %d", err, resp.StatusCode) } dir := t.TempDir() sessionPath := dir + "/claude.session.json" if err := claude.Save(sessionPath); err != nil { t.Fatal(err) } ready := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() 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) awaitReady(t, ready) blob, _ := e2ee.EncryptJSON(dmKey, map[string]any{"name": owner.Name, "uid": ownerUID, "text": "a hello button please", "ts": time.Now().UnixMilli()}) body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob}) http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)) // Despite the bogus app_id, a NEW app is created (fresh app- id), not failed. waitFor(t, "app created despite bogus id", func() bool { apps, _ := s.db.listMemberApps() return len(apps) == 1 }) apps, _ := s.db.listMemberApps() if !strings.HasPrefix(apps[0].ID, "app-") || apps[0].ID == "hello-button" { t.Fatalf("should have minted a fresh id, got %q", apps[0].ID) } replies := botReplies(t, s, dmID, dmKey, "Claude") for _, r := range replies { if strings.Contains(r.Text, "couldn't") || strings.Contains(r.Text, "no app with id") { t.Fatalf("build should not have reported a failure: %q", r.Text) } } } const builtCompanionApp = "```js\n" + `import { defineApp, addItem } from "../apps.js"; import { formatBytes } from "../format.js"; export const app = defineApp({ name: "habit", title: "Habits", glyph: "🌱", initialState: () => ({ items: [] }), events: { "habit.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text }) } : s), "habit.tick": (s, e) => ({ items: s.items.map((h) => (h.id === e.id ? { ...h, ticked: true } : h)) }), }, chatMessage: (e) => (e.type === "habit.add" ? { text: "tracking " + e.text, action: true } : null), commands: { "/habit": (args, ctx) => { const t = args.trim(); if (!t) return ctx.reply({ text: "Usage: /habit " }); ctx.emit({ type: "habit.add", id: ctx.id(), text: t }); ctx.reply({ text: "Tracking " + t }); }, }, render(state, el, ctx) { for (const it of ((state && state.items) || [])) { const row = document.createElement("div"); row.textContent = it.text; el.appendChild(row); } }, }); ` + "\n```" // A companion app with a slash command and a format.js import deploys with a // manifest that reflects all three: chat:true (from the source's chatMessage), // the declared command, and it passes validation (format import allowed). func TestBotBuildsCompanionCommandApp(t *testing.T) { fakeAI := scriptedAnthropic(t, []map[string]any{ {"type": "text", "text": "Building it."}, {"type": "tool_use", "name": "create_app", "input": map[string]any{ "name": "habit", "title": "Habits", "glyph": "🌱", "spec": "a habit tracker; /habit adds one; the thread reads as a conversation", "commands": []any{map[string]any{"name": "/habit", "desc": "Track a habit", "args": ""}}, }}, }, []map[string]any{{"type": "text", "text": builtCompanionApp}}, ) t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL) ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) claude := provisionNamed(t, ts.URL, "Claude", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, claude) ownerUID, _ := s.db.deviceTokenUser(owner.Token) claudeUID, _ := s.db.deviceTokenUser(claude.Token) instKey, _ := e2ee.GenerateKey() wrapped, _ := e2ee.WrapThreadKey(e2ee.B64uEncode(instKey), claude.UserPub) grant, _ := json.Marshal(map[string]any{"token": owner.Token, "grants": []map[string]string{{"user_id": claudeUID, "wrapped": wrapped}}}) if resp, err := http.Post(ts.URL+"/api/instance-key", "application/json", bytes.NewReader(grant)); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("grant instance key: %v %d", err, resp.StatusCode) } dir := t.TempDir() sessionPath := dir + "/claude.session.json" if err := claude.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: claude, SessionPath: sessionPath, Recipe: bot.DefaultRecipe(), AIKey: "test-key", Ready: func() { close(ready) }, Logf: t.Logf} go eng.Run(ctx) awaitReady(t, ready) 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()}) body, _ := json.Marshal(map[string]string{"thread_id": dmID, "blob": blob}) if resp, err := http.Post(ts.URL+"/api/messages?token="+owner.Token, "application/json", bytes.NewReader(body)); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("post ask: %v %d", err, resp.StatusCode) } waitFor(t, "companion app deployed", func() bool { apps, _ := s.db.listMemberApps() return len(apps) == 1 }) apps, _ := s.db.listMemberApps() var doc struct { Source string `json:"source"` Manifest struct { Name string `json:"name"` Types []string Chat bool `json:"chat"` Commands []struct { Name string `json:"name"` Desc string `json:"desc"` Args string `json:"args"` } `json:"commands"` } `json:"manifest"` } if err := e2ee.DecryptJSON(instKey, apps[0].Blob, &doc); err != nil { t.Fatalf("decrypt: %v", err) } if !doc.Manifest.Chat { t.Error("manifest.chat should be true (source has chatMessage)") } if len(doc.Manifest.Commands) != 1 || doc.Manifest.Commands[0].Name != "/habit" || doc.Manifest.Commands[0].Desc != "Track a habit" { t.Fatalf("bad manifest commands: %+v", doc.Manifest.Commands) } if fmt.Sprint(doc.Manifest.Types) != "[habit.add habit.tick]" { t.Fatalf("bad types: %v", doc.Manifest.Types) } if !strings.Contains(doc.Source, "formatBytes") { t.Error("source should keep the format.js import (allowed)") } }