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
20.5 KiB526 linesraw
1import { assertEquals, assertExists } from "@std/assert";
2import { fill, press } from "@berry/util/dom.ts";
3import type { ConfigurationContext } from "@berry/api/web.ts";
4import type { ExtensionInfo } from "./conversation.tsx";
5import type { ModelInfo } from "./settings.tsx";
6import { type Page, withBerry } from "./testing.ts";
7
8const tid = "aaaaaaaaaaaaa";
9const model = { provider: "test", modelId: "model" };
10const extension = (
11  name: string,
12  { promptProse = null, ...patch }: Partial<ExtensionInfo> = {},
13): ExtensionInfo => ({
14  name,
15  description: null,
16  status: "loaded",
17  error: null,
18  builtin: false,
19  running: true,
20  alwaysOn: false,
21  defaultEnabled: false,
22  configuration: null,
23  promptProse,
24  web: null,
25  formerly: [],
26  ...patch,
27});
28const installed = [
29  extension("berry", { builtin: true, alwaysOn: true }),
30  extension("subagent", { builtin: true }),
31  extension("code", { defaultEnabled: true }),
32  extension("web", { defaultEnabled: true }),
33];
34const configurationSource = `export default { configuration() {
35  const element = document.createElement("section");
36  element.className = "configuration-probe";
37  return {
38    element,
39    update(ctx) {
40      element.context = ctx;
41      element.hidden = ctx.model?.provider !== "openrouter";
42      if (!element.hidden && !element.firstChild) element.append(ctx.renderDefault());
43    },
44    dispose() { element.dataset.disposed = "true"; },
45  };
46}};`;
47const configuration = { properties: { setting: { type: "string", default: "default" } } };
48type ConfigurationProbe = HTMLElement & { context: ConfigurationContext };
49
50const models: ModelInfo[] = [{
51  provider: model.provider,
52  id: model.modelId,
53  name: "Test model",
54  input: ["text"],
55  reasoning: true,
56  thinkingLevels: ["off", "high"],
57  contextWindow: 100_000,
58  cost: { input: 0, output: 0 },
59}];
60
61function withApp(hash: string, test: (page: Page) => Promise<void>) {
62  return withBerry(async (page) => {
63    page.server.respond(({ method, path }) =>
64      path === "/api/models" ? models : method === "POST" ? { tid } : []
65    );
66    const { app } = await import("./app.tsx");
67    document.body.append(app(page.scope));
68    page.server.snapshot("settings", { models: [model] });
69    page.server.snapshot("extensions", installed);
70    await page.settle();
71    await test(page);
72  }, { url: `http://berry.test/${hash}` });
73}
74
75function checkbox(selector: string, name: string): HTMLInputElement {
76  const label = [...document.querySelectorAll(`${selector} label`)].find((label) =>
77    label.textContent?.trim() === name
78  );
79  assertExists(label, `${name} is selectable in ${selector}`);
80  const input = label.querySelector<HTMLInputElement>('input[type="checkbox"]');
81  assertExists(input);
82  assertEquals(input.disabled, false);
83  return input;
84}
85
86/** Sends the first message from the new-conversation page. */
87async function start(page: Page, text = "hello") {
88  const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
89  fill(input, text);
90  press(input, "Enter");
91  await page.settle();
92}
93
94/** What the new-conversation page asked the server to create. */
95const created = (page: Page) =>
96  page.server.changes().find(({ method, path }) =>
97    method === "POST" && path === "/api/conversations"
98  )?.body;
99
100/** Shows the conversation with this agent document. */
101async function agent(page: Page, doc: object) {
102  page.server.snapshot(`conversation:${tid}`, { entries: [], docs: { "pi.agent": doc } });
103  await page.settle();
104}
105
106/** Installs an always-on extension whose frontend is this module source. */
107async function frontend(page: Page, name: string, source: string, patch = {}) {
108  const module = `data:text/javascript,${encodeURIComponent(source)}`;
109  await import(module);
110  const installs = extension(name, { alwaysOn: true, web: { module, styles: [] }, ...patch });
111  page.server.snapshot("extensions", [...installed, installs]);
112  await page.settle();
113  return installs;
114}
115
116Deno.test("a new conversation starts with the extensions that ask to be, until one is chosen, which it remembers", async () => {
117  await withApp("#/new", async (page) => {
118    const subagent = checkbox(".start", "subagent");
119    assertEquals(
120      [subagent.checked, checkbox(".start", "code").checked, checkbox(".start", "web").checked],
121      [false, true, true],
122    );
123    subagent.click();
124    await start(page, "hello");
125    assertEquals(created(page), {
126      extensions: { subagent: {}, code: {}, web: {} },
127      model,
128      thinkingLevel: "high",
129    });
130    assertEquals(page.server.changes().at(-1), {
131      method: "POST",
132      path: `/api/conversations/${tid}/messages`,
133      body: { content: "hello" },
134    });
135    assertEquals(location.hash, `#/${tid}`);
136    // the next one starts the way this one did
137    location.hash = "#/new";
138    await page.settle();
139    assertEquals(
140      [
141        checkbox(".start", "subagent").checked,
142        checkbox(".start", "code").checked,
143        checkbox(".start", "web").checked,
144      ],
145      [true, true, true],
146    );
147  });
148});
149
150Deno.test("a new conversation starts blank, its draft waiting in its composer rather than sent", async () => {
151  await withApp("#/new", async (page) => {
152    fill(document.querySelector<HTMLTextAreaElement>(".composer textarea")!, "half a thought");
153    document.querySelector<HTMLButtonElement>(".start-blank")!.click();
154    await page.settle();
155    assertEquals(created(page), {
156      extensions: { code: {}, web: {} },
157      model,
158      thinkingLevel: "high",
159    });
160    assertEquals(page.server.changes().at(-1)?.path, "/api/conversations");
161    assertEquals(location.hash, `#/${tid}`);
162    await agent(page, { model, extensions: [{ name: "code" }, { name: "web" }] });
163    assertEquals(
164      document.querySelector<HTMLTextAreaElement>(".composer textarea")!.value,
165      "half a thought",
166    );
167    // it moved, rather than being left for the next new conversation too
168    location.hash = "#/new";
169    await page.settle();
170    assertEquals(document.querySelector<HTMLTextAreaElement>(".composer textarea")!.value, "");
171  });
172});
173
174Deno.test("a new conversation asks its extensions' questions in installed order, and starts in a chosen project", async () => {
175  await withApp("#/new", async (page) => {
176    const { server, settle } = page;
177    const ordered = [
178      extension("environment", {
179        alwaysOn: true,
180        configuration: {
181          properties: {
182            environment: { type: "string", default: "local" },
183            cwd: { type: "string", default: "" },
184          },
185        },
186      }),
187      extension("openrouter-routing", { alwaysOn: true, configuration }),
188      extension("code", { configuration, defaultEnabled: true }),
189      extension("web", { configuration, defaultEnabled: true }),
190      extension("subagent", { configuration }),
191      extension("other", { alwaysOn: true, configuration }),
192    ];
193    server.snapshot("projects", {
194      berry: {
195        name: "Berry",
196        roots: [{ environment: "local", cwd: "/work/berry", platform: "linux" }],
197        defaultRoot: 0,
198      },
199    });
200    server.snapshot("extensions", ordered);
201    await settle();
202    const setup = document.querySelector<HTMLElement>(".start")!;
203    const legends = () => [...setup.querySelectorAll("legend")].map((legend) => legend.textContent);
204    assertEquals(legends(), ["openrouter-routing", "code", "web", "other"]);
205    const project = setup.querySelector<HTMLSelectElement>('[aria-label="project"]')!;
206    project.value = "berry";
207    project.dispatchEvent(new Event("change"));
208    const folder = () => setup.querySelector<HTMLInputElement>(".start-environment input")!;
209    assertEquals(folder().value, "/work/berry");
210    const routing = () => setup.querySelector<HTMLInputElement>(".start-options fieldset input")!;
211    fill(routing(), "routing");
212    checkbox(".start", "code").click();
213    assertEquals(legends(), ["openrouter-routing", "web", "other"]);
214    assertEquals([folder().value, routing().value], ["/work/berry", "routing"]);
215    await start(page);
216    assertEquals(created(page), {
217      extensions: {
218        environment: { environment: "local", cwd: "/work/berry" },
219        "openrouter-routing": { setting: "routing" },
220        web: {},
221        other: {},
222      },
223      model,
224      thinkingLevel: "high",
225    });
226  });
227});
228
229Deno.test("the header's subagent box follows legacy inheritance, explicit choices, and subagents' own conversations", async () => {
230  await withApp(`#/${tid}`, async (page) => {
231    const { server, settle } = page;
232    await agent(page, { extensions: { add: [] } });
233    const details = document.querySelector<HTMLDetailsElement>(".conversation-header .extensions");
234    assertExists(details);
235    details.querySelector("summary")!.click();
236    const subagent = checkbox(".conversation-header", "subagent");
237    const shown = () => [subagent.checked, details.querySelector("summary")!.textContent];
238    const patched = () => server.changes().at(-1)?.body;
239    assertEquals(shown(), [true, "subagent"]);
240    subagent.click();
241    await settle();
242    assertEquals(server.changes(), [{
243      method: "PATCH",
244      path: `/api/conversations/${tid}/agent`,
245      body: { extensions: [] },
246    }]);
247    await agent(page, { extensions: { add: [], remove: ["subagent"] } });
248    assertEquals(shown(), [false, "none"]);
249    subagent.click();
250    await settle();
251    assertEquals(patched(), { extensions: ["subagent"] });
252    await agent(page, { extensions: { add: ["subagent"] } });
253    assertEquals(shown(), [true, "subagent"]);
254    assertEquals(server.changes().length, 2);
255
256    // a subagent's own conversation does not inherit delegation, and a toggle keeps the others
257    server.snapshot("conversations", {
258      [tid]: { id: 2, created: 1, owner: "bbbbbbbbbbbbb", parent: { tid: "bbbbbbbbbbbbb", at: 1 } },
259    });
260    await agent(page, { extensions: ["code"] });
261    assertEquals(subagent.checked, false);
262    subagent.click();
263    await settle();
264    assertEquals(patched(), { extensions: ["code", "subagent"] });
265  });
266});
267
268Deno.test("header contributions honor selection and always-on status and invoke user commands", async () => {
269  await withApp(`#/${tid}`, async (page) => {
270    const { window, server, settle } = page;
271    const probe = await frontend(
272      page,
273      "probe",
274      `export default { header() {
275        const element = document.createElement("button");
276        element.className = "header-probe";
277        return {
278          element,
279          update(ctx) {
280            element.textContent = ctx.model?.provider ?? "none";
281            element.onclick = () => void ctx.command("probe", "args");
282          },
283          dispose() { element.dataset.disposed = "true"; },
284        };
285      }};`,
286      { alwaysOn: false },
287    );
288    const update = (extensions: string[], provider = "anthropic") =>
289      agent(page, { extensions, model: { provider, modelId: "test" } });
290    await update([]);
291    assertEquals(document.querySelector(".header-probe"), null);
292    await update(["probe"]);
293    const first = document.querySelector<HTMLButtonElement>(".header-probe")!;
294    assertExists(first);
295    assertEquals(first.textContent, "anthropic");
296    first.click();
297    await settle();
298    assertEquals(server.changes().at(-1), {
299      method: "POST",
300      path: `/api/conversations/${tid}/commands/probe`,
301      body: { args: "args" },
302    });
303    await update(["probe"], "openai-codex");
304    assertEquals(first.textContent, "openai-codex");
305    await update([], "openai-codex");
306    assertEquals(first.dataset.disposed, "true");
307    assertEquals(document.querySelector(".header-probe"), null);
308    server.snapshot("extensions", [...installed, { ...probe, alwaysOn: true }]);
309    await settle();
310    const always = document.querySelector<HTMLButtonElement>(".header-probe")!;
311    assertExists(always);
312    assertEquals(always.textContent, "openai-codex");
313    window.location.hash = "#/settings";
314    await settle();
315    assertEquals(always.dataset.disposed, "true");
316  });
317});
318
319Deno.test("configuration renderers follow the live model, keep saved values, and dispose on reload", async () => {
320  await withApp(`#/${tid}`, async (page) => {
321    const { server, settle } = page;
322    await frontend(page, "probe", configurationSource, { configuration });
323    const probe = document.querySelector<ConfigurationProbe>(
324      ".conversation-header .configuration-probe",
325    )!;
326    assertExists(probe);
327    assertEquals(probe.hidden, true);
328    server.snapshot(`configuration:${tid}:probe`, { setting: "saved" });
329    await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/model" } });
330    assertEquals(probe.hidden, false);
331    const form = probe.firstChild;
332    assertEquals(probe.context.renderDefault(), form);
333    assertEquals(probe.querySelector<HTMLInputElement>("input")!.value, "saved");
334    assertEquals(probe.context.extension, "probe");
335    assertEquals(probe.context.tid, tid);
336    await agent(page, { extensions: [], model });
337    assertEquals(probe.hidden, true);
338    await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/other" } });
339    assertEquals(probe.hidden, false);
340    assertEquals(probe.firstChild, form);
341    assertEquals(probe.querySelector<HTMLInputElement>("input")!.value, "saved");
342    assertEquals(server.changes(), []);
343
344    server.respond(() => ({ result: "ok" }));
345    assertEquals(await probe.context.request!("providers", { model: "lab/other" }), "ok");
346    assertEquals(
347      server.changes().at(-1)?.path,
348      `/api/conversations/${tid}/extensions/probe/providers`,
349    );
350    assertEquals(await probe.context.command!("choose", "fast"), "ok");
351    assertEquals(server.changes().at(-1)?.path, `/api/conversations/${tid}/commands/choose`);
352
353    await frontend(page, "probe", configurationSource + "\nexport const revision = 2;", {
354      configuration,
355    });
356    assertEquals(probe.dataset.disposed, "true");
357    await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/other" } });
358    server.snapshot(`configuration:${tid}:probe`, { setting: "saved" });
359    const replacement = document.querySelector<ConfigurationProbe>(
360      ".conversation-header .configuration-probe",
361    )!;
362    assertEquals(replacement.hidden, false);
363    assertEquals(replacement.querySelector<HTMLInputElement>("input")!.value, "saved");
364    await settle();
365  });
366});
367
368Deno.test("custom configuration stages edits until dismissal and retains newer drafts through pending and failed writes", async () => {
369  await withApp(`#/${tid}`, async (page) => {
370    const { server, settle } = page;
371    await frontend(page, "probe", configurationSource, { configuration });
372    await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/model" } });
373    server.snapshot(`configuration:${tid}:probe`, { setting: "saved" });
374    await settle();
375    const probe = document.querySelector<ConfigurationProbe>(
376      ".conversation-header .configuration-probe",
377    )!;
378    const dropdown = document.querySelector<HTMLDetailsElement>(
379      ".conversation-header .extensions",
380    )!;
381    const open = async () => {
382      dropdown.querySelector("summary")!.click();
383      await settle();
384    };
385    await open();
386    await probe.context.save({ setting: "updated" });
387    assertEquals(probe.context.values, { setting: "updated" });
388    assertEquals(server.changes(), []);
389    const pending = Promise.withResolvers<unknown>();
390    server.respond(() => pending.promise);
391    document.body.click();
392    assertEquals(dropdown.open, false);
393    await settle();
394    assertEquals(probe.context.saveStatus, "saving");
395    assertEquals(server.changes(), [{
396      method: "PATCH",
397      path: `/api/conversations/${tid}/configuration/probe`,
398      body: { setting: "updated" },
399    }]);
400    await open();
401    await probe.context.save({ setting: "newer" });
402    server.snapshot(`configuration:${tid}:probe`, { setting: "updated" });
403    assertEquals(probe.context.values, { setting: "newer" });
404    server.respond(() => Response.json({ error: "invalid setting" }, { status: 400 }));
405    dropdown.querySelector("summary")!.click();
406    await settle();
407    assertEquals(server.changes().length, 1);
408    pending.resolve({ setting: "updated" });
409    await settle();
410    assertEquals(server.changes().at(-1)?.body, { setting: "newer" });
411    assertEquals(probe.context.saveStatus, "error");
412    assertEquals(probe.context.error, "invalid setting");
413    assertEquals(probe.context.values, { setting: "newer" });
414    server.respond(({ body }) => body);
415    await open();
416    await probe.context.save({ setting: "recovered" });
417    document.body.click();
418    await settle();
419    assertEquals(probe.context.saveStatus, "idle");
420    assertEquals(probe.context.error, null);
421    assertEquals(probe.querySelector<HTMLInputElement>("input")!.value, "recovered");
422  });
423});
424
425Deno.test("new-conversation configuration renderers preserve answers across model switches and save before submit", async () => {
426  await withApp("#/new", async (page) => {
427    const { server } = page;
428    const routed = { provider: "openrouter", modelId: "lab/model" };
429    server.snapshot("settings", { models: [model, routed] });
430    await frontend(page, "probe", configurationSource, { configuration });
431    const probe = document.querySelector<ConfigurationProbe>(".start .configuration-probe")!;
432    const choose = (key: string) => {
433      const select = document.querySelector<HTMLSelectElement>(".start select.model")!;
434      select.value = key;
435      select.dispatchEvent(new Event("change"));
436    };
437    assertEquals(probe.hidden, true);
438    choose("openrouter/lab/model");
439    assertEquals(probe.hidden, false);
440    assertEquals(probe.context.tid, null);
441    assertEquals(probe.context.request, undefined);
442    assertEquals(probe.context.command, undefined);
443    assertEquals(probe.querySelector<HTMLInputElement>("input")!.value, "default");
444    await probe.context.save({ setting: "custom" });
445    choose("test/model");
446    assertEquals(probe.hidden, true);
447    choose("openrouter/lab/model");
448    assertEquals(probe.querySelector<HTMLInputElement>("input")!.value, "custom");
449    fill(probe.querySelector<HTMLInputElement>("input")!, "typed");
450    await start(page);
451    assertEquals(
452      created(page),
453      {
454        extensions: { probe: { setting: "typed" }, code: {}, web: {} },
455        model: routed,
456        thinkingLevel: "high",
457      },
458    );
459  });
460});
461
462Deno.test("a view opens from its labelled link, and navigation away waits for it, restoring the address if it refuses", async () => {
463  await withApp(`#/${tid}`, async (page) => {
464    const { window, settle } = page;
465    await frontend(
466      page,
467      "probe",
468      `export default { view: { label: "term", create() {
469        const element = document.createElement("div");
470        element.className = "view-leave-probe";
471        return {
472          element,
473          update(ctx) { element.dataset.route = ctx.route; },
474          beforeLeave(next) {
475            element.dataset.destination = JSON.stringify(next);
476            return new Promise(resolve => { element.resolveLeave = resolve; });
477          },
478          dispose() { element.dataset.disposed = "true"; },
479        };
480      } } };`,
481    );
482    await agent(page, { extensions: [] });
483    const link = document.querySelector<HTMLAnchorElement>(".view-links a")!;
484    assertEquals(link.textContent, "term");
485    assertEquals(link.getAttribute("href"), `#/${tid}/probe`);
486    window.location.hash = link.getAttribute("href")!;
487    await settle();
488    const element = document.querySelector<HTMLElement>(".view-leave-probe") as HTMLElement & {
489      resolveLeave(allowed: boolean): void;
490    };
491    assertExists(element);
492    window.location.hash = "#/settings";
493    await settle();
494    assertEquals(element.dataset.disposed, undefined);
495    assertEquals(document.querySelector(".view-leave-probe"), element);
496    element.resolveLeave(false);
497    await settle();
498    assertEquals(window.location.hash, `#/${tid}/probe`);
499    assertEquals(element.dataset.disposed, undefined);
500
501    window.location.hash = `#/${tid}/probe/a%20b`;
502    await settle();
503    assertEquals(element.dataset.route, "");
504    assertEquals(JSON.parse(element.dataset.destination!).route, "a b");
505    element.resolveLeave(true);
506    await settle();
507    assertEquals(element.dataset.route, "a b");
508    assertEquals(element.dataset.disposed, undefined);
509    window.location.hash = "#/settings";
510    await settle();
511    const obsolete = element.resolveLeave;
512    window.location.hash = `#/${tid}/probe/a%20b`;
513    await settle();
514    assertEquals(JSON.parse(element.dataset.destination!).route, "a b");
515    obsolete(true);
516    await settle();
517    assertEquals(element.dataset.disposed, undefined);
518    element.resolveLeave(true);
519    await settle();
520    window.location.hash = "#/settings";
521    await settle();
522    element.resolveLeave(true);
523    await settle();
524    assertEquals(element.dataset.disposed, "true");
525  });
526});