char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somadd timestamps to transcript5147173

main
8.2 KiB210 linesraw
1import { assert, assertEquals, assertStrictEquals } from "@std/assert";
2import type { ConversationId, EntryId, EntryRecord, LiveState } from "@earendil-works/pi-durable";
3import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
4import { withDom } from "@berry/util/dom.ts";
5
6const record = (id: number, kind: string, model: EntryRecord["model"]): EntryRecord => ({
7  id: id as EntryId,
8  conversationId: 1 as ConversationId,
9  kind,
10  model,
11});
12const said = (id: number) =>
13  record(id, "pi.user", [{ role: "user", content: `message ${id}`, timestamp: 0 }]);
14
15Deno.test("a long history draws its newest entries, and more only as the reader scrolls up to them", async () => {
16  await withDom(async () => {
17    const { transcript } = await import("./transcript.tsx");
18    const view = transcript();
19    const list = view.element.querySelector<HTMLElement>(".entries")!;
20    const texts = () =>
21      [...view.element.querySelectorAll(".message.user .text")].map((m) => m.textContent);
22    const entries = Array.from({ length: 200 }, (_, i) => said(i + 1));
23    // what a scroller offers the content it holds, which has no layout of its own here
24    const pane = { clientHeight: 400, scrollTop: 0, getBoundingClientRect: () => ({ top: 0 }) };
25    try {
26      view.update(entries, {});
27      const newest = texts();
28      assert(newest.length < entries.length);
29      assertEquals(newest, entries.slice(-newest.length).map((entry) => `message ${entry.id}`));
30      // the history above what is drawn stands in as space, so the scrollbar knows how much there is
31      assert(parseFloat(list.style.paddingTop) > 0);
32
33      const last = list.lastElementChild;
34      view.grow(pane);
35      assertEquals(texts(), entries.map((entry) => `message ${entry.id}`));
36      assertEquals(list.style.paddingTop, "");
37      assertStrictEquals(list.lastElementChild, last);
38
39      // another branch from the middle opens at its own newest entries, which are not the ones it shares
40      const branch = [
41        ...entries.slice(0, 100),
42        ...Array.from({ length: 50 }, (_, i) => said(i + 1000)),
43      ];
44      view.update(branch, {});
45      const drawn = texts();
46      assert(drawn.length < branch.length);
47      assertEquals(drawn, branch.slice(-drawn.length).map((entry) => `message ${entry.id}`));
48    } finally {
49      view.dispose();
50    }
51  });
52});
53
54Deno.test("message timestamps follow user messages and streaming responses through commit", async () => {
55  await withDom(async () => {
56    const { transcript } = await import("./transcript.tsx");
57    const view = transcript();
58    const streamed = fauxAssistantMessage([fauxText("hello")], { timestamp: 10_000 });
59    try {
60      view.update([said(1)], {
61        generation: { attempt: 0, message: streamed },
62      } as unknown as LiveState);
63      const userTime = view.element.querySelector<HTMLTimeElement>(".user time")!;
64      const assistantTime = view.element.querySelector<HTMLTimeElement>(".assistant time")!;
65      assertEquals(userTime.dateTime, new Date(0).toISOString());
66      assertEquals(assistantTime.dateTime, new Date(10_000).toISOString());
67      assertEquals(assistantTime.title, new Date(10_000).toLocaleString());
68      const committed = { ...streamed, timestamp: 20_000 };
69      view.update([said(1), record(2, "pi.assistant", [committed])], {});
70      assertStrictEquals(view.element.querySelector(".assistant time"), assistantTime);
71      assertEquals(assistantTime.dateTime, new Date(20_000).toISOString());
72    } finally {
73      view.dispose();
74    }
75  });
76});
77
78Deno.test("a response keeps its nodes, and the cards the reader opened, once it commits", async () => {
79  await withDom(async () => {
80    const { transcript } = await import("./transcript.tsx");
81    const view = transcript();
82    const run = { taskId: 2, inputs: [] };
83    const streamed = fauxAssistantMessage([
84      fauxText("let me look"),
85      fauxToolCall("read", { path: "a.ts" }, { id: "call" }),
86    ], { stopReason: "toolUse" });
87    try {
88      view.update(
89        [said(1)],
90        { run, generation: { attempt: 0, message: streamed } } as unknown as LiveState,
91      );
92      const message = view.element.querySelector(".message.assistant");
93      const card = view.element.querySelector<HTMLElement>(".tool")!;
94      assertEquals(card.dataset.status, "streaming");
95      card.querySelector<HTMLButtonElement>(".tool-header")!.click();
96      assertEquals(card.dataset.view, "open");
97
98      view.update(
99        [said(1), record(2, "pi.assistant", [streamed])],
100        {
101          run,
102          tools: [{ callId: "call", name: "read", status: "running" }],
103        } as unknown as LiveState,
104      );
105      assertEquals(view.element.querySelectorAll(".message.assistant").length, 1);
106      assertStrictEquals(view.element.querySelector(".entries .message.assistant"), message);
107      assertStrictEquals(view.element.querySelector(".tool"), card);
108      assertEquals(card.dataset.status, "running");
109      assertEquals(card.dataset.view, "open");
110    } finally {
111      view.dispose();
112    }
113  });
114});
115
116Deno.test("tool time follows execution across navigation and progress, then stops at the reported duration", async () => {
117  await withDom(async () => {
118    const { transcript } = await import("./transcript.tsx");
119    const originalNow = Date.now;
120    let now = 10_000;
121    Date.now = () => now;
122    const history = [record(1, "pi.assistant", [
123      fauxAssistantMessage(fauxToolCall("hold", {}, { id: "held" }), { stopReason: "toolUse" }),
124    ])];
125    const running = {
126      tools: [{ callId: "held", name: "hold", status: "running" as const, startedAt: 1_000 }],
127    };
128    let view = transcript();
129    const time = () => view.element.querySelector<HTMLElement>(".tool-time")!;
130    try {
131      view.update(history, running);
132      assertEquals(time().textContent, "9s");
133      view.dispose();
134      now = 13_000;
135      view = transcript();
136      view.update(history, running);
137      assertEquals(time().textContent, "12s");
138      view.update(history, {
139        tools: [{ ...running.tools![0], details: ["progress without timing"] }],
140      });
141      assertEquals(time().textContent, "12s");
142      now = 30_000;
143      const finished = [
144        ...history,
145        record(2, "pi.tool-result", [{
146          role: "toolResult",
147          toolCallId: "held",
148          toolName: "hold",
149          content: [{ type: "text", text: "done" }],
150          isError: false,
151          timestamp: 14_750,
152          durationMs: 13_750,
153        }]),
154      ];
155      view.update(finished, {});
156      assertEquals(time().textContent, "13s");
157      view.dispose();
158      view = transcript();
159      view.update(finished, {});
160      assertEquals(time().textContent, "13s");
161      view.update(
162        history,
163        { tools: [{ callId: "held", name: "hold", status: "running" }] } as LiveState,
164      );
165      assertEquals(time().hidden, true);
166    } finally {
167      Date.now = originalNow;
168      view.dispose();
169    }
170  });
171});
172
173Deno.test("a call that succeeded but came to a failure says so in place of ok", async () => {
174  await withDom(async () => {
175    const { transcript } = await import("./transcript.tsx");
176    const { syncRenderers } = await import("./renderers.ts");
177    const module = `export default { tools: { probe: {
178      outcome: (call) => call.result === "bad" ? "exit 3" : null,
179    } } };`;
180    await syncRenderers([{
181      name: "probe",
182      formerly: [],
183      web: { module: `data:text/javascript,${encodeURIComponent(module)}`, styles: [] },
184    }]);
185    const view = transcript();
186    const finished = (text: string) => [
187      record(1, "pi.assistant", [
188        fauxAssistantMessage(fauxToolCall("probe", {}, { id: "call" }), { stopReason: "toolUse" }),
189      ]),
190      record(2, "pi.tool-result", [{
191        role: "toolResult",
192        toolCallId: "call",
193        toolName: "probe",
194        content: [{ type: "text", text }],
195        isError: false,
196        timestamp: 0,
197      }]),
198    ];
199    try {
200      view.update(finished("bad"), {});
201      const card = view.element.querySelector<HTMLElement>(".tool")!;
202      assertEquals(card.dataset.status, "error");
203      view.update(finished("good"), {});
204      assertEquals(card.dataset.status, "ok");
205    } finally {
206      view.dispose();
207      await syncRenderers([]);
208    }
209  });
210});