char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somrestore queued messages to the composer on abort3d23fd2

main
11.1 KiB272 linesraw
1import { Signal } from "@char/aftercare";
2import { assert, assertEquals, assertStringIncludes } from "@std/assert";
3import { decodeBase64 } from "@std/encoding/base64";
4import type { ImageContent } from "@earendil-works/pi-ai";
5import { IDBFactory } from "fake-indexeddb";
6import { PNG, until } from "@berry/util";
7import { withDom } from "@berry/util/dom.ts";
8import { composer } from "./composer.tsx";
9import { imageDraft } from "./image-drafts.ts";
10import type { CommandInfo } from "./conversation.tsx";
11import { Scope } from "./ui.ts";
12
13Deno.test("pasted images are durable route-specific drafts and sends retire only captured attachments", async (t) => {
14  let decode = () => Promise.resolve({ width: 1, height: 1, close() {} });
15  const globals = {
16    indexedDB: new IDBFactory(),
17    crypto: { getRandomValues: crypto.getRandomValues.bind(crypto) },
18    createImageBitmap: () => decode(),
19  };
20  await withDom(async (window) => {
21    const commands = new Signal<readonly CommandInfo[]>([]);
22    const scopes: Scope[] = [];
23    const errors: string[] = [];
24    const calls: { text: string; followUp: boolean; images: readonly ImageContent[] }[] = [];
25    let send = () => Promise.resolve();
26    const file = (name = "screenshot.png") =>
27      new File([decodeBase64(PNG)], name, { type: "image/png" });
28    const open = async (route: string, busy = false) => {
29      location.hash = `#/${route}`;
30      const scope = new Scope();
31      scopes.push(scope);
32      const view = composer(scope, route, new Signal(busy), commands, (text, followUp, images) => {
33        calls.push({ text, followUp, images });
34        return send();
35      }, (message) => errors.push(message));
36      document.body.replaceChildren(view.element);
37      await imageDraft(route).ready;
38      const input = view.element.querySelector("textarea")!;
39      return {
40        ...view,
41        input,
42        paste(files: readonly File[], text = "") {
43          const event = new window.Event("paste", { cancelable: true, bubbles: true });
44          Object.defineProperty(event, "clipboardData", {
45            value: {
46              items: files.map((file) => ({
47                kind: "file",
48                type: file.type,
49                getAsFile: () => file,
50              })),
51              getData: (format: string) => format === "text/plain" ? text : "",
52            },
53          });
54          input.dispatchEvent(event as unknown as Event);
55          return event.defaultPrevented;
56        },
57        async saved() {
58          await until(() =>
59            imageDraft(route).items.get().length > 0 &&
60            imageDraft(route).items.get().every((image) => image.status !== "loading")
61          );
62        },
63        async submit(followUp = false) {
64          input.dispatchEvent(
65            new window.KeyboardEvent("keydown", {
66              key: "Enter",
67              altKey: followUp,
68            }) as unknown as Event,
69          );
70          await until(() => !imageDraft(route).submitting);
71        },
72        leave() {
73          scope.dispose();
74          view.element.remove();
75        },
76      };
77    };
78    let reloads = 0;
79    const fresh = async (route: string) => {
80      const module = await import(
81        new URL(`./image-drafts.ts?reload=${++reloads}`, import.meta.url).href
82      ) as typeof import("./image-drafts.ts");
83      const draft = module.imageDraft(route);
84      await draft.ready;
85      assertEquals(draft.error.get(), null);
86      return draft.items.get();
87    };
88    try {
89      await t.step(
90        "ordinary paste stays native and mixed paste preserves the text selection",
91        async () => {
92          const view = await open("mixed");
93          assertEquals(view.paste([], "plain text"), false);
94          view.setText("hello world");
95          view.input.setSelectionRange(6, 11);
96          assertEquals(view.paste([file()], "there"), true);
97          assertEquals(view.input.value, "hello there");
98          await view.saved();
99          assertEquals(view.element.querySelector("img")!.alt, "screenshot.png");
100          await view.submit();
101          assertEquals(calls.at(-1), {
102            text: "hello there",
103            followUp: false,
104            images: [{ type: "image", mimeType: "image/png", data: PNG }],
105          });
106          assertEquals(view.input.value, "");
107          assertEquals(view.element.querySelectorAll(".attachment").length, 0);
108          assertEquals(await fresh("mixed"), []);
109        },
110      );
111      await t.step(
112        "images survive navigation and a fresh frontend without crossing branch routes",
113        async () => {
114          for (const route of ["draft", "draft/fork@4"]) {
115            const view = await open(route);
116            view.paste([file(`${route}.png`)]);
117            await view.saved();
118            view.leave();
119          }
120          for (const route of ["draft", "draft/fork@4"]) {
121            const restored = await fresh(route);
122            assertEquals(restored.length, 1);
123            assertEquals(restored[0].name, `${route}.png`);
124            assertEquals(restored[0].blob.type, "image/png");
125            assertEquals(new Uint8Array(await restored[0].blob.arrayBuffer()), decodeBase64(PNG));
126            const view = await open(route);
127            assertEquals(view.element.querySelector("img")!.alt, `${route}.png`);
128            view.leave();
129          }
130          assertEquals((await open("unrelated")).element.querySelectorAll(".attachment").length, 0);
131        },
132      );
133      await t.step(
134        "failed sends retain images and image-only retries clear their durable draft",
135        async () => {
136          const view = await open("retry-images");
137          view.paste([file()]);
138          await view.saved();
139          send = () => Promise.reject(new Error("offline"));
140          await view.submit();
141          assertEquals(errors.at(-1), "offline");
142          assertEquals((await fresh("retry-images")).length, 1);
143          assertEquals(view.element.querySelectorAll(".attachment").length, 1);
144          send = () => Promise.resolve();
145          await view.submit();
146          assertEquals(calls.at(-1)!.text, "");
147          assertEquals(calls.at(-1)!.images[0].data, PNG);
148          assertEquals(await fresh("retry-images"), []);
149        },
150      );
151      await t.step(
152        "pending sends protect later pastes and edits, even after returning to the route",
153        async () => {
154          const pending = Promise.withResolvers<void>();
155          send = () => pending.promise;
156          const view = await open("pending-images");
157          view.setText("old message");
158          view.paste([file("old.png")]);
159          await view.saved();
160          const before = calls.length;
161          const submitted = view.submit();
162          await until(() => calls.length === before + 1);
163          view.leave();
164          const returned = await open("pending-images");
165          const duplicate = returned.submit();
166          assertEquals(calls.length, before + 1);
167          returned.setText("next message");
168          returned.paste([file("next.png")]);
169          await returned.saved();
170          pending.resolve();
171          await submitted;
172          await duplicate;
173          assertEquals(returned.input.value, "next message");
174          assertEquals([...returned.element.querySelectorAll("img")].map((img) => img.alt), [
175            "next.png",
176          ]);
177          assertEquals((await fresh("pending-images")).map((image) => image.name), ["next.png"]);
178          send = () => Promise.resolve();
179        },
180      );
181      await t.step(
182        "commands refuse attachments and busy sends preserve their delivery mode",
183        async () => {
184          let commandsRun = 0;
185          commands.set([{
186            name: "local",
187            extension: "test",
188            description: "local",
189            run: () => {
190              commandsRun++;
191              return Promise.resolve();
192            },
193          }]);
194          const view = await open("command-images");
195          view.paste([file()]);
196          await view.saved();
197          view.setText("/local");
198          const before = calls.length;
199          await view.submit();
200          assertEquals(commandsRun, 0);
201          assertEquals(calls.length, before);
202          assertEquals(errors.at(-1), "Slash commands cannot include images");
203          assertEquals((await fresh("command-images")).length, 1);
204          const busy = await open("busy-images", true);
205          busy.paste([file()]);
206          await busy.saved();
207          await busy.submit(true);
208          assertEquals(calls.at(-1)!.followUp, true);
209          assertEquals(calls.at(-1)!.images[0].data, PNG);
210        },
211      );
212      await t.step(
213        "restored messages retain attachments and never replace text or image drafts",
214        async () => {
215          const view = await open("restored-images");
216          const content = [
217            { type: "text" as const, text: "Bring this back" },
218            { type: "image" as const, mimeType: "image/png", data: PNG },
219          ];
220          view.setText(" ");
221          assertEquals(await view.restore(content), false);
222          assertEquals(view.input.value, " ");
223          view.setText("");
224          assertEquals(await view.restore(content), true);
225          assertEquals(view.input.value, "Bring this back");
226          assertEquals((await fresh("restored-images")).length, 1);
227          view.setText("");
228          assertEquals(await view.restore("must not replace the image"), false);
229          assertEquals(view.input.value, "");
230          view.setText("Bring this back");
231          await view.submit();
232          assertEquals(calls.at(-1), {
233            text: "Bring this back",
234            followUp: false,
235            images: [{ type: "image", mimeType: "image/png", data: PNG }],
236          });
237          assertEquals(await fresh("restored-images"), []);
238        },
239      );
240      await t.step("invalid and undecodable images cannot be sent and can be removed", async () => {
241        const view = await open("invalid-images");
242        const before = calls.length;
243        view.paste([new File(["not an image"], "broken.png", { type: "image/png" })]);
244        await view.saved();
245        assertStringIncludes(
246          view.element.querySelector(".attachment-status")!.textContent!,
247          "declared format",
248        );
249        await view.submit();
250        assertEquals(calls.length, before);
251        assertEquals(await fresh("invalid-images"), []);
252        view.element.querySelector<HTMLButtonElement>('[title="Remove image"]')!.click();
253        await until(() => imageDraft("invalid-images").items.get().length === 0);
254        decode = () => Promise.reject(new Error("Cannot decode image"));
255        view.paste([file()]);
256        await view.saved();
257        assertStringIncludes(
258          view.element.querySelector(".attachment-status")!.textContent!,
259          "Cannot decode",
260        );
261        decode = () => Promise.resolve({ width: 1, height: 1, close() {} });
262        const crowded = await open("crowded-images");
263        crowded.paste(Array.from({ length: 5 }, () => file()));
264        await until(() => errors.at(-1)?.includes("at most 4") === true);
265        assertEquals(crowded.element.querySelectorAll(".attachment").length, 0);
266      });
267      assert(calls.length > 0);
268    } finally {
269      for (const scope of scopes) scope.dispose();
270    }
271  }, { globals });
272});