import { assert, assertEquals, assertStrictEquals } from "@std/assert"; import type { ConversationId, EntryId, EntryRecord, LiveState } from "@earendil-works/pi-durable"; import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; import { withDom } from "@berry/util/dom.ts"; const record = (id: number, kind: string, model: EntryRecord["model"]): EntryRecord => ({ id: id as EntryId, conversationId: 1 as ConversationId, kind, model, }); const said = (id: number) => record(id, "pi.user", [{ role: "user", content: `message ${id}`, timestamp: 0 }]); Deno.test("a long history draws its newest entries, and more only as the reader scrolls up to them", async () => { await withDom(async () => { const { transcript } = await import("./transcript.tsx"); const view = transcript(); const list = view.element.querySelector(".entries")!; const texts = () => [...view.element.querySelectorAll(".message.user .text")].map((m) => m.textContent); const entries = Array.from({ length: 200 }, (_, i) => said(i + 1)); // what a scroller offers the content it holds, which has no layout of its own here const pane = { clientHeight: 400, scrollTop: 0, getBoundingClientRect: () => ({ top: 0 }) }; try { view.update(entries, {}); const newest = texts(); assert(newest.length < entries.length); assertEquals(newest, entries.slice(-newest.length).map((entry) => `message ${entry.id}`)); // the history above what is drawn stands in as space, so the scrollbar knows how much there is assert(parseFloat(list.style.paddingTop) > 0); const last = list.lastElementChild; view.grow(pane); assertEquals(texts(), entries.map((entry) => `message ${entry.id}`)); assertEquals(list.style.paddingTop, ""); assertStrictEquals(list.lastElementChild, last); // another branch from the middle opens at its own newest entries, which are not the ones it shares const branch = [ ...entries.slice(0, 100), ...Array.from({ length: 50 }, (_, i) => said(i + 1000)), ]; view.update(branch, {}); const drawn = texts(); assert(drawn.length < branch.length); assertEquals(drawn, branch.slice(-drawn.length).map((entry) => `message ${entry.id}`)); } finally { view.dispose(); } }); }); Deno.test("message timestamps follow user messages and streaming responses through commit", async () => { await withDom(async () => { const { transcript } = await import("./transcript.tsx"); const view = transcript(); const streamed = fauxAssistantMessage([fauxText("hello")], { timestamp: 10_000 }); try { view.update([said(1)], { generation: { attempt: 0, message: streamed }, } as unknown as LiveState); const userTime = view.element.querySelector(".user time")!; const assistantTime = view.element.querySelector(".assistant time")!; assertEquals(userTime.dateTime, new Date(0).toISOString()); assertEquals(assistantTime.dateTime, new Date(10_000).toISOString()); assertEquals(assistantTime.title, new Date(10_000).toLocaleString()); const committed = { ...streamed, timestamp: 20_000 }; view.update([said(1), record(2, "pi.assistant", [committed])], {}); assertStrictEquals(view.element.querySelector(".assistant time"), assistantTime); assertEquals(assistantTime.dateTime, new Date(20_000).toISOString()); } finally { view.dispose(); } }); }); Deno.test("a response keeps its nodes, and the cards the reader opened, once it commits", async () => { await withDom(async () => { const { transcript } = await import("./transcript.tsx"); const view = transcript(); const run = { taskId: 2, inputs: [] }; const streamed = fauxAssistantMessage([ fauxText("let me look"), fauxToolCall("read", { path: "a.ts" }, { id: "call" }), ], { stopReason: "toolUse" }); try { view.update( [said(1)], { run, generation: { attempt: 0, message: streamed } } as unknown as LiveState, ); const message = view.element.querySelector(".message.assistant"); const card = view.element.querySelector(".tool")!; assertEquals(card.dataset.status, "streaming"); card.querySelector(".tool-header")!.click(); assertEquals(card.dataset.view, "open"); view.update( [said(1), record(2, "pi.assistant", [streamed])], { run, tools: [{ callId: "call", name: "read", status: "running" }], } as unknown as LiveState, ); assertEquals(view.element.querySelectorAll(".message.assistant").length, 1); assertStrictEquals(view.element.querySelector(".entries .message.assistant"), message); assertStrictEquals(view.element.querySelector(".tool"), card); assertEquals(card.dataset.status, "running"); assertEquals(card.dataset.view, "open"); } finally { view.dispose(); } }); }); Deno.test("tool time follows execution across navigation and progress, then stops at the reported duration", async () => { await withDom(async () => { const { transcript } = await import("./transcript.tsx"); const originalNow = Date.now; let now = 10_000; Date.now = () => now; const history = [record(1, "pi.assistant", [ fauxAssistantMessage(fauxToolCall("hold", {}, { id: "held" }), { stopReason: "toolUse" }), ])]; const running = { tools: [{ callId: "held", name: "hold", status: "running" as const, startedAt: 1_000 }], }; let view = transcript(); const time = () => view.element.querySelector(".tool-time")!; try { view.update(history, running); assertEquals(time().textContent, "9s"); view.dispose(); now = 13_000; view = transcript(); view.update(history, running); assertEquals(time().textContent, "12s"); view.update(history, { tools: [{ ...running.tools![0], details: ["progress without timing"] }], }); assertEquals(time().textContent, "12s"); now = 30_000; const finished = [ ...history, record(2, "pi.tool-result", [{ role: "toolResult", toolCallId: "held", toolName: "hold", content: [{ type: "text", text: "done" }], isError: false, timestamp: 14_750, durationMs: 13_750, }]), ]; view.update(finished, {}); assertEquals(time().textContent, "13s"); view.dispose(); view = transcript(); view.update(finished, {}); assertEquals(time().textContent, "13s"); view.update( history, { tools: [{ callId: "held", name: "hold", status: "running" }] } as LiveState, ); assertEquals(time().hidden, true); } finally { Date.now = originalNow; view.dispose(); } }); }); Deno.test("a call that succeeded but came to a failure says so in place of ok", async () => { await withDom(async () => { const { transcript } = await import("./transcript.tsx"); const { syncRenderers } = await import("./renderers.ts"); const module = `export default { tools: { probe: { outcome: (call) => call.result === "bad" ? "exit 3" : null, } } };`; await syncRenderers([{ name: "probe", formerly: [], web: { module: `data:text/javascript,${encodeURIComponent(module)}`, styles: [] }, }]); const view = transcript(); const finished = (text: string) => [ record(1, "pi.assistant", [ fauxAssistantMessage(fauxToolCall("probe", {}, { id: "call" }), { stopReason: "toolUse" }), ]), record(2, "pi.tool-result", [{ role: "toolResult", toolCallId: "call", toolName: "probe", content: [{ type: "text", text }], isError: false, timestamp: 0, }]), ]; try { view.update(finished("bad"), {}); const card = view.element.querySelector(".tool")!; assertEquals(card.dataset.status, "error"); view.update(finished("good"), {}); assertEquals(card.dataset.status, "ok"); } finally { view.dispose(); await syncRenderers([]); } }); });