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
33.5 KiB874 linesraw
1import { Signal } from "@char/aftercare";
2import { fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai/providers/faux";
3import { assert, assertEquals, assertExists, assertStrictEquals } from "@std/assert";
4import { type FakeRequest, fill, press } from "@berry/util/dom.ts";
5import type { Shared } from "./conversation.tsx";
6import { withBerry } from "./testing.ts";
7
8const tid = "aaaaaaaaaaaaa";
9const steer = { id: 1, mode: "steer", content: "First message" };
10const followUp = { id: 2, mode: "followUp", content: "Second message" };
11const attachment = { type: "image", mimeType: "image/png", data: "unchanged-image-bytes" };
12const illustrated = {
13  ...followUp,
14  content: [{ type: "text", text: "Original caption" }, attachment],
15};
16const run = { taskId: 1, inputs: [3] };
17type Page = {
18  requests: () => FakeRequest[];
19  shared: Shared;
20  snapshot: (items: unknown[], live?: object, agent?: object, entries?: unknown[]) => Promise<void>;
21  shell: (state: object) => Promise<void>;
22  configuration: (name: string, values: object) => Promise<void>;
23  settle: () => Promise<void>;
24  respond: (response: () => unknown) => void;
25  tree: (value: unknown) => void;
26  commandCatalog: (response: () => unknown) => void;
27};
28
29function withConversation(test: (page: Page) => Promise<void>) {
30  return withBerry(async ({ server, scope, settle }) => {
31    let respond: () => unknown = () => ({});
32    let tree: unknown = {};
33    let commandCatalog: () => unknown = () => [];
34    server.respond(({ method, path }) =>
35      method !== "GET"
36        ? respond()
37        : path.endsWith("/tree")
38        ? tree
39        : path.endsWith("/commands")
40        ? commandCatalog()
41        : []
42    );
43    const shared: Shared = {
44      extensions: new Signal([]),
45      models: new Signal([]),
46      enabled: new Signal([]),
47      machines: new Signal(["local"]),
48      projects: new Signal({}),
49      refreshModels() {},
50    };
51    const { conversation } = await import("./conversation.tsx");
52    document.body.append(...conversation(scope, tid, shared));
53    await test({
54      requests: server.changes,
55      shared,
56      settle,
57      async snapshot(items, live = {}, agent = {}, entries = []) {
58        server.snapshot(`conversation:${tid}`, {
59          entries,
60          docs: { "pi.inbox": { items }, "pi.live": live, "pi.agent": agent },
61        });
62        await settle();
63      },
64      async shell(state) {
65        server.snapshot(`shell:${tid}`, state);
66        await settle();
67      },
68      async configuration(name, values) {
69        server.snapshot(`configuration:${tid}:${name}`, values);
70        await settle();
71      },
72      respond: (next) => void (respond = next),
73      tree: (value) => void (tree = value),
74      commandCatalog: (next) => void (commandCatalog = next),
75    });
76  }, { url: `http://berry.test/#/${tid}` });
77}
78
79Deno.test("sending from the bottom follows the message when the working status reduces the viewport", async () => {
80  await withConversation(async ({ snapshot, settle }) => {
81    const scroller = document.querySelector<HTMLElement>(".scroller")!;
82    const working = document.querySelector<HTMLElement>(".working")!;
83    let top = 0;
84    Object.defineProperties(scroller, {
85      clientHeight: { get: () => working.hidden ? 400 : 376 },
86      scrollHeight: {
87        get: () => 800 + scroller.querySelectorAll(".message").length * 200,
88      },
89      scrollTop: {
90        get: () => top,
91        set: (value: number) => {
92          top = Math.max(0, Math.min(value, scroller.scrollHeight - scroller.clientHeight));
93        },
94      },
95    });
96    const previous = {
97      id: 1,
98      kind: "pi.user",
99      model: [{ role: "user", content: "earlier message", timestamp: 0 }],
100    };
101    await snapshot([], {}, {}, [previous]);
102    assertEquals(top, scroller.scrollHeight - scroller.clientHeight);
103
104    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
105    fill(input, "new message");
106    press(input, "Enter");
107    await settle();
108    await snapshot([], { run }, {}, [previous, {
109      id: 2,
110      kind: "pi.user",
111      model: [{ role: "user", content: "new message", timestamp: 0 }],
112    }]);
113    assertEquals(working.hidden, false);
114    assertEquals(top, scroller.scrollHeight - scroller.clientHeight);
115  });
116});
117
118function row(index = 0): HTMLElement {
119  const element = document.querySelectorAll<HTMLElement>(".inbox .queued")[index];
120  assertExists(element);
121  return element;
122}
123
124function button(element: Element, label: string): HTMLButtonElement {
125  const found = [...element.querySelectorAll("button")].find((b) => b.textContent === label);
126  assertExists(found);
127  return found;
128}
129
130function editor(element: Element): HTMLTextAreaElement {
131  const textarea = element.querySelector("textarea");
132  assertExists(textarea);
133  return textarea;
134}
135
136Deno.test("abort restores the first stopped input, keeps the rest out of the live queue, and protects newer drafts", async () => {
137  await withConversation(async ({ snapshot, respond, requests, settle }) => {
138    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
139    const abort = document.querySelector<HTMLButtonElement>('[aria-label="Abort response"]')!;
140    await snapshot([steer, followUp], { run });
141    const response = Promise.withResolvers<unknown>();
142    respond(() => response.promise);
143    abort.click();
144    await settle();
145    await snapshot([]);
146    response.resolve({ inputs: [followUp, { ...steer, content: "Latest queued edit" }] });
147    await settle();
148    assertEquals(input.value, "Latest queued edit");
149    const stopped = document.querySelector(".stopped-inputs")!;
150    assertEquals(stopped.querySelectorAll(".queued").length, 1);
151    assertEquals(stopped.textContent!.includes(followUp.content), true);
152    button(stopped, "Restore").click();
153    await settle();
154    assertEquals(input.value, "Latest queued edit");
155    assertEquals(stopped.querySelectorAll(".queued").length, 1);
156    fill(input, "");
157    button(stopped, "Restore").click();
158    await settle();
159    assertEquals(input.value, followUp.content);
160    assertEquals(stopped.querySelectorAll(".queued").length, 0);
161    assertEquals(requests().filter(({ path }) => path.endsWith("/messages")), []);
162
163    fill(input, "");
164    await snapshot([steer], { run });
165    const delayed = Promise.withResolvers<unknown>();
166    respond(() => delayed.promise);
167    abort.click();
168    await settle();
169    fill(input, "Typed while aborting");
170    await snapshot([]);
171    delayed.resolve({ inputs: [steer] });
172    await settle();
173    assertEquals(input.value, "Typed while aborting");
174    assertEquals(stopped.querySelectorAll(".queued").length, 1);
175    button(stopped, "Dismiss").click();
176    await settle();
177    assertEquals(stopped.querySelectorAll(".queued").length, 0);
178
179    await snapshot([followUp], { run });
180    respond(() => Response.json({ error: "Could not stop" }, { status: 500 }));
181    abort.click();
182    await settle();
183    assertEquals(input.value, "Typed while aborting");
184    assertEquals(stopped.querySelectorAll(".queued").length, 0);
185    assertEquals(row().textContent!.includes(followUp.content), true);
186    fill(input, "");
187  });
188});
189
190Deno.test("pending configuration does not block multiple follow-ups, but idle sends wait for it", async () => {
191  await withConversation(async ({ shared, snapshot, configuration, respond, requests, settle }) => {
192    shared.extensions.set([{
193      name: "probe",
194      description: null,
195      status: "loaded",
196      error: null,
197      running: true,
198      builtin: false,
199      alwaysOn: false,
200      defaultEnabled: false,
201      web: null,
202      formerly: [],
203      promptProse: null,
204      configuration: {
205        properties: {
206          setting: { type: "string", default: "" },
207          other: { type: "string", default: "" },
208        },
209      },
210    }]);
211    await snapshot([], { run }, { extensions: ["probe"] });
212    await configuration("probe", { setting: "original", other: "original" });
213    const saved = Promise.withResolvers<unknown>();
214    let responding = 0;
215    respond(() => responding++ === 0 ? saved.promise : {});
216    const dropdown = document.querySelector<HTMLDetailsElement>(
217      ".conversation-header .extensions",
218    )!;
219    dropdown.querySelector("summary")!.click();
220    await settle();
221    const setting = document.querySelector<HTMLInputElement>(".configuration input")!;
222    setting.focus();
223    fill(setting, "updated");
224    const other = document.querySelectorAll<HTMLInputElement>(".configuration input")[1];
225    other.focus();
226    assertEquals(other.disabled, false);
227    fill(other, "also updated");
228    other.blur();
229    assertEquals(requests().filter(({ method }) => method === "PATCH"), []);
230    document.body.click();
231    assertEquals(dropdown.open, false);
232    await settle();
233    assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/configuration/probe`);
234    assertEquals(requests().at(-1)?.body, { setting: "updated", other: "also updated" });
235
236    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
237    fill(input, "First follow-up");
238    press(input, "Enter", { altKey: true });
239    await settle();
240    assertEquals(input.value, "");
241    fill(input, "Second follow-up");
242    press(input, "Enter", { altKey: true });
243    await settle();
244    assertEquals(
245      requests().filter(({ path }) => path.endsWith("/messages")).map(({ body }) => body),
246      [
247        { content: "First follow-up", whenBusy: "followUp" },
248        { content: "Second follow-up", whenBusy: "followUp" },
249      ],
250    );
251
252    await snapshot([], {}, { extensions: ["probe"] });
253    fill(input, "Idle message");
254    press(input, "Enter");
255    await settle();
256    assertEquals(input.value, "Idle message");
257    assertEquals(requests().filter(({ path }) => path.endsWith("/messages")).length, 2);
258    saved.resolve({ setting: "updated", other: "also updated" });
259    await settle();
260    assertEquals(requests().at(-1)?.body, { content: "Idle message" });
261    assertEquals(input.value, "");
262  });
263});
264
265Deno.test("command completion follows target changes and ignores stale catalogs", async () => {
266  await withConversation(async ({ snapshot, commandCatalog, settle }) => {
267    const workspace = { name: "workspace", extension: "code", description: "jj workspace" };
268    commandCatalog(() => [workspace]);
269    const binding = (id: number, environment: string, cwd: string) => ({
270      id,
271      kind: "berry.event",
272      data: { ext: "environment", event: { type: "bind", environment, cwd } },
273    });
274    const first = binding(1, "local", "/repo");
275    await snapshot([], {}, { extensions: ["code"] }, [first]);
276    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
277    fill(input, "/work");
278    await settle();
279    const offered = () =>
280      [...document.querySelectorAll(".suggestions button")].some((button) =>
281        button.textContent?.includes("/workspace")
282      );
283    assertEquals(offered(), true);
284
285    let resolveOld!: (value: unknown) => void;
286    commandCatalog(() => new Promise((resolve) => resolveOld = resolve));
287    const second = binding(2, "local", "/elsewhere");
288    await snapshot([], {}, { extensions: ["code"] }, [first, second]);
289    assertEquals(offered(), false);
290    commandCatalog(() => []);
291    const third = binding(3, "remote", "/elsewhere");
292    await snapshot([], {}, { extensions: ["code"] }, [first, second, third]);
293    resolveOld([workspace]);
294    await settle();
295    assertEquals(offered(), false);
296
297    commandCatalog(() => [workspace]);
298    await snapshot([], {}, { extensions: ["code"] }, [
299      first,
300      second,
301      third,
302      binding(4, "remote", "/repo"),
303    ]);
304    assertEquals(offered(), true);
305  });
306});
307
308Deno.test("the queue lists steers before follow-ups, as they will reach the agent", async () => {
309  await withConversation(async ({ snapshot }) => {
310    const later = { ...followUp, id: 1 };
311    const sooner = { ...steer, id: 2 };
312    await snapshot([later], { run });
313    await snapshot([later, sooner], { run });
314    const content = (index: number) => row(index).querySelector(".queued-content")!.textContent;
315    assertEquals([content(0), content(1)], [sooner.content, later.content]);
316  });
317});
318
319Deno.test("queued editors retain identity, focus, selection and local drafts across snapshots", async () => {
320  await withConversation(async ({ snapshot, shared, requests }) => {
321    await snapshot([steer, followUp], { run, generation: { attempt: 1 } });
322    const first = row();
323    const second = row(1);
324    button(first, "Edit").click();
325    const draft = editor(first);
326    fill(draft, "Unsent local draft");
327    draft.setSelectionRange(3, 9);
328    await snapshot([steer, followUp, { ...steer, id: 4 }], {
329      run,
330      tools: [{ callId: "call", name: "test", status: "running" }],
331    });
332    shared.machines.set(["local", "remote"]);
333    assertStrictEquals(row(), first);
334    assertStrictEquals(editor(first), draft);
335    assertStrictEquals(document.activeElement, draft);
336    assertEquals([draft.selectionStart, draft.selectionEnd], [3, 9]);
337    assertEquals(draft.value, "Unsent local draft");
338    button(second, "Edit").click();
339    fill(editor(second), "Another local draft");
340    await snapshot([steer, followUp]);
341    assertEquals(editor(first).value, "Unsent local draft");
342    assertEquals(editor(second).value, "Another local draft");
343    assertEquals(requests(), []);
344  });
345});
346
347Deno.test("queued rows save only literal text, need text unless they hold images, and retract once the snapshot agrees", async () => {
348  await withConversation(async ({ snapshot, requests, respond, settle }) => {
349    await snapshot([steer, illustrated]);
350    const first = row();
351    const second = row(1);
352    button(second, "Edit").click();
353    assertEquals(editor(second).value, "Original caption");
354    fill(editor(second), "/not-a-command replacement");
355    const response = Promise.withResolvers<Response>();
356    respond(() => response.promise);
357    button(second, "Save").click();
358    assertEquals([...second.querySelectorAll("button")].every((b) => b.disabled), true);
359    assertEquals(editor(second).readOnly, true);
360    assertEquals(requests(), [{
361      method: "PATCH",
362      path: `/api/conversations/${tid}/inbox/2`,
363      body: { text: "/not-a-command replacement" },
364    }]);
365    const newer = {
366      ...illustrated,
367      content: [{ type: "text", text: "Newer server revision" }, attachment],
368    };
369    await snapshot([steer, newer]);
370    assertEquals(editor(second).value, "/not-a-command replacement");
371    response.resolve(Response.json({}));
372    await settle();
373    assertStrictEquals(row(), first);
374    assertStrictEquals(row(1), second);
375    assertEquals(second.querySelector("form")!.hidden, true);
376    assertEquals(second.querySelector(".queued-content")!.textContent, "Newer server revision");
377    assertEquals(document.querySelector<HTMLTextAreaElement>(".composer textarea")!.value, "");
378
379    respond(() => ({}));
380    button(first, "Edit").click();
381    fill(editor(first), " \n ");
382    assertEquals(button(first, "Save").disabled, true);
383    button(first, "Save").click();
384    assertEquals(requests().length, 1);
385    button(first, "Cancel").click();
386    button(second, "Edit").click();
387    fill(editor(second), "");
388    assertEquals(button(second, "Save").disabled, false);
389    button(second, "Save").click();
390    await settle();
391    assertEquals(requests().at(-1), {
392      method: "PATCH",
393      path: `/api/conversations/${tid}/inbox/2`,
394      body: { text: "" },
395    });
396    const pictured = { ...illustrated, content: [attachment] };
397    await snapshot([steer, pictured]);
398
399    const retracting = Promise.withResolvers<Response>();
400    respond(() => retracting.promise);
401    button(first, "Retract").click();
402    assertEquals(button(first, "Edit").disabled, true);
403    assertEquals(button(first, "Retract").disabled, true);
404    assertEquals(requests().at(-1), {
405      method: "DELETE",
406      path: `/api/conversations/${tid}/inbox/1`,
407      body: undefined,
408    });
409    retracting.resolve(Response.json({}));
410    await settle();
411    assertStrictEquals(row(), first);
412    await snapshot([pictured]);
413    assertStrictEquals(row(), second);
414    assertEquals(document.querySelectorAll(".queued").length, 1);
415  });
416});
417
418Deno.test("failed queue edits and retractions keep the row, say why, and can be retried", async () => {
419  await withConversation(async ({ snapshot, respond, settle, requests }) => {
420    await snapshot([steer]);
421    button(row(), "Edit").click();
422    const draft = editor(row());
423    fill(draft, "Keep this draft");
424    respond(() =>
425      Promise.resolve(Response.json({ error: "Input is no longer queued" }, { status: 409 }))
426    );
427    button(row(), "Save").click();
428    await settle();
429    await snapshot([steer]);
430    assertStrictEquals(editor(row()), draft);
431    assertEquals(draft.value, "Keep this draft");
432    assertEquals(draft.readOnly, false);
433    assertEquals(button(row(), "Save").disabled, false);
434    const error = row().querySelector<HTMLElement>('[role="alert"]')!;
435    assertEquals(error.hidden, false);
436    assertEquals(error.textContent, "Input is no longer queued");
437    respond(() => ({}));
438    button(row(), "Save").click();
439    await settle();
440    assertEquals(requests().length, 2);
441    assertEquals(row().querySelector("form")!.hidden, true);
442    assertEquals(error.hidden, true);
443
444    respond(() => Promise.reject(new TypeError("Network unavailable")));
445    button(row(), "Retract").click();
446    await settle();
447    assertEquals(error.hidden, false);
448    assertEquals(error.textContent, "Network unavailable");
449    assertEquals(button(row(), "Retract").disabled, false);
450  });
451});
452Deno.test("compaction status follows live tasks without making background compaction busy", async () => {
453  await withConversation(async ({ snapshot, requests, settle }) => {
454    const status = document.querySelector<HTMLElement>(".working")!;
455    const abort = document.querySelector<HTMLButtonElement>('[aria-label="Abort response"]')!;
456    const compaction = { taskId: 2, reason: "background", blocking: false, attempt: 0 };
457    await snapshot([], { compactions: [compaction] });
458    assertEquals(status.hidden, false);
459    assertEquals(abort.hidden, true);
460    fill(document.querySelector<HTMLTextAreaElement>(".composer textarea")!, "Keep going");
461    document.querySelector<HTMLButtonElement>(".composer button.primary")!.click();
462    await settle();
463    const sent = requests().find((request) => request.path.endsWith("/messages"));
464    assertExists(sent);
465    assertEquals((sent.body as { whenBusy?: string }).whenBusy, undefined);
466
467    await snapshot([], {
468      run,
469      compactions: [{ ...compaction, retry: { at: Date.now() + 1000, error: "Try again" } }],
470    });
471    assertEquals(abort.hidden, false);
472
473    await snapshot([], {
474      run,
475      compactions: [{ ...compaction, reason: "threshold", blocking: true }],
476    });
477    await snapshot([], { run, compactions: [] });
478    assertEquals(status.hidden, false);
479    await snapshot([]);
480    assertEquals(status.hidden, true);
481  });
482});
483
484Deno.test("context usage drops immediately after compaction and uses the next response's measurement", async () => {
485  await withConversation(async ({ shared, snapshot }) => {
486    const model = { provider: "test", modelId: "model" };
487    shared.models.set([{
488      provider: model.provider,
489      id: model.modelId,
490      name: "Test model",
491      input: ["text"],
492      reasoning: false,
493      thinkingLevels: ["off"],
494      contextWindow: 100_000,
495      cost: { input: 0, output: 0 },
496    }]);
497    const oldMessage = fauxAssistantMessage([fauxText("r".repeat(800))]);
498    oldMessage.usage = { ...oldMessage.usage, input: 79_800, output: 200, totalTokens: 80_000 };
499    const retained = {
500      id: 2,
501      conversationId: tid,
502      kind: "pi.assistant",
503      model: [oldMessage],
504    };
505    const trailing = {
506      id: 3,
507      conversationId: tid,
508      kind: "pi.user",
509      model: [{ role: "user", content: "u".repeat(1200), timestamp: 0 }],
510    };
511    await snapshot([], {}, { model }, [retained, trailing]);
512    const context = document.querySelector<HTMLElement>(".conversation-header .usage span")!;
513    assertEquals(context.textContent, "80k/100k");
514
515    const summary = {
516      id: 4,
517      conversationId: tid,
518      kind: "pi.compaction",
519      head: retained.id,
520      model: [{ role: "user", content: "s".repeat(400), timestamp: 0 }],
521      data: { reason: "manual" },
522    };
523    await snapshot([], {}, { model }, [summary, retained, trailing]);
524    assertEquals(context.textContent, "600/100k");
525
526    const freshMessage = fauxAssistantMessage([fauxText("Fresh response")]);
527    freshMessage.usage = { ...freshMessage.usage, input: 1100, output: 100, totalTokens: 1200 };
528    const response = {
529      id: 5,
530      conversationId: tid,
531      kind: "pi.assistant",
532      model: [freshMessage],
533    };
534    await snapshot([], {}, { model }, [summary, retained, trailing, response]);
535    assertEquals(context.textContent, "1.2k/100k");
536
537    await snapshot([], {}, { model }, [{
538      id: 6,
539      conversationId: tid,
540      kind: "pi.reset",
541      head: 6,
542    }]);
543    assertEquals(context.textContent, "0/100k");
544  });
545});
546
547const branches = {
548  head: 11,
549  branches: [{ id: 11, tip: 4, created: 0 }],
550  entries: [
551    { id: 1, parent: null, branch: 11, kind: "berry.start", text: "" },
552    { id: 2, parent: 1, branch: 11, kind: "pi.user", text: "Original message" },
553    { id: 3, parent: 2, branch: 11, kind: "pi.assistant", text: "Original reply" },
554    { id: 4, parent: 3, branch: 11, kind: "pi.user", text: "Latest message" },
555  ],
556};
557/** A transcript of entries with these IDs. */
558const through = (...ids: number[]) =>
559  ids.map((id) => ({ id, conversationId: 1, kind: "berry.start" }));
560
561async function openTree(command: string, settle: () => Promise<void>) {
562  const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
563  fill(input, command);
564  document.querySelector<HTMLButtonElement>(".composer button.primary")!.click();
565  await settle();
566  return document.querySelector<HTMLDialogElement>("dialog")!;
567}
568
569Deno.test("the tree stays open until the transcript reaches the choice, which shows until the conversation moves on", async () => {
570  await withConversation(async ({ snapshot, tree, requests, settle }) => {
571    const agent = { extensions: ["code"] };
572    await snapshot([], {}, agent, through(1, 2, 3, 4));
573    tree(branches);
574    const dialog = await openTree("/tree", settle);
575    document.querySelector<HTMLButtonElement>('.tree-row[data-id="3"]')!.click();
576    await settle();
577    assertEquals(requests(), [{
578      method: "POST",
579      path: `/api/conversations/${tid}/branch`,
580      body: { entryId: 3 },
581    }]);
582    const continuation = document.querySelector<HTMLElement>(".continuation")!;
583    assertEquals(dialog.isConnected, true);
584    assertEquals(continuation.hidden, true);
585
586    await snapshot([], {}, agent, through(1, 2, 3));
587    assertEquals(dialog.isConnected, false);
588    assertEquals(continuation.hidden, false);
589    assertEquals(continuation.textContent?.includes("Original reply"), true);
590
591    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
592    fill(input, "!printf hello");
593    document.querySelector<HTMLButtonElement>(".composer button.primary")!.click();
594    await settle();
595    assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/shell`);
596    await snapshot([], {}, agent, through(1, 2, 3, 5));
597    assertEquals(continuation.hidden, true);
598  });
599});
600
601Deno.test("choosing a message of yours in the tree brings it back to edit from before it, until undone", async () => {
602  await withConversation(async ({ snapshot, tree, requests, settle }) => {
603    await snapshot([], {}, {}, through(1, 2, 3, 4));
604    tree(branches);
605    await openTree("/tree", settle);
606    document.querySelector<HTMLButtonElement>('.tree-row[data-id="2"]')!.click();
607    await settle();
608    assertEquals(requests(), [{
609      method: "POST",
610      path: `/api/conversations/${tid}/branch`,
611      body: { entryId: 1 },
612    }]);
613    await snapshot([], {}, {}, through(1));
614    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
615    assertEquals(input.value, "Original message");
616    const continuation = document.querySelector<HTMLElement>(".continuation")!;
617
618    button(continuation, "undo").click();
619    await settle();
620    assertEquals(requests().at(-1), {
621      method: "POST",
622      path: `/api/conversations/${tid}/branch`,
623      body: { entryId: 4 },
624    });
625    assertEquals(input.value, "");
626    await snapshot([], {}, {}, through(1, 2, 3, 4));
627    assertEquals(continuation.hidden, true);
628  });
629});
630
631Deno.test("regenerating the agent's message from the tree closes it once the transcript moves past it", async () => {
632  await withConversation(async ({ snapshot, tree, requests, settle }) => {
633    await snapshot([], {}, {}, through(1, 2, 3, 4));
634    tree(branches);
635    const dialog = await openTree("/tree", settle);
636    assertEquals(
637      [...document.querySelectorAll<HTMLElement>(".tree-regenerate")].map((b) => b.dataset.id),
638      ["3"],
639    );
640    document.querySelector<HTMLButtonElement>('.tree-regenerate[data-id="3"]')!.click();
641    await settle();
642    assertEquals(requests(), [{
643      method: "POST",
644      path: `/api/conversations/${tid}/regenerate`,
645      body: { entryId: 3 },
646    }]);
647    assertEquals(dialog.isConnected, true);
648    // the branch and the run's first entry can arrive in one frame
649    await snapshot([], {}, {}, through(1, 2, 5));
650    assertEquals(dialog.isConnected, false);
651    assertEquals(document.querySelector<HTMLElement>(".continuation")!.hidden, true);
652    assertEquals(document.querySelector<HTMLElement>(".notice")!.hidden, false);
653  });
654});
655
656Deno.test("forking from the tree creates a conversation after the chosen entry, and a refusal stays in the picker saying why", async () => {
657  await withConversation(async ({ snapshot, tree, requests, settle, respond }) => {
658    await snapshot([], {}, {}, through(1, 2, 3, 4));
659    tree(branches);
660    respond(() => Response.json({ error: "Busy" }, { status: 400 }));
661    const dialog = await openTree("/fork", settle);
662    const choose = async () => {
663      document.querySelector<HTMLButtonElement>('.tree-row[data-id="3"]')!.click();
664      await settle();
665    };
666    await choose();
667    assertEquals(dialog.open, true);
668    assertEquals(dialog.querySelector('[role="alert"]')?.textContent, "Busy");
669    assertEquals(location.hash, `#/${tid}`);
670    assertEquals(document.querySelector<HTMLTextAreaElement>(".composer textarea")!.value, "");
671    assertEquals(document.querySelector<HTMLElement>(".continuation")!.hidden, true);
672
673    respond(() => ({ tid: "bbbbbbbbbbbbb" }));
674    await choose();
675    assertEquals(requests().map(({ path, body }) => [path, body]), [
676      [`/api/conversations/${tid}/fork`, { entryId: 3 }],
677      [`/api/conversations/${tid}/fork`, { entryId: 3 }],
678    ]);
679    assertEquals(location.hash, "#/bbbbbbbbbbbbb");
680    assertEquals(document.querySelector("dialog"), null);
681  });
682});
683
684Deno.test("bang commands use shell execution only with code enabled, including the no-context form", async () => {
685  await withConversation(async ({ snapshot, requests, settle }) => {
686    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
687    const send = document.querySelector<HTMLButtonElement>(".composer button.primary")!;
688    await snapshot([]);
689    fill(input, "!literal message");
690    send.click();
691    await settle();
692    assertEquals(requests().at(-1), {
693      method: "POST",
694      path: `/api/conversations/${tid}/messages`,
695      body: { content: "!literal message" },
696    });
697    await snapshot([], {}, { extensions: ["code"] });
698    for (
699      const [text, command, excludeFromContext] of [
700        ["!printf 'hello'", "printf 'hello'", false],
701        [
702          "!!printf 'private'\nprintf 'second line'",
703          "printf 'private'\nprintf 'second line'",
704          true,
705        ],
706      ] as const
707    ) {
708      fill(input, text);
709      send.click();
710      await settle();
711      assertEquals(requests().at(-1), {
712        method: "POST",
713        path: `/api/conversations/${tid}/shell`,
714        body: { command, excludeFromContext },
715      });
716      assertEquals(input.value, "");
717    }
718  });
719});
720
721Deno.test("rejected shell commands keep the composer draft rather than steering the assistant", async () => {
722  await withConversation(async ({ snapshot, requests, settle, respond }) => {
723    await snapshot([], { run }, { extensions: ["code"] });
724    respond(() =>
725      Promise.resolve(Response.json({ error: "Busy: let it finish" }, { status: 400 }))
726    );
727    const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
728    fill(input, "!touch should-not-run");
729    const steer = [...document.querySelectorAll<HTMLButtonElement>(".composer button")]
730      .find((button) => button.textContent === "steer")!;
731    steer.click();
732    await settle();
733    assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/shell`);
734    assertEquals(input.value, "!touch should-not-run");
735    assertEquals(document.querySelector(".notice")?.textContent, "Busy: let it finish");
736  });
737});
738
739Deno.test("live user shell execution is abortable and becomes one persistent transcript card", async () => {
740  await withConversation(async ({ snapshot, shell, requests, settle }) => {
741    await snapshot([], {}, { extensions: ["code"] });
742    const execution = {
743      taskId: 17,
744      command: "printf hello",
745      excludeFromContext: true,
746      status: "running",
747      result: null,
748      details: { rawOutput: "hello" },
749      error: null,
750      startedAt: Date.now(),
751      durationMs: null,
752    };
753    await shell({ run: execution });
754    assertEquals(
755      document.querySelector(".transcript .tool-header")?.getAttribute("aria-expanded"),
756      "true",
757    );
758    const abort = document.querySelector<HTMLButtonElement>('[aria-label="Abort response"]')!;
759    assertEquals(abort.hidden, false);
760    abort.click();
761    await settle();
762    assertEquals(requests().at(-1)?.path, `/api/conversations/${tid}/abort`);
763    const entry = {
764      id: 99,
765      conversationId: 1,
766      kind: "berry.shell",
767      data: {
768        ...execution,
769        status: "ok",
770        result: { output: "hello", exitCode: 0 },
771        durationMs: 50,
772      },
773    };
774    await snapshot([], {}, { extensions: ["code"] }, [entry]);
775    assertEquals(
776      document.querySelector(".transcript .tool-header")?.getAttribute("aria-expanded"),
777      "true",
778    );
779    assertEquals(document.querySelectorAll(".transcript .tool").length, 1);
780    await shell({});
781    assertEquals(abort.hidden, true);
782    assertEquals(document.querySelectorAll(".transcript .tool").length, 1);
783    assertEquals(document.querySelector(".transcript")?.textContent?.includes("hello"), true);
784  });
785});
786
787Deno.test("an idle message flushes the open view before sending, but a busy one does not", async () => {
788  await withBerry(async ({ server, scope, settle }) => {
789    const respond: () => unknown = () => ({});
790    server.respond(({ method, path }) =>
791      method !== "GET" ? respond() : path.endsWith("/tree") ? {} : []
792    );
793    const shared: Shared = {
794      extensions: new Signal([]),
795      models: new Signal([]),
796      enabled: new Signal([]),
797      machines: new Signal(["local"]),
798      projects: new Signal({}),
799      refreshModels() {},
800    };
801    const viewing = new Signal("diff");
802    const { conversation } = await import("./conversation.tsx");
803    document.body.append(...conversation(scope, tid, shared, viewing));
804    const { syncRenderers } = await import("./renderers.ts");
805    const module = `data:text/javascript,${
806      encodeURIComponent(
807        `let context = null;
808       export default { view: { label: "diff", create: () => {
809         const element = document.createElement("section");
810         element.className = "flush-probe";
811         return {
812           element,
813           update: (next) => void (context = next),
814           flush: async () => void await context.command("diff-finish", JSON.stringify({})),
815         };
816       } } };`,
817      )
818    }`;
819    try {
820      shared.extensions.set([{
821        name: "diff",
822        description: null,
823        status: "loaded",
824        error: null,
825        running: true,
826        builtin: false,
827        alwaysOn: true,
828        defaultEnabled: false,
829        web: null,
830        formerly: [],
831        promptProse: null,
832        configuration: null,
833      }]);
834      await syncRenderers([{ name: "diff", formerly: [], web: { module, styles: [] } }]);
835      server.snapshot(`conversation:${tid}`, {
836        entries: [],
837        docs: { "pi.live": {}, "pi.agent": {} },
838      });
839      await settle();
840      assertExists(document.querySelector(".flush-probe"));
841
842      const input = document.querySelector<HTMLTextAreaElement>(".composer textarea")!;
843      fill(input, "idle hello");
844      press(input, "Enter");
845      await settle();
846      const sent = server.changes().filter(({ method }) => method === "POST");
847      const finish = sent.findIndex(({ path }) => path.endsWith("/commands/diff-finish"));
848      const message = sent.findIndex(({ path }) => path.endsWith("/messages"));
849      assertExists(document.querySelector(".flush-probe"));
850      assert(
851        finish !== -1 && message !== -1 && finish < message,
852        `flush before message: ${sent.map(({ path }) => path)}`,
853      );
854      assertEquals(sent[message].body, { content: "idle hello" });
855
856      const before = server.changes().length;
857      server.snapshot(`conversation:${tid}`, {
858        entries: [],
859        docs: { "pi.live": { run }, "pi.agent": {} },
860      });
861      await settle();
862      fill(input, "busy hello");
863      press(input, "Enter");
864      await settle();
865      const after = server.changes().slice(before);
866      const busyMessage = after.find(({ path }) => path.endsWith("/messages"));
867      assertExists(busyMessage);
868      assertEquals(busyMessage.body, { content: "busy hello", whenBusy: "steer" });
869      assertEquals(after.some(({ path }) => path.endsWith("/commands/diff-finish")), false);
870    } finally {
871      await syncRenderers([]);
872    }
873  }, { url: `http://berry.test/#/${tid}` });
874});