char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd background subagents (!!!)2d39747

main
10.3 KiB252 linesraw
1import { assert, assertEquals, assertObjectMatch } from "@std/assert";
2import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
3import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
4import type { Message } from "@earendil-works/pi-ai";
5import { type Berry, Conversations } from "./berry.ts";
6import { blocking, setup } from "./fixture.ts";
7import { summaries, type Summary } from "./summaries.ts";
8import { SettledDoc } from "./settled.ts";
9import { until } from "@berry/util";
10
11/** A conversation's summary, as a fresh feed first sees it. */
12async function summary(berry: Berry, tid: string): Promise<Summary> {
13  const watched = await summaries(berry);
14  try {
15    return (watched.feed().value as Record<string, Summary>)[tid];
16  } finally {
17    watched.stop();
18  }
19}
20
21Deno.test("a summary says whether a conversation is answering, how it began, and when it was last active", async () => {
22  await using harness = await setup();
23  const { faux, berry, model, gate } = harness;
24  const blocker = blocking();
25  await berry.install(blocker.extension);
26  const tid = await berry.create({ extensions: { gate: {}, blocking: {} }, model });
27  const conversation = (await berry.conversation(tid))!;
28  const created = (await berry.harness.snapshot(Conversations, context))![tid].created;
29  assertObjectMatch(await summary(berry, tid), {
30    busy: false,
31    preview: "",
32    reply: "",
33    unread: false,
34    updated: created,
35  });
36
37  // a queued follow-up keeps it working until the final answer
38  const followUp = {
39    entered: Promise.withResolvers<void>(),
40    release: Promise.withResolvers<void>(),
41  };
42  faux.setResponses([
43    fauxAssistantMessage(fauxToolCall("hold", {}), { stopReason: "toolUse" }),
44    fauxAssistantMessage("first done"),
45    async () => {
46      followUp.entered.resolve();
47      await followUp.release.promise;
48      return fauxAssistantMessage("follow-up done");
49    },
50  ]);
51  await until(() => Date.now() > created);
52  const submitted = Date.now();
53  await conversation.submit({ type: "input", content: "hello\n  there" }, context);
54  await gate.entered.promise;
55  await conversation.submit({ type: "input", content: "next", whenBusy: "followUp" }, context);
56  const sending = await summary(berry, tid);
57  assertObjectMatch(sending, { busy: true, preview: "hello there", unread: false, reply: "" });
58  assert(sending.updated >= submitted);
59  gate.release.resolve("released");
60  await followUp.entered.promise;
61  const queued = await summary(berry, tid);
62  assertObjectMatch(queued, { busy: true, unread: false, reply: "first done" });
63  assert(queued.updated >= sending.updated);
64  let returned = Date.now();
65  followUp.release.resolve();
66  await conversation.waitForIdle(context);
67  let current = await summary(berry, tid);
68  assertObjectMatch(current, {
69    busy: false,
70    preview: "hello there",
71    unread: true,
72    reply: "follow-up done",
73  });
74  assert(current.updated >= returned);
75
76  // interrupted tools return control without a new answer
77  faux.setResponses([fauxAssistantMessage(fauxToolCall("block", {}), { stopReason: "toolUse" })]);
78  await until(() => Date.now() > current.updated);
79  const resubmitted = Date.now();
80  await conversation.submit({ type: "input", content: "again" }, context);
81  await until(() => blocker.running > 0);
82  const interrupting = await summary(berry, tid);
83  assert(interrupting.updated >= resubmitted);
84  assert(interrupting.updated > current.updated);
85  await until(() => Date.now() > interrupting.updated);
86  returned = Date.now();
87  await conversation.abort(context);
88  current = await summary(berry, tid);
89  assertObjectMatch(current, { busy: false });
90  assert(current.updated >= returned);
91
92  // as does a failure before generation
93  await berry.configure(tid, { model: null });
94  await until(() => Date.now() > current.updated);
95  returned = Date.now();
96  const failed = await (await conversation.submit({ type: "input", content: "go" }, context))
97    .wait(context);
98  assertEquals(failed.status, "unanswered");
99  await conversation.waitForIdle(context);
100  assert((await summary(berry, tid)).updated >= returned);
101});
102
103Deno.test("unread answers follow read receipts across feeds, forks, branches, and resets", async () => {
104  await using harness = await setup();
105  const { faux, berry, model } = harness;
106  const tid = await berry.create({ extensions: {}, model });
107  const watched = await summaries(berry);
108  const feeds = [watched.feed(), watched.feed()];
109  const seen = feeds.map((feed) => feed.value as Record<string, Summary>);
110  feeds.forEach((feed, i) =>
111    feed.start((value) => {
112      seen[i] = value as Record<string, Summary>;
113      return Promise.resolve();
114    })
115  );
116  const unread = (expected: boolean) =>
117    until(() => seen.every((value) => value[tid]?.unread === expected));
118  let conversation = (await berry.conversation(tid))!;
119  const answer = async () => {
120    faux.setResponses([fauxAssistantMessage("done")]);
121    await conversation.submit({ type: "input", content: "go" }, context);
122    await conversation.waitForIdle(context);
123    await unread(true);
124    return (await conversation.context(context)).entries.at(-1)!.id;
125  };
126  try {
127    const old = await answer();
128    await berry.markRead(tid, old);
129    await unread(false);
130    const latest = await answer();
131    // an older receipt cannot clear a newer answer
132    await berry.markRead(tid, old);
133    assertEquals((await summary(berry, tid)).unread, true);
134    await berry.markRead(tid, latest);
135    await unread(false);
136    await berry.markRead(tid, old);
137    assertEquals((await summary(berry, tid)).unread, false);
138
139    const answered = (await summary(berry, tid)).updated;
140    const fork = await conversation.fork(latest, { ownership: { kind: "ownerless" } }, context);
141    assertObjectMatch(await summary(berry, await berry.tidOf(fork.id)), {
142      unread: false,
143      updated: answered,
144    });
145
146    const [start] = (await conversation.context(context)).entries;
147    await berry.branch(tid, start.id);
148    conversation = (await berry.conversation(tid))!;
149    await answer();
150    const branched = (await summary(berry, tid)).updated;
151    await conversation.reset(undefined, context);
152    assertObjectMatch(await summary(berry, tid), { unread: false, updated: branched });
153    await berry.branch(tid, latest);
154    assertEquals((await summary(berry, tid)).updated, answered);
155  } finally {
156    for (const feed of feeds) await feed.stop();
157    watched.stop();
158  }
159});
160
161Deno.test("legacy summaries find completed answers across history pages", async () => {
162  await using harness = await setup();
163  const { berry, model } = harness;
164  const tid = await berry.create({ extensions: {}, model });
165  const conversation = (await berry.conversation(tid))!;
166  await conversation.commit((tx) => tx.retireDoc(SettledDoc, conversation.id), context);
167  const created = (await berry.harness.snapshot(Conversations, context))![tid].created;
168  const messages: Message[] = [
169    { role: "user", content: "hello", timestamp: 1000 },
170    fauxAssistantMessage("done", { timestamp: 2000 }),
171  ];
172  for (const message of messages) {
173    await conversation.commit(async (tx) => {
174      await tx.appendEntry(conversation.id, { kind: `pi.${message.role}`, model: [message] });
175      for (let i = 0; i < 32; i++) {
176        await tx.appendEntry(conversation.id, {
177          kind: "berry.event",
178          data: { ext: "dir", event: { type: "cd", cwd: "/elsewhere" } },
179        });
180      }
181    }, context);
182    assertEquals(
183      (await summary(berry, tid)).updated,
184      message.role === "assistant" ? message.timestamp : created,
185    );
186  }
187});
188
189Deno.test("renaming one conversation leaves the others' summaries as they were", async () => {
190  await using harness = await setup();
191  const { berry, model } = harness;
192  const renamed = await berry.create({ extensions: {}, model });
193  const other = await berry.create({ extensions: {}, model });
194  const watched = await summaries(berry);
195  try {
196    const before = watched.feed().value as Record<string, Summary>;
197    await berry.rename(renamed, "named");
198    await berry.archive(renamed, true);
199    await until(() => watched.feed().value !== before);
200    const after = watched.feed().value as Record<string, Summary>;
201    assert(after[other] === before[other]);
202  } finally {
203    watched.stop();
204  }
205});
206
207Deno.test("a busy background subagent's conversation gets a summary that leaves the feed once it settles", async () => {
208  await using harness = await setup();
209  const { berry, faux, model } = harness;
210  const blocker = blocking();
211  await berry.install(blocker.extension);
212  const tid = await berry.create({ extensions: { subagent: {}, blocking: {} }, model });
213  const parent = (await berry.conversation(tid))!;
214  const last = (messages: readonly Message[]) => messages.findLast((m) => m.role !== "system")!;
215  const text = (message: Message) =>
216    typeof message.content === "string"
217      ? message.content
218      : message.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n");
219  faux.setResponses(Array.from({ length: 20 }, () => (request) => {
220    const message = last(request.messages);
221    if (message.role === "user" && text(message) === "go") {
222      return fauxAssistantMessage(
223        fauxToolCall("codemode", {
224          code: `return await tools.subagent({task:"child work", model:${
225            JSON.stringify(model)
226          }, background:true})`,
227        }),
228        { stopReason: "toolUse" },
229      );
230    }
231    if (message.role === "user" && text(message) === "child work") {
232      return fauxAssistantMessage(fauxToolCall("block", {}), { stopReason: "toolUse" });
233    }
234    if (message.role === "toolResult" && message.toolName === "block") {
235      return fauxAssistantMessage(text(message));
236    }
237    return fauxAssistantMessage("done");
238  }));
239  await (await parent.submit({ type: "input", content: "go" }, context)).wait(context);
240  await until(() => blocker.running > 0);
241  const index = (await berry.harness.snapshot(Conversations, context))!;
242  const [child] = Object.keys(index).filter((key) => index[key].owner === tid);
243  assert(child);
244  const busy = await summary(berry, child);
245  assertObjectMatch(busy, { busy: true, preview: "child work" });
246  blocker.release.resolve("child answer");
247  for (let i = 0;; i++) {
248    if (i > 250) throw new Error("the settled subagent never left the feed");
249    if (await summary(berry, child) === undefined) break;
250    await new Promise((resolve) => setTimeout(resolve, 20));
251  }
252});