import { assertEquals, assertExists } from "@std/assert"; import { fill, press } from "@berry/util/dom.ts"; import type { ConfigurationContext } from "@berry/api/web.ts"; import type { ExtensionInfo } from "./conversation.tsx"; import type { ModelInfo } from "./settings.tsx"; import { type Page, withBerry } from "./testing.ts"; const tid = "aaaaaaaaaaaaa"; const model = { provider: "test", modelId: "model" }; const extension = ( name: string, { promptProse = null, ...patch }: Partial = {}, ): ExtensionInfo => ({ name, description: null, status: "loaded", error: null, builtin: false, running: true, alwaysOn: false, defaultEnabled: false, configuration: null, promptProse, web: null, formerly: [], ...patch, }); const installed = [ extension("berry", { builtin: true, alwaysOn: true }), extension("subagent", { builtin: true }), extension("code", { defaultEnabled: true }), extension("web", { defaultEnabled: true }), ]; const configurationSource = `export default { configuration() { const element = document.createElement("section"); element.className = "configuration-probe"; return { element, update(ctx) { element.context = ctx; element.hidden = ctx.model?.provider !== "openrouter"; if (!element.hidden && !element.firstChild) element.append(ctx.renderDefault()); }, dispose() { element.dataset.disposed = "true"; }, }; }};`; const configuration = { properties: { setting: { type: "string", default: "default" } } }; type ConfigurationProbe = HTMLElement & { context: ConfigurationContext }; const models: ModelInfo[] = [{ provider: model.provider, id: model.modelId, name: "Test model", input: ["text"], reasoning: true, thinkingLevels: ["off", "high"], contextWindow: 100_000, cost: { input: 0, output: 0 }, }]; function withApp(hash: string, test: (page: Page) => Promise) { return withBerry(async (page) => { page.server.respond(({ method, path }) => path === "/api/models" ? models : method === "POST" ? { tid } : [] ); const { app } = await import("./app.tsx"); document.body.append(app(page.scope)); page.server.snapshot("settings", { models: [model] }); page.server.snapshot("extensions", installed); await page.settle(); await test(page); }, { url: `http://berry.test/${hash}` }); } function checkbox(selector: string, name: string): HTMLInputElement { const label = [...document.querySelectorAll(`${selector} label`)].find((label) => label.textContent?.trim() === name ); assertExists(label, `${name} is selectable in ${selector}`); const input = label.querySelector('input[type="checkbox"]'); assertExists(input); assertEquals(input.disabled, false); return input; } /** Sends the first message from the new-conversation page. */ async function start(page: Page, text = "hello") { const input = document.querySelector(".composer textarea")!; fill(input, text); press(input, "Enter"); await page.settle(); } /** What the new-conversation page asked the server to create. */ const created = (page: Page) => page.server.changes().find(({ method, path }) => method === "POST" && path === "/api/conversations" )?.body; /** Shows the conversation with this agent document. */ async function agent(page: Page, doc: object) { page.server.snapshot(`conversation:${tid}`, { entries: [], docs: { "pi.agent": doc } }); await page.settle(); } /** Installs an always-on extension whose frontend is this module source. */ async function frontend(page: Page, name: string, source: string, patch = {}) { const module = `data:text/javascript,${encodeURIComponent(source)}`; await import(module); const installs = extension(name, { alwaysOn: true, web: { module, styles: [] }, ...patch }); page.server.snapshot("extensions", [...installed, installs]); await page.settle(); return installs; } Deno.test("a new conversation starts with the extensions that ask to be, until one is chosen, which it remembers", async () => { await withApp("#/new", async (page) => { const subagent = checkbox(".start", "subagent"); assertEquals( [subagent.checked, checkbox(".start", "code").checked, checkbox(".start", "web").checked], [false, true, true], ); subagent.click(); await start(page, "hello"); assertEquals(created(page), { extensions: { subagent: {}, code: {}, web: {} }, model, thinkingLevel: "high", }); assertEquals(page.server.changes().at(-1), { method: "POST", path: `/api/conversations/${tid}/messages`, body: { content: "hello" }, }); assertEquals(location.hash, `#/${tid}`); // the next one starts the way this one did location.hash = "#/new"; await page.settle(); assertEquals( [ checkbox(".start", "subagent").checked, checkbox(".start", "code").checked, checkbox(".start", "web").checked, ], [true, true, true], ); }); }); Deno.test("a new conversation starts blank, its draft waiting in its composer rather than sent", async () => { await withApp("#/new", async (page) => { fill(document.querySelector(".composer textarea")!, "half a thought"); document.querySelector(".start-blank")!.click(); await page.settle(); assertEquals(created(page), { extensions: { code: {}, web: {} }, model, thinkingLevel: "high", }); assertEquals(page.server.changes().at(-1)?.path, "/api/conversations"); assertEquals(location.hash, `#/${tid}`); await agent(page, { model, extensions: [{ name: "code" }, { name: "web" }] }); assertEquals( document.querySelector(".composer textarea")!.value, "half a thought", ); // it moved, rather than being left for the next new conversation too location.hash = "#/new"; await page.settle(); assertEquals(document.querySelector(".composer textarea")!.value, ""); }); }); Deno.test("a new conversation asks its extensions' questions in installed order, and starts in a chosen project", async () => { await withApp("#/new", async (page) => { const { server, settle } = page; const ordered = [ extension("environment", { alwaysOn: true, configuration: { properties: { environment: { type: "string", default: "local" }, cwd: { type: "string", default: "" }, }, }, }), extension("openrouter-routing", { alwaysOn: true, configuration }), extension("code", { configuration, defaultEnabled: true }), extension("web", { configuration, defaultEnabled: true }), extension("subagent", { configuration }), extension("other", { alwaysOn: true, configuration }), ]; server.snapshot("projects", { berry: { name: "Berry", roots: [{ environment: "local", cwd: "/work/berry", platform: "linux" }], defaultRoot: 0, }, }); server.snapshot("extensions", ordered); await settle(); const setup = document.querySelector(".start")!; const legends = () => [...setup.querySelectorAll("legend")].map((legend) => legend.textContent); assertEquals(legends(), ["openrouter-routing", "code", "web", "other"]); const project = setup.querySelector('[aria-label="project"]')!; project.value = "berry"; project.dispatchEvent(new Event("change")); const folder = () => setup.querySelector(".start-environment input")!; assertEquals(folder().value, "/work/berry"); const routing = () => setup.querySelector(".start-options fieldset input")!; fill(routing(), "routing"); checkbox(".start", "code").click(); assertEquals(legends(), ["openrouter-routing", "web", "other"]); assertEquals([folder().value, routing().value], ["/work/berry", "routing"]); await start(page); assertEquals(created(page), { extensions: { environment: { environment: "local", cwd: "/work/berry" }, "openrouter-routing": { setting: "routing" }, web: {}, other: {}, }, model, thinkingLevel: "high", }); }); }); Deno.test("the header's subagent box follows legacy inheritance, explicit choices, and subagents' own conversations", async () => { await withApp(`#/${tid}`, async (page) => { const { server, settle } = page; await agent(page, { extensions: { add: [] } }); const details = document.querySelector(".conversation-header .extensions"); assertExists(details); details.querySelector("summary")!.click(); const subagent = checkbox(".conversation-header", "subagent"); const shown = () => [subagent.checked, details.querySelector("summary")!.textContent]; const patched = () => server.changes().at(-1)?.body; assertEquals(shown(), [true, "subagent"]); subagent.click(); await settle(); assertEquals(server.changes(), [{ method: "PATCH", path: `/api/conversations/${tid}/agent`, body: { extensions: [] }, }]); await agent(page, { extensions: { add: [], remove: ["subagent"] } }); assertEquals(shown(), [false, "none"]); subagent.click(); await settle(); assertEquals(patched(), { extensions: ["subagent"] }); await agent(page, { extensions: { add: ["subagent"] } }); assertEquals(shown(), [true, "subagent"]); assertEquals(server.changes().length, 2); // a subagent's own conversation does not inherit delegation, and a toggle keeps the others server.snapshot("conversations", { [tid]: { id: 2, created: 1, owner: "bbbbbbbbbbbbb", parent: { tid: "bbbbbbbbbbbbb", at: 1 } }, }); await agent(page, { extensions: ["code"] }); assertEquals(subagent.checked, false); subagent.click(); await settle(); assertEquals(patched(), { extensions: ["code", "subagent"] }); }); }); Deno.test("header contributions honor selection and always-on status and invoke user commands", async () => { await withApp(`#/${tid}`, async (page) => { const { window, server, settle } = page; const probe = await frontend( page, "probe", `export default { header() { const element = document.createElement("button"); element.className = "header-probe"; return { element, update(ctx) { element.textContent = ctx.model?.provider ?? "none"; element.onclick = () => void ctx.command("probe", "args"); }, dispose() { element.dataset.disposed = "true"; }, }; }};`, { alwaysOn: false }, ); const update = (extensions: string[], provider = "anthropic") => agent(page, { extensions, model: { provider, modelId: "test" } }); await update([]); assertEquals(document.querySelector(".header-probe"), null); await update(["probe"]); const first = document.querySelector(".header-probe")!; assertExists(first); assertEquals(first.textContent, "anthropic"); first.click(); await settle(); assertEquals(server.changes().at(-1), { method: "POST", path: `/api/conversations/${tid}/commands/probe`, body: { args: "args" }, }); await update(["probe"], "openai-codex"); assertEquals(first.textContent, "openai-codex"); await update([], "openai-codex"); assertEquals(first.dataset.disposed, "true"); assertEquals(document.querySelector(".header-probe"), null); server.snapshot("extensions", [...installed, { ...probe, alwaysOn: true }]); await settle(); const always = document.querySelector(".header-probe")!; assertExists(always); assertEquals(always.textContent, "openai-codex"); window.location.hash = "#/settings"; await settle(); assertEquals(always.dataset.disposed, "true"); }); }); Deno.test("configuration renderers follow the live model, keep saved values, and dispose on reload", async () => { await withApp(`#/${tid}`, async (page) => { const { server, settle } = page; await frontend(page, "probe", configurationSource, { configuration }); const probe = document.querySelector( ".conversation-header .configuration-probe", )!; assertExists(probe); assertEquals(probe.hidden, true); server.snapshot(`configuration:${tid}:probe`, { setting: "saved" }); await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/model" } }); assertEquals(probe.hidden, false); const form = probe.firstChild; assertEquals(probe.context.renderDefault(), form); assertEquals(probe.querySelector("input")!.value, "saved"); assertEquals(probe.context.extension, "probe"); assertEquals(probe.context.tid, tid); await agent(page, { extensions: [], model }); assertEquals(probe.hidden, true); await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/other" } }); assertEquals(probe.hidden, false); assertEquals(probe.firstChild, form); assertEquals(probe.querySelector("input")!.value, "saved"); assertEquals(server.changes(), []); server.respond(() => ({ result: "ok" })); assertEquals(await probe.context.request!("providers", { model: "lab/other" }), "ok"); assertEquals( server.changes().at(-1)?.path, `/api/conversations/${tid}/extensions/probe/providers`, ); assertEquals(await probe.context.command!("choose", "fast"), "ok"); assertEquals(server.changes().at(-1)?.path, `/api/conversations/${tid}/commands/choose`); await frontend(page, "probe", configurationSource + "\nexport const revision = 2;", { configuration, }); assertEquals(probe.dataset.disposed, "true"); await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/other" } }); server.snapshot(`configuration:${tid}:probe`, { setting: "saved" }); const replacement = document.querySelector( ".conversation-header .configuration-probe", )!; assertEquals(replacement.hidden, false); assertEquals(replacement.querySelector("input")!.value, "saved"); await settle(); }); }); Deno.test("custom configuration stages edits until dismissal and retains newer drafts through pending and failed writes", async () => { await withApp(`#/${tid}`, async (page) => { const { server, settle } = page; await frontend(page, "probe", configurationSource, { configuration }); await agent(page, { extensions: [], model: { provider: "openrouter", modelId: "lab/model" } }); server.snapshot(`configuration:${tid}:probe`, { setting: "saved" }); await settle(); const probe = document.querySelector( ".conversation-header .configuration-probe", )!; const dropdown = document.querySelector( ".conversation-header .extensions", )!; const open = async () => { dropdown.querySelector("summary")!.click(); await settle(); }; await open(); await probe.context.save({ setting: "updated" }); assertEquals(probe.context.values, { setting: "updated" }); assertEquals(server.changes(), []); const pending = Promise.withResolvers(); server.respond(() => pending.promise); document.body.click(); assertEquals(dropdown.open, false); await settle(); assertEquals(probe.context.saveStatus, "saving"); assertEquals(server.changes(), [{ method: "PATCH", path: `/api/conversations/${tid}/configuration/probe`, body: { setting: "updated" }, }]); await open(); await probe.context.save({ setting: "newer" }); server.snapshot(`configuration:${tid}:probe`, { setting: "updated" }); assertEquals(probe.context.values, { setting: "newer" }); server.respond(() => Response.json({ error: "invalid setting" }, { status: 400 })); dropdown.querySelector("summary")!.click(); await settle(); assertEquals(server.changes().length, 1); pending.resolve({ setting: "updated" }); await settle(); assertEquals(server.changes().at(-1)?.body, { setting: "newer" }); assertEquals(probe.context.saveStatus, "error"); assertEquals(probe.context.error, "invalid setting"); assertEquals(probe.context.values, { setting: "newer" }); server.respond(({ body }) => body); await open(); await probe.context.save({ setting: "recovered" }); document.body.click(); await settle(); assertEquals(probe.context.saveStatus, "idle"); assertEquals(probe.context.error, null); assertEquals(probe.querySelector("input")!.value, "recovered"); }); }); Deno.test("new-conversation configuration renderers preserve answers across model switches and save before submit", async () => { await withApp("#/new", async (page) => { const { server } = page; const routed = { provider: "openrouter", modelId: "lab/model" }; server.snapshot("settings", { models: [model, routed] }); await frontend(page, "probe", configurationSource, { configuration }); const probe = document.querySelector(".start .configuration-probe")!; const choose = (key: string) => { const select = document.querySelector(".start select.model")!; select.value = key; select.dispatchEvent(new Event("change")); }; assertEquals(probe.hidden, true); choose("openrouter/lab/model"); assertEquals(probe.hidden, false); assertEquals(probe.context.tid, null); assertEquals(probe.context.request, undefined); assertEquals(probe.context.command, undefined); assertEquals(probe.querySelector("input")!.value, "default"); await probe.context.save({ setting: "custom" }); choose("test/model"); assertEquals(probe.hidden, true); choose("openrouter/lab/model"); assertEquals(probe.querySelector("input")!.value, "custom"); fill(probe.querySelector("input")!, "typed"); await start(page); assertEquals( created(page), { extensions: { probe: { setting: "typed" }, code: {}, web: {} }, model: routed, thinkingLevel: "high", }, ); }); }); Deno.test("a view opens from its labelled link, and navigation away waits for it, restoring the address if it refuses", async () => { await withApp(`#/${tid}`, async (page) => { const { window, settle } = page; await frontend( page, "probe", `export default { view: { label: "term", create() { const element = document.createElement("div"); element.className = "view-leave-probe"; return { element, update(ctx) { element.dataset.route = ctx.route; }, beforeLeave(next) { element.dataset.destination = JSON.stringify(next); return new Promise(resolve => { element.resolveLeave = resolve; }); }, dispose() { element.dataset.disposed = "true"; }, }; } } };`, ); await agent(page, { extensions: [] }); const link = document.querySelector(".view-links a")!; assertEquals(link.textContent, "term"); assertEquals(link.getAttribute("href"), `#/${tid}/probe`); window.location.hash = link.getAttribute("href")!; await settle(); const element = document.querySelector(".view-leave-probe") as HTMLElement & { resolveLeave(allowed: boolean): void; }; assertExists(element); window.location.hash = "#/settings"; await settle(); assertEquals(element.dataset.disposed, undefined); assertEquals(document.querySelector(".view-leave-probe"), element); element.resolveLeave(false); await settle(); assertEquals(window.location.hash, `#/${tid}/probe`); assertEquals(element.dataset.disposed, undefined); window.location.hash = `#/${tid}/probe/a%20b`; await settle(); assertEquals(element.dataset.route, ""); assertEquals(JSON.parse(element.dataset.destination!).route, "a b"); element.resolveLeave(true); await settle(); assertEquals(element.dataset.route, "a b"); assertEquals(element.dataset.disposed, undefined); window.location.hash = "#/settings"; await settle(); const obsolete = element.resolveLeave; window.location.hash = `#/${tid}/probe/a%20b`; await settle(); assertEquals(JSON.parse(element.dataset.destination!).route, "a b"); obsolete(true); await settle(); assertEquals(element.dataset.disposed, undefined); element.resolveLeave(true); await settle(); window.location.hash = "#/settings"; await settle(); element.resolveLeave(true); await settle(); assertEquals(element.dataset.disposed, "true"); }); });