| 1 | package 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 | |
| 11 | import ( |
| 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). |
| 30 | func 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 | |
| 55 | var buggyNotesSource = `import { defineApp, addItem, removeItem } from "../apps.js"; |
| 56 | |
| 57 | export 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 | |
| 76 | var fixedNotesApp = "```js\n" + |
| 77 | strings.Replace(buggyNotesSource, `"buggy-marker-7"`, "e.text /* fixed-marker-9 */", 1) + |
| 78 | "\n```" |
| 79 | |
| 80 | func 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 | |