pi-mono/packages/durable/test/examples/09-context.ts
Mario Zechner 837d8a16bd feat(durable): first runnable chat turn (Package 15)
Submissions with request-ID deduplication and handles, the pi.live run
control and presentation document, the built-in pi.generation task
(system prompt preparation, streamed durable partials, retries, deferred
polling, abort), typed entry tokens, stream options and retry policy,
TaskRuntime reads, Tx.settleSubmission, registry conversation setups,
Storage.scanSubmissions, Harness.inspect, and byTaskId attribution.
Waiting calls enable scheduling. The scratch tour is split into
test/examples.
2026-09-29 14:08:28 +02:00

141 lines
4.8 KiB
TypeScript

// Transcript history and model context.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/09-context.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
type AssistantMessage,
createModels,
type Message,
type StopReason,
type ToolResultMessage,
} from "@earendil-works/pi-ai";
import { createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const harness = await Harness.open(
new MemoryStorage(),
{ models: createModels(), registry: createRegistry() },
context,
);
// Entries are immutable. `model` holds the messages an entry contributes to
// the next model request; `data` is for the app only. context() turns the
// stored transcript into those request messages:
// - an entry with `head` starts a new context; older entries stay stored,
// - `edits` replace or omit what an earlier entry contributes,
// - aborted, error, and deferred assistant messages are not sent,
// - tool results are sent right after their call, in call order,
// - a call without a result gets a synthesized error result.
function assistantMessage(text: string, calls: readonly string[] = [], stopReason?: StopReason): AssistantMessage {
return {
role: "assistant",
content: [
{ type: "text", text },
...calls.map((id) => ({ type: "toolCall" as const, id, name: "read", arguments: {} })),
],
api: "example",
provider: "example",
model: "example",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: stopReason ?? (calls.length > 0 ? "toolUse" : "stop"),
timestamp: 2,
};
}
function toolResultMessage(id: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId: id,
toolName: "read",
content: [{ type: "text", text: `file ${id}` }],
isError: false,
timestamp: 3,
};
}
function show(message: Message): string {
switch (message.role) {
case "user":
return `user: ${message.content as string}`;
case "system":
return `system: ${JSON.stringify(message.sections)}`;
case "assistant":
return `assistant: ${message.content
.map((part) => (part.type === "text" ? part.text : part.type === "toolCall" ? `call(${part.id})` : ""))
.join(" ")}`;
case "toolResult":
return `result(${message.toolCallId})${message.isError ? " error" : ""}`;
}
}
const transcript = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
const say = (kind: string, ...model: Message[]) =>
transcript.commit((tx) => tx.appendEntry(transcript.id, { kind, model }), context);
const question = await say("message", { role: "user", content: "read a and b", timestamp: 1 });
await say("message", assistantMessage("I crashed", [], "aborted")); // stored, never sent
const calls = await say("message", assistantMessage("reading", ["a", "b"]));
await say("message", toolResultMessage("b")); // results finish out of order
await say("pi.system", { role: "system", content: "", sections: { cwd: "<cwd>/repo</cwd>" }, timestamp: 4 });
await say("message", toolResultMessage("a"));
await say("message", assistantMessage("a and b look fine"));
await transcript.commit(
(tx) =>
tx.appendEntry(transcript.id, {
kind: "edit",
data: "user fixed a typo",
edits: [
{
target: question.id,
action: "replace",
messages: [{ role: "user", content: "read files a and b", timestamp: 1 }],
},
],
}),
context,
);
await transcript.commit((tx) => tx.appendEntry(transcript.id, { kind: "note", data: "display only" }), context);
let view = await transcript.context(context);
console.log(
"raw active entries:",
view.entries.map((entry) => entry.kind),
);
console.log("request messages:", view.messages.map(show));
// A fork at the tool call has no results yet; context() fills them in.
const cut = await transcript.fork(calls.id, { ownership: { kind: "ownerless" } }, context);
console.log("fork messages:", (await cut.context(context)).messages.map(show));
// A headed summary replaces everything before the entry it points at.
// "self" points the head at the summary entry itself.
await transcript.commit(
(tx) =>
tx.appendEntry(transcript.id, {
kind: "summary",
head: "self",
model: [{ role: "user", content: "Summary: a and b are fine.", timestamp: 5 }],
}),
context,
);
view = await transcript.context(context);
console.log("after summary:", view.head?.kind, view.messages.map(show));
// entries() pages the stored transcript, newest first, including inherited
// parent entries. Nothing is ever deleted by heads or edits.
const history = await transcript.entries({}, 3, undefined, context);
console.log(
"newest stored entries:",
history.items.map((entry) => entry.kind),
"more:",
history.next !== undefined,
);
await harness.close(context);