pi-mono/packages/durable/test/examples/31-transcript.ts
Mario Zechner f3255de765 feat(durable): windowed conversation view, ConversationTranscript and runFailure; remove watchEvents
ConversationView.entries is a window over the fork-aware history with before;
conversation.watch(ctx, { from, min, max }) and observeConversation take the
window. ConversationTranscript merges windows into a client's transcript with
gaps, loadEarlier, fillGap and resume. pi.live.runFailure says why a run ended
without an answer. watchEvents and its event types are removed; the changelog
has the migration guide.

closes #11327
2026-10-27 16:57:24 +00:00

106 lines
3.8 KiB
TypeScript

// A client's transcript over the windowed conversation view: `ConversationTranscript` merges every value of a watch
// into the client's whole transcript, marks entries a stall skipped as a gap, and loads them, and earlier pages, on
// demand. A client that drops resumes after the newest entry it has.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/31-transcript.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import {
ConversationTranscript,
createRegistry,
Harness,
MemoryStorage,
type TranscriptItem,
type TranscriptView,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const harness = await Harness.open(
new MemoryStorage(),
{ models: createModels(), registry: createRegistry() },
context,
);
const root = await harness.root(context);
let count = 0;
/** Commit `n` chat lines, one entry each. */
const chat = async (n: number): Promise<void> => {
for (let index = 0; index < n; index++) {
await root.commit((tx) => tx.appendEntry(root.id, { kind: "chat", data: `line ${++count}` }), context);
}
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** One line per item: ranges of entries, gaps, and the "load earlier" marker. */
function render(label: string, view: TranscriptView): void {
const rows: string[] = [];
let run: [number, number] | undefined;
const flush = () => {
if (run !== undefined) rows.push(run[0] === run[1] ? `${run[0]}` : `${run[0]}..${run[1]}`);
run = undefined;
};
for (const item of view.items) {
if (item.kind === "entry") {
const line = Number(String(item.entry.data).slice("line ".length));
run = run === undefined ? [line, line] : [run[0], line];
continue;
}
flush();
rows.push(item.kind === "gap" ? "[load more]" : "[load earlier]");
}
flush();
console.log(`${label.padEnd(28)} ${rows.join(" ")}`);
}
const firstGap = (view: TranscriptView) =>
view.items.find((item): item is Extract<TranscriptItem, { kind: "gap" }> => item.kind === "gap");
await chat(200);
// Attach: the window holds the newest `min` entries; the transcript shows "load earlier" above them.
const transcript = new ConversationTranscript((query, limit, ctx) => root.entries(query, limit, undefined, ctx), {
pageSize: 20,
});
const window = { min: 10, max: 20 };
let watch = await root.watch(context, window);
let view = transcript.merge(watch.value);
render("attach", view);
watch.start(async ({ value }) => {
view = transcript.merge(value);
});
// Live: appends. The window drops its oldest past `max`; the transcript keeps them.
await chat(15);
await settle();
render("15 more, live", view);
// The user scrolls up: one page.
view = await transcript.loadEarlier(context);
render("load earlier", view);
// A drop: the client misses 40 entries, more than `max`, and resumes after the newest it has. The window starts at the
// newest `min` again, and `before` shows the transcript a gap.
await watch.stop();
await chat(40);
watch = await root.watch(context, { ...window, ...transcript.resume });
view = transcript.merge(watch.value);
render("resume after 40 missed", view);
watch.start(async ({ value }) => {
view = transcript.merge(value);
});
// "load more" fills the gap a page at a time.
for (let gap = firstGap(view); gap !== undefined; gap = firstGap(view)) {
view = await transcript.fillGap(gap, context);
render("fill gap", view);
}
// A short drop resumes without a gap: the window starts right after the newest entry.
await watch.stop();
await chat(3);
watch = await root.watch(context, { ...window, ...transcript.resume });
console.log(`${"resume after 3 missed".padEnd(28)} window: ${watch.value.entries.length} entries`);
view = transcript.merge(watch.value);
render("", view);
await watch.stop();
await harness.close(context);