char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd defaultEnabled to extensions and enable code+web32b51b2

main
14.3 KiB400 linesraw
1import { assertEquals, assertExists, assertStringIncludes } from "@std/assert";
2import { withBerry } from "./testing.ts";
3import { fill } from "@berry/util/dom.ts";
4import { until } from "@berry/util";
5
6const initial = {
7  userName: "Charlotte",
8  generalGuidelines: "Ask $USER first.",
9};
10
11Deno.test("available providers start collapsed, expand for search, and remember manual expansion", async () => {
12  await withBerry(async ({ scope, server, settle }) => {
13    const available = [
14      {
15        provider: "alpha",
16        id: "a1",
17        name: "Alpha One",
18        input: ["text"],
19        reasoning: false,
20        thinkingLevels: ["off"],
21        contextWindow: 1000,
22        cost: { input: 0, output: 0 },
23      },
24      {
25        provider: "beta",
26        id: "b1",
27        name: "Beta One",
28        input: ["text"],
29        reasoning: false,
30        thinkingLevels: ["off"],
31        contextWindow: 1000,
32        cost: { input: 0, output: 0 },
33      },
34    ];
35    server.respond(({ path }) => path === "/api/models" ? available : []);
36    const { app } = await import("./app.tsx");
37    document.body.append(app(scope));
38    server.snapshot("settings", {
39      models: [{ provider: "alpha", modelId: "a1" }],
40      systemPrompt: initial,
41    });
42    server.snapshot("extensions", []);
43    await settle();
44    const groups = [
45      ...document.querySelectorAll<HTMLDetailsElement>(".model-list details.provider"),
46    ];
47    assertEquals(groups.length, 2);
48    assertEquals(
49      groups.map((group) => group.open),
50      [false, false],
51    );
52    groups[0].open = true;
53    await settle();
54    server.snapshot("settings", { models: [], systemPrompt: initial });
55    await settle();
56    assertEquals(document.querySelector<HTMLDetailsElement>(".model-list details")!.open, true);
57
58    const filter = document.querySelector<HTMLInputElement>(".settings input.filter")!;
59    filter.focus();
60    fill(filter, "beta");
61    await settle();
62    const shown = [...document.querySelectorAll(".model-list details.provider")];
63    assertEquals(shown.length, 1);
64    assertEquals((shown[0] as HTMLDetailsElement).open, true);
65    assertStringIncludes(shown[0].textContent, "Beta One");
66    fill(filter, "");
67    await settle();
68    assertEquals(
69      [...document.querySelectorAll<HTMLDetailsElement>(".model-list details")].map((group) =>
70        group.open
71      ),
72      [true, false],
73    );
74  }, { url: "http://berry.test/#/settings" });
75});
76
77Deno.test("global prompt drafts survive remote updates, save only changed fields, and recover from failure", async () => {
78  await withBerry(async ({ scope, server, settle }) => {
79    server.respond(() => []);
80    const { app } = await import("./app.tsx");
81    document.body.append(app(scope));
82    server.snapshot("settings", { models: [], systemPrompt: initial });
83    server.snapshot("extensions", []);
84    await settle();
85
86    const editor = document.querySelector<HTMLFieldSetElement>(".system-prompt");
87    assertExists(editor);
88    const name = editor.querySelector<HTMLInputElement>("input")!;
89    const general = editor.querySelector<HTMLTextAreaElement>("textarea")!;
90    assertEquals([name.value, general.value], Object.values(initial));
91
92    general.focus();
93    fill(general, "");
94    const remote = { ...initial, userName: "Sam" };
95    server.snapshot("settings", { models: [], systemPrompt: remote });
96    await settle();
97    assertEquals([name.value, general.value], ["Sam", ""]);
98
99    server.respond(() => new Response(JSON.stringify({ error: "disk full" }), { status: 500 }));
100    general.blur();
101    await settle();
102    assertEquals(general.value, "");
103    assertEquals(editor.querySelector('[role="status"]')!.textContent, "disk full");
104    assertEquals(editor.disabled, false);
105
106    server.respond(() => ({ models: [], systemPrompt: { ...remote, generalGuidelines: "" } }));
107    general.focus();
108    general.blur();
109    await settle();
110    assertEquals(server.changes().at(-1), {
111      method: "PATCH",
112      path: "/api/settings",
113      body: { systemPrompt: { generalGuidelines: "" } },
114    });
115    server.snapshot("settings", {
116      models: [],
117      systemPrompt: { ...remote, generalGuidelines: "New global rule." },
118    });
119    await settle();
120    assertEquals(general.value, "New global rule.");
121  }, { url: "http://berry.test/#/settings" });
122});
123
124Deno.test("blur saves are queued without blocking editing or overwriting newer drafts", async () => {
125  await withBerry(async ({ scope, server, settle }) => {
126    server.respond(() => []);
127    const { app } = await import("./app.tsx");
128    document.body.append(app(scope));
129    server.snapshot("settings", { models: [], systemPrompt: initial });
130    server.snapshot("extensions", []);
131    await settle();
132
133    const editor = document.querySelector<HTMLFieldSetElement>(".system-prompt")!;
134    const name = editor.querySelector<HTMLInputElement>("input")!;
135    const general = editor.querySelector<HTMLTextAreaElement>("textarea")!;
136    const entered = Promise.withResolvers<void>();
137    const first = Promise.withResolvers<unknown>();
138    let saved = { ...initial };
139    server.respond(({ method, body }) => {
140      if (method !== "PATCH") return [];
141      if (server.changes().length === 1) {
142        entered.resolve();
143        return first.promise;
144      }
145      saved = { ...saved, ...(body as { systemPrompt: Partial<typeof initial> }).systemPrompt };
146      return { systemPrompt: saved };
147    });
148
149    general.focus();
150    fill(general, "First edit");
151    assertEquals(server.changes().length, 0);
152    name.focus();
153    await entered.promise;
154    assertEquals(editor.disabled, false);
155
156    general.focus();
157    fill(general, "Second edit");
158    name.focus();
159    fill(name, "Alex");
160    assertEquals(server.changes().length, 1);
161
162    saved = { ...saved, generalGuidelines: "First edit" };
163    server.snapshot("settings", { models: [], systemPrompt: saved });
164    first.resolve({ systemPrompt: saved });
165    await until(() => server.changes().length === 2);
166    await settle();
167    assertEquals(general.value, "Second edit");
168    assertEquals(name.value, "Alex");
169    assertEquals(server.changes().map(({ body }) => body), [
170      { systemPrompt: { generalGuidelines: "First edit" } },
171      { systemPrompt: { generalGuidelines: "Second edit" } },
172    ]);
173
174    name.blur();
175    await settle();
176    assertEquals(server.changes().at(-1)?.body, { systemPrompt: { userName: "Alex" } });
177    const count = server.changes().length;
178    general.focus();
179    general.blur();
180    await settle();
181    assertEquals(server.changes().length, count);
182  }, { url: "http://berry.test/#/settings" });
183});
184
185Deno.test("extension prose saves per extension and keeps dirty drafts across remote updates", async () => {
186  await withBerry(async ({ scope, server, settle }) => {
187    server.respond(() => []);
188    const { app } = await import("./app.tsx");
189    document.body.append(app(scope));
190    const extensions = [{
191      name: "code",
192      directory: "/ext/code",
193      description: "edit files",
194      status: "loaded",
195      error: null,
196      running: true,
197      builtin: false,
198      alwaysOn: false,
199      defaultEnabled: false,
200      web: null,
201      formerly: [],
202      configuration: null,
203      promptProse: { default: "Default prose." },
204      startup: null,
205    }];
206    server.snapshot("extensions", extensions);
207    await settle();
208
209    const row = document.querySelector(".extension")!;
210    const area = row.querySelector<HTMLTextAreaElement>("textarea")!;
211    const fieldset = row.querySelector<HTMLFieldSetElement>("fieldset")!;
212    assertExists(area);
213    assertEquals(fieldset.disabled, true);
214    assertEquals(area.value, "");
215
216    server.snapshot("settings", { models: [], systemPrompt: { ...initial, promptProse: {} } });
217    await settle();
218    assertEquals(fieldset.disabled, false);
219    assertEquals(area.value, "Default prose.");
220
221    area.focus();
222    fill(area, "Edited prose.");
223    server.snapshot("settings", {
224      models: [],
225      systemPrompt: { ...initial, userName: "Sam", promptProse: {} },
226    });
227    await settle();
228    assertEquals(area.value, "Edited prose.");
229
230    server.respond(() => ({
231      systemPrompt: { ...initial, promptProse: { code: "Edited prose." } },
232    }));
233    area.blur();
234    await settle();
235    assertEquals(server.changes().at(-1), {
236      method: "PATCH",
237      path: "/api/settings",
238      body: { systemPrompt: { promptProse: { code: "Edited prose." } } },
239    });
240
241    server.snapshot("settings", {
242      models: [],
243      systemPrompt: { ...initial, promptProse: { code: "Remote prose." } },
244    });
245    await settle();
246    assertEquals(area.value, "Remote prose.");
247
248    // a reloaded default reaches an untouched draft, but never overwrites an edited one
249    server.snapshot("settings", {
250      models: [],
251      systemPrompt: { ...initial, promptProse: {} },
252    });
253    await settle();
254    server.snapshot("extensions", [{
255      ...extensions[0],
256      promptProse: { default: "Reloaded prose." },
257    }]);
258    await settle();
259    const freshRow = document.querySelector(".extension")!;
260    const freshArea = freshRow.querySelector<HTMLTextAreaElement>("textarea")!;
261    assertEquals(freshArea.value, "Reloaded prose.");
262    assertEquals(freshArea, area);
263    const reloaded = freshArea;
264    reloaded.focus();
265    fill(reloaded, "Draft prose.");
266    assertEquals(document.activeElement, reloaded);
267    server.snapshot("extensions", [{
268      ...extensions[0],
269      description: "new description",
270      promptProse: { default: "Newer prose." },
271    }]);
272    await settle();
273    assertEquals(reloaded.value, "Draft prose.");
274    assertEquals(document.activeElement, reloaded);
275    assertEquals(document.querySelector(".extension")!, freshRow);
276    assertEquals(document.querySelector(".extension textarea"), reloaded);
277    assertEquals(freshRow.querySelector(".description")!.textContent, "new description");
278
279    const entered = Promise.withResolvers<void>();
280    const first = Promise.withResolvers<unknown>();
281    let kept = "Draft prose.";
282    server.respond(({ method, body }) => {
283      if (method !== "PATCH") return [];
284      if (server.changes().length === 2) {
285        entered.resolve();
286        return first.promise;
287      }
288      kept = (body as { systemPrompt: { promptProse: { code: string } } }).systemPrompt.promptProse
289        .code;
290      return { systemPrompt: { ...initial, promptProse: { code: kept } } };
291    });
292    reloaded.focus();
293    reloaded.blur();
294    await entered.promise;
295    reloaded.focus();
296    fill(reloaded, "Second queued.");
297    reloaded.blur();
298    await settle();
299    assertEquals(reloaded.value, "Second queued.");
300    first.resolve({ systemPrompt: { ...initial, promptProse: { code: "Draft prose." } } });
301    await until(() => server.changes().length === 3);
302    await settle();
303    assertEquals(reloaded.value, "Second queued.");
304    assertEquals(server.changes().slice(1).map(({ body }) => body), [
305      { systemPrompt: { promptProse: { code: "Draft prose." } } },
306      { systemPrompt: { promptProse: { code: "Second queued." } } },
307    ]);
308
309    server.respond(() => new Response(JSON.stringify({ error: "disk full" }), { status: 500 }));
310    reloaded.focus();
311    fill(reloaded, "Failing edit.");
312    reloaded.blur();
313    await settle();
314    assertEquals(reloaded.value, "Failing edit.");
315    assertEquals(
316      document.querySelector(".extension")!.querySelector('[role="status"]')!.textContent,
317      "disk full",
318    );
319  }, { url: "http://berry.test/#/settings" });
320});
321
322Deno.test("the local environment toggle follows global settings and restores its value after a failed save", async () => {
323  await withBerry(async ({ scope, server, settle }) => {
324    server.respond(() => []);
325    const { app } = await import("./app.tsx");
326    document.body.append(app(scope));
327    server.snapshot("settings", { models: [] });
328    server.snapshot("extensions", []);
329    await settle();
330
331    const local = document.querySelector<HTMLInputElement>('.environments input[type="checkbox"]');
332    assertExists(local);
333    assertEquals(local.checked, true);
334    server.respond(() => ({ allowLocalEnvironment: false }));
335    local.click();
336    await settle();
337    assertEquals(server.changes().at(-1), {
338      method: "PATCH",
339      path: "/api/settings",
340      body: { allowLocalEnvironment: false },
341    });
342    assertEquals(local.checked, false);
343
344    server.respond(() => new Response(JSON.stringify({ error: "disk full" }), { status: 500 }));
345    local.click();
346    await settle();
347    assertEquals(local.checked, false);
348    assertEquals(local.disabled, false);
349    assertEquals(document.querySelector(".environments .error")?.textContent, "disk full");
350
351    server.snapshot("settings", { models: [], allowLocalEnvironment: true });
352    await settle();
353    assertEquals(local.checked, true);
354  }, { url: "http://berry.test/#/settings" });
355});
356
357Deno.test("the notification switch explains a page that cannot ask, and stores only a granted choice", async () => {
358  await withBerry(async ({ scope, server, settle }) => {
359    server.respond(() => []);
360    const { app } = await import("./app.tsx");
361    document.body.append(app(scope));
362    server.snapshot("settings", { models: [] });
363    server.snapshot("extensions", []);
364    await settle();
365    const input = document.querySelector<HTMLInputElement>(".notify input[type=checkbox]");
366    assertExists(input);
367    assertEquals(input.disabled, true);
368    assertEquals(input.checked, false);
369    assertStringIncludes(
370      document.querySelector(".notify [role=status]")?.textContent ?? "",
371      "secure",
372    );
373  }, { url: "http://berry.test/#/settings" });
374
375  class Granted {
376    static permission: NotificationPermission = "granted";
377    static requestPermission = () => Promise.resolve("granted" as const);
378    constructor() {}
379    close() {}
380  }
381  await withBerry(async ({ scope, server, settle }) => {
382    server.respond(() => []);
383    const { app } = await import("./app.tsx");
384    document.body.append(app(scope));
385    server.snapshot("settings", { models: [] });
386    server.snapshot("extensions", []);
387    await settle();
388    const input = document.querySelector<HTMLInputElement>(".notify input[type=checkbox]");
389    assertExists(input);
390    assertEquals(input.disabled, false);
391    input.click();
392    await settle();
393    assertEquals(input.checked, true);
394    assertEquals(localStorage.getItem("berry.notifications"), "on");
395    input.click();
396    await settle();
397    assertEquals(input.checked, false);
398    assertEquals(localStorage.getItem("berry.notifications"), null);
399  }, { url: "http://berry.test/#/settings", globals: { Notification: Granted } });
400});