char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somallow project reorderingb4fc6cf

main
38.3 KiB930 linesraw
1import { assert, assertEquals, assertStringIncludes } from "@std/assert";
2import { encodeBase64 } from "@std/encoding/base64";
3import { applyImmutable, type Op } from "@earendil-works/chord/delta";
4import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
5import { type ImageContent, type Message, Type } from "@earendil-works/pi-ai";
6import { createModels } from "@earendil-works/pi-ai/models";
7import {
8  fauxAssistantMessage,
9  fauxProvider,
10  fauxToolCall,
11} from "@earendil-works/pi-ai/providers/faux";
12import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
13import { InboxDoc, LiveDoc, MemoryStorage } from "@earendil-works/pi-durable";
14import { MAX_MESSAGE_BYTES } from "@berry/api/images.ts";
15import { PNG, tempDir, until } from "@berry/util";
16import { defineExtension } from "@berry/api";
17import { type Host, openBerry } from "./berry.ts";
18import { createHandler } from "./http.ts";
19import { extensionLoader } from "./loader.ts";
20import { extensionWeb } from "./web.ts";
21import type { ServerMessage } from "./pubsub.ts";
22import { blocking, gated } from "./fixture.ts";
23
24const TOKEN = "test-token";
25
26const dir = defineExtension<{ cwd: string }, { cwd: string }>({
27  name: "dir",
28  initial: () => ({ cwd: "/" }),
29  apply: (_, event) => ({ cwd: event.cwd }),
30  setup(ext) {
31    ext.configuration({
32      parameters: Type.Object({ cwd: Type.String({ default: "/" }) }),
33      values: ({ cwd }) => ({ cwd }),
34      events: ({ cwd }) => {
35        if (!cwd.startsWith("/")) throw new Error(`not absolute: ${cwd}`);
36        return [{ cwd }];
37      },
38    });
39    ext.tool({
40      name: "noop",
41      description: "nothing",
42      parameters: Type.Object({}),
43      execute: () => undefined,
44    });
45    ext.channel({
46      name: "echo",
47      parameters: Type.Object({ prefix: Type.String({ default: "" }) }),
48      open({ prefix }, channel, ctx) {
49        if (prefix === "refuse") throw new Error("not today");
50        void (async () => {
51          for await (const message of channel.messages) {
52            if (message === "bye") channel.close();
53            else if (message instanceof Uint8Array) channel.send(message.reverse());
54            else channel.send({ echo: message, prefix, cwd: ctx.state.cwd });
55          }
56        })();
57      },
58    });
59    ext.handle({
60      name: "where",
61      parameters: Type.Object({ under: Type.String({ default: "" }) }),
62      execute: ({ under }, ctx) => ctx.state.cwd + under,
63    });
64    return (conv) =>
65      conv.command({
66        name: "cd",
67        description: "change directory",
68        async execute(path, ctx) {
69          if (!path.startsWith("/")) throw new Error(`not absolute: ${path}`);
70          await ctx.emit({ cwd: path });
71          return path;
72        },
73      });
74  },
75});
76
77async function serve(host?: Host, supportsImages = true) {
78  const faux = fauxProvider(
79    supportsImages ? undefined : { models: [{ id: "text", input: ["text"] }] },
80  );
81  const credentials = new InMemoryCredentialStore();
82  const models = createModels({ credentials });
83  models.setProvider(faux.provider);
84  // a provider whose login asks for a key, as most do
85  models.setProvider({
86    ...faux.provider,
87    id: "keyed",
88    name: "Keyed",
89    auth: {
90      apiKey: {
91        name: "Keyed API key",
92        login: async (interaction) => {
93          const key = await interaction.prompt({ type: "secret", message: "your key?" });
94          interaction.notify({ type: "info", message: "thanks" });
95          return { type: "api_key", key };
96        },
97        resolve: ({ credential }) =>
98          Promise.resolve(
99            credential?.key
100              ? { auth: { apiKey: credential.key }, source: "stored credential" }
101              : undefined,
102          ),
103      },
104    },
105  });
106  const berry = await openBerry(new MemoryStorage(), models);
107  await berry.install(dir);
108  await berry.enableModels([{ provider: "faux", modelId: faux.getModel().id }]);
109  const web = extensionWeb();
110  const server = Deno.serve(
111    { port: 0, onListen() {} },
112    createHandler({
113      berry,
114      models,
115      credentials,
116      web,
117      loader: extensionLoader({ berry, web, root: await Deno.makeTempDir() }),
118      host,
119      token: TOKEN,
120    }),
121  );
122  const base = `http://127.0.0.1:${server.addr.port}`;
123  const api = async (method: string, path: string, body?: unknown) => {
124    const response = await fetch(base + path, {
125      method,
126      headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
127      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
128    });
129    return { status: response.status, json: await response.json() };
130  };
131  return {
132    berry,
133    faux,
134    base,
135    api,
136    model: { provider: "faux", modelId: faux.getModel().id },
137    async close() {
138      await server.shutdown();
139      await berry.close();
140    },
141    [Symbol.asyncDispose]() {
142      return this.close();
143    },
144  };
145}
146
147Deno.test("the model list refreshes extension catalogs before returning available models", async () => {
148  await using server = await serve();
149  const faux = fauxProvider();
150  let source = [{ ...faux.getModel(), provider: "dynamic", id: "first" }];
151  let catalog = source;
152  await server.berry.install(defineExtension({
153    name: "provider",
154    initial: () => null,
155    setup(ext) {
156      ext.provider({
157        ...faux.provider,
158        id: "dynamic",
159        getModels: () => catalog,
160        async refreshModels(ctx) {
161          if (ctx.allowNetwork) {
162            await ctx.publish({ update: () => void (catalog = source) });
163          }
164        },
165      });
166    },
167  }));
168  source = [{ ...source[0], id: "discovered" }];
169  const result = await server.api("GET", "/api/models");
170  assertEquals(result.status, 200);
171  assertEquals(
172    (result.json as { provider: string; id: string }[]).filter((model) =>
173      model.provider === "dynamic"
174    )
175      .map((model) => model.id),
176    ["discovered"],
177  );
178});
179
180Deno.test("settings accept partial prompt edits and reject invalid fields without saving them", async () => {
181  await using server = await serve();
182  const original = (await server.api("GET", "/api/settings")).json;
183  const saved = await server.api("PATCH", "/api/settings", {
184    systemPrompt: { userName: "Jordan", generalGuidelines: "" },
185  });
186  assertEquals(saved.status, 200);
187  assertEquals(saved.json.systemPrompt, {
188    ...original.systemPrompt,
189    userName: "Jordan",
190    generalGuidelines: "",
191    promptProse: {},
192  });
193  assertEquals(saved.json.models, original.models);
194  const prose = await server.api("PATCH", "/api/settings", {
195    systemPrompt: { promptProse: { code: "Keep it small.", other: "" } },
196  });
197  assertEquals(prose.status, 200);
198  assertEquals(prose.json.systemPrompt.promptProse, { code: "Keep it small.", other: "" });
199  assertEquals(prose.json.systemPrompt.userName, "Jordan");
200  const kept = await server.api("PATCH", "/api/settings", {
201    systemPrompt: { promptProse: { other: "Changed." } },
202  });
203  assertEquals(kept.json.systemPrompt.promptProse, { code: "Keep it small.", other: "Changed." });
204  const invalid = await server.api("PATCH", "/api/settings", {
205    systemPrompt: { promptProse: { code: ["not text"] } },
206  });
207  assertEquals(invalid.status, 400);
208  assertEquals((await server.api("GET", "/api/settings")).json, kept.json);
209});
210
211Deno.test("settings can disable and re-enable local without replacing other settings", async () => {
212  await using server = await serve();
213  const original = (await server.api("GET", "/api/settings")).json;
214  const saved = await server.api("PATCH", "/api/settings", { allowLocalEnvironment: false });
215  assertEquals(saved.status, 200);
216  assertEquals(saved.json, { ...original, allowLocalEnvironment: false });
217  const invalid = await server.api("PATCH", "/api/settings", { allowLocalEnvironment: "false" });
218  assertEquals(invalid.status, 400);
219  assertEquals((await server.api("GET", "/api/settings")).json.allowLocalEnvironment, false);
220  const restored = await server.api("PATCH", "/api/settings", { allowLocalEnvironment: true });
221  assertEquals(restored.json.allowLocalEnvironment, true);
222});
223
224/** Replays every topic from its snapshot and ops, as the frontend will. */
225async function subscriber(base: string) {
226  const socket = new WebSocket(base.replace("http", "ws") + "/ws");
227  const values = new Map<string, unknown>();
228  const errors: string[] = [];
229  let changed = () => {};
230  socket.onmessage = (event) => {
231    const message = JSON.parse(event.data) as ServerMessage;
232    if (message.t === "snapshot") values.set(message.topic, message.value);
233    if (message.t === "older") {
234      const value = values.get(message.topic) as { entries: unknown[] };
235      values.set(message.topic, { ...value, entries: [...message.entries, ...value.entries] });
236    }
237    if (message.t === "ops") {
238      values.set(message.topic, applyImmutable(values.get(message.topic), message.ops as Op[]));
239    }
240    if (message.t === "error") errors.push(message.message);
241    changed();
242  };
243  await new Promise((resolve) => (socket.onopen = resolve));
244  socket.send(JSON.stringify({ t: "auth", token: TOKEN }));
245  return {
246    values,
247    errors,
248    sub: (topic: string) => socket.send(JSON.stringify({ t: "sub", topic })),
249    async until<T>(topic: string, check: (value: T) => boolean): Promise<T> {
250      while (!(values.has(topic) && check(values.get(topic) as T))) {
251        await new Promise<void>((resolve, reject) => {
252          const timeout = setTimeout(
253            () => reject(new Error(`timed out waiting on ${topic}`)),
254            5000,
255          );
256          changed = () => (clearTimeout(timeout), resolve());
257        });
258      }
259      return values.get(topic) as T;
260    },
261    close: () => new Promise((resolve) => ((socket.onclose = resolve), socket.close())),
262  };
263}
264
265Deno.test("api requests need the bearer token and a valid body, which is refused by its failing path", async () => {
266  await using server = await serve();
267  const { api, base } = server;
268  assertEquals((await fetch(base + "/api/conversations")).status, 401);
269  const wrong = await fetch(base + "/api/conversations", {
270    headers: { authorization: "Bearer nope" },
271  });
272  assertEquals(wrong.status, 401);
273  await wrong.body?.cancel();
274  const response = await api("POST", "/api/conversations", { extensions: { dir: "nope" } });
275  assertEquals(response.status, 400);
276  assert(String(response.json.error).includes("/extensions/dir"));
277  assertEquals(
278    (await api("POST", "/api/conversations", { extensions: { missing: {} } })).status,
279    400,
280  );
281});
282
283Deno.test("restart refuses code that fails its check, and restarts after responding when it passes", async () => {
284  let failures: string | null = "error: TS2304 nope";
285  let restarts = 0;
286  const restarted = Promise.withResolvers<void>();
287  await using server = await serve({
288    root: "/berry",
289    check: () => Promise.resolve(failures),
290    restart: () => {
291      restarts++;
292      restarted.resolve();
293    },
294    extensions: () => [],
295    reload: () => Promise.reject(new Error("no extensions")),
296  });
297  const { api } = server;
298  assertEquals(await api("POST", "/api/restart"), {
299    status: 400,
300    json: { error: failures },
301  });
302  assertEquals(restarts, 0);
303  failures = null;
304  assertEquals(await api("POST", "/api/restart"), { status: 200, json: {} });
305  await restarted.promise;
306  assertEquals(restarts, 1);
307});
308
309Deno.test("topics replay to the server's state over the websocket", async () => {
310  await using server = await serve();
311  const { api, base, faux, model } = server;
312  const client = await subscriber(base);
313
314  client.sub("conversations");
315  const { json: { tid } } = await api("POST", "/api/conversations", {
316    extensions: { dir: {} },
317    model,
318  });
319  await client.until<Record<string, unknown>>("conversations", (index) => tid in index);
320
321  client.sub(`conversation:${tid}`);
322  client.sub(`state:${tid}:dir`);
323  await client.until(`conversation:${tid}`, () => true);
324  assertEquals(await client.until(`state:${tid}:dir`, () => true), { cwd: "/" });
325
326  faux.setResponses([fauxAssistantMessage("hello there")]);
327  await api("POST", `/api/conversations/${tid}/messages`, { content: "hi" });
328  type View = {
329    entries: { kind: string; model?: { content: { type: string; text?: string }[] }[] }[];
330  };
331  const view = await client.until<View>(
332    `conversation:${tid}`,
333    (view) => view.entries.some((entry) => entry.kind === "pi.assistant"),
334  );
335  const answer = view.entries.find((entry) => entry.kind === "pi.assistant")!.model![0].content[0];
336  assertEquals(answer.text, "hello there");
337
338  await api("POST", `/api/conversations/${tid}/events`, {
339    ext: "dir",
340    event: { cwd: "/elsewhere" },
341  });
342  await client.until<{ cwd: string }>(`state:${tid}:dir`, (state) => state.cwd === "/elsewhere");
343
344  client.sub("nonsense");
345  await client.until("conversations", () => client.errors.length > 0);
346  assertEquals(client.errors, ["no topic nonsense"]);
347
348  await client.close();
349});
350
351Deno.test("a conversation's configuration, commands, and handlers are served over http", async () => {
352  await using server = await serve();
353  const { base, api, model } = server;
354  const client = await subscriber(base);
355  try {
356    const { tid } =
357      (await api("POST", "/api/conversations", { extensions: { dir: {} }, model })).json;
358    const conversation = `/api/conversations/${tid}`;
359    const topic = `configuration:${tid}:dir`;
360    const until = (cwd: string) =>
361      client.until<{ cwd: string }>(topic, (values) => values.cwd === cwd);
362    client.sub(topic);
363    await until("/");
364    const configuration = `${conversation}/configuration/dir`;
365    assertEquals(await api("PATCH", configuration, { cwd: "/edited" }), {
366      status: 200,
367      json: { cwd: "/edited" },
368    });
369    await until("/edited");
370    assertEquals((await api("PATCH", configuration, { cwd: 5 })).status, 400);
371    assertEquals(await api("PATCH", configuration, { cwd: "relative" }), {
372      status: 400,
373      json: { error: "not absolute: relative" },
374    });
375    assertEquals((await api("PATCH", configuration, { hidden: true })).status, 400);
376
377    assertEquals((await api("GET", `${conversation}/commands`)).json, [
378      { name: "cd", extension: "dir", description: "change directory" },
379    ]);
380    assertEquals(await api("POST", `${conversation}/commands/cd`, { args: "/x" }), {
381      status: 200,
382      json: { result: "/x" },
383    });
384    await until("/x");
385    assertEquals(await api("POST", `${conversation}/commands/cd`, { args: "x" }), {
386      status: 400,
387      json: { error: "not absolute: x" },
388    });
389    assertEquals((await api("POST", `${conversation}/commands/nope`, {})).status, 400);
390
391    const where = (body: unknown) => api("POST", `${conversation}/extensions/dir/where`, body);
392    assertEquals(await where({}), { status: 200, json: { result: "/x" } });
393    assertEquals(await where({ args: { under: "/y" } }), { status: 200, json: { result: "/x/y" } });
394    assertEquals(await where({ args: { under: 1 } }), {
395      status: 400,
396      json: { error: "under: must be string" },
397    });
398    assertEquals((await api("POST", `${conversation}/extensions/dir/nope`, {})).status, 400);
399    // a long answer is gzipped for a client that accepts it
400    const under = "/y".repeat(5000);
401    const fetchWhere = (encoding: string) =>
402      fetch(`${base}${conversation}/extensions/dir/where`, {
403        method: "POST",
404        headers: {
405          authorization: `Bearer ${TOKEN}`,
406          "content-type": "application/json",
407          "accept-encoding": encoding,
408        },
409        body: JSON.stringify({ args: { under } }),
410      });
411    const gzipped = await fetchWhere("gzip, br");
412    assertEquals(gzipped.headers.get("content-encoding"), "gzip");
413    assertEquals(await gzipped.json(), { result: `/x${under}` });
414    const plain = await fetchWhere("identity");
415    assertEquals(plain.headers.get("content-encoding"), null);
416    assertEquals(await plain.json(), { result: `/x${under}` });
417
418    await api("PATCH", `${conversation}/agent`, { extensions: [] });
419    assertEquals((await api("PATCH", configuration, { cwd: "/disabled" })).status, 400);
420  } finally {
421    await client.close();
422  }
423});
424
425Deno.test("an extension's channels carry json and bytes both ways once the first message authorizes them", async () => {
426  await using server = await serve();
427  const { base, api, model } = server;
428  const { json: { tid } } = await api("POST", "/api/conversations", {
429    extensions: { dir: { cwd: "/x" } },
430    model,
431  });
432  const open = (name: string, first: unknown) => {
433    const socket = new WebSocket(
434      `${base.replace("http", "ws")}/api/conversations/${tid}/extensions/dir/${name}`,
435    );
436    socket.binaryType = "arraybuffer";
437    const received: unknown[] = [];
438    const closed = new Promise<{ code: number; reason: string }>((resolve) =>
439      socket.onclose = ({ code, reason }) => resolve({ code, reason })
440    );
441    socket.onmessage = ({ data }) =>
442      received.push(typeof data === "string" ? JSON.parse(data) : [...new Uint8Array(data)]);
443    socket.onopen = () => socket.send(JSON.stringify(first));
444    return { socket, received, closed };
445  };
446
447  const echo = open("echo", { token: TOKEN, args: { prefix: ">" } });
448  await new Promise((resolve) => echo.socket.addEventListener("open", resolve));
449  echo.socket.send(JSON.stringify({ hello: 1 }));
450  echo.socket.send(new Uint8Array([1, 2, 3]));
451  echo.socket.send(JSON.stringify("bye"));
452  assertEquals(await echo.closed, { code: 1000, reason: "" });
453  assertEquals(echo.received, [{ echo: { hello: 1 }, prefix: ">", cwd: "/x" }, [3, 2, 1]]);
454
455  assertEquals((await open("echo", { token: "wrong" }).closed).code, 4001);
456  assertEquals(await open("echo", { token: TOKEN, args: { prefix: "refuse" } }).closed, {
457    code: 4400,
458    reason: "not today",
459  });
460  assertEquals(
461    (await open("nope", { token: TOKEN }).closed).reason,
462    "dir has no channel named nope",
463  );
464  assertEquals((await open("echo", { token: TOKEN, args: { prefix: 1 } }).closed).code, 4400);
465});
466
467Deno.test("a conversation forks at an entry or at its newest, and branches in place with its topic following", async () => {
468  await using server = await serve();
469  const { api, base, faux, model } = server;
470  const client = await subscriber(base);
471  try {
472    const { json: { tid } } = await api("POST", "/api/conversations", { extensions: {}, model });
473    type View = { entries: { id: number; kind: string }[] };
474    const topic = `conversation:${tid}`;
475    const has = (kind: string) => (view: View) => view.entries.some((entry) => entry.kind === kind);
476    client.sub(topic);
477    faux.setResponses([fauxAssistantMessage("hi")]);
478    await api("POST", `/api/conversations/${tid}/messages`, { content: "hello" });
479    const { entries } = await client.until<View>(topic, has("pi.assistant"));
480    const [start, user] = entries;
481
482    const { json: { tid: copy } } = await api("POST", `/api/conversations/${tid}/fork`, {});
483    const { json: { tid: edit } } = await api("POST", `/api/conversations/${tid}/fork`, {
484      entryId: start.id,
485    });
486    const index = (await api("GET", "/api/conversations")).json;
487    assertEquals(index[copy].parent, { tid, at: entries.at(-1)!.id });
488    assertEquals(index[edit].parent, { tid, at: start.id });
489    const forked = (await api("GET", `/api/conversations/${edit}/tree`)).json;
490    assertEquals(forked.branches, [{
491      id: index[edit].id,
492      created: index[edit].created,
493      tip: start.id,
494    }]);
495    // what a fork at an entry starts with: the entries through it
496    const context = await api("GET", `/api/conversations/${tid}/context?at=${user.id}`);
497    assertEquals(context.json.entries.map((entry: { id: number }) => entry.id), [
498      start.id,
499      user.id,
500    ]);
501    assertEquals((await api("GET", `/api/conversations/${tid}/context`)).status, 400);
502    // the edit's history ends before the first message
503    assertEquals(await api("GET", `/api/conversations/${edit}/context?at=${user.id}`), {
504      status: 400,
505      json: { error: `no entry ${user.id} in ${edit}` },
506    });
507    assertEquals(
508      (await api("POST", `/api/conversations/${edit}/fork`, { entryId: user.id })).status,
509      400,
510    );
511
512    // before the first message, then back to where it was
513    assertEquals(await api("POST", `/api/conversations/${tid}/branch`, { entryId: start.id }), {
514      status: 200,
515      json: {},
516    });
517    await client.until<View>(topic, (view) => !has("pi.user")(view));
518    const tree = (await api("GET", `/api/conversations/${tid}/tree`)).json;
519    assertEquals(tree.branches.length, 2);
520    assertEquals(tree.head, tree.branches[1].id);
521    await api("POST", `/api/conversations/${tid}/branch`, { entryId: entries.at(-1)!.id });
522    await client.until<View>(topic, has("pi.assistant"));
523    assertEquals(
524      (await api("GET", `/api/conversations/${tid}/tree`)).json.head,
525      tree.branches[0].id,
526    );
527    assertEquals(
528      (await api("POST", `/api/conversations/${tid}/branch`, { entryId: 1e9 })).status,
529      400,
530    );
531  } finally {
532    await client.close();
533  }
534});
535
536Deno.test("logins ask through their topic and are answered over http", async () => {
537  await using server = await serve();
538  const { api, base } = server;
539  const client = await subscriber(base);
540  type Provider = {
541    id: string;
542    apiKey: { name: string } | null;
543    auth: { type: string } | null;
544    stored: boolean;
545  };
546  const keyed = async () =>
547    ((await api("GET", "/api/providers")).json as Provider[]).find((provider) =>
548      provider.id === "keyed"
549    )!;
550  assertEquals((await keyed()).stored, false);
551
552  type Login = {
553    status: string;
554    prompt: { id: number; type: string; message: string } | null;
555    events: { message?: string }[];
556  };
557  const { json: { login } } = await api("POST", "/api/providers/keyed/login", { type: "api_key" });
558  client.sub(`login:${login}`);
559  const asked = await client.until<Login>(`login:${login}`, (state) => state.prompt !== null);
560  assertEquals(asked.prompt, { id: asked.prompt!.id, type: "secret", message: "your key?" });
561  await api("POST", `/api/logins/${login}/answer`, { prompt: asked.prompt!.id, value: "sekrit" });
562  const done = await client.until<Login>(`login:${login}`, (state) => state.status === "done");
563  assertEquals(done.events.map((event) => event.message), ["thanks"]);
564  assertEquals((await keyed()).stored, true);
565  assertEquals((await keyed()).auth?.type, "api_key");
566  // the question is gone once answered
567  assertEquals(
568    (await api("POST", `/api/logins/${login}/answer`, { prompt: asked.prompt!.id, value: "x" }))
569      .status,
570    400,
571  );
572
573  await api("POST", "/api/providers/keyed/logout");
574  assertEquals((await keyed()).stored, false);
575  assertEquals((await keyed()).auth, null);
576
577  const { json: { login: again } } = await api("POST", "/api/providers/keyed/login", {
578    type: "api_key",
579  });
580  client.sub(`login:${again}`);
581  await client.until<Login>(`login:${again}`, (state) => state.prompt !== null);
582  await api("POST", `/api/logins/${again}/cancel`);
583  const cancelled = await client.until<Login>(
584    `login:${again}`,
585    (state) => state.status !== "running",
586  );
587  assertEquals([cancelled.status, cancelled.prompt], ["cancelled", null]);
588  assertEquals((await keyed()).stored, false);
589
590  assertEquals((await api("POST", "/api/providers/keyed/login", { type: "oauth" })).status, 400);
591  await client.close();
592});
593
594Deno.test("a conversation's read receipt, name, and archive state change independently over http", async () => {
595  await using server = await serve();
596  const { api, berry, faux, model } = server;
597  const { json: { tid } } = await api("POST", "/api/conversations", { extensions: {}, model });
598  const path = `/api/conversations/${tid}`;
599  const info = async () => (await api("GET", "/api/conversations")).json[tid];
600  faux.setResponses([fauxAssistantMessage("done")]);
601  await api("POST", `${path}/messages`, { content: "go" });
602  const current = (await berry.conversation(tid))!;
603  await current.waitForIdle(context);
604  const { entries } = await current.context(context);
605  const entryId = entries.findLast((entry) => entry.kind === "pi.assistant")!.id;
606  const read = (target: string, entryId: number) =>
607    api("POST", `/api/conversations/${target}/read`, { entryId });
608  assertEquals((await read(tid, entryId)).status, 200);
609  assertEquals((await info()).readThrough, entryId);
610  assertEquals((await read(tid, 1.5)).status, 400);
611  const { json: { tid: other } } = await api("POST", "/api/conversations", { extensions: {} });
612  assertEquals((await read(other, entryId)).status, 400);
613  assertEquals((await read("aaaaaaaaaaaaa", entryId)).status, 404);
614
615  assertEquals((await api("PATCH", path, { title: "  planning 🦊  " })).status, 200);
616  assertEquals((await info()).title, "planning 🦊");
617  await api("PATCH", path, { archived: true });
618  const archived = (await info()).archived;
619  assertEquals(typeof archived, "number");
620  await api("PATCH", path, { title: "another name" });
621  const renamed = await info();
622  assertEquals([renamed.title, renamed.archived, renamed.readThrough], [
623    "another name",
624    archived,
625    entryId,
626  ]);
627  await api("PATCH", path, { title: "   ", archived: false });
628  assertEquals([(await info()).title, (await info()).archived], [undefined, undefined]);
629  assertEquals((await api("PATCH", path, { title: 5 })).status, 400);
630  assertEquals(
631    (await api("PATCH", "/api/conversations/aaaaaaaaaaaaa", { archived: true })).status,
632    404,
633  );
634});
635
636Deno.test("an image message reaches the model and its history whole, and invalid ones are refused", async () => {
637  await using server = await serve();
638  const { api, berry, faux, model } = server;
639  const image: ImageContent = { type: "image", mimeType: "image/png", data: PNG };
640  const seen: Message[][] = [];
641  faux.setResponses([(request) => {
642    seen.push([...request.messages]);
643    return fauxAssistantMessage("I see the image");
644  }]);
645  const { json: { tid } } = await api("POST", "/api/conversations", { extensions: {}, model });
646  const messages = `/api/conversations/${tid}/messages`;
647  const refused = async (content: unknown) => (await api("POST", messages, { content })).status;
648  assertEquals(await refused([{ ...image, data: "not base64!" }]), 400);
649  assertEquals(await refused([{ ...image, mimeType: "image/svg+xml" }]), 400);
650  assertEquals(await refused(Array.from({ length: 5 }, () => image)), 400);
651  const large = encodeBase64(new Uint8Array(5 * 1024 * 1024 + 1));
652  assertEquals(await refused([{ ...image, data: large }]), 413);
653  assertEquals(await refused("x".repeat(31 * 1024 * 1024)), 413);
654
655  const content = [{ type: "text" as const, text: "What is this?" }, image, image];
656  await api("POST", messages, { content });
657  const current = (await berry.conversation(tid))!;
658  await current.waitForIdle(context);
659  const users = (messages: readonly Message[]) =>
660    messages.filter((message) => message.role === "user").map((message) => message.content);
661  assertEquals(users(seen.at(-1)!), [content]);
662  assertEquals(users((await current.context(context)).messages), [content]);
663  const { json: tree } = await api("GET", `/api/conversations/${tid}/tree`);
664  const user = tree.entries.find((entry: { kind: string }) => entry.kind === "pi.user");
665  assertEquals([user.text, user.imageCount], ["What is this?", 2]);
666
667  await using textOnly = await serve(undefined, false);
668  const { json: { tid: text } } = await textOnly.api("POST", "/api/conversations", {
669    extensions: {},
670    model: textOnly.model,
671  });
672  const response = await textOnly.api("POST", `/api/conversations/${text}/messages`, {
673    content: [image],
674  });
675  assertEquals(response.status, 400);
676  assertStringIncludes(response.json.error, "supports image input");
677});
678
679Deno.test("abort returns edited queued inputs with attachments, not the input already in the transcript", async () => {
680  await using server = await serve();
681  const { api, berry, faux, model } = server;
682  const blocker = blocking();
683  await berry.install(blocker.extension);
684  faux.setResponses([
685    fauxAssistantMessage(fauxToolCall("block", {}), { stopReason: "toolUse" }),
686  ]);
687  const tid = await berry.create({ extensions: { blocking: {} }, model });
688  const current = (await berry.conversation(tid))!;
689  const base = `/api/conversations/${tid}`;
690  try {
691    await current.submit({ type: "input", content: "already placed" }, context);
692    await until(() => blocker.running > 0);
693    const image: ImageContent = { type: "image", mimeType: "image/png", data: PNG };
694    const first = await api("POST", `${base}/messages`, {
695      content: [{ type: "text", text: "old" }, image],
696      whenBusy: "steer",
697    });
698    await api("PATCH", `${base}/inbox/${first.json.submission}`, { text: "latest" });
699    await api("POST", `${base}/messages`, { content: "later", whenBusy: "followUp" });
700    const queued = (await berry.harness.snapshot(InboxDoc, current.id, context))!;
701    const response = await api("POST", `${base}/abort`);
702    assertEquals(response, { status: 200, json: { inputs: queued.items } });
703    assertEquals((await berry.harness.snapshot(InboxDoc, current.id, context))!.items, []);
704    assertEquals((await api("POST", `${base}/abort`)).json, { inputs: [] });
705  } finally {
706    blocker.release.resolve("released");
707  }
708});
709
710Deno.test("queued input edits keep identity, mode, order, attachments, and original receipts", async () => {
711  const whenBusy = "followUp";
712  await using server = await serve();
713  const { api, berry, faux, model } = server;
714  const { gate, extension } = gated();
715  await berry.install(extension);
716  const image: ImageContent = { type: "image", mimeType: "image/png", data: PNG };
717  const seen: Message[][] = [];
718  const record = (request: { messages: readonly Message[] }) => {
719    seen.push([...request.messages]);
720    return fauxAssistantMessage("done");
721  };
722  try {
723    const tid = await berry.create({ extensions: { gate: {} }, model });
724    const current = (await berry.conversation(tid))!;
725    const base = `/api/conversations/${tid}`;
726    faux.setResponses([
727      fauxAssistantMessage(fauxToolCall("hold", {}), { stopReason: "toolUse" }),
728      record,
729      record,
730      record,
731    ]);
732    const active = await current.submit({ type: "input", content: "wait" }, context);
733    await gate.entered.promise;
734    const contents = [
735      [{ type: "text", text: "old" }, image, { type: "text", text: "also old" }, image],
736      "retract me",
737      "old tail",
738    ];
739    const receipts = [];
740    for (const [index, content] of contents.entries()) {
741      const response = await api("POST", `${base}/messages`, {
742        content,
743        whenBusy,
744        requestId: `queued-${index}`,
745      });
746      assertEquals(response.status, 200);
747      receipts.push((await berry.harness.submission(response.json.submission, context))!);
748    }
749    const before = await berry.harness.snapshot(InboxDoc, current.id, context);
750    const live = await berry.harness.snapshot(LiveDoc, current.id, context);
751    const original = await Promise.all(receipts.map((receipt) => receipt.status(context)));
752    assertEquals(
753      before!.items.map(({ id, mode }) => ({ id, mode })),
754      receipts.map(({ id }) => ({ id, mode: whenBusy })),
755    );
756    const withdrawn = receipts[1].wait(context);
757    assertEquals(await api("PATCH", `${base}/inbox/${receipts[0].id}`, { text: "edited" }), {
758      status: 200,
759      json: {},
760    });
761    assertEquals(
762      (await api("PATCH", `${base}/inbox/${receipts[2].id}`, {
763        text: "edited tail",
764      })).status,
765      200,
766    );
767    assertEquals(await api("DELETE", `${base}/inbox/${receipts[1].id}`), {
768      status: 200,
769      json: {},
770    });
771    const edited = [{ type: "text" as const, text: "edited" }, image, image];
772    assert(before!.items[0].mode !== "write" && before!.items[2].mode !== "write");
773    assertEquals(await berry.harness.snapshot(InboxDoc, current.id, context), {
774      items: [
775        { ...before!.items[0], content: edited },
776        { ...before!.items[2], content: "edited tail" },
777      ],
778    });
779    assert(original[1].status === "queued");
780    assertEquals(await withdrawn, { ...original[1], status: "unanswered", reason: "aborted" });
781    assertEquals(await receipts[0].status(context), original[0]);
782    assertEquals(await receipts[2].status(context), original[2]);
783    assertEquals(await berry.harness.snapshot(LiveDoc, current.id, context), live);
784    assertEquals((await active.status(context)).status, "placed");
785    const duplicate = await api("POST", `${base}/messages`, {
786      content: "must not replace the edit",
787      requestId: "queued-0",
788    });
789    assertEquals(duplicate.json.submission, receipts[0].id);
790    gate.release.resolve("released");
791    await current.waitForIdle(context);
792    assertEquals(
793      seen.at(-1)!.filter((message) => message.role === "user").map((message) => message.content),
794      ["wait", edited, "edited tail"],
795    );
796    assertEquals((await active.wait(context)).status, "done");
797    for (const receipt of [receipts[0], receipts[2]]) {
798      const settled = await receipt.wait(context);
799      assertEquals(settled.id, receipt.id);
800      assertEquals(settled.status, "done");
801    }
802    const late = `${base}/inbox/${receipts[0].id}`;
803    assertEquals((await api("PATCH", late, { text: "late" })).status, 409);
804    assertEquals((await api("DELETE", late)).status, 409);
805  } finally {
806    gate.release.resolve("released");
807  }
808});
809
810Deno.test("queued input routes refuse invalid edits and other conversations' inputs and writes, and bound image edits", async () => {
811  await using server = await serve();
812  const { api, berry, faux, model } = server;
813  const { gate, extension } = gated();
814  await berry.install(extension);
815  const image: ImageContent = { type: "image", mimeType: "image/png", data: PNG };
816  try {
817    const tid = await berry.create({ extensions: { gate: {}, dir: {} }, model });
818    const other = await berry.create({ extensions: {}, model });
819    const current = (await berry.conversation(tid))!;
820    const base = `/api/conversations/${tid}/inbox`;
821    faux.setResponses([
822      fauxAssistantMessage(fauxToolCall("hold", {}), { stopReason: "toolUse" }),
823      fauxAssistantMessage("done"),
824      fauxAssistantMessage("done"),
825    ]);
826    const active = await current.submit({ type: "input", content: "wait" }, context);
827    await gate.entered.promise;
828    const queued = await current.submit({ type: "input", content: "original" }, context);
829    const pictured = await current.submit({ type: "input", content: [image] }, context);
830    const write = await berry.emit(tid, "dir", { cwd: "/kept" });
831    const before = await berry.harness.snapshot(InboxDoc, current.id, context);
832    const change = (path: string, text = "change") => api("PATCH", path, { text });
833    assertEquals((await change(`${base}/1junk`)).status, 400);
834    assertEquals((await change(`${base}/${queued.id}`, " \n\t")).status, 400);
835    assertEquals((await change(`${base}/${write.id}`)).status, 404);
836    assertEquals((await change(`/api/conversations/${other}/inbox/${queued.id}`)).status, 404);
837    assertEquals((await change(`${base}/${active.id}`)).status, 409);
838    // two bytes a character, so only the image takes it over the limit
839    const text = "é".repeat((MAX_MESSAGE_BYTES - 100) / 2);
840    assertEquals((await change(`${base}/${pictured.id}`, text)).status, 413);
841    assertEquals(await berry.harness.snapshot(InboxDoc, current.id, context), before);
842    // an image needs no text
843    assertEquals((await change(`${base}/${pictured.id}`, "")).status, 200);
844    const [, picture] = (await berry.harness.snapshot(InboxDoc, current.id, context))!.items;
845    assertEquals(picture, { id: pictured.id, mode: "followUp", content: [image] });
846
847    gate.release.resolve("released");
848    await current.waitForIdle(context);
849    const start = (await current.context(context)).entries[0].id;
850    assertEquals(
851      (await api("POST", `/api/conversations/${tid}/branch`, { entryId: start })).status,
852      200,
853    );
854    assertEquals((await change(`${base}/${queued.id}`)).status, 404);
855  } finally {
856    gate.release.resolve("released");
857  }
858});
859
860Deno.test("references and the shell follow the conversation's directory while code is selected", async () => {
861  const { default: code } = await import("../extensions/code/mod.ts");
862  await using rootDir = await tempDir();
863  const root = rootDir.path;
864  await using s = await serve();
865  await s.berry.install(code);
866  await Deno.mkdir(`${root}/other`);
867  await Deno.writeTextFile(`${root}/first.ts`, "");
868  await Deno.writeTextFile(`${root}/other/second.ts`, "");
869  // before a conversation works there, as a new one's composer asks
870  assertEquals(
871    await s.api("GET", `/api/references?cwd=${encodeURIComponent(root)}&prefix=f`),
872    { status: 200, json: ["first.ts"] },
873  );
874  const created = await s.api("POST", "/api/conversations", {
875    extensions: { environment: { cwd: root }, code: {} },
876    model: s.model,
877  });
878  const tid = created.json.tid;
879  const base = `/api/conversations/${tid}`;
880  const conv = (await s.berry.conversation(tid))!;
881  const entries = await conv.entries({ order: "descending" }, 1, undefined, context);
882  const at = entries.items[0].id;
883  assertEquals(await s.api("GET", `${base}/references?prefix=f`), {
884    status: 200,
885    json: ["first.ts"],
886  });
887  await s.api("PATCH", `${base}/configuration/environment`, { cwd: `${root}/other` });
888  assertEquals(await s.api("GET", `${base}/references?prefix=s`), {
889    status: 200,
890    json: ["second.ts"],
891  });
892  assertEquals(await s.api("GET", `${base}/references?prefix=f&at=${at}`), {
893    status: 200,
894    json: ["first.ts"],
895  });
896  const { json: { task } } = await s.api("POST", `${base}/shell`, { command: "touch made" });
897  await s.berry.harness.waitForTask(task, context);
898  await Deno.stat(`${root}/other/made`);
899
900  await s.api("PATCH", `${base}/agent`, { extensions: [] });
901  assertEquals(await s.api("GET", `${base}/references`), { status: 200, json: [] });
902});
903
904Deno.test("projects publish their edits, and folders are suggested over http", async () => {
905  await using rootDir = await tempDir();
906  const root = rootDir.path;
907  await using server = await serve();
908  const { api, base } = server;
909  await Deno.mkdir(`${root}/workspace`);
910  const socket = await subscriber(base);
911  try {
912    const folders = `/api/folders?cwd=${encodeURIComponent(root)}&prefix=work`;
913    assertEquals(await api("GET", folders), { status: 200, json: ["workspace/"] });
914    socket.sub("projects");
915    const { json: { id } } = await api("POST", "/api/projects", {
916      name: "Berry",
917      defaultRoot: 0,
918      roots: [{ environment: "local", cwd: root }],
919    });
920    type Index = Record<string, { name: string }>;
921    await socket.until<Index>("projects", (value) => value[id]?.name === "Berry");
922    socket.sub("project-order");
923    await api("PUT", "/api/project-order", { ids: ["", id] });
924    await socket.until<{ ids: string[] }>("project-order", (value) => value.ids[0] === "");
925    await api("DELETE", `/api/projects/${id}`);
926    await socket.until<Index>("projects", (value) => !(id in value));
927  } finally {
928    await socket.close();
929  }
930});