import { Signal } from "@char/aftercare"; import { fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai/providers/faux"; import { assert, assertEquals, assertExists, assertStrictEquals } from "@std/assert"; import { type FakeRequest, fill, press } from "@berry/util/dom.ts"; import type { Shared } from "./conversation.tsx"; import { withBerry } from "./testing.ts"; const tid = "aaaaaaaaaaaaa"; const steer = { id: 1, mode: "steer", content: "First message" }; const followUp = { id: 2, mode: "followUp", content: "Second message" }; const attachment = { type: "image", mimeType: "image/png", data: "unchanged-image-bytes" }; const illustrated = { ...followUp, content: [{ type: "text", text: "Original caption" }, attachment], }; const run = { taskId: 1, inputs: [3] }; type Page = { requests: () => FakeRequest[]; shared: Shared; snapshot: (items: unknown[], live?: object, agent?: object, entries?: unknown[]) => Promise; shell: (state: object) => Promise; configuration: (name: string, values: object) => Promise; settle: () => Promise; respond: (response: () => unknown) => void; tree: (value: unknown) => void; commandCatalog: (response: () => unknown) => void; }; function withConversation(test: (page: Page) => Promise) { return withBerry(async ({ server, scope, settle }) => { let respond: () => unknown = () => ({}); let tree: unknown = {}; let commandCatalog: () => unknown = () => []; server.respond(({ method, path }) => method !== "GET" ? respond() : path.endsWith("/tree") ? tree : path.endsWith("/commands") ? commandCatalog() : [] ); const shared: Shared = { extensions: new Signal([]), models: new Signal([]), enabled: new Signal([]), machines: new Signal(["local"]), projects: new Signal({}), refreshModels() {}, }; const { conversation } = await import("./conversation.tsx"); document.body.append(...conversation(scope, tid, shared)); await test({ requests: server.changes, shared, settle, async snapshot(items, live = {}, agent = {}, entries = []) { server.snapshot(`conversation:${tid}`, { entries, docs: { "pi.inbox": { items }, "pi.live": live, "pi.agent": agent }, }); await settle(); }, async shell(state) { server.snapshot(`shell:${tid}`, state); await settle(); }, async configuration(name, values) { server.snapshot(`configuration:${tid}:${name}`, values); await settle(); }, respond: (next) => void (respond = next), tree: (value) => void (tree = value), commandCatalog: (next) => void (commandCatalog = next), }); }, { url: `http://berry.test/#/${tid}` }); } Deno.test("sending from the bottom follows the message when the working status reduces the viewport", async () => { await withConversation(async ({ snapshot, settle }) => { const scroller = document.querySelector(".scroller")!; const working = document.querySelector(".working")!; let top = 0; Object.defineProperties(scroller, { clientHeight: { get: () => working.hidden ? 400 : 376 }, scrollHeight: { get: () => 800 + scroller.querySelectorAll(".message").length * 200, }, scrollTop: { get: () => top, set: (value: number) => { top = Math.max(0, Math.min(value, scroller.scrollHeight - scroller.clientHeight)); }, }, }); const previous = { id: 1, kind: "pi.user", model: [{ role: "user", content: "earlier message", timestamp: 0 }], }; await snapshot([], {}, {}, [previous]); assertEquals(top, scroller.scrollHeight - scroller.clientHeight); const input = document.querySelector(".composer textarea")!; fill(input, "new message"); press(input, "Enter"); await settle(); await snapshot([], { run }, {}, [previous, { id: 2, kind: "pi.user", model: [{ role: "user", content: "new message", timestamp: 0 }], }]); assertEquals(working.hidden, false); assertEquals(top, scroller.scrollHeight - scroller.clientHeight); }); }); function row(index = 0): HTMLElement { const element = document.querySelectorAll(".inbox .queued")[index]; assertExists(element); return element; } function button(element: Element, label: string): HTMLButtonElement { const found = [...element.querySelectorAll("button")].find((b) => b.textContent === label); assertExists(found); return found; } function editor(element: Element): HTMLTextAreaElement { const textarea = element.querySelector("textarea"); assertExists(textarea); return textarea; } Deno.test("abort restores the first stopped input, keeps the rest out of the live queue, and protects newer drafts", async () => { await withConversation(async ({ snapshot, respond, requests, settle }) => { const input = document.querySelector(".composer textarea")!; const abort = document.querySelector('[aria-label="Abort response"]')!; await snapshot([steer, followUp], { run }); const response = Promise.withResolvers(); respond(() => response.promise); abort.click(); await settle(); await snapshot([]); response.resolve({ inputs: [followUp, { ...steer, content: "Latest queued edit" }] }); await settle(); assertEquals(input.value, "Latest queued edit"); const stopped = document.querySelector(".stopped-inputs")!; assertEquals(stopped.querySelectorAll(".queued").length, 1); assertEquals(stopped.textContent!.includes(followUp.content), true); button(stopped, "Restore").click(); await settle(); assertEquals(input.value, "Latest queued edit"); assertEquals(stopped.querySelectorAll(".queued").length, 1); fill(input, ""); button(stopped, "Restore").click(); await settle(); assertEquals(input.value, followUp.content); assertEquals(stopped.querySelectorAll(".queued").length, 0); assertEquals(requests().filter(({ path }) => path.endsWith("/messages")), []); fill(input, ""); await snapshot([steer], { run }); const delayed = Promise.withResolvers(); respond(() => delayed.promise); abort.click(); await settle(); fill(input, "Typed while aborting"); await snapshot([]); delayed.resolve({ inputs: [steer] }); await settle(); assertEquals(input.value, "Typed while aborting"); assertEquals(stopped.querySelectorAll(".queued").length, 1); button(stopped, "Dismiss").click(); await settle(); assertEquals(stopped.querySelectorAll(".queued").length, 0); await snapshot([followUp], { run }); respond(() => Response.json({ error: "Could not stop" }, { status: 500 })); abort.click(); await settle(); assertEquals(input.value, "Typed while aborting"); assertEquals(stopped.querySelectorAll(".queued").length, 0); assertEquals(row().textContent!.includes(followUp.content), true); fill(input, ""); }); }); Deno.test("pending configuration does not block multiple follow-ups, but idle sends wait for it", async () => { await withConversation(async ({ shared, snapshot, configuration, respond, requests, settle }) => { shared.extensions.set([{ name: "probe", description: null, status: "loaded", error: null, running: true, builtin: false, alwaysOn: false, defaultEnabled: false, web: null, formerly: [], promptProse: null, configuration: { properties: { setting: { type: "string", default: "" }, other: { type: "string", default: "" }, }, }, }]); await snapshot([], { run }, { extensions: ["probe"] }); await configuration("probe", { setting: "original", other: "original" }); const saved = Promise.withResolvers(); let responding = 0; respond(() => responding++ === 0 ? saved.promise : {}); const dropdown = document.querySelector( ".conversation-header .extensions", )!; dropdown.querySelector("summary")!.click(); await settle(); const setting = document.querySelector(".configuration input")!; setting.focus(); fill(setting, "updated"); const other = document.querySelectorAll(".configuration input")[1]; other.focus(); assertEquals(other.disabled, false); fill(other, "also updated"); other.blur(); assertEquals(requests().filter(({ method }) => method === "PATCH"), []); document.body.click(); assertEquals(dropdown.open, false); await settle(); assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/configuration/probe`); assertEquals(requests().at(-1)?.body, { setting: "updated", other: "also updated" }); const input = document.querySelector(".composer textarea")!; fill(input, "First follow-up"); press(input, "Enter", { altKey: true }); await settle(); assertEquals(input.value, ""); fill(input, "Second follow-up"); press(input, "Enter", { altKey: true }); await settle(); assertEquals( requests().filter(({ path }) => path.endsWith("/messages")).map(({ body }) => body), [ { content: "First follow-up", whenBusy: "followUp" }, { content: "Second follow-up", whenBusy: "followUp" }, ], ); await snapshot([], {}, { extensions: ["probe"] }); fill(input, "Idle message"); press(input, "Enter"); await settle(); assertEquals(input.value, "Idle message"); assertEquals(requests().filter(({ path }) => path.endsWith("/messages")).length, 2); saved.resolve({ setting: "updated", other: "also updated" }); await settle(); assertEquals(requests().at(-1)?.body, { content: "Idle message" }); assertEquals(input.value, ""); }); }); Deno.test("command completion follows target changes and ignores stale catalogs", async () => { await withConversation(async ({ snapshot, commandCatalog, settle }) => { const workspace = { name: "workspace", extension: "code", description: "jj workspace" }; commandCatalog(() => [workspace]); const binding = (id: number, environment: string, cwd: string) => ({ id, kind: "berry.event", data: { ext: "environment", event: { type: "bind", environment, cwd } }, }); const first = binding(1, "local", "/repo"); await snapshot([], {}, { extensions: ["code"] }, [first]); const input = document.querySelector(".composer textarea")!; fill(input, "/work"); await settle(); const offered = () => [...document.querySelectorAll(".suggestions button")].some((button) => button.textContent?.includes("/workspace") ); assertEquals(offered(), true); let resolveOld!: (value: unknown) => void; commandCatalog(() => new Promise((resolve) => resolveOld = resolve)); const second = binding(2, "local", "/elsewhere"); await snapshot([], {}, { extensions: ["code"] }, [first, second]); assertEquals(offered(), false); commandCatalog(() => []); const third = binding(3, "remote", "/elsewhere"); await snapshot([], {}, { extensions: ["code"] }, [first, second, third]); resolveOld([workspace]); await settle(); assertEquals(offered(), false); commandCatalog(() => [workspace]); await snapshot([], {}, { extensions: ["code"] }, [ first, second, third, binding(4, "remote", "/repo"), ]); assertEquals(offered(), true); }); }); Deno.test("the queue lists steers before follow-ups, as they will reach the agent", async () => { await withConversation(async ({ snapshot }) => { const later = { ...followUp, id: 1 }; const sooner = { ...steer, id: 2 }; await snapshot([later], { run }); await snapshot([later, sooner], { run }); const content = (index: number) => row(index).querySelector(".queued-content")!.textContent; assertEquals([content(0), content(1)], [sooner.content, later.content]); }); }); Deno.test("queued editors retain identity, focus, selection and local drafts across snapshots", async () => { await withConversation(async ({ snapshot, shared, requests }) => { await snapshot([steer, followUp], { run, generation: { attempt: 1 } }); const first = row(); const second = row(1); button(first, "Edit").click(); const draft = editor(first); fill(draft, "Unsent local draft"); draft.setSelectionRange(3, 9); await snapshot([steer, followUp, { ...steer, id: 4 }], { run, tools: [{ callId: "call", name: "test", status: "running" }], }); shared.machines.set(["local", "remote"]); assertStrictEquals(row(), first); assertStrictEquals(editor(first), draft); assertStrictEquals(document.activeElement, draft); assertEquals([draft.selectionStart, draft.selectionEnd], [3, 9]); assertEquals(draft.value, "Unsent local draft"); button(second, "Edit").click(); fill(editor(second), "Another local draft"); await snapshot([steer, followUp]); assertEquals(editor(first).value, "Unsent local draft"); assertEquals(editor(second).value, "Another local draft"); assertEquals(requests(), []); }); }); Deno.test("queued rows save only literal text, need text unless they hold images, and retract once the snapshot agrees", async () => { await withConversation(async ({ snapshot, requests, respond, settle }) => { await snapshot([steer, illustrated]); const first = row(); const second = row(1); button(second, "Edit").click(); assertEquals(editor(second).value, "Original caption"); fill(editor(second), "/not-a-command replacement"); const response = Promise.withResolvers(); respond(() => response.promise); button(second, "Save").click(); assertEquals([...second.querySelectorAll("button")].every((b) => b.disabled), true); assertEquals(editor(second).readOnly, true); assertEquals(requests(), [{ method: "PATCH", path: `/api/conversations/${tid}/inbox/2`, body: { text: "/not-a-command replacement" }, }]); const newer = { ...illustrated, content: [{ type: "text", text: "Newer server revision" }, attachment], }; await snapshot([steer, newer]); assertEquals(editor(second).value, "/not-a-command replacement"); response.resolve(Response.json({})); await settle(); assertStrictEquals(row(), first); assertStrictEquals(row(1), second); assertEquals(second.querySelector("form")!.hidden, true); assertEquals(second.querySelector(".queued-content")!.textContent, "Newer server revision"); assertEquals(document.querySelector(".composer textarea")!.value, ""); respond(() => ({})); button(first, "Edit").click(); fill(editor(first), " \n "); assertEquals(button(first, "Save").disabled, true); button(first, "Save").click(); assertEquals(requests().length, 1); button(first, "Cancel").click(); button(second, "Edit").click(); fill(editor(second), ""); assertEquals(button(second, "Save").disabled, false); button(second, "Save").click(); await settle(); assertEquals(requests().at(-1), { method: "PATCH", path: `/api/conversations/${tid}/inbox/2`, body: { text: "" }, }); const pictured = { ...illustrated, content: [attachment] }; await snapshot([steer, pictured]); const retracting = Promise.withResolvers(); respond(() => retracting.promise); button(first, "Retract").click(); assertEquals(button(first, "Edit").disabled, true); assertEquals(button(first, "Retract").disabled, true); assertEquals(requests().at(-1), { method: "DELETE", path: `/api/conversations/${tid}/inbox/1`, body: undefined, }); retracting.resolve(Response.json({})); await settle(); assertStrictEquals(row(), first); await snapshot([pictured]); assertStrictEquals(row(), second); assertEquals(document.querySelectorAll(".queued").length, 1); }); }); Deno.test("failed queue edits and retractions keep the row, say why, and can be retried", async () => { await withConversation(async ({ snapshot, respond, settle, requests }) => { await snapshot([steer]); button(row(), "Edit").click(); const draft = editor(row()); fill(draft, "Keep this draft"); respond(() => Promise.resolve(Response.json({ error: "Input is no longer queued" }, { status: 409 })) ); button(row(), "Save").click(); await settle(); await snapshot([steer]); assertStrictEquals(editor(row()), draft); assertEquals(draft.value, "Keep this draft"); assertEquals(draft.readOnly, false); assertEquals(button(row(), "Save").disabled, false); const error = row().querySelector('[role="alert"]')!; assertEquals(error.hidden, false); assertEquals(error.textContent, "Input is no longer queued"); respond(() => ({})); button(row(), "Save").click(); await settle(); assertEquals(requests().length, 2); assertEquals(row().querySelector("form")!.hidden, true); assertEquals(error.hidden, true); respond(() => Promise.reject(new TypeError("Network unavailable"))); button(row(), "Retract").click(); await settle(); assertEquals(error.hidden, false); assertEquals(error.textContent, "Network unavailable"); assertEquals(button(row(), "Retract").disabled, false); }); }); Deno.test("compaction status follows live tasks without making background compaction busy", async () => { await withConversation(async ({ snapshot, requests, settle }) => { const status = document.querySelector(".working")!; const abort = document.querySelector('[aria-label="Abort response"]')!; const compaction = { taskId: 2, reason: "background", blocking: false, attempt: 0 }; await snapshot([], { compactions: [compaction] }); assertEquals(status.hidden, false); assertEquals(abort.hidden, true); fill(document.querySelector(".composer textarea")!, "Keep going"); document.querySelector(".composer button.primary")!.click(); await settle(); const sent = requests().find((request) => request.path.endsWith("/messages")); assertExists(sent); assertEquals((sent.body as { whenBusy?: string }).whenBusy, undefined); await snapshot([], { run, compactions: [{ ...compaction, retry: { at: Date.now() + 1000, error: "Try again" } }], }); assertEquals(abort.hidden, false); await snapshot([], { run, compactions: [{ ...compaction, reason: "threshold", blocking: true }], }); await snapshot([], { run, compactions: [] }); assertEquals(status.hidden, false); await snapshot([]); assertEquals(status.hidden, true); }); }); Deno.test("context usage drops immediately after compaction and uses the next response's measurement", async () => { await withConversation(async ({ shared, snapshot }) => { const model = { provider: "test", modelId: "model" }; shared.models.set([{ provider: model.provider, id: model.modelId, name: "Test model", input: ["text"], reasoning: false, thinkingLevels: ["off"], contextWindow: 100_000, cost: { input: 0, output: 0 }, }]); const oldMessage = fauxAssistantMessage([fauxText("r".repeat(800))]); oldMessage.usage = { ...oldMessage.usage, input: 79_800, output: 200, totalTokens: 80_000 }; const retained = { id: 2, conversationId: tid, kind: "pi.assistant", model: [oldMessage], }; const trailing = { id: 3, conversationId: tid, kind: "pi.user", model: [{ role: "user", content: "u".repeat(1200), timestamp: 0 }], }; await snapshot([], {}, { model }, [retained, trailing]); const context = document.querySelector(".conversation-header .usage span")!; assertEquals(context.textContent, "80k/100k"); const summary = { id: 4, conversationId: tid, kind: "pi.compaction", head: retained.id, model: [{ role: "user", content: "s".repeat(400), timestamp: 0 }], data: { reason: "manual" }, }; await snapshot([], {}, { model }, [summary, retained, trailing]); assertEquals(context.textContent, "600/100k"); const freshMessage = fauxAssistantMessage([fauxText("Fresh response")]); freshMessage.usage = { ...freshMessage.usage, input: 1100, output: 100, totalTokens: 1200 }; const response = { id: 5, conversationId: tid, kind: "pi.assistant", model: [freshMessage], }; await snapshot([], {}, { model }, [summary, retained, trailing, response]); assertEquals(context.textContent, "1.2k/100k"); await snapshot([], {}, { model }, [{ id: 6, conversationId: tid, kind: "pi.reset", head: 6, }]); assertEquals(context.textContent, "0/100k"); }); }); const branches = { head: 11, branches: [{ id: 11, tip: 4, created: 0 }], entries: [ { id: 1, parent: null, branch: 11, kind: "berry.start", text: "" }, { id: 2, parent: 1, branch: 11, kind: "pi.user", text: "Original message" }, { id: 3, parent: 2, branch: 11, kind: "pi.assistant", text: "Original reply" }, { id: 4, parent: 3, branch: 11, kind: "pi.user", text: "Latest message" }, ], }; /** A transcript of entries with these IDs. */ const through = (...ids: number[]) => ids.map((id) => ({ id, conversationId: 1, kind: "berry.start" })); async function openTree(command: string, settle: () => Promise) { const input = document.querySelector(".composer textarea")!; fill(input, command); document.querySelector(".composer button.primary")!.click(); await settle(); return document.querySelector("dialog")!; } Deno.test("the tree stays open until the transcript reaches the choice, which shows until the conversation moves on", async () => { await withConversation(async ({ snapshot, tree, requests, settle }) => { const agent = { extensions: ["code"] }; await snapshot([], {}, agent, through(1, 2, 3, 4)); tree(branches); const dialog = await openTree("/tree", settle); document.querySelector('.tree-row[data-id="3"]')!.click(); await settle(); assertEquals(requests(), [{ method: "POST", path: `/api/conversations/${tid}/branch`, body: { entryId: 3 }, }]); const continuation = document.querySelector(".continuation")!; assertEquals(dialog.isConnected, true); assertEquals(continuation.hidden, true); await snapshot([], {}, agent, through(1, 2, 3)); assertEquals(dialog.isConnected, false); assertEquals(continuation.hidden, false); assertEquals(continuation.textContent?.includes("Original reply"), true); const input = document.querySelector(".composer textarea")!; fill(input, "!printf hello"); document.querySelector(".composer button.primary")!.click(); await settle(); assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/shell`); await snapshot([], {}, agent, through(1, 2, 3, 5)); assertEquals(continuation.hidden, true); }); }); Deno.test("choosing a message of yours in the tree brings it back to edit from before it, until undone", async () => { await withConversation(async ({ snapshot, tree, requests, settle }) => { await snapshot([], {}, {}, through(1, 2, 3, 4)); tree(branches); await openTree("/tree", settle); document.querySelector('.tree-row[data-id="2"]')!.click(); await settle(); assertEquals(requests(), [{ method: "POST", path: `/api/conversations/${tid}/branch`, body: { entryId: 1 }, }]); await snapshot([], {}, {}, through(1)); const input = document.querySelector(".composer textarea")!; assertEquals(input.value, "Original message"); const continuation = document.querySelector(".continuation")!; button(continuation, "undo").click(); await settle(); assertEquals(requests().at(-1), { method: "POST", path: `/api/conversations/${tid}/branch`, body: { entryId: 4 }, }); assertEquals(input.value, ""); await snapshot([], {}, {}, through(1, 2, 3, 4)); assertEquals(continuation.hidden, true); }); }); Deno.test("regenerating the agent's message from the tree closes it once the transcript moves past it", async () => { await withConversation(async ({ snapshot, tree, requests, settle }) => { await snapshot([], {}, {}, through(1, 2, 3, 4)); tree(branches); const dialog = await openTree("/tree", settle); assertEquals( [...document.querySelectorAll(".tree-regenerate")].map((b) => b.dataset.id), ["3"], ); document.querySelector('.tree-regenerate[data-id="3"]')!.click(); await settle(); assertEquals(requests(), [{ method: "POST", path: `/api/conversations/${tid}/regenerate`, body: { entryId: 3 }, }]); assertEquals(dialog.isConnected, true); // the branch and the run's first entry can arrive in one frame await snapshot([], {}, {}, through(1, 2, 5)); assertEquals(dialog.isConnected, false); assertEquals(document.querySelector(".continuation")!.hidden, true); assertEquals(document.querySelector(".notice")!.hidden, false); }); }); Deno.test("forking from the tree creates a conversation after the chosen entry, and a refusal stays in the picker saying why", async () => { await withConversation(async ({ snapshot, tree, requests, settle, respond }) => { await snapshot([], {}, {}, through(1, 2, 3, 4)); tree(branches); respond(() => Response.json({ error: "Busy" }, { status: 400 })); const dialog = await openTree("/fork", settle); const choose = async () => { document.querySelector('.tree-row[data-id="3"]')!.click(); await settle(); }; await choose(); assertEquals(dialog.open, true); assertEquals(dialog.querySelector('[role="alert"]')?.textContent, "Busy"); assertEquals(location.hash, `#/${tid}`); assertEquals(document.querySelector(".composer textarea")!.value, ""); assertEquals(document.querySelector(".continuation")!.hidden, true); respond(() => ({ tid: "bbbbbbbbbbbbb" })); await choose(); assertEquals(requests().map(({ path, body }) => [path, body]), [ [`/api/conversations/${tid}/fork`, { entryId: 3 }], [`/api/conversations/${tid}/fork`, { entryId: 3 }], ]); assertEquals(location.hash, "#/bbbbbbbbbbbbb"); assertEquals(document.querySelector("dialog"), null); }); }); Deno.test("bang commands use shell execution only with code enabled, including the no-context form", async () => { await withConversation(async ({ snapshot, requests, settle }) => { const input = document.querySelector(".composer textarea")!; const send = document.querySelector(".composer button.primary")!; await snapshot([]); fill(input, "!literal message"); send.click(); await settle(); assertEquals(requests().at(-1), { method: "POST", path: `/api/conversations/${tid}/messages`, body: { content: "!literal message" }, }); await snapshot([], {}, { extensions: ["code"] }); for ( const [text, command, excludeFromContext] of [ ["!printf 'hello'", "printf 'hello'", false], [ "!!printf 'private'\nprintf 'second line'", "printf 'private'\nprintf 'second line'", true, ], ] as const ) { fill(input, text); send.click(); await settle(); assertEquals(requests().at(-1), { method: "POST", path: `/api/conversations/${tid}/shell`, body: { command, excludeFromContext }, }); assertEquals(input.value, ""); } }); }); Deno.test("rejected shell commands keep the composer draft rather than steering the assistant", async () => { await withConversation(async ({ snapshot, requests, settle, respond }) => { await snapshot([], { run }, { extensions: ["code"] }); respond(() => Promise.resolve(Response.json({ error: "Busy: let it finish" }, { status: 400 })) ); const input = document.querySelector(".composer textarea")!; fill(input, "!touch should-not-run"); const steer = [...document.querySelectorAll(".composer button")] .find((button) => button.textContent === "steer")!; steer.click(); await settle(); assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/shell`); assertEquals(input.value, "!touch should-not-run"); assertEquals(document.querySelector(".notice")?.textContent, "Busy: let it finish"); }); }); Deno.test("live user shell execution is abortable and becomes one persistent transcript card", async () => { await withConversation(async ({ snapshot, shell, requests, settle }) => { await snapshot([], {}, { extensions: ["code"] }); const execution = { taskId: 17, command: "printf hello", excludeFromContext: true, status: "running", result: null, details: { rawOutput: "hello" }, error: null, startedAt: Date.now(), durationMs: null, }; await shell({ run: execution }); assertEquals( document.querySelector(".transcript .tool-header")?.getAttribute("aria-expanded"), "true", ); const abort = document.querySelector('[aria-label="Abort response"]')!; assertEquals(abort.hidden, false); abort.click(); await settle(); assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/abort`); const entry = { id: 99, conversationId: 1, kind: "berry.shell", data: { ...execution, status: "ok", result: { output: "hello", exitCode: 0 }, durationMs: 50, }, }; await snapshot([], {}, { extensions: ["code"] }, [entry]); assertEquals( document.querySelector(".transcript .tool-header")?.getAttribute("aria-expanded"), "true", ); assertEquals(document.querySelectorAll(".transcript .tool").length, 1); await shell({}); assertEquals(abort.hidden, true); assertEquals(document.querySelectorAll(".transcript .tool").length, 1); assertEquals(document.querySelector(".transcript")?.textContent?.includes("hello"), true); }); }); Deno.test("an idle message flushes the open view before sending, but a busy one does not", async () => { await withBerry(async ({ server, scope, settle }) => { const respond: () => unknown = () => ({}); server.respond(({ method, path }) => method !== "GET" ? respond() : path.endsWith("/tree") ? {} : [] ); const shared: Shared = { extensions: new Signal([]), models: new Signal([]), enabled: new Signal([]), machines: new Signal(["local"]), projects: new Signal({}), refreshModels() {}, }; const viewing = new Signal("diff"); const { conversation } = await import("./conversation.tsx"); document.body.append(...conversation(scope, tid, shared, viewing)); const { syncRenderers } = await import("./renderers.ts"); const module = `data:text/javascript,${ encodeURIComponent( `let context = null; export default { view: { label: "diff", create: () => { const element = document.createElement("section"); element.className = "flush-probe"; return { element, update: (next) => void (context = next), flush: async () => void await context.command("diff-finish", JSON.stringify({})), }; } } };`, ) }`; try { shared.extensions.set([{ name: "diff", description: null, status: "loaded", error: null, running: true, builtin: false, alwaysOn: true, defaultEnabled: false, web: null, formerly: [], promptProse: null, configuration: null, }]); await syncRenderers([{ name: "diff", formerly: [], web: { module, styles: [] } }]); server.snapshot(`conversation:${tid}`, { entries: [], docs: { "pi.live": {}, "pi.agent": {} }, }); await settle(); assertExists(document.querySelector(".flush-probe")); const input = document.querySelector(".composer textarea")!; fill(input, "idle hello"); press(input, "Enter"); await settle(); const sent = server.changes().filter(({ method }) => method === "POST"); const finish = sent.findIndex(({ path }) => path.endsWith("/commands/diff-finish")); const message = sent.findIndex(({ path }) => path.endsWith("/messages")); assertExists(document.querySelector(".flush-probe")); assert( finish !== -1 && message !== -1 && finish < message, `flush before message: ${sent.map(({ path }) => path)}`, ); assertEquals(sent[message].body, { content: "idle hello" }); const before = server.changes().length; server.snapshot(`conversation:${tid}`, { entries: [], docs: { "pi.live": { run }, "pi.agent": {} }, }); await settle(); fill(input, "busy hello"); press(input, "Enter"); await settle(); const after = server.changes().slice(before); const busyMessage = after.find(({ path }) => path.endsWith("/messages")); assertExists(busyMessage); assertEquals(busyMessage.body, { content: "busy hello", whenBusy: "steer" }); assertEquals(after.some(({ path }) => path.endsWith("/commands/diff-finish")), false); } finally { await syncRenderers([]); } }, { url: `http://berry.test/#/${tid}` }); });