char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somcomposer: no @-completion in !/!! shell entries, and render them monospacef154c1f

main
13.0 KiB315 linesraw
1import { Signal } from "@char/aftercare";
2import { assertEquals } from "@std/assert";
3import { until } from "@berry/util";
4import { fill, press, withDom } from "@berry/util/dom.ts";
5import { composer, go } from "./composer.tsx";
6import type { CommandInfo } from "./conversation.tsx";
7import { Scope } from "./ui.ts";
8
9Deno.test("composer drafts survive leaving a view and successful sends retire only the sent text", async (t) => {
10  await withDom(async (window) => {
11    const scopes: Scope[] = [];
12    const errors: string[] = [];
13    const commands = new Signal<readonly CommandInfo[]>([]);
14    let send: (text: string, followUp: boolean) => Promise<void> = () => Promise.resolve();
15    let completeReferences: (prefix: string) => Promise<readonly string[]> = () =>
16      Promise.resolve([]);
17    const open = (route: string, busy = new Signal(false), shellEnabled = new Signal(false)) => {
18      location.hash = `#/${route}`;
19      const scope = new Scope();
20      scopes.push(scope);
21      const view = composer(
22        scope,
23        route,
24        busy,
25        commands,
26        (text, followUp) => send(text, followUp),
27        (message) => errors.push(message),
28        undefined,
29        (prefix) => completeReferences(prefix),
30        shellEnabled,
31      );
32      document.body.replaceChildren(view.element);
33      const input = view.element.querySelector("textarea")!;
34      return {
35        ...view,
36        input,
37        edit: (text: string) => fill(input, text),
38        key: (key: string) => void press(input, key),
39        leave() {
40          scope.dispose();
41          view.element.remove();
42        },
43      };
44    };
45    try {
46      await t.step(
47        "typed drafts are independent for conversations, branch points and forks",
48        async () => {
49          const routes = ["first", "second", "first@4", "first@5", "first/fork@4"];
50          for (const route of routes) {
51            const view = open(route);
52            assertEquals(view.input.value, "");
53            view.edit(`  ${route}\nunfinished  `);
54            view.leave();
55          }
56          for (const route of routes) {
57            const view = open(route);
58            assertEquals(view.input.value, `  ${route}\nunfinished  `);
59            view.leave();
60          }
61          await window.happyDOM.waitUntilComplete();
62        },
63      );
64      await t.step("a fresh frontend restores the draft from browser storage", async () => {
65        const view = open("reload");
66        view.setText("survives reload");
67        view.leave();
68        const fresh = await import(
69          new URL("./composer.tsx?reload", import.meta.url).href
70        ) as typeof import("./composer.tsx");
71        const scope = new Scope();
72        scopes.push(scope);
73        const restored = fresh.composer(
74          scope,
75          "reload",
76          new Signal(false),
77          commands,
78          () => Promise.resolve(),
79          () => {},
80        );
81        document.body.replaceChildren(restored.element);
82        assertEquals(restored.element.querySelector("textarea")!.value, "survives reload");
83        await window.happyDOM.waitUntilComplete();
84      });
85      await t.step(
86        "tree prefills and later edits survive returning to the destination",
87        async () => {
88          const source = open("source");
89          go({ tid: "prefilled", at: 8, fork: true, text: "original message" }, source.setText);
90          source.leave();
91          const destination = open("prefilled/fork@8");
92          assertEquals(destination.input.value, "original message");
93          destination.leave();
94          const returned = open("prefilled/fork@8");
95          assertEquals(returned.input.value, "original message");
96          returned.edit("edited message");
97          returned.leave();
98          assertEquals(open("prefilled/fork@8").input.value, "edited message");
99          await window.happyDOM.waitUntilComplete();
100        },
101      );
102      await t.step(
103        "references complete at the caret, browse directories and save the draft",
104        async () => {
105          const requested: string[] = [];
106          completeReferences = (prefix) => {
107            requested.push(prefix);
108            return Promise.resolve(prefix === "src/" ? ["src/main.ts", "src/mod.ts"] : ["src/"]);
109          };
110          const view = open("references");
111          const suggestions = view.element.querySelector<HTMLElement>(".suggestions")!;
112          view.edit("read @sr then explain");
113          view.input.setSelectionRange(8, 8);
114          view.input.click();
115          await until(() => !suggestions.hidden && suggestions.children.length === 1);
116          view.key("Tab");
117          assertEquals(view.input.value, "read @src/ then explain");
118          await until(() => suggestions.children.length === 2);
119          view.key("ArrowDown");
120          view.key("Enter");
121          assertEquals(view.input.value, "read @src/mod.ts then explain");
122          assertEquals(requested, ["sr", "src/"]);
123          view.leave();
124          assertEquals(open("references").input.value, "read @src/mod.ts then explain");
125          await window.happyDOM.waitUntilComplete();
126        },
127      );
128      await t.step("leaving a reference clears its suggestions immediately", async () => {
129        completeReferences = () => Promise.resolve(["src/main.ts"]);
130        commands.set([{ name: "example", extension: "test", description: "example" }]);
131        const view = open("leaving-reference");
132        const suggestions = view.element.querySelector<HTMLElement>(".suggestions")!;
133        view.edit("@src");
134        await until(() => !suggestions.hidden);
135        view.edit("@src ");
136        assertEquals(suggestions.hidden, true);
137        view.edit("/ex");
138        assertEquals(suggestions.querySelector(".command")?.textContent, "/example");
139        view.edit("@src");
140        assertEquals(suggestions.hidden, true);
141        await until(() => suggestions.textContent === "@src/main.ts");
142        view.key("Escape");
143        assertEquals(suggestions.hidden, true);
144        view.leave();
145        await window.happyDOM.waitUntilComplete();
146      });
147      await t.step("shell entries do not complete references and use shell text", async () => {
148        const requested: string[] = [];
149        completeReferences = (prefix) => {
150          requested.push(prefix);
151          return Promise.resolve(["src/main.ts"]);
152        };
153        const view = open("shell-references", new Signal(false), new Signal(true));
154        const suggestions = view.element.querySelector<HTMLElement>(".suggestions")!;
155        view.edit("!cat @sr");
156        assertEquals(view.input.classList.contains("shell"), true);
157        view.edit("!!cat @sr");
158        assertEquals(view.input.classList.contains("shell"), true);
159        // reference lookups are debounced; give a suppressed one time to fire
160        await new Promise((resolve) => setTimeout(resolve, 150));
161        assertEquals(requested, []);
162        assertEquals(suggestions.hidden, true);
163        view.edit("cat @sr");
164        assertEquals(view.input.classList.contains("shell"), false);
165        await until(() => !suggestions.hidden);
166        assertEquals(requested, ["sr"]);
167        view.edit("!cat @sr");
168        assertEquals(suggestions.hidden, true);
169        view.leave();
170        const returned = open("shell-references", new Signal(false), new Signal(true));
171        assertEquals(returned.input.value, "!cat @sr");
172        assertEquals(returned.input.classList.contains("shell"), true);
173        assertEquals(returned.element.querySelector<HTMLElement>(".suggestions")!.hidden, true);
174        returned.leave();
175        await window.happyDOM.waitUntilComplete();
176      });
177      await t.step("a leading bang without shell still completes references", async () => {
178        completeReferences = () => Promise.resolve(["src/main.ts"]);
179        const view = open("bang-message");
180        view.edit("!cat @sr");
181        assertEquals(view.input.classList.contains("shell"), false);
182        await until(() => !view.element.querySelector<HTMLElement>(".suggestions")!.hidden);
183        view.leave();
184        await window.happyDOM.waitUntilComplete();
185      });
186      await t.step("outdated reference results cannot revive dismissed suggestions", async () => {
187        const pending: ((paths: string[]) => void)[] = [];
188        const asked: string[] = [];
189        completeReferences = (prefix) => {
190          asked.push(prefix);
191          return new Promise((resolve) => pending.push(resolve));
192        };
193        const view = open("stale-references");
194        view.edit("@old");
195        await until(() => asked.length === 1);
196        view.edit("@new");
197        await until(() => asked.length === 2);
198        pending[0](["old.ts"]);
199        await Promise.resolve();
200        assertEquals(view.element.querySelector(".suggestions")!.children.length, 0);
201        view.key("Escape");
202        pending[1](["new.ts"]);
203        await Promise.resolve();
204        view.key("Tab");
205        assertEquals(view.input.value, "@new");
206        view.edit("@gone");
207        await until(() => asked.length === 3);
208        view.leave();
209        pending[2](["gone.ts"]);
210        await Promise.resolve();
211        assertEquals(view.element.querySelector(".suggestions")!.children.length, 0);
212        await window.happyDOM.waitUntilComplete();
213      });
214      await t.step("failed sends retain text and successful retries clear it", async () => {
215        send = () => Promise.reject(new Error("offline"));
216        const view = open("retry");
217        view.edit("  send me\nplease  ");
218        view.key("Enter");
219        await window.happyDOM.waitUntilComplete();
220        assertEquals(errors, ["offline"]);
221        view.leave();
222        const returned = open("retry");
223        assertEquals(returned.input.value, "  send me\nplease  ");
224        const sent: string[] = [];
225        send = (text) => {
226          sent.push(text);
227          return Promise.resolve();
228        };
229        returned.key("Enter");
230        await window.happyDOM.waitUntilComplete();
231        assertEquals(sent, ["send me\nplease"]);
232        assertEquals(returned.input.value, "");
233        returned.leave();
234        assertEquals(open("retry").input.value, "");
235        await window.happyDOM.waitUntilComplete();
236      });
237      await t.step(
238        "a pending send cannot erase text typed after sending or duplicate the send",
239        async () => {
240          let finish!: () => void;
241          let sends = 0;
242          send = () => {
243            sends++;
244            return new Promise<void>((resolve) => finish = resolve);
245          };
246          const view = open("pending");
247          view.edit("sent text");
248          view.key("Enter");
249          view.key("Enter");
250          view.edit("next message");
251          finish();
252          await window.happyDOM.waitUntilComplete();
253          assertEquals(sends, 1);
254          assertEquals(view.input.value, "next message");
255          view.leave();
256          assertEquals(open("pending").input.value, "next message");
257          await window.happyDOM.waitUntilComplete();
258        },
259      );
260      await t.step(
261        "on a touch screen, enter starts a new line and buttons send, steer and follow up",
262        async () => {
263          Object.defineProperty(window.navigator, "maxTouchPoints", {
264            configurable: true,
265            value: 5,
266          });
267          const sent: [string, boolean][] = [];
268          send = (text, followUp) => {
269            sent.push([text, followUp]);
270            return Promise.resolve();
271          };
272          const busy = new Signal(false);
273          const view = open("touch", busy);
274          const press = (label: string) =>
275            ([...view.element.querySelectorAll("button")] as HTMLButtonElement[])
276              .find((button) =>
277                !button.closest("[hidden]") && button.textContent?.trim() === label
278              )!
279              .click();
280          view.edit("first line");
281          view.key("Enter");
282          await window.happyDOM.waitUntilComplete();
283          assertEquals(sent, []);
284          assertEquals(view.input.value, "first line");
285          press("send");
286          await window.happyDOM.waitUntilComplete();
287          busy.set(true);
288          view.edit("later");
289          const more = view.element.querySelector<HTMLButtonElement>(
290            '[aria-label="Other send options"]',
291          )!;
292          const menu = document.getElementById(more.getAttribute("aria-controls")!)!;
293          assertEquals(more.getAttribute("aria-expanded"), "false");
294          view.input.focus();
295          more.click();
296          assertEquals(more.getAttribute("aria-expanded"), "true");
297          assertEquals(menu.hidden, false);
298          press("follow up");
299          await window.happyDOM.waitUntilComplete();
300          assertEquals(more.getAttribute("aria-expanded"), "false");
301          assertEquals(menu.hidden, true);
302          assertEquals(document.activeElement, view.input);
303          view.edit("now");
304          press("steer");
305          await window.happyDOM.waitUntilComplete();
306          assertEquals(sent, [["first line", false], ["later", true], ["now", false]]);
307          view.leave();
308          Reflect.deleteProperty(window.navigator, "maxTouchPoints");
309        },
310      );
311    } finally {
312      for (const scope of scopes) scope.dispose();
313    }
314  });
315});