package main // End-to-end test of the bot FIXING an existing member app it didn't build // (docs/member-apps.md): the member deployed the app themselves (author = // owner = member, the App Studio shape), so nothing about it appears in the // conversation. The bot must discover it from the apps note in its context, // read its source via read_app_source, then update it in place via create_app // with the real app_id — landing as version 2 through the server's // agent-for-owner overwrite gate, with author/owner unchanged. import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) // recordingAnthropic is scriptedAnthropic plus request capture — each call's // raw body is appended to reqs, so the test can assert what the model was // actually shown (the apps note, the tool_result source, the build prompt). func recordingAnthropic(t *testing.T, mu *sync.Mutex, reqs *[]string, responses ...[]map[string]any) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) var body struct { Stream bool `json:"stream"` } json.Unmarshal(raw, &body) mu.Lock() i := len(*reqs) *reqs = append(*reqs, string(raw)) mu.Unlock() if i >= len(responses) { i = len(responses) - 1 } 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 } var buggyNotesSource = `import { defineApp, addItem, removeItem } from "../apps.js"; export const app = defineApp({ name: "notes", title: "Notes", glyph: "📝", initialState: () => ({ items: [] }), events: { "notes.add": (s, e) => ({ items: addItem(s.items, { id: e.id, text: "buggy-marker-7" }) }), "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); } }, }); ` var fixedNotesApp = "```js\n" + strings.Replace(buggyNotesSource, `"buggy-marker-7"`, "e.text /* fixed-marker-9 */", 1) + "\n```" func TestBotReadsAndFixesExistingApp(t *testing.T) { var mu sync.Mutex var reqs []string fakeAI := recordingAnthropic(t, &mu, &reqs, // Call 1 — the conversational turn: the model reads the app it found // in its context's apps note (the id appears nowhere in the thread). []map[string]any{ {"type": "tool_use", "id": "tu-read", "name": "read_app_source", "input": map[string]any{"app_id": "app-fixme01"}}, }, // Call 2 — after the tool_result carrying the source: diagnose + fix. []map[string]any{ {"type": "text", "text": "Found it — the add handler drops your text. Fixing now."}, {"type": "tool_use", "id": "tu-build", "name": "create_app", "input": map[string]any{ "app_id": "app-fixme01", "name": "notes", "title": "Notes", "glyph": "📝", "spec": "fix notes.add so the item keeps the text the member typed", }}, }, // Call 3 — the dedicated build call (streamed): the corrected module. []map[string]any{{"type": "text", "text": fixedNotesApp}}, ) 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, 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) } // The member deploys the buggy app THEMSELVES (App Studio shape: author = // owner = member), before the bot ever connects. seedBlob, err := e2ee.EncryptJSON(instKey, map[string]any{ "source": buggyNotesSource, "manifest": map[string]any{ "v": 1, "name": "notes", "title": "Notes", "glyph": "📝", "places": []string{"you"}, "types": []string{"notes.add", "notes.remove"}, "owner": ownerUID, "by": "Owner", }, }) if err != nil { t.Fatal(err) } seed, _ := json.Marshal(map[string]string{"token": owner.Token, "blob": seedBlob}) seedReq, _ := http.NewRequest("PUT", ts.URL+"/api/member-apps/app-fixme01", bytes.NewReader(seed)) seedReq.Header.Set("Content-Type", "application/json") if resp, err := http.DefaultClient.Do(seedReq); err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("seed deploy: %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) // The ask never mentions the app id — discovery is the point. blob, err := e2ee.EncryptJSON(dmKey, map[string]any{ "name": owner.Name, "uid": ownerUID, "text": "There's a bug in my notes app — items lose the text I type. Can you fix it?", "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 fix lands as version 2 of the SAME app — no second app, and the // server's agent-for-owner gate let the bot overwrite a member-authored row. waitFor(t, "app updated to version 2", func() bool { app, ok, _ := s.db.getMemberApp("app-fixme01") return ok && app.Version == 2 }) apps, _ := s.db.listMemberApps() if len(apps) != 1 { t.Fatalf("the fix must update in place, not add an app: %d apps", len(apps)) } app, _, _ := s.db.getMemberApp("app-fixme01") if app.AuthorID != ownerUID || app.OwnerID != ownerUID { t.Fatalf("author/owner must not transfer on a bot fix: %+v", app) } var doc struct { Source string `json:"source"` Manifest struct { Name string `json:"name"` Owner string `json:"owner"` BuiltBy string `json:"builtBy"` } `json:"manifest"` } if err := e2ee.DecryptJSON(instKey, app.Blob, &doc); err != nil { t.Fatalf("decrypt updated blob: %v", err) } if !strings.Contains(doc.Source, "fixed-marker-9") || strings.Contains(doc.Source, "buggy-marker-7") { t.Fatalf("deployed source should be the fixed module:\n%s", doc.Source) } if doc.Manifest.Name != "notes" || doc.Manifest.Owner != ownerUID || doc.Manifest.BuiltBy != "Claude" { t.Fatalf("bad updated manifest: %+v", doc.Manifest) } // The confirmation quotes the verified new version. waitFor(t, "confirmation posts version 2", func() bool { for _, rp := range botReplies(t, s, dmID, dmKey, "Claude") { if strings.Contains(rp.Text, "version 2") { return true } } return false }) // What the model was shown, call by call: the first call's context carried // the apps note (id + ownership framing); the second carried the app's // real source as the read's tool_result; the build call regenerated FROM // that source (prevSource in the prompt). mu.Lock() defer mu.Unlock() if len(reqs) < 3 { t.Fatalf("expected 3 model calls, got %d", len(reqs)) } if !strings.Contains(reqs[0], "app-fixme01") || !strings.Contains(reqs[0], "already owns") { t.Errorf("first call should carry the apps note with the exact id") } if !strings.Contains(reqs[1], "buggy-marker-7") { t.Errorf("second call should carry the app's source as the read result") } if !strings.Contains(reqs[2], "buggy-marker-7") { t.Errorf("build call should regenerate from the current source") } }