char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd background subagents (!!!)2d39747

main
13.2 KiB341 linesraw
1import { assert, assertEquals, assertStringIncludes } from "@std/assert";
2import type { ConversationId, EntryId, EntryRecord, LiveState } from "@earendil-works/pi-durable";
3import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
4import { withBerry } from "./testing.ts";
5
6const entry = (id: number, kind: string, message: unknown) =>
7  ({
8    id: id as EntryId,
9    conversationId: 1 as ConversationId,
10    kind,
11    model: [message],
12  }) as EntryRecord;
13
14Deno.test("direct and codemode subagent cards link to the child, stream its transcript, retain its tool cards, and release their subscriptions", async (t) => {
15  await withBerry(async ({ server, settle }) => {
16    const { transcript } = await import("./transcript.tsx");
17    const { watch } = await import("./client.ts");
18    for (const mode of ["direct", "codemode"] as const) {
19      await t.step(mode, async () => {
20        const view = transcript();
21        const tid = "a2b3c4d5e6f7g";
22        const args = {
23          task: "Investigate **tests**",
24          model: { provider: "faux", modelId: "worker" },
25        };
26        const name = mode === "direct" ? "subagent" : "codemode";
27        const entries = [entry(
28          1,
29          "pi.assistant",
30          fauxAssistantMessage(
31            fauxToolCall(name, mode === "direct" ? args : { code: "await tools.subagent(task);" }, {
32              id: "call",
33            }),
34            { stopReason: "toolUse" },
35          ),
36        )];
37        const nested = {
38          name: "subagent",
39          extension: "subagent",
40          args,
41          status: "running",
42          details: { tid },
43        };
44        const details = mode === "direct" ? { tid } : { calls: [nested] };
45        try {
46          view.update(entries, {
47            run: { taskId: 2 },
48            tools: [{ callId: "call", name, status: "running", details }],
49          } as unknown as LiveState);
50          const link = () => view.element.querySelector("a.tid")?.getAttribute("href");
51          assertEquals(link(), `#/${tid}`);
52          const pane = view.element.querySelector<HTMLElement>(
53            "[aria-label='Subagent transcript']",
54          )!;
55          assert(pane);
56          const body = pane.parentElement!;
57          assertEquals(body.querySelector("strong")?.textContent, "tests");
58          assertStringIncludes(body.textContent!, "faux/worker");
59          const childEntries = [
60            entry(1, "pi.user", { role: "user", content: args.task, timestamp: 0 }),
61          ];
62          const worker = fauxAssistantMessage(
63            fauxToolCall("inspect", { path: "tests" }, { id: "inspect" }),
64            {
65              stopReason: "toolUse",
66            },
67          );
68          childEntries.push(entry(2, "pi.assistant", worker));
69          server.snapshot(`conversation:${tid}`, {
70            entries: childEntries,
71            docs: {
72              "pi.agent": { model: args.model, thinkingLevel: "high" },
73              "pi.live": {
74                run: { taskId: 3 },
75                tools: [{ callId: "inspect", name: "inspect", status: "running" }],
76              },
77            },
78          });
79          await settle();
80          assert(!pane.hidden);
81          assertStringIncludes(body.textContent!, "high");
82          assertStringIncludes(pane.textContent!, args.task);
83          const tool = pane.querySelector<HTMLButtonElement>(".tool-header")!;
84          tool.click();
85          childEntries.push(entry(3, "pi.tool-result", {
86            role: "toolResult",
87            toolCallId: "inspect",
88            toolName: "inspect",
89            content: [{ type: "text", text: "located the failing test" }],
90            isError: false,
91            timestamp: 0,
92          }));
93          const final = fauxAssistantMessage("The fix is **ready**.");
94          server.snapshot(`conversation:${tid}`, {
95            entries: childEntries,
96            docs: { "pi.live": { generation: { message: final } } },
97          });
98          await settle();
99          assert(pane.querySelector(".tool-header") === tool);
100          assertEquals(tool.getAttribute("aria-expanded"), "true");
101          assertStringIncludes(pane.textContent!, "located the failing test");
102          const paragraph = pane.querySelector(".message.assistant .markdown p")!;
103          assertEquals(paragraph.querySelector("strong")?.textContent, "ready");
104          childEntries.push(entry(4, "pi.assistant", final));
105          server.snapshot(`conversation:${tid}`, { entries: childEntries, docs: {} });
106          const finishedDetails = mode === "direct" ? { tid } : {
107            calls: [{
108              ...nested,
109              status: "ok",
110              value: { conversation: tid, answer: "The fix is **ready**." },
111            }],
112          };
113          view.update([
114            ...entries,
115            entry(2, "pi.tool-result", {
116              role: "toolResult",
117              toolCallId: "call",
118              toolName: name,
119              content: [{ type: "text", text: "The fix is **ready**." }],
120              details: finishedDetails,
121              isError: false,
122              timestamp: 0,
123            }),
124          ], {});
125          await settle();
126          assert(pane.hidden);
127          assertEquals(pane.textContent, "");
128          assertEquals(link(), `#/${tid}`);
129          assertEquals(body.querySelector(".markdown:not([hidden]) strong")?.textContent, "ready");
130          assert(!body.textContent!.includes("located the failing test"));
131          let cached = false;
132          const release = watch(`conversation:${tid}`, () => cached = true);
133          release();
134          assert(!cached);
135        } finally {
136          view.dispose();
137        }
138      });
139    }
140  });
141});
142
143Deno.test("continuations leave earlier answers alone and only the running call follows the child", async () => {
144  await withBerry(async ({ server, settle }) => {
145    const { transcript } = await import("./transcript.tsx");
146    const view = transcript();
147    const tid = "a2b3c4d5e6f7g";
148    const result = (call: string, text: string) => ({
149      role: "toolResult",
150      toolCallId: call,
151      toolName: "subagent",
152      content: [{ type: "text", text }],
153      details: { tid },
154      isError: false,
155      timestamp: 0,
156    });
157    const entries = [
158      entry(
159        1,
160        "pi.assistant",
161        fauxAssistantMessage([
162          fauxToolCall("subagent", { task: "first task" }, { id: "first" }),
163          fauxToolCall("subagent", { task: "follow up", conversation: tid }, { id: "next" }),
164        ], { stopReason: "toolUse" }),
165      ),
166      entry(2, "pi.tool-result", result("first", "First **answer**.")),
167    ];
168    try {
169      view.update(entries, {
170        run: { taskId: 2 },
171        tools: [{ callId: "next", name: "subagent", status: "running", details: { tid } }],
172      } as unknown as LiveState);
173      server.snapshot(`conversation:${tid}`, {
174        entries: [entry(1, "pi.assistant", fauxAssistantMessage("First **answer**."))],
175        docs: { "pi.live": { generation: { message: fauxAssistantMessage("Following up now…") } } },
176      });
177      await settle();
178      const bodies = view.element.querySelectorAll<HTMLElement>(".subagent-body");
179      assertEquals(bodies.length, 2);
180      assert(bodies[0].querySelector<HTMLElement>(".subagent-history")!.hidden);
181      assertEquals(bodies[0].querySelector("strong")?.textContent, "answer");
182      assert(!bodies[0].textContent!.includes("Following up now"));
183      assert(!bodies[1].querySelector<HTMLElement>(".subagent-history")!.hidden);
184      assertStringIncludes(bodies[1].textContent!, "Following up now");
185      view.update(
186        [...entries, entry(3, "pi.tool-result", result("next", "Second **answer**."))],
187        {},
188      );
189      await settle();
190      assert(bodies[1].querySelector<HTMLElement>(".subagent-history")!.hidden);
191      assert(!bodies[1].textContent!.includes("Following up now"));
192      assertStringIncludes(bodies[1].textContent!, "Second answer");
193      assertStringIncludes(bodies[0].textContent!, "First answer");
194    } finally {
195      view.dispose();
196    }
197  });
198});
199
200Deno.test("a subagent report renders its status and answer and links to the child", async () => {
201  await withBerry(async ({ settle }) => {
202    const { transcript } = await import("./transcript.tsx");
203    const { describe } = await import("./tree.tsx");
204    const view = transcript();
205    const tid = "a2b3c4d5e6f7g";
206    try {
207      const record = {
208        id: 1 as EntryId,
209        conversationId: 1 as ConversationId,
210        kind: "berry.subagent-report",
211        model: [{
212          role: "user",
213          content: `Background subagent ${tid} finished its task.`,
214          timestamp: 0,
215        }],
216        data: {
217          tid,
218          task: "Investigate tests",
219          status: "completed",
220          answer: "The fix is **ready**.",
221        },
222      } as unknown as EntryRecord;
223      view.update([record], {});
224      assertEquals(describe({ kind: "berry.subagent-report", text: "finished" } as never), [
225        "report",
226        "finished",
227      ]);
228      await settle();
229      const report = view.element.querySelector(".report")!;
230      assert(report);
231      assert(!report.closest(".message.user"));
232      assertStringIncludes(report.textContent!, "completed");
233      assertStringIncludes(report.textContent!, "Investigate tests");
234      assertEquals(report.querySelector("strong")?.textContent, "ready");
235      assertEquals(report.querySelector("a.tid")?.getAttribute("href"), `#/${tid}`);
236      // the raw kind never shows
237      assert(!report.textContent!.includes("berry.subagent-report"));
238    } finally {
239      view.dispose();
240    }
241  });
242});
243
244Deno.test("a background card keeps its transcript open while the child works and releases it when it idles", async (t) => {
245  await withBerry(async ({ server, settle }) => {
246    const { transcript } = await import("./transcript.tsx");
247    const { watch } = await import("./client.ts");
248    for (const mode of ["direct", "codemode"] as const) {
249      await t.step(mode, async () => {
250        const view = transcript();
251        const tid = "a2b3c4d5e6f7g";
252        const args = {
253          task: "Investigate tests",
254          model: { provider: "faux", modelId: "worker" },
255          background: true,
256        };
257        const name = mode === "direct" ? "subagent" : "codemode";
258        const entries = [entry(
259          1,
260          "pi.assistant",
261          fauxAssistantMessage(
262            fauxToolCall(name, mode === "direct" ? args : { code: "await tools.subagent(task);" }, {
263              id: "call",
264            }),
265            { stopReason: "toolUse" },
266          ),
267        )];
268        const running = { conversation: tid, status: "running" };
269        const nested = {
270          name: "subagent",
271          extension: "subagent",
272          args,
273          status: "running",
274          details: { tid, conversationId: 1, status: "running" },
275          value: running,
276        };
277        const details = mode === "direct"
278          ? { tid, conversationId: 1, status: "running" }
279          : { calls: [nested] };
280        try {
281          view.update(entries, {
282            run: { taskId: 2 },
283            tools: [{ callId: "call", name, status: "running", details }],
284          } as unknown as LiveState);
285          const link = () => view.element.querySelector("a.tid")?.getAttribute("href");
286          assertEquals(link(), `#/${tid}`);
287          const pane = view.element.querySelector<HTMLElement>(
288            "[aria-label='Subagent transcript']",
289          )!;
290          assert(pane);
291          const body = pane.parentElement!;
292          const childEntries = [
293            entry(1, "pi.user", { role: "user", content: args.task, timestamp: 0 }),
294          ];
295          server.snapshot(`conversation:${tid}`, {
296            entries: childEntries,
297            docs: {
298              "pi.agent": { model: args.model },
299              "pi.live": { run: { taskId: 3 }, tools: [] },
300            },
301          });
302          await settle();
303          assert(!pane.hidden);
304          assertStringIncludes(body.textContent!, args.task);
305          // the spawn settles while the child is still working: the card stays open
306          const finishedDetails = mode === "direct"
307            ? { tid, conversationId: 1, status: "running" }
308            : { calls: [{ ...nested, value: running }] };
309          view.update([
310            ...entries,
311            entry(2, "pi.tool-result", {
312              role: "toolResult",
313              toolCallId: "call",
314              toolName: name,
315              content: [{ type: "text", text: "spawned" }],
316              details: finishedDetails,
317              isError: false,
318              timestamp: 0,
319            }),
320          ], {});
321          await settle();
322          assert(!pane.hidden);
323          assertEquals(link(), `#/${tid}`);
324          assertStringIncludes(body.textContent!, args.task);
325          // once the child idles the card releases its subscription
326          server.snapshot(`conversation:${tid}`, { entries: childEntries, docs: {} });
327          await settle();
328          assert(pane.hidden);
329          assertEquals(pane.textContent, "");
330          assertEquals(link(), `#/${tid}`);
331          let cached = false;
332          const release = watch(`conversation:${tid}`, () => cached = true);
333          release();
334          assert(!cached);
335        } finally {
336          view.dispose();
337        }
338      });
339    }
340  });
341});