feat(durable): inbox, reset, usage, conversation view, and experimental events (Package 17)

This commit is contained in:
Mario Zechner 2026-09-29 21:23:46 +02:00
parent 749e45ae69
commit 47f65f1a3a
32 changed files with 3424 additions and 120 deletions

View file

@ -27,6 +27,9 @@
- `TaskRuntime` now requires `env`, `hooks`, `getTask()`, `waitForTask()`, and `entry()`; `ToolExecutionApi` requires `env` and `diagnostic()` and no longer declares `conversation()` until owned-conversation handles land.
- `createRegistry()` also pre-registers the built-in `pi.tool` and `pi.post-tools` tasks.
- `ToolResultEntry` now carries `data: { diagnostics }`.
- `Tx` now requires `placeSubmission()` and `latestHeadMarker()`.
- Busy submissions no longer reject with `ConversationBusy`: they queue in the conversation's `pi.inbox`, except input with `whenBusy: "reject"`.
- The built-in `pi` setup now also creates `pi.inbox` and `pi.usage` in every Harness conversation.
- The environment shell no longer keeps a bounded, throttled output view: `ShellExecOptions.capture` and `onUpdate` are replaced by raw `onOutput` chunks and `spill: { afterBytes, afterLines }`, `ShellExecResult` is `{ exitCode, spillPath? }`, and `ExecutionError.spillPath` reports the spill of a timed-out or aborted command.
### Added
@ -64,6 +67,13 @@
- Added `ToolRegistration.prepareArguments()` to repair malformed arguments before validation; the edit tool uses it for `edits` sent as a JSON string or a single object and for top-level `oldText`/`newText`.
- Tool output retained by `api.output()` is an exact slice of whole lines, sanitized of control characters.
- Added the `bench:tool-output` benchmark of tool output rates, retention, backends, replay, and 1 GiB throughput.
- Added the inbox: busy steer and follow-up inputs and passive writes queue in the `pi.inbox` document (`InboxDoc`) and are placed at post-tools and final boundaries, writes before user items, by the new `steeringMode` and `followUpMode` configuration with `get/set` accessors. Withdrawal removes the item; stale head writes settle `unanswered`; queued items survive a failed run until the next submission.
- Added `Conversation.reset(handoff)` and the post-tools `handoff` control, which write the headed `pi.reset` entry (`ResetEntry`); a reset queued during a tool round ends the run.
- `onYield` continuations now apply only when the final boundary places no queued user input and no reset.
- A generation that faults or is orphaned now converts its committed partial into an aborted assistant entry, which counts in `pi.usage`, instead of discarding it.
- Added the `pi.usage` ledger (`UsageDoc`) of assistant usage per model and tool usage per tool, `ToolExecutionResult.usage`, and `Harness.usage()` for the Session total.
- Added the structural `ConversationView` with `Conversation.viewState()` and `Conversation.watch()`.
- Added the experimental `watchEvents()` agent event adapter with snapshot events, translated message and tool deltas, and overflow to a snapshot.
### Fixed

View file

@ -22,3 +22,8 @@ export const SystemEntry = defineEntry("pi.system");
* the structured diagnostics, possibly none. Written by tool tasks, and by generation for calls it did not offer.
*/
export const ToolResultEntry = defineEntry<{ diagnostics: ToolDiagnostic[] }>("pi.tool-result");
/**
* Start of a new context: always `head: "self"`, with `model` absent for a plain reset or `[UserMessage]` carrying the
* handoff text. Written by `Conversation.reset()` and the post-tools `handoff` control.
*/
export const ResetEntry = defineEntry("pi.reset");

View file

@ -1,6 +1,6 @@
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
import { defineDoc } from "../documents.ts";
import type { ConversationRetryPolicy, ConversationStreamOptions, ToolExecutionMode } from "./types.ts";
import type { ConversationRetryPolicy, ConversationStreamOptions, QueueMode, ToolExecutionMode } from "./types.ts";
/** Durable per-conversation model, thinking level, request options, and desired tool loadout. */
export type ConversationConfigState = {
@ -14,6 +14,10 @@ export type ConversationConfigState = {
retry?: ConversationRetryPolicy;
/** Whether a round's tools run at once or in call order; absent means `parallel`. */
toolExecution?: ToolExecutionMode;
/** How many queued steers a boundary places; absent means `one-at-a-time`. */
steeringMode?: QueueMode;
/** How many queued follow-ups a final boundary places; absent means `one-at-a-time`. */
followUpMode?: QueueMode;
};
export const DEFAULT_RETRY_POLICY: ConversationRetryPolicy = {

View file

@ -63,6 +63,43 @@ export async function deriveContext(
context: Context,
): Promise<ContextView> {
if (bounds === undefined) return { head: undefined, entries: [], messages: [] };
const head = bounds.head;
const range = await scanRange(storage, conversationId, bounds, context);
const edits = new Map<EntryId, ContextEdit>();
for (const entry of range) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);
const entries = selectActive(head, range);
const messages: Message[] = [];
for (const entry of entries) {
const edit = edits.get(entry.id);
if (edit?.action === "omit") continue;
const contributed = edit?.action === "replace" ? edit.messages : (entry.model ?? []);
for (const message of contributed) {
if (message.role === "assistant" && EXCLUDED_STOP_REASONS.has(message.stopReason)) continue;
messages.push(message);
}
}
return { head, entries, messages: orderToolResults(messages) };
}
/** The raw active entries within captured bounds, without deriving model context. */
export async function activeEntries(
storage: Storage,
conversationId: ConversationId,
bounds: ContextBounds | undefined,
context: Context,
): Promise<EntryRecord[]> {
if (bounds === undefined) return [];
return selectActive(bounds.head, await scanRange(storage, conversationId, bounds, context));
}
/** Visible entries from the head marker's head, or transcript start, through the tail, oldest first. */
async function scanRange(
storage: Storage,
conversationId: ConversationId,
bounds: ContextBounds,
context: Context,
): Promise<EntryRecord[]> {
const head = bounds.head;
const range: EntryRecord[] = [];
let cursor: Cursor | undefined;
@ -78,23 +115,12 @@ export async function deriveContext(
range.push(...page.items);
cursor = page.next;
} while (cursor !== undefined);
range.reverse();
return range.reverse();
}
const edits = new Map<EntryId, ContextEdit>();
for (const entry of range) for (const edit of entry.edits ?? []) edits.set(edit.target, edit);
const entries = head === undefined ? range : [head, ...range.filter((entry) => entry.head === undefined)];
const messages: Message[] = [];
for (const entry of entries) {
const edit = edits.get(entry.id);
if (edit?.action === "omit") continue;
const contributed = edit?.action === "replace" ? edit.messages : (entry.model ?? []);
for (const message of contributed) {
if (message.role === "assistant" && EXCLUDED_STOP_REASONS.has(message.stopReason)) continue;
messages.push(message);
}
}
return { head, entries, messages: orderToolResults(messages) };
/** The head marker followed by the range's non-head entries, or the whole range without a marker. */
function selectActive(head: ContextBounds["head"], range: EntryRecord[]): EntryRecord[] {
return head === undefined ? range : [head, ...range.filter((entry) => entry.head === undefined)];
}
/**

View file

@ -0,0 +1,385 @@
import type { Context, JsonValue } from "@earendil-works/chord";
import type { Op, Path } from "@earendil-works/chord/delta";
import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";
import { CommittedWatch } from "../session/observation.ts";
import type {
CommitChange,
CommitPublication,
ConversationId,
EntryRecord,
JsonObject,
SubmissionId,
SubmissionRecord,
TaskId,
WatchEnd,
} from "../types.ts";
import { ConversationConfig, type ConversationConfigState } from "./config.ts";
import type { InboxItem, InboxState } from "./inbox.ts";
import type { LiveState, ToolSlot } from "./live.ts";
import type { Harness, ToolDiagnostic } from "./types.ts";
import { UsageDoc, type UsageState } from "./usage.ts";
import { type ConversationView, conversationViews } from "./view.ts";
type Block = AssistantMessage["content"][number];
type QueuedItem = { id: SubmissionId; mode: InboxItem["mode"] };
/** One change to the in-flight assistant message, relative to that message. */
export type MessageChange =
| { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: Block }
| { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
| { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
| { type: "block"; contentIndex: number; block: Block }
| { type: "message"; message: AssistantMessage };
export type SnapshotEvent = {
type: "snapshot";
entries: readonly EntryRecord[];
run?: { inputs: readonly SubmissionId[] };
/** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
generation?: {
attempt: number;
message?: AssistantMessage;
retry?: { at: number; error: string };
deferred?: { pollAt: number };
};
tools: readonly ToolSlot[];
inbox: readonly QueuedItem[];
config: ConversationConfigState;
usage: UsageState;
};
/** Experimental agent event, shaped like the coding agent's session events (spec §9.4). */
export type AgentEvent =
| SnapshotEvent
| { type: "run_start"; inputs: readonly SubmissionId[] }
| { type: "run_end"; inputs: readonly SubmissionId[] }
| { type: "turn_start" }
| { type: "turn_end" }
| { type: "message_start"; message: Message }
/** `usage` is the partial's current usage, as in the coding agent's JSON mode. */
| { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
| { type: "message_end"; entry: EntryRecord }
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: JsonObject }
| {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
/** A front trim and then an append of the retained window, or its replacement. */
output?: { trimStart?: number; append?: string } | { set: string };
details?: JsonValue;
diagnostics?: readonly ToolDiagnostic[];
}
/** `entry` is absent when the tool task faulted or was orphaned. */
| { type: "tool_execution_end"; toolCallId: string; toolName: string; entry?: EntryRecord }
| { type: "inbox_update"; items: readonly QueuedItem[] }
| { type: "submission"; record: SubmissionRecord }
| { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
| { type: "auto_retry_end"; attempt: number }
| { type: "deferred_poll"; pollAt: number }
| { type: "entry_appended"; entry: EntryRecord }
| { type: "config_changed"; config: ConversationConfigState }
| { type: "usage_changed"; usage: UsageState }
| { type: "task_failed"; taskId: TaskId; kind: string; message: string };
/** Serialized stream of one conversation's event batches, one per commit. */
export interface AgentEventStream {
/** The `snapshot` event at attachment. */
readonly snapshot: SnapshotEvent;
start(listener: (events: readonly AgentEvent[], context: Context) => Promise<void>): void;
stop(): Promise<WatchEnd>;
readonly closed: Promise<WatchEnd>;
}
/** The typed parts of a view the events read. */
type Parts = {
live: LiveState;
inbox: InboxState | undefined;
config: ConversationConfigState | undefined;
usage: UsageState | undefined;
};
function parts(view: ConversationView): Parts {
return {
live: (view.docs["pi.live"] ?? {}) as LiveState,
inbox: view.docs["pi.inbox"] as InboxState | undefined,
config: view.docs["pi.conversation.config"] as ConversationConfigState | undefined,
usage: view.docs["pi.usage"] as UsageState | undefined,
};
}
function snapshotOf(view: ConversationView): SnapshotEvent {
const { live, inbox, config, usage } = parts(view);
return {
type: "snapshot",
entries: view.entries,
...(live.run === undefined ? {} : { run: { inputs: live.run.inputs } }),
...(live.generation === undefined ? {} : { generation: live.generation as SnapshotEvent["generation"] }),
tools: live.tools ?? [],
inbox: queued(inbox),
config: config ?? ConversationConfig.definition.initial(),
usage: usage ?? UsageDoc.definition.initial(),
};
}
function queued(inbox: InboxState | undefined): QueuedItem[] {
return (inbox?.items ?? []).map((item) => ({ id: item.id, mode: item.mode }));
}
/**
* Experimental: attach to one conversation's agent events (spec §9.4). The snapshot and the registration for later
* commits are captured atomically on the Session line; overflow replaces undelivered batches with one snapshot.
*/
export async function watchEvents(
harness: Harness,
conversationId: ConversationId,
context: Context,
): Promise<AgentEventStream> {
let watch!: CommittedWatch<readonly AgentEvent[]>;
let snapshot!: SnapshotEvent;
await conversationViews(harness).attach(
conversationId,
(initial, release) => {
let current = initial;
snapshot = snapshotOf(initial);
// Batches are the watch's values; an overflow delivers a snapshot of the newest view instead.
watch = new CommittedWatch<readonly AgentEvent[]>([], release, () => [snapshotOf(current)]);
return {
publication: (before, after, ops, publication, commitContext) => {
current = after;
const events = translate(conversationId, before, after, ops, publication);
if (events.length > 0) watch.advance(events, [], commitContext);
},
closeSession: () => watch.closeSession(),
};
},
context,
);
// Like a watch, the acquisition context governs the stream's lifetime.
const signal = context.abortSignal;
if (signal?.aborted) {
watch.cancel();
throw signal.reason;
}
if (signal !== undefined) watch.observeCancellation(signal);
return {
snapshot,
start: (listener) => watch.start((events, _ops, deliveryContext) => listener(events, deliveryContext)),
stop: () => watch.stop(),
closed: watch.closed,
};
}
type TaskChange = Extract<CommitChange, { type: "task" }>;
/** Every event one publication causes, in the order of spec §9.4. */
function translate(
conversationId: ConversationId,
before: ConversationView,
after: ConversationView,
viewOps: readonly Op[],
publication: CommitPublication,
): AgentEvent[] {
const entries: EntryRecord[] = [];
const tasks = new Map<TaskId, TaskChange["value"]>();
const submissions: SubmissionRecord[] = [];
for (const change of publication.changes) {
if (change.type === "entry" && change.value.conversationId === conversationId) entries.push(change.value);
if (change.type === "task" && change.value.conversationId === conversationId)
tasks.set(change.value.id, change.value);
if (change.type === "submission" && change.value.conversationId === conversationId) {
submissions.push(change.value);
}
}
if (viewOps.length === 0 && entries.length === 0 && tasks.size === 0 && submissions.length === 0) return [];
// Entries are appended in ID order; submission records are published in the order the commit first touched them.
submissions.sort((a, b) => a.id - b.id);
const was = parts(before);
const now = parts(after);
const events: AgentEvent[] = [];
// Progress: tool starts, the in-flight message, tool updates, retry and deferred state.
const slotsBefore = new Map((was.live.tools ?? []).map((slot) => [slot.callId, slot]));
const slots = now.live.tools ?? [];
for (const slot of slots) {
if (slot.status !== "running" || slotsBefore.get(slot.callId)?.status === "running") continue;
const checkpoint = slot.taskId === undefined ? undefined : tasks.get(slot.taskId)?.state.checkpoint;
const args = (checkpoint as { arguments?: JsonObject } | undefined)?.arguments ?? {};
events.push({ type: "tool_execution_start", toolCallId: slot.callId, toolName: slot.name, args });
}
const partialBefore = was.live.generation?.message as AssistantMessage | undefined;
const partial = now.live.generation?.message as AssistantMessage | undefined;
if (partial !== undefined && partialBefore === undefined) events.push({ type: "message_start", message: partial });
else if (partial !== undefined && partial !== partialBefore) {
events.push({ type: "message_update", usage: partial.usage, changes: messageChanges(viewOps, partial) });
}
for (const [index, slot] of slots.entries()) {
const previous = slotsBefore.get(slot.callId);
if (slot.status !== "running" || previous?.status !== "running") continue;
const update = toolUpdate(viewOps, index, slot, previous);
if (update === undefined) continue;
events.push({ type: "tool_execution_update", toolCallId: slot.callId, toolName: slot.name, ...update });
}
const generation = now.live.generation;
const generationBefore = was.live.generation;
if (generation?.retry !== undefined && generationBefore?.retry === undefined) {
const { at, error } = generation.retry;
events.push({ type: "auto_retry_start", attempt: generation.attempt, at, errorMessage: error });
}
if (generationBefore?.retry !== undefined && generation?.retry === undefined) {
events.push({ type: "auto_retry_end", attempt: generationBefore.attempt });
}
if (generation?.deferred !== undefined && generation.deferred.pollAt !== generationBefore?.deferred?.pollAt) {
events.push({ type: "deferred_poll", pollAt: generation.deferred.pollAt });
}
// Tools that end in this commit: a slot that becomes done, one created done (a call not offered), or an unfinished
// one that vanishes because its run ended. A done slot that vanishes ended earlier.
const toolEnds: Extract<AgentEvent, { type: "tool_execution_end" }>[] = [];
const endTool = (callId: string, name: string, entryId: number | undefined): void => {
const entry = entries.find((candidate) => candidate.id === entryId);
toolEnds.push({
type: "tool_execution_end",
toolCallId: callId,
toolName: name,
...(entry === undefined ? {} : { entry }),
});
};
for (const previous of slotsBefore.values()) {
if (previous.status === "done") continue;
const slot = slots.find((candidate) => candidate.callId === previous.callId);
if (slot === undefined || slot.status === "done") endTool(previous.callId, previous.name, slot?.entry);
}
for (const slot of slots) {
if (slot.status === "done" && !slotsBefore.has(slot.callId)) endTool(slot.callId, slot.name, slot.entry);
}
// Entries in append order; a tool's end directly precedes its result's message, as in the coding agent.
let assistantAppended = false;
for (const entry of entries) {
events.push(...toolEnds.filter((end) => end.entry === entry));
const message = entry.model?.[0];
if (message === undefined) {
events.push({ type: "entry_appended", entry });
continue;
}
// A streamed answer already started with its first partial.
const streamed = message.role === "assistant" && partialBefore !== undefined && !assistantAppended;
if (message.role === "assistant") assistantAppended = true;
if (!streamed) events.push({ type: "message_start", message });
events.push({ type: "message_end", entry });
}
// Ends without a result entry: a faulted or orphaned tool, or one whose run ended.
events.push(...toolEnds.filter((end) => end.entry === undefined));
// Task failures, then turn and run ends.
let postToolsCreated = false;
let turnEnded = false;
for (const task of tasks.values()) {
if (task.kind === "pi.post-tools" && task.state.status === "pending") postToolsCreated = true;
if (task.state.status !== "terminal") continue;
if (task.kind === "pi.generation" || task.kind === "pi.post-tools") turnEnded = true;
const outcome = task.state.outcome;
if (outcome.status === "faulted" || outcome.status === "orphaned") {
const message = outcome.status === "faulted" ? outcome.error.message : outcome.reason;
events.push({ type: "task_failed", taskId: task.id, kind: task.kind, message });
}
}
if (turnEnded && !postToolsCreated) events.push({ type: "turn_end" });
const run = now.live.run;
const runBefore = was.live.run;
const runChanged = run?.inputs[0] !== runBefore?.inputs[0];
if (runBefore !== undefined && runChanged) events.push({ type: "run_end", inputs: runBefore.inputs });
// Submissions, document state, then what began.
for (const record of submissions) events.push({ type: "submission", record });
if (now.inbox !== was.inbox) events.push({ type: "inbox_update", items: queued(now.inbox) });
// A retired document reads as its initial value, as in a snapshot.
if (now.config !== was.config) {
events.push({ type: "config_changed", config: now.config ?? ConversationConfig.definition.initial() });
}
if (now.usage !== was.usage)
events.push({ type: "usage_changed", usage: now.usage ?? UsageDoc.definition.initial() });
if (run !== undefined && runChanged) events.push({ type: "run_start", inputs: run.inputs });
if (run !== undefined && run.taskId !== runBefore?.taskId && tasks.get(run.taskId)?.kind === "pi.generation") {
events.push({ type: "turn_start" });
}
return events;
}
const PARTIAL_PATH = ["docs", "pi.live", "generation", "message"];
/** Translate the view operations on the in-flight message into message changes (spec §9.4). */
function messageChanges(viewOps: readonly Op[], message: AssistantMessage): MessageChange[] {
const changes: MessageChange[] = [];
// A block sent whole already holds every later change to it in this batch.
const whole = new Set<number>();
for (const op of viewOps) {
// View operations never replace the root.
const path = op[1] as Path;
if (!startsWith(path, PARTIAL_PATH)) {
// The whole message or generation was replaced.
if (startsWith(PARTIAL_PATH, path)) return [{ type: "message", message }];
continue;
}
const rest = path.slice(PARTIAL_PATH.length);
if (rest[0] === "usage") continue;
if (rest[0] !== "content") return [{ type: "message", message }];
if (rest.length === 1) {
if (op[0] !== "p" || op[3] !== 0) return [{ type: "message", message }];
for (const [offset, block] of (op[4] as unknown as Block[]).entries()) {
const type =
block.type === "text" ? "text_start" : block.type === "thinking" ? "thinking_start" : "toolcall_start";
changes.push({ type, contentIndex: op[2] + offset, block });
}
continue;
}
const contentIndex = rest[1] as number;
const field = rest[2];
if (whole.has(contentIndex)) continue;
if (op[0] === "a" && rest.length === 3 && (field === "text" || field === "thinking")) {
changes.push({ type: field === "text" ? "text_delta" : "thinking_delta", contentIndex, delta: op[2] });
} else if (op[0] === "a" && field === "arguments") {
changes.push({ type: "toolcall_delta", contentIndex, path: rest.slice(3), delta: op[2] });
} else {
whole.add(contentIndex);
changes.push({ type: "block", contentIndex, block: message.content[contentIndex]! });
}
}
return changes;
}
/** Output, details, and diagnostics changes of a running slot, from the view operations on it. */
function toolUpdate(
viewOps: readonly Op[],
index: number,
slot: ToolSlot,
previous: ToolSlot,
): Omit<Extract<AgentEvent, { type: "tool_execution_update" }>, "type" | "toolCallId" | "toolName"> | undefined {
const outputPath = ["docs", "pi.live", "tools", index, "output"];
let trimStart = 0;
let append = "";
let set = false;
for (const op of viewOps) {
if (!startsWith(op[1] as Path, outputPath)) continue;
if (op[0] === "t") trimStart += op[2];
else if (op[0] === "a") append += op[2];
else set = true;
}
let output: { trimStart?: number; append?: string } | { set: string } | undefined;
if (set || (slot.output !== previous.output && trimStart === 0 && append === ""))
output = { set: slot.output ?? "" };
else if (trimStart > 0 || append !== "") {
output = { ...(trimStart > 0 ? { trimStart } : {}), ...(append === "" ? {} : { append }) };
}
// A safe replay clears a running slot's progress: removed details send `null`, removed diagnostics `[]`.
const update = {
...(output === undefined ? {} : { output }),
...(slot.details === previous.details ? {} : { details: slot.details ?? null }),
...(slot.diagnostics === previous.diagnostics ? {} : { diagnostics: slot.diagnostics ?? [] }),
};
return Object.keys(update).length === 0 ? undefined : update;
}
function startsWith(path: Path, prefix: readonly (string | number)[]): boolean {
return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
}

View file

@ -13,8 +13,18 @@ import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/u
import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
import { AssistantEntry, SystemEntry, UserEntry } from "../entries.ts";
import { defineTask } from "../tasks.ts";
import type { ConversationId, EntryId, NextTaskState, TaskId, TaskRuntime, Tx, TypedEntry } from "../types.ts";
import type {
ConversationId,
EntryId,
NextTaskState,
SubmissionId,
TaskId,
TaskRuntime,
Tx,
TypedEntry,
} from "../types.ts";
import { ConversationConfig, DEFAULT_RETRY_POLICY } from "./config.ts";
import { applyBoundary, prepareBoundary } from "./inbox.ts";
import { assignJson } from "./json.ts";
import { endRun, LiveDoc, type LiveState, type ToolSlot } from "./live.ts";
import { PostToolsTask } from "./post-tools.ts";
@ -29,6 +39,7 @@ import type {
ToolRegistration,
UserInput,
} from "./types.ts";
import { recordUsage } from "./usage.ts";
export type GenerationInput = Record<string, never>;
@ -205,8 +216,11 @@ async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context:
}, context);
}
/** Append a committed partial left by an interrupted attempt as an aborted assistant entry; the caller replaces `generation`. */
async function convertPartial(tx: Tx, live: Draft<LiveState>, conversationId: ConversationId): Promise<void> {
/**
* Append a committed partial left by an interrupted, aborted, faulted, or orphaned attempt as an aborted assistant
* entry; the caller replaces or removes `generation`.
*/
export async function convertPartial(tx: Tx, live: Draft<LiveState>, conversationId: ConversationId): Promise<void> {
const partial = live.generation?.message;
if (partial === undefined) return;
const message = copyJson(partial) as unknown as AssistantMessage;
@ -256,7 +270,9 @@ async function streamResponse(
try {
const events = runtime.models.streamSimple(model, { messages: [...messages] }, options);
for await (const event of events) {
if (event.type === "done" || event.type === "error") continue;
// A partial without content, such as pi-ai's opening `start` event, shows nothing; a deferred response
// never gets past it, so it never leaves a partial.
if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) continue;
pending = event.partial;
if (timer === undefined && inFlight === undefined) timer = setTimeout(flush, PARTIAL_THROTTLE_MS);
}
@ -325,8 +341,9 @@ async function classify(
}
/**
* A final answer. The first `onYield` continuation appends a user message and hands the run to a successor
* generation; otherwise the run's inputs settle `done` and the final boundary applies.
* A final answer; the final boundary places queued items (spec §6). The first `onYield` continuation appends a user
* message and hands the run to a successor generation, but only when the boundary selected no user item and no reset.
* Otherwise the run's inputs settle `done`, and selected user items start the next run.
*/
async function answer(runtime: Runtime, message: AssistantMessage, context: Context): Promise<void> {
let continuation: UserInput | undefined;
@ -336,17 +353,20 @@ async function answer(runtime: Runtime, message: AssistantMessage, context: Cont
});
const conversationId = runtime.conversationId;
await runtime.commit(async (tx): Promise<Next> => {
const boundary = await prepareBoundary(tx, conversationId);
const live = await tx.doc(LiveDoc, conversationId);
const entry = await appendAssistant(tx, conversationId, message);
const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
if (continuation === undefined) {
endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
const { users, reset } = await applyBoundary(tx, boundary, "final", runtime.now());
if (continuation !== undefined && users.length === 0 && !reset) {
const user = { role: "user", content: continuation, timestamp: runtime.now() } as const;
await tx.appendEntry(UserEntry, conversationId, { model: [user] });
handOver(live, runtime.taskId, await tx.createTask(GenerationTask, {}));
delete live.generation;
return result;
}
const user = { role: "user", content: continuation, timestamp: runtime.now() } as const;
await tx.appendEntry(UserEntry, conversationId, { model: [user] });
handOver(live, runtime.taskId, await tx.createTask(GenerationTask, {}));
delete live.generation;
endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
if (users.length > 0) await startRun(tx, conversationId, live, users);
return result;
}, context);
}
@ -399,17 +419,28 @@ async function startToolRound(
}
/**
* Append a provider result. Every built-in writer of assistant entries goes through here.
* REMINDER: Package 17 adds the `pi.usage` totals update here, in the same commit as the entry.
* Append a provider result and add its usage to `pi.usage` in the same commit.
* REMINDER: every built-in writer of assistant entries goes through here, so the usage ledger stays complete.
*/
function appendAssistant(
async function appendAssistant(
tx: Tx,
conversationId: ConversationId,
message: AssistantMessage,
): Promise<TypedEntry<never>> {
await recordUsage(tx, conversationId, "models", `${message.provider}/${message.model}`, message.usage);
return tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
}
/** Start a run for `inputs`, placed input submissions: a new generation takes `pi.live.run`. */
export async function startRun(
tx: Tx,
conversationId: ConversationId,
live: Draft<LiveState>,
inputs: SubmissionId[],
): Promise<void> {
live.run = { taskId: await tx.createTask(GenerationTask, {}, { conversationId }), inputs };
}
/** Hand run control from `from` to `to`; the run's inputs move with it. */
export function handOver(live: Draft<LiveState>, from: TaskId, to: TaskId): void {
if (live.run?.taskId === from) live.run.taskId = to;

View file

@ -1,6 +1,7 @@
import type { Context, Draft, JsonValue } from "@earendil-works/chord";
import type { AttachedReplicatedState, Context, Draft, JsonValue } from "@earendil-works/chord";
import { withoutAbortSignal } from "@earendil-works/chord/context";
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
import { ResetEntry } from "../entries.ts";
import { SessionImpl } from "../session/session.ts";
import type { Transaction } from "../session/transaction.ts";
import type {
@ -17,6 +18,7 @@ import type {
TaskId,
TaskRecord,
Tx,
WatchHandle,
} from "../types.ts";
import { ROOT_CONVERSATION_ID } from "../types.ts";
import { ConversationConfig, type ConversationConfigState, DEFAULT_RETRY_POLICY } from "./config.ts";
@ -36,6 +38,7 @@ import type {
HarnessOptions,
Harness as HarnessType,
ModelRef,
QueueMode,
RegistryReader,
RegistrySnapshot,
SettledTask,
@ -44,7 +47,9 @@ import type {
ToolExecutionMode,
ToolRegistration,
} from "./types.ts";
import { addUsageState, UsageDoc, type UsageState } from "./usage.ts";
import { scanAll } from "./util.ts";
import { type ConversationView, ConversationViews } from "./view.ts";
const SCAN_PAGE_SIZE = 256;
@ -65,6 +70,8 @@ type ConversationHost<Tool extends ToolRegistration> = {
readonly registry: RegistryReader<Tool>;
readonly tasks: TaskScheduler;
readonly submissions: Submissions;
readonly views: ConversationViews;
readonly now: () => number;
create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise<Conversation>;
};
@ -142,10 +149,41 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
}, context);
}
async getSteeringMode(context: Context): Promise<QueueMode> {
return (await this.#config(context)).steeringMode ?? "one-at-a-time";
}
setSteeringMode(mode: QueueMode | undefined, context: Context): Promise<void> {
return this.#editConfig((config) => {
if (mode === undefined) delete config.steeringMode;
else config.steeringMode = mode;
}, context);
}
async getFollowUpMode(context: Context): Promise<QueueMode> {
return (await this.#config(context)).followUpMode ?? "one-at-a-time";
}
setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise<void> {
return this.#editConfig((config) => {
if (mode === undefined) delete config.followUpMode;
else config.followUpMode = mode;
}, context);
}
submit(submission: SubmissionDraft, context: Context): Promise<Submission> {
return this.#host.submissions.submit(this.id, submission, context);
}
async reset(handoff: string | undefined, context: Context): Promise<void> {
const model =
handoff === undefined
? {}
: { model: [{ role: "user", content: handoff, timestamp: this.#host.now() } as const] };
const entry = { kind: ResetEntry.kind, head: "self", ...model } as const;
await this.#host.submissions.submit(this.id, { type: "write", entry }, context);
}
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T> {
return this.#host.harness.commitWith(change, context, { conversationId: this.id });
}
@ -181,6 +219,14 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
return this.#host.tasks.waitForIdle(this.id, context);
}
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>> {
return this.#host.views.state(this.id, context);
}
watch(context: Context): Promise<WatchHandle<ConversationView>> {
return this.#host.views.watch(this.id, context);
}
async #config(context: Context): Promise<Readonly<ConversationConfigState>> {
return (
(await this.#host.harness.snapshot(ConversationConfig, this.id, context)) ??
@ -228,6 +274,8 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
registry: options.registry,
tasks: this.#tasks,
submissions: this.#submissions,
views: new ConversationViews(this, storage),
now,
create: (target, init, context) => this.#create(target, init, context),
};
}
@ -285,6 +333,19 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
return this.#tasks.waitForIdle(undefined, context);
}
/** Sum every conversation's committed `pi.usage`. Each document is read at its own point; totals only grow. */
async usage(context: Context): Promise<UsageState> {
const conversations = await this.readOnLine(() =>
scanAll((cursor) => this.#storage.scanConversations({}, SCAN_PAGE_SIZE, cursor, context)),
);
const total = UsageDoc.definition.initial();
for (const { id } of conversations) {
const state = await this.snapshot(UsageDoc, id, context);
if (state !== undefined) addUsageState(total, state);
}
return total;
}
root(context: Context, options?: { readonly init?: ConversationInit }): Promise<Conversation> {
return this.#create({ kind: "root" }, options?.init, context);
}

View file

@ -0,0 +1,117 @@
import type { Draft, JsonRepresentation } from "@earendil-works/chord";
import { defineDoc } from "../documents.ts";
import { UserEntry } from "../entries.ts";
import type { ConversationId, EntryDraft, EntryId, JsonObject, SubmissionId, Tx } from "../types.ts";
import { ConversationConfig } from "./config.ts";
import type { QueueMode, UserInput } from "./types.ts";
/** A queued submission: user input for a run, or a passive entry write. */
export type InboxItem =
| { id: SubmissionId; mode: "steer" | "followUp"; content: JsonRepresentation<UserInput> }
/** `entry` is an `EntryDraft`, stored as plain JSON. */
| { id: SubmissionId; mode: "write"; entry: JsonObject };
/** Built-in queue of one conversation's submissions waiting for a boundary, in ID order. */
export type InboxState = { items: InboxItem[] };
export const InboxDoc = defineDoc<InboxState>({
kind: "pi.inbox",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ items: [] }),
checkpointWhen: (value) => value.items.length === 0,
});
/** What a boundary reads before the commit's first table write, and the newest head it has seen so far. */
export type Boundary = {
readonly conversationId: ConversationId;
readonly inbox: Draft<InboxState>;
readonly steeringMode: QueueMode;
readonly followUpMode: QueueMode;
/** Start of the active range, the newest head marker's `head`; advanced by heads written in this commit. */
head: EntryId | undefined;
};
/** Selected user items, in ID order, and whether a `head: "self"` write (a reset) was placed. */
export type BoundaryResult = { readonly users: SubmissionId[]; readonly reset: boolean };
/**
* Read what a boundary needs. Table reads must precede the commit's first table write, so callers prepare the
* boundary at the start of their commit.
*/
export async function prepareBoundary(tx: Tx, conversationId: ConversationId): Promise<Boundary> {
const head = (await tx.latestHeadMarker(conversationId))?.head;
const inbox = await tx.doc(InboxDoc, conversationId);
const config = await tx.doc(ConversationConfig, conversationId);
return {
conversationId,
inbox,
steeringMode: config.steeringMode ?? "one-at-a-time",
followUpMode: config.followUpMode ?? "one-at-a-time",
head,
};
}
/**
* Place the queued items a boundary selects (spec §6): every write, the first or all steers, and at `final` the first
* or all follow-ups. A selected reset turns a `postTools` boundary into `final`. Writes are placed first and user
* items after them, each in ID order, so user items queued before a reset run in the new context. A write whose head
* targets an entry before the active range, including a range started earlier in this commit, is stale. Selected and
* stale items are removed positionally.
*/
export async function applyBoundary(
tx: Tx,
boundary: Boundary,
at: "postTools" | "final",
now: number,
): Promise<BoundaryResult> {
const { conversationId, inbox } = boundary;
const items = inbox.items;
const reset = items.some((item) => item.mode === "write" && item.entry.head === "self");
const final = at === "final" || reset;
const pick = (mode: "steer" | "followUp", queueMode: QueueMode): number[] => {
const indexes = items.flatMap((item, index) => (item.mode === mode ? [index] : []));
return queueMode === "all" ? indexes : indexes.slice(0, 1);
};
const writes = items.flatMap((item, index) => (item.mode === "write" ? [index] : []));
const users = [...pick("steer", boundary.steeringMode), ...(final ? pick("followUp", boundary.followUpMode) : [])];
users.sort((a, b) => a - b);
// `appendEntry()` copies the drafts' values; the items are removed only afterwards.
for (const index of writes) {
const item = items[index] as Draft<Extract<InboxItem, { mode: "write" }>>;
const draft = item.entry as unknown as EntryDraft;
if (isStale(boundary, draft)) {
tx.settleSubmission(item.id, { status: "unanswered", reason: "stale" });
continue;
}
const entry = await tx.appendEntry(conversationId, draft);
if (draft.head !== undefined) boundary.head = draft.head === "self" ? entry.id : draft.head;
tx.placeSubmission(item.id, entry.id);
}
const placed: SubmissionId[] = [];
for (const index of users) {
const item = items[index] as Draft<Extract<InboxItem, { mode: "steer" | "followUp" }>>;
const message = { role: "user", content: item.content as UserInput, timestamp: now } as const;
const entry = await tx.appendEntry(UserEntry, conversationId, { model: [message] });
tx.placeSubmission(item.id, entry.id);
placed.push(item.id);
}
const removed = [...writes, ...users].sort((a, b) => b - a);
for (const index of removed) items.splice(index, 1);
return { users: placed, reset };
}
/** Whether a head write targets an entry before the active range, so placing it would bring back cut history. */
export function isStale(boundary: Boundary, entry: EntryDraft): boolean {
return typeof entry.head === "number" && boundary.head !== undefined && entry.head < boundary.head;
}
/** Remove a withdrawn submission's item; the caller settles the submission. */
export async function removeInboxItem(tx: Tx, conversationId: ConversationId, id: SubmissionId): Promise<void> {
const items = (await tx.doc(InboxDoc, conversationId)).items;
const index = items.findIndex((item) => item.id === id);
if (index >= 0) items.splice(index, 1);
}

View file

@ -3,6 +3,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
import { defineDoc } from "../documents.ts";
import type { Transaction } from "../session/transaction.ts";
import type { EntryId, SubmissionId, SubmissionSettlement, TaskId, TaskRecord, Tx } from "../types.ts";
import { convertPartial } from "./generation.ts";
import type { SchedulerOutcome } from "./scheduler.ts";
import type { ToolDiagnostic } from "./types.ts";
@ -99,7 +100,13 @@ export function clearProgress(slot: Draft<ToolSlot>): void {
/**
* Harness cleanup for a terminal outcome the scheduler writes itself (`faulted` or `orphaned`). A run task ends its
* run; a tool task's slot is marked done without an entry, and context derivation synthesizes the missing result.
* Ignores other kinds so it never creates `pi.live` elsewhere. Committed partials are discarded without an entry.
* Ignores other kinds so it never creates `pi.live` elsewhere. The scheduler calls this without knowing task kinds;
* the Harness passes it in (spec §5.4).
* REMINDER: a committed generation partial becomes an aborted assistant entry here, exactly as in the generation abort
* handler, so the transcript keeps what the model produced and `pi.usage` counts its spend. The scheduler's commit has
* no task scope, so that entry has no `byTaskId`. Faults come from task bugs
* or malformed provider data (a non-JSON value in a response), or a commit the Storage rejected without effect; an
* uncertain storage failure poisons the Session instead and writes no outcome.
*/
export async function settleSchedulerOutcome(
tx: Transaction,
@ -114,6 +121,7 @@ export async function settleSchedulerOutcome(
if (!RUN_TASK_KINDS.has(record.kind)) return;
const live = await tx.doc(LiveDoc, record.conversationId);
if (live.run?.taskId !== record.id) return;
await convertPartial(tx, live, record.conversationId);
endRun(
tx,
live,

View file

@ -1,7 +1,9 @@
import { ResetEntry } from "../entries.ts";
import { defineTask } from "../tasks.ts";
import type { EntryId, TaskId } from "../types.ts";
import { ConversationConfig } from "./config.ts";
import { GenerationTask, handOver } from "./generation.ts";
import { GenerationTask, handOver, startRun } from "./generation.ts";
import { applyBoundary, prepareBoundary } from "./inbox.ts";
import { endRun, LiveDoc } from "./live.ts";
import type { ToolTaskResult } from "./tool.ts";
import type { PostToolsHooks, ToolControl } from "./types.ts";
@ -12,7 +14,8 @@ export type PostToolsResult = Record<string, never>;
/**
* Built-in post-tools task: runs once every tool task of the round is terminal, applies the round's controls, and either
* ends the run at the final boundary or hands it to the next generation at the `postTools` boundary.
* ends the run at the final boundary (`terminate`, `handoff`, or a queued reset) or hands it to the next generation at
* the `postTools` boundary.
*/
export const PostToolsTask = defineTask<PostToolsInput, PostToolsCheckpoint, PostToolsResult, PostToolsHooks>({
name: "pi.post-tools",
@ -36,17 +39,36 @@ export const PostToolsTask = defineTask<PostToolsInput, PostToolsCheckpoint, Pos
slots.length > 0 &&
slots.every((slot) => slot.taskId !== undefined && controls.get(slot.taskId)?.terminate === true);
const added = [...controls.values()].flatMap((control) => control?.addTools ?? []);
// The last handoff in call order wins.
const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff;
await runtime.commit(async (tx) => {
const boundary = await prepareBoundary(tx, conversationId);
if (added.length > 0) {
const config = await tx.doc(ConversationConfig, conversationId);
for (const name of added) if (!config.activeTools.includes(name)) config.activeTools.push(name);
}
const live = await tx.doc(LiveDoc, conversationId);
if (terminate) {
const now = runtime.now();
if (terminate || handoff !== undefined) {
if (handoff !== undefined) {
const message = { role: "user", content: handoff, timestamp: now } as const;
const entry = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] });
boundary.head = entry.id;
}
const { users } = await applyBoundary(tx, boundary, "final", now);
endRun(tx, live, runtime.taskId, { status: "done", answer: assistant });
if (users.length > 0) await startRun(tx, conversationId, live, users);
} else {
delete live.tools;
handOver(live, runtime.taskId, await tx.createTask(GenerationTask, {}));
const { users, reset } = await applyBoundary(tx, boundary, "postTools", now);
if (reset) {
// The queued reset cut the run's context before an answer.
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" });
if (users.length > 0) await startRun(tx, conversationId, live, users);
} else {
delete live.tools;
if (live.run?.taskId === runtime.taskId) live.run.inputs.push(...users);
handOver(live, runtime.taskId, await tx.createTask(GenerationTask, {}));
}
}
return { status: "terminal", outcome: { status: "completed", result: {} } };
}, context);

View file

@ -1,6 +1,7 @@
import type { ConversationRecord, Tx } from "../types.ts";
import { ConversationConfig } from "./config.ts";
import { GenerationTask } from "./generation.ts";
import { InboxDoc } from "./inbox.ts";
import { LiveDoc } from "./live.ts";
import { PostToolsTask } from "./post-tools.ts";
import { ToolTask } from "./tool.ts";
@ -19,6 +20,7 @@ import type {
ToolRegistration,
ToolWrapper,
} from "./types.ts";
import { UsageDoc } from "./usage.ts";
const SECTION_KEY = /^[a-z][a-z0-9_-]*$/;
@ -27,9 +29,14 @@ export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, Post
export const BUILTIN_SETUP_KEY = "pi";
/** Built-in documents: an empty `pi.live`, and for a new conversation the default configuration with every registered tool active. */
/**
* Built-in documents: empty `pi.live`, `pi.inbox`, and `pi.usage`, and for a new conversation the default
* configuration with every registered tool active.
*/
async function builtinSetup(tx: Tx, conversation: ConversationRecord, registry: RegistrySnapshot): Promise<void> {
await tx.doc(LiveDoc, conversation.id);
await tx.doc(InboxDoc, conversation.id);
await tx.doc(UsageDoc, conversation.id);
if (conversation.parent !== undefined) return;
(await tx.doc(ConversationConfig, conversation.id)).activeTools = [...registry.toolNames()];
}

View file

@ -1,11 +1,19 @@
import type { Context } from "@earendil-works/chord";
import { type Context, copyJson, type JsonRepresentation } from "@earendil-works/chord";
import { UserEntry } from "../entries.ts";
import { ConversationBusy } from "../errors.ts";
import type { SessionImpl } from "../session/session.ts";
import type { CommitPublication, ConversationId, Storage, SubmissionId, SubmissionRecord } from "../types.ts";
import { GenerationTask } from "./generation.ts";
import type {
CommitPublication,
ConversationId,
JsonObject,
Storage,
SubmissionId,
SubmissionRecord,
} from "../types.ts";
import { startRun } from "./generation.ts";
import { applyBoundary, InboxDoc, isStale, prepareBoundary, removeInboxItem } from "./inbox.ts";
import { LiveDoc } from "./live.ts";
import type { SettledSubmissionRecord, Submission, SubmissionDraft } from "./types.ts";
import type { SettledSubmissionRecord, Submission, SubmissionDraft, UserInput } from "./types.ts";
import { closedError, Waiters } from "./util.ts";
type AbortResult = "aborted" | "already_placed" | "settled";
@ -33,9 +41,10 @@ export class Submissions {
}
/**
* Admit a submission in one commit. A known request ID returns its existing submission without writing. Until the
* inbox exists, a busy conversation rejects with `ConversationBusy` and writes nothing. Idle input places a user
* entry and starts a generation run; an idle write appends its entry and settles `done`.
* Admit a submission in one commit (spec §6). A known request ID returns its existing submission without writing.
* A busy conversation queues it in `pi.inbox`, or rejects `whenBusy: "reject"` input with `ConversationBusy`
* without writing. An idle conversation with queued items queues it behind them and runs a final boundary. Otherwise
* idle input places a user entry and starts a run, and an idle write appends its entry and settles `done`.
*/
async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> {
this.#resume();
@ -52,9 +61,38 @@ export class Submissions {
}
}
const live = await tx.doc(LiveDoc, conversationId);
if (live.run !== undefined) throw new ConversationBusy(conversationId);
const busy = live.run !== undefined;
if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId);
const requestId = draft.requestId === undefined ? {} : { requestId: draft.requestId };
// A boundary reads the table, so it is prepared before the first table write; a busy one needs none.
const boundary = busy ? undefined : await prepareBoundary(tx, conversationId);
if (boundary === undefined || boundary.inbox.items.length > 0) {
const { id } = await tx.createSubmission({
conversationId,
...requestId,
type: draft.type,
status: "queued",
});
// Hosts may leave optional fields `undefined`; drafts take strict JSON.
const value = copyJson(draft.type === "write" ? draft.entry : draft.content, {
omitUndefinedProperties: true,
});
const items = (boundary?.inbox ?? (await tx.doc(InboxDoc, conversationId))).items;
if (draft.type === "write") items.push({ id, mode: "write", entry: value as JsonObject });
else {
const mode = draft.whenBusy === "steer" ? "steer" : "followUp";
items.push({ id, mode, content: value as JsonRepresentation<UserInput> });
}
if (boundary === undefined) return id;
const { users } = await applyBoundary(tx, boundary, "final", this.#now());
if (users.length > 0) await startRun(tx, conversationId, live, users);
return id;
}
if (draft.type === "write") {
if (isStale(boundary, draft.entry)) {
const stale = { status: "unanswered", reason: "stale" } as const;
return (await tx.createSubmission({ conversationId, ...requestId, type: "write", ...stale })).id;
}
const entry = await tx.appendEntry(conversationId, draft.entry);
const write = { conversationId, ...requestId, type: "write", status: "done", entry: entry.id } as const;
return (await tx.createSubmission(write)).id;
@ -63,7 +101,7 @@ export class Submissions {
const entry = await tx.appendEntry(UserEntry, conversationId, { model: [message] });
const input = { conversationId, ...requestId, type: "input", status: "placed", entry: entry.id } as const;
const { id } = await tx.createSubmission(input);
live.run = { taskId: await tx.createTask(GenerationTask, {}, { conversationId }), inputs: [id] };
await startRun(tx, conversationId, live, [id]);
return id;
}, context);
return new SubmissionHandle(id, this);
@ -95,7 +133,7 @@ export class Submissions {
return found.promise;
}
/** Withdraw a queued submission; placed inputs and settled submissions are reported, not changed. */
/** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */
abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> {
return this.#session.commitWith(async (tx) => {
const record = await tx.submission(id);
@ -104,6 +142,7 @@ export class Submissions {
}
if (record.status === "queued") {
tx.settleSubmission(id, { status: "unanswered", reason: "aborted" });
await removeInboxItem(tx, record.conversationId, id);
return "aborted";
}
return record.status === "placed" ? "already_placed" : "settled";

View file

@ -28,6 +28,7 @@ import type {
ToolHooks,
ToolRegistration,
} from "./types.ts";
import { recordUsage } from "./usage.ts";
export type ToolTaskInput = { assistant: EntryId; callId: string };
@ -409,7 +410,7 @@ function truncated(
/**
* Append a `pi.tool-result` entry. The content ends with the rendered diagnostics, so the stored message is exactly
* what the model sees; `data` keeps the structured list.
* what the model sees; `data` keeps the structured list. A result's usage is added to `pi.usage` in the same commit.
*/
export async function appendToolResult(
tx: Tx,
@ -427,9 +428,11 @@ export async function appendToolResult(
toolName: call.name,
content,
...(result.details === undefined ? {} : { details: result.details }),
...(result.usage === undefined ? {} : { usage: result.usage }),
isError: result.isError ?? false,
timestamp,
} as ToolResultMessage;
if (result.usage !== undefined) await recordUsage(tx, conversationId, "tools", call.name, result.usage);
return tx.appendEntry(ToolResultEntry, conversationId, { model: [message], data: { diagnostics } });
}

View file

@ -1,4 +1,4 @@
import type { Context, JsonValue } from "@earendil-works/chord";
import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord";
import type {
AssistantMessage,
CacheRetention,
@ -9,6 +9,7 @@ import type {
ToolCall,
ToolResultMessage,
Transport,
Usage,
UserMessage,
} from "@earendil-works/pi-ai";
import type { ExecutionEnv } from "../env/index.ts";
@ -34,7 +35,10 @@ import type {
TaskRecord,
TaskState,
Tx,
WatchHandle,
} from "../types.ts";
import type { UsageState } from "./usage.ts";
import type { ConversationView } from "./view.ts";
/** Provider and model ID resolved through pi-ai `Models`. */
export type ModelRef = {
@ -126,12 +130,17 @@ export type ToolExecutionResult = {
readonly details?: JsonValue;
/** Added after those recorded through `api.diagnostic()`. */
readonly diagnostics?: readonly ToolDiagnostic[];
/** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */
readonly usage?: Usage;
readonly control?: ToolControl;
};
/** Whether the tools of one round run at once or one after another in call order. */
export type ToolExecutionMode = "parallel" | "sequential";
/** How many queued items of one mode a boundary places: the first, or all of them. */
export type QueueMode = "all" | "one-at-a-time";
/**
* Operations available to one tool invocation. A plain object, so a wrapper can pass `{ ...api, env }` to the tool it
* wraps. Every operation rejects after the invocation ends.
@ -408,12 +417,25 @@ export interface Conversation {
getToolExecution(context: Context): Promise<ToolExecutionMode>;
/** `undefined` removes the configured mode. */
setToolExecution(mode: ToolExecutionMode | undefined, context: Context): Promise<void>;
/** `one-at-a-time` when unset. */
getSteeringMode(context: Context): Promise<QueueMode>;
/** `undefined` removes the configured mode. */
setSteeringMode(mode: QueueMode | undefined, context: Context): Promise<void>;
/** `one-at-a-time` when unset. */
getFollowUpMode(context: Context): Promise<QueueMode>;
/** `undefined` removes the configured mode. */
setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise<void>;
/**
* Durably admit user input or a passive entry write. Until the inbox exists, a busy conversation rejects every
* submission with `ConversationBusy`.
* Durably admit user input or a passive entry write. A busy conversation, or one with queued items, queues it in
* `pi.inbox`; `whenBusy: "reject"` rejects with `ConversationBusy` instead and writes nothing.
*/
submit(submission: SubmissionDraft, context: Context): Promise<Submission>;
/**
* Admit a write of a `pi.reset` entry that starts a new context, carrying `handoff` as a user message when given.
* Resolves after admission; while busy, it is placed at the next boundary.
*/
reset(handoff: string | undefined, context: Context): Promise<void>;
/** Session commit whose `tx.createTask()` defaults to this conversation. */
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
@ -428,6 +450,10 @@ export interface Conversation {
fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise<Conversation>;
/** Resolve when no live non-background task belongs to this conversation. */
waitForIdle(context: Context): Promise<void>;
/** The structural view (spec §9.3) as a disposable read-only Chord state. */
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
/** The structural view as a serialized exact-frame watch with bounded pending frames. */
watch(context: Context): Promise<WatchHandle<ConversationView>>;
}
// TODO: decide how Harness exposes subscribeCommits() and subscribeClose(). Their listeners run on the Session line
@ -442,7 +468,6 @@ export interface Harness extends Session {
/** Return the reserved root conversation, creating it with `init` in one commit when absent. */
root(context: Context, options?: { readonly init?: ConversationInit }): Promise<Conversation>;
// Conversation activity (active/idle notifications) is specified with run control in Package 17.
conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;
@ -466,6 +491,8 @@ export interface Harness extends Session {
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
/** Resolve when no live non-background task exists. */
waitForIdle(context: Context): Promise<void>;
/** Session total: every conversation's `pi.usage` summed. */
usage(context: Context): Promise<UsageState>;
}
/** What a hook may use: committed reads and the asking task's memos, which hooks and the task share. */

View file

@ -0,0 +1,72 @@
import { copyJson, type Draft, type JsonRepresentation } from "@earendil-works/chord";
import type { Usage } from "@earendil-works/pi-ai";
import { defineDoc } from "../documents.ts";
import type { ConversationId, Tx } from "../types.ts";
/** Spend recorded by one conversation's own entries; the entries stay authoritative. */
export type UsageState = {
/** Assistant entries, keyed `provider/modelId`. */
models: Record<string, JsonRepresentation<Usage>>;
/** Tool results, keyed by tool name; their usage has no model identity. */
tools: Record<string, JsonRepresentation<Usage>>;
};
export const UsageDoc = defineDoc<UsageState>({
kind: "pi.usage",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ models: {}, tools: {} }),
checkpointWhen: () => true,
});
/** Add `usage` to one bucket of the conversation's `pi.usage`, in the commit that appends its entry. */
export async function recordUsage(
tx: Tx,
conversationId: ConversationId,
bucket: keyof UsageState,
key: string,
usage: Usage,
): Promise<void> {
const totals = (await tx.doc(UsageDoc, conversationId))[bucket];
// Own keys only: a tool may be called `toString`.
const total = Object.hasOwn(totals, key) ? totals[key] : undefined;
// Providers may leave optional counters `undefined`; drafts take strict JSON.
if (total === undefined)
totals[key] = copyJson(usage, { omitUndefinedProperties: true }) as JsonRepresentation<Usage>;
else addUsage(total, usage);
}
/** Add every counter of `usage` to `total`; optional counters are added once either side reports them. */
export function addUsage(total: Draft<Usage> | Usage, usage: Usage): void {
total.input += usage.input;
total.output += usage.output;
total.cacheRead += usage.cacheRead;
total.cacheWrite += usage.cacheWrite;
total.totalTokens += usage.totalTokens;
if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
total.cost.input += usage.cost.input;
total.cost.output += usage.cost.output;
total.cost.cacheRead += usage.cost.cacheRead;
total.cost.cacheWrite += usage.cost.cacheWrite;
total.cost.total += usage.cost.total;
}
/** Add every bucket of `state` into `sum`. */
export function addUsageState(sum: UsageState, state: Readonly<UsageState>): void {
for (const bucket of ["models", "tools"] as const) {
for (const [key, usage] of Object.entries(state[bucket])) {
const total = Object.hasOwn(sum[bucket], key) ? sum[bucket][key] : undefined;
// Define rather than assign: assigning a tool named `__proto__` would set the prototype.
if (total !== undefined) addUsage(total, usage);
else
Object.defineProperty(sum[bucket], key, {
value: structuredClone(usage),
enumerable: true,
writable: true,
});
}
}
}

View file

@ -0,0 +1,241 @@
import { type AttachedReplicatedState, type Context, type JsonValue, replicatedState } from "@earendil-works/chord";
import { withoutAbortSignal } from "@earendil-works/chord/context";
import { applyImmutable, type NonEmptyPath, type Op, type Path } from "@earendil-works/chord/delta";
import { CommittedStateSource, CommittedWatch } from "../session/observation.ts";
import type { SessionImpl } from "../session/session.ts";
import type {
CommitPublication,
ConversationDocToken,
ConversationId,
ConversationRecord,
DocumentId,
EntryRecord,
JsonObject,
Storage,
WatchHandle,
} from "../types.ts";
import { ConversationConfig } from "./config.ts";
import { activeEntries, captureContextBounds } from "./context.ts";
import { InboxDoc } from "./inbox.ts";
import { LiveDoc } from "./live.ts";
import { UsageDoc } from "./usage.ts";
import { closedError } from "./util.ts";
/** Structural mount of one conversation's active transcript and built-in documents (spec §9.3). */
export type ConversationView = {
readonly conversation: ConversationRecord;
/** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */
readonly entries: readonly EntryRecord[];
/** `pi.conversation.config`, `pi.live`, `pi.inbox`, and `pi.usage`, keyed by kind; absent documents are absent. */
readonly docs: Readonly<Record<string, JsonObject>>;
};
/** Receives each next revision of a mount, and the Session's close. */
export type ViewObserver = {
advance?(value: ConversationView, ops: readonly Op[], context: Context): void;
/** Every publication, after the mount took it; `ops` are the mount's, possibly none. */
publication?(
before: ConversationView,
after: ConversationView,
ops: readonly Op[],
publication: CommitPublication,
context: Context,
): void;
closeSession(): void;
};
const MOUNTED = [
ConversationConfig,
LiveDoc,
InboxDoc,
UsageDoc,
] as unknown as readonly ConversationDocToken<JsonObject>[];
const MOUNTED_KINDS: ReadonlySet<string> = new Set(MOUNTED.map((token) => token.definition.kind));
/** One conversation's mount: its current revision, the document incarnations it shows, and its observers. */
type Mount = {
value: ConversationView;
/** Mounted incarnation and definition version per kind; another incarnation or version is set whole. */
readonly docs: Map<string, { id: DocumentId; version: number }>;
readonly observers: Set<ViewObserver>;
};
const VIEWS = new WeakMap<object, ConversationViews>();
/** The view mounts of a Harness, for adapters built on them. */
export function conversationViews(harness: object): ConversationViews {
const views = VIEWS.get(harness);
if (views === undefined) throw new Error("Not a Harness");
return views;
}
/**
* The Harness's conversation view mounts: at most one per conversation, built on the Session line by its first
* observer and dropped with its last. Each mount advances from the Session's commit publications, which are durable.
*/
export class ConversationViews {
readonly #session: SessionImpl;
readonly #storage: Storage;
readonly #mounts = new Map<ConversationId, Mount>();
#closed = false;
constructor(session: SessionImpl, storage: Storage) {
this.#session = session;
this.#storage = storage;
VIEWS.set(session, this);
session.subscribeCommits((publication, context) => {
for (const [id, mount] of this.#mounts) advance(id, mount, publication, context);
});
session.subscribeClose(() => {
this.#closed = true;
for (const mount of this.#mounts.values())
for (const observer of [...mount.observers]) observer.closeSession();
this.#mounts.clear();
});
}
/** A disposable read-only Chord state of the view. */
async state(id: ConversationId, context: Context): Promise<AttachedReplicatedState<ConversationView>> {
const { observer, detach } = await this.attach(
id,
(value, release) => new CommittedStateSource<ConversationView>(value, release),
context,
);
try {
return replicatedState(observer);
} catch (error) {
detach();
throw error;
}
}
/** A serialized exact-frame watch of the view; cancelling `context` stops it. */
async watch(id: ConversationId, context: Context): Promise<WatchHandle<ConversationView>> {
const { observer } = await this.attach(
id,
(value, release) => new CommittedWatch<ConversationView>(value, release),
context,
);
const signal = context.abortSignal;
if (signal?.aborted) {
observer.cancel();
throw signal.reason;
}
if (signal !== undefined) observer.observeCancellation(signal);
return observer;
}
/**
* Register an observer created from the current revision, atomically on the Session line: it sees every later
* publication and nothing earlier. `release` drops it, and the mount with its last observer.
*/
attach<O extends ViewObserver>(
id: ConversationId,
create: (value: ConversationView, release: () => void) => O,
context: Context,
): Promise<{ observer: O; detach: () => void }> {
return this.#session.readOnLine(async () => {
const mount = this.#mounts.get(id) ?? (await this.#build(id, context));
// Close or cancellation may begin while the mount hydrates; register nothing then.
if (this.#closed) throw closedError();
context.abortSignal?.throwIfAborted();
this.#mounts.set(id, mount);
const detach = (): void => {
mount.observers.delete(observer);
if (mount.observers.size === 0 && this.#mounts.get(id) === mount) this.#mounts.delete(id);
};
const observer = create(mount.value, detach);
mount.observers.add(observer);
return { observer, detach };
});
}
async #build(id: ConversationId, context: Context): Promise<Mount> {
const conversation = await this.#storage.conversation(id, context);
if (conversation === undefined) throw new Error(`Conversation ${id} does not exist`);
const bounds = await captureContextBounds(this.#storage, id, context);
const entries = await activeEntries(this.#storage, id, bounds, context);
const docs: Record<string, JsonObject> = {};
const incarnations = new Map<string, { id: DocumentId; version: number }>();
for (const token of MOUNTED) {
const loaded = await this.#session.conversationDocumentOnLine(token, id, context);
if (loaded === undefined) continue;
docs[token.definition.kind] = loaded.value;
incarnations.set(token.definition.kind, { id: loaded.record.id, version: loaded.version });
}
return { value: { conversation, entries, docs }, docs: incarnations, observers: new Set() };
}
}
/** Derive the mount's operations from one publication, apply them, and hand the revision to every observer. */
function advance(id: ConversationId, mount: Mount, publication: CommitPublication, context: Context): void {
const docOps: Op[] = [];
const entryOps: Op[] = [];
let entries = mount.value.entries;
// Entry writes are published in ID order.
for (const change of publication.changes) {
if (change.type === "entry" && change.value.conversationId === id) {
const entry = change.value;
const value = entry as unknown as JsonValue;
if (entry.head === undefined) {
entryOps.push(["p", ["entries"], entries.length, 0, [value]]);
entries = [...entries, entry];
continue;
}
// A head marker keeps the non-head entries from its head, which are always a suffix, and goes in front.
const target = entry.head;
let kept = entries.findIndex((candidate) => candidate.head === undefined && candidate.id >= target);
if (kept < 0) kept = entries.length;
entryOps.push(["p", ["entries"], 0, kept, [value]]);
entries = [entry, ...entries.slice(kept)];
continue;
}
if (change.type !== "document" || change.conversationId !== id) continue;
const kind = change.record.kind;
if (!MOUNTED_KINDS.has(kind) || change.record.key !== undefined) continue;
const path: NonEmptyPath = ["docs", kind];
const mounted = mount.docs.get(kind);
if (change.value === null) {
if (mounted?.id !== change.record.id) continue;
mount.docs.delete(kind);
docOps.push(["d", path]);
} else if (mounted?.id === change.record.id && mounted.version === change.version) {
for (const op of change.ops) docOps.push(prefixed(op, path));
} else {
mount.docs.set(kind, { id: change.record.id, version: change.version! });
docOps.push(["s", path, change.value]);
}
}
const before = mount.value;
const ops = [...docOps, ...entryOps];
const frameContext = withoutAbortSignal(context);
if (ops.length > 0) {
const value = docOps.length === 0 ? mount.value : applyImmutable(mount.value, docOps);
mount.value = entries === value.entries ? value : { ...value, entries };
for (const observer of [...mount.observers]) observer.advance?.(mount.value, ops, frameContext);
}
for (const observer of [...mount.observers]) {
observer.publication?.(before, mount.value, ops, publication, frameContext);
}
}
/** `op` moved under `prefix`; a root replacement becomes a set of the prefix. */
function prefixed(op: Op, prefix: NonEmptyPath): Op {
const at = (path: Path): NonEmptyPath => [...prefix, ...path] as unknown as NonEmptyPath;
switch (op[0]) {
case "r":
return ["s", prefix, op[1]];
case "p":
return ["p", at(op[1]), op[2], op[3], op[4]];
case "m":
return ["m", at(op[1]), op[2]];
case "s":
return ["s", at(op[1]), op[2]];
case "d":
return ["d", at(op[1])];
case "a":
return ["a", at(op[1]), op[2]];
case "t":
return ["t", at(op[1]), op[2]];
}
}

View file

@ -1,7 +1,14 @@
export { defineDoc, defineDocFamily } from "./documents.ts";
export { AssistantEntry, defineEntry, SystemEntry, ToolResultEntry, UserEntry } from "./entries.ts";
export { AssistantEntry, defineEntry, ResetEntry, SystemEntry, ToolResultEntry, UserEntry } from "./entries.ts";
export { ConversationBusy, ReadAfterWrite, StorageRejected } from "./errors.ts";
export { ConversationConfig, type ConversationConfigState } from "./harness/config.ts";
export {
type AgentEvent,
type AgentEventStream,
type MessageChange,
type SnapshotEvent,
watchEvents,
} from "./harness/events.ts";
export {
type GenerationCheckpoint,
type GenerationInput,
@ -9,6 +16,7 @@ export {
GenerationTask,
} from "./harness/generation.ts";
export { Harness } from "./harness/harness.ts";
export { InboxDoc, type InboxItem, type InboxState } from "./harness/inbox.ts";
export { LiveDoc, type LiveState, type ToolSlot } from "./harness/live.ts";
export {
type PostToolsCheckpoint,
@ -42,6 +50,7 @@ export type {
PromptInput,
PromptSection,
PromptSectionWrapper,
QueueMode,
Registration,
Registry,
RegistryFailure,
@ -62,6 +71,8 @@ export type {
ToolWrapper,
UserInput,
} from "./harness/types.ts";
export { UsageDoc, type UsageState } from "./harness/usage.ts";
export type { ConversationView } from "./harness/view.ts";
export { createSession } from "./session/session.ts";
export { MemoryStorage } from "./storage/memory.ts";
export { defineTask } from "./tasks.ts";

View file

@ -1,5 +1,6 @@
import type {
Context,
JsonValue,
ReplicatedStateSource,
ReplicatedStateSourceAttachment,
ReplicatedStateSourceFrame,
@ -16,23 +17,27 @@ const MAX_PENDING_WATCH_FRAMES = 100;
/** Canonical terminal update for a retired document incarnation. */
export const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]];
/** Session-to-Chord bridge owned one-to-one by one attached document state. @internal */
export class SessionDocumentSource implements ReplicatedStateSource<ObservedDocumentValue> {
readonly #attachments = new Set<SessionSourceAttachment>();
/**
* Session-to-Chord bridge owned one-to-one by one attached state: a document, or a conversation view. A `null` value
* retires it. @internal
*/
export class CommittedStateSource<T = ObservedDocumentValue> implements ReplicatedStateSource<T> {
readonly #attachments = new Set<SessionSourceAttachment<T>>();
#release: (() => void) | undefined;
#value: ObservedDocumentValue;
/** Released on disposal. */
#value: T | undefined;
#cursor = 0;
#retired = false;
#closed = false;
constructor(value: Readonly<JsonObject>, release: () => void) {
constructor(value: T, release: () => void) {
this.#value = value;
this.#release = release;
}
attach(): ReplicatedStateSourceAttachment<ObservedDocumentValue> {
if (this.#closed) throw new Error("Document state source is closed");
const attachment = new SessionSourceAttachment({ value: this.#value, cursor: this.#cursor }, () => {
attach(): ReplicatedStateSourceAttachment<T> {
if (this.#closed) throw new Error("State source is closed");
const attachment = new SessionSourceAttachment({ value: this.#value as T, cursor: this.#cursor }, () => {
this.#attachments.delete(attachment);
if (this.#attachments.size === 0) this.#finishDisposal();
});
@ -40,12 +45,12 @@ export class SessionDocumentSource implements ReplicatedStateSource<ObservedDocu
return attachment;
}
advance(value: ObservedDocumentValue, ops: readonly Op[], context: Context): void {
advance(value: T, ops: readonly Op[], context: Context): void {
if (this.#closed || this.#retired) return;
this.#value = value;
this.#cursor += 1;
if (value === null) this.#retired = true;
const frame: ReplicatedStateSourceFrame<ObservedDocumentValue> = {
const frame: ReplicatedStateSourceFrame<T> = {
cursor: this.#cursor,
value,
ops,
@ -63,37 +68,37 @@ export class SessionDocumentSource implements ReplicatedStateSource<ObservedDocu
#finishDisposal(): void {
if (this.#closed) return;
this.#closed = true;
this.#value = null;
this.#value = undefined;
const release = this.#release;
this.#release = undefined;
release?.();
}
}
class SessionSourceAttachment implements ReplicatedStateSourceAttachment<ObservedDocumentValue> {
readonly snapshot: { readonly value: ObservedDocumentValue; readonly cursor: number };
class SessionSourceAttachment<T> implements ReplicatedStateSourceAttachment<T> {
readonly snapshot: { readonly value: T; readonly cursor: number };
#release: (() => void) | undefined;
readonly #frames: ReplicatedStateSourceFrame<ObservedDocumentValue>[] = [];
#listener: ((frame: ReplicatedStateSourceFrame<ObservedDocumentValue>) => void) | undefined;
readonly #frames: ReplicatedStateSourceFrame<T>[] = [];
#listener: ((frame: ReplicatedStateSourceFrame<T>) => void) | undefined;
#activated = false;
#disposed = false;
#scheduled = false;
#delivering = false;
constructor(snapshot: { readonly value: ObservedDocumentValue; readonly cursor: number }, release: () => void) {
constructor(snapshot: { readonly value: T; readonly cursor: number }, release: () => void) {
this.snapshot = snapshot;
this.#release = release;
}
activate(listener: (frame: ReplicatedStateSourceFrame<ObservedDocumentValue>) => void): void {
if (this.#activated) throw new Error("Document state attachment is already active");
if (this.#disposed) throw new Error("Document state attachment is disposed");
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void {
if (this.#activated) throw new Error("State attachment is already active");
if (this.#disposed) throw new Error("State attachment is disposed");
this.#activated = true;
this.#listener = listener;
this.#drain();
}
publish(frame: ReplicatedStateSourceFrame<ObservedDocumentValue>): void {
publish(frame: ReplicatedStateSourceFrame<T>): void {
if (this.#disposed) return;
this.#frames.push(frame);
if (!this.#activated || this.#delivering || this.#scheduled) return;
@ -137,20 +142,24 @@ class SessionSourceAttachment implements ReplicatedStateSourceAttachment<Observe
}
}
type WatchFrame = {
readonly value: ObservedDocumentValue;
type WatchFrame<T> = {
readonly value: T;
readonly ops: readonly Op[];
readonly context: Context;
};
/** Serialized exact-frame watch bound to one concrete document incarnation. @internal */
export class SessionDocumentWatch implements WatchHandle<ObservedDocumentValue> {
/**
* Serialized exact-frame watch bound to one document incarnation or conversation view. A `null` value retires it.
* @internal
*/
export class CommittedWatch<T = ObservedDocumentValue> implements WatchHandle<T> {
readonly #detach: () => void;
readonly #replace: (() => T) | undefined;
readonly #closedPromise: Promise<WatchEnd>;
readonly #pending: WatchFrame[] = [];
readonly #pending: WatchFrame<T>[] = [];
#resolveClosed!: (end: WatchEnd) => void;
#value: ObservedDocumentValue;
#listener: ((value: ObservedDocumentValue, ops: readonly Op[], context: Context) => Promise<void>) | undefined;
#value: T;
#listener: ((value: T, ops: readonly Op[], context: Context) => Promise<void>) | undefined;
#started = false;
#scheduled = false;
#running = false;
@ -161,15 +170,17 @@ export class SessionDocumentWatch implements WatchHandle<ObservedDocumentValue>
#cancellationSignal: AbortSignal | undefined;
#cancellationListener: (() => void) | undefined;
constructor(value: Readonly<JsonObject>, detach: () => void) {
/** `replace` gives the value an overflow delivers; by default the newest value. */
constructor(value: T, detach: () => void, replace?: () => T) {
this.#value = value;
this.#detach = detach;
this.#replace = replace;
this.#closedPromise = new Promise((resolve) => {
this.#resolveClosed = resolve;
});
}
get value(): ObservedDocumentValue {
get value(): T {
return this.#value;
}
@ -177,9 +188,9 @@ export class SessionDocumentWatch implements WatchHandle<ObservedDocumentValue>
return this.#closedPromise;
}
start(listener: (value: ObservedDocumentValue, ops: readonly Op[], context: Context) => Promise<void>): void {
if (this.#started) throw new Error("Document watch is already started");
if (this.#end !== undefined) throw new Error("Document watch is stopped");
start(listener: (value: T, ops: readonly Op[], context: Context) => Promise<void>): void {
if (this.#started) throw new Error("Watch is already started");
if (this.#end !== undefined) throw new Error("Watch is stopped");
this.#started = true;
this.#listener = listener;
if (this.#pending.length > 0) this.#schedule();
@ -191,7 +202,7 @@ export class SessionDocumentWatch implements WatchHandle<ObservedDocumentValue>
}
observeCancellation(signal: AbortSignal): void {
if (this.#cancellationSignal !== undefined) throw new Error("Document watch cancellation is already installed");
if (this.#cancellationSignal !== undefined) throw new Error("Watch cancellation is already installed");
if (this.#end !== undefined) return;
this.#cancellationSignal = signal;
this.#cancellationListener = () => this.cancel();
@ -207,12 +218,13 @@ export class SessionDocumentWatch implements WatchHandle<ObservedDocumentValue>
this.#terminate({ reason: "session_closed" });
}
advance(value: ObservedDocumentValue, ops: readonly Op[], context: Context): void {
advance(value: T, ops: readonly Op[], context: Context): void {
if (this.#end !== undefined || this.#retired) return;
if (value === null) this.#retired = true;
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {
this.#pending.length = 0;
this.#pending.push({ value, ops: [["r", value]], context });
const replacement = this.#replace?.() ?? value;
this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context });
} else {
this.#pending.push({ value, ops, context });
}

View file

@ -19,6 +19,7 @@ import type {
ConversationRecord,
DocumentAddress,
DocumentCommitChange,
DocumentRecord,
DocumentState,
DocumentWatch,
EntryId,
@ -36,7 +37,12 @@ import type {
TaskId,
Tx,
} from "../types.ts";
import { RETIREMENT_OPERATIONS, SessionDocumentSource, SessionDocumentWatch } from "./observation.ts";
import {
CommittedStateSource,
CommittedWatch,
type ObservedDocumentValue,
RETIREMENT_OPERATIONS,
} from "./observation.ts";
import { type LoadedDocument, Transaction, type TransactionHost, type TransactionScope } from "./transaction.ts";
/** Open a Session kernel over one storage backend. */
@ -107,6 +113,24 @@ export class SessionImpl implements Session {
});
}
/**
* Internal: a conversation document's current incarnation and value, for a job already running on the line (see
* `readOnLine()`). Absent documents are `undefined`.
*/
async conversationDocumentOnLine(
token: ConversationDocToken<JsonObject>,
conversationId: ConversationId,
context: Context,
): Promise<{ readonly record: DocumentRecord; readonly version: number; readonly value: JsonObject } | undefined> {
const definition = token.definition;
const resolved = resolveAddress(definition, [conversationId, context]);
const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
if (loaded === undefined) return undefined;
checkRecordScope(definition, loaded.record);
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
return { record: loaded.record, version: loaded.valueVersion, value: loaded.tracker.value };
}
snapshot<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | undefined>;
snapshot<T extends JsonObject>(
token: ConversationDocToken<T>,
@ -198,7 +222,7 @@ export class SessionImpl implements Session {
const { observer: source, detach } = this.#attachDocument(
definition,
loaded,
(value, release) => new SessionDocumentSource(value, release),
(value, release) => new CommittedStateSource<ObservedDocumentValue>(value, release),
);
try {
return replicatedState(source) as DocumentState<JsonObject>;
@ -261,7 +285,7 @@ export class SessionImpl implements Session {
return this.#attachDocument(
definition,
loaded,
(value, release) => new SessionDocumentWatch(value, release),
(value, release) => new CommittedWatch<ObservedDocumentValue>(value, release),
).observer;
});
if (watch === undefined) return undefined;
@ -445,7 +469,7 @@ export class SessionImpl implements Session {
* Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed
* changes and close. `detach` removes both subscriptions.
*/
#attachDocument<O extends SessionDocumentSource | SessionDocumentWatch>(
#attachDocument<O extends CommittedStateSource | CommittedWatch>(
definition: AnyDocToken["definition"],
loaded: LoadedDocument,
create: (value: JsonObject, detach: () => void) => O,
@ -464,7 +488,7 @@ export class SessionImpl implements Session {
for (const change of publication.changes) {
if (change.type !== "document" || change.record.id !== loaded.record.id) continue;
// A document state's frames carry no caller cancellation; a watch observes its own cancellation.
const frameContext = observer instanceof SessionDocumentSource ? withoutAbortSignal(context) : context;
const frameContext = observer instanceof CommittedStateSource ? withoutAbortSignal(context) : context;
observer.advance(change.value, observedOperations(observed, change), frameContext);
}
});

View file

@ -57,14 +57,25 @@ import { prepareForkDocumentCopies } from "./forks.ts";
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
/** Complete record after applying one settlement; only a placed input can be answered. A settled record stays. */
function settledSubmission(current: SubmissionRecord, settlement: SubmissionSettlement): SubmissionRecord {
/** A staged submission change: a settlement, or the placement of a queued submission at its entry. */
type SubmissionChange = SubmissionSettlement | { readonly status: "placed"; readonly entry: EntryId };
/**
* Complete record after applying one change. Placement turns a queued input `placed` and a queued write `done`; only a
* placed input can be answered. A settled record stays.
*/
function applySubmissionChange(current: SubmissionRecord, change: SubmissionChange): SubmissionRecord {
if (current.status === "done" || current.status === "unanswered") return current;
if (settlement.status === "done" && current.status !== "placed") {
if (change.status === "placed") {
if (current.status !== "queued") throw new Error(`Submission ${current.id} is not queued`);
const status = current.type === "input" ? "placed" : "done";
return { ...current, status, entry: change.entry } as SubmissionRecord;
}
if (change.status === "done" && current.status !== "placed") {
throw new Error(`Submission ${current.id} is not a placed input`);
}
// Queued and placed records carry no answer, reason, or detail; an unanswered input keeps its entry.
return { ...current, ...settlement } as SubmissionRecord;
return { ...current, ...change } as SubmissionRecord;
}
const INTERNAL_SCAN_PAGE_SIZE = 256;
@ -195,8 +206,8 @@ export class Transaction implements Tx {
readonly #tasksById = new Map<TaskId, TransactionTask>();
/** Submissions created by this transaction, by ID. */
readonly #submissions = new Map<SubmissionId, SubmissionRecord>();
/** Settlements in staging order; resolved against the latest candidate record during assembly. */
readonly #settlements: { readonly id: SubmissionId; readonly settlement: SubmissionSettlement }[] = [];
/** Submission settlements and placements in staging order; resolved against the latest candidate record during assembly. */
readonly #submissionChanges: { readonly id: SubmissionId; readonly change: SubmissionChange }[] = [];
/** Write and publication plans of every staged incarnation, built during assembly. */
readonly #plans: DocumentPlan[] = [];
@ -242,6 +253,12 @@ export class Transaction implements Tx {
return this.#read("scanEntries", () => this.#host.storage.scanEntries(query, limit, cursor, this.#context));
}
latestHeadMarker(conversationId: ConversationId) {
return this.#read("latestHeadMarker", () =>
this.#host.storage.findLatestHeadMarker(conversationId, undefined, this.#context),
);
}
scanTasks(query: TaskQuery, limit: number, cursor?: Cursor) {
return this.#read("scanTasks", () => this.#host.storage.scanTasks(query, limit, cursor, this.#context));
}
@ -414,7 +431,14 @@ export class Transaction implements Tx {
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void {
this.#assertOpen();
this.#hasTableWrite = true;
this.#settlements.push({ id, settlement: copyJson(settlement) as SubmissionSettlement });
this.#submissionChanges.push({ id, change: copyJson(settlement) as SubmissionSettlement });
}
/** Place a queued submission at `entry`; resolved during assembly like `settleSubmission()`. */
placeSubmission(id: SubmissionId, entry: EntryId): void {
this.#assertOpen();
this.#hasTableWrite = true;
this.#submissionChanges.push({ id, change: { status: "placed", entry } });
}
/** Internal: replace one task record completely. Tasks change their own state through their runtime. */
@ -766,11 +790,11 @@ export class Transaction implements Tx {
plan.conversationId = task.publicationConversationId;
}
for (const { id, settlement } of this.#settlements) {
for (const { id, change } of this.#submissionChanges) {
const current = this.#submissions.get(id) ?? (await storage.submission(id, this.#context));
if (current === undefined) throw new Error(`Submission ${id} does not exist`);
const settled = settledSubmission(current, settlement);
if (settled !== current) this.#submissions.set(id, settled);
const next = applySubmissionChange(current, change);
if (next !== current) this.#submissions.set(id, next);
}
const writes = this.#writes;

View file

@ -709,6 +709,8 @@ export interface Tx {
cursor?: Cursor,
): Promise<Page<ConversationRecord, Cursor>>;
scanEntries(query: EntryQuery, limit: number, cursor?: Cursor): Promise<Page<EntryRecord, Cursor>>;
/** Newest visible entry of the conversation that carries a `head`. */
latestHeadMarker(conversationId: ConversationId): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
scanTasks(
query: TaskQuery,
limit: number,
@ -742,6 +744,11 @@ export interface Tx {
* Run tasks settle the inputs they answer.
*/
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
/**
* Place a queued submission at `entry`: an input becomes `placed`, a write `done`. Resolved like
* `settleSubmission()`. Inbox boundaries place the submissions they select.
*/
placeSubmission(id: SubmissionId, entry: EntryId): void;
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;

View file

@ -0,0 +1,50 @@
// Print mode: submit one prompt and print its answer, like `pi -p`. The host awaits its own Submission, not global
// idle. Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/18-print.ts "What is in this directory?"
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import { AssistantEntry, createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
import { createBashTool, createReadTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const prompt = process.argv[2] ?? "What is in this directory?";
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
const faux = fauxProvider();
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
faux.setResponses([
fauxAssistantMessage([fauxToolCall("bash", { command: "ls" }, { id: "call-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("This directory holds the durable package sources, tests, and docs.")]),
]);
}
const registry = createRegistry();
registry.systemPrompt.section("preamble", () => "You are a concise coding assistant.", { tag: false });
registry.tools.add(createReadTool());
registry.tools.add(createBashTool());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const harness = await Harness.open(new MemoryStorage(), { models, registry, env }, context);
const root = await harness.root(context);
await root.setModel(model, context);
const submission = await root.submit({ type: "input", content: prompt }, context);
const settled = await submission.wait(context);
if (settled.status === "done" && settled.type === "input") {
const entry = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
const answer = entry?.model?.[0] as AssistantMessage;
console.log(answer.content.flatMap((content) => (content.type === "text" ? [content.text] : [])).join(""));
} else {
console.error(`unanswered: ${settled.status === "unanswered" ? settled.reason : settled.status}`);
process.exitCode = 1;
}
await harness.close(context);

View file

@ -0,0 +1,92 @@
// JSON mode: stream one prompt's run as JSON lines, like `pi --mode json`. Two modes:
// --events (default): the experimental agent events of watchEvents(), starting with a `snapshot` event.
// --ops: the raw ConversationView frames, starting with the view itself; each later line holds one commit's ops.
// --storage sqlite (default) | jsonl | memory: sqlite writes a temporary database file, jsonl a temporary directory;
// neither is deleted, and the path is printed to stderr at the end.
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/19-json.ts --events "What is in this directory?"
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import { createRegistry, Harness, MemoryStorage, type Storage, watchEvents } from "../../src/index.ts";
import { openNodeJsonlStorage } from "../../src/storage/jsonl/node.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
import { createBashTool, createReadTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const args = process.argv.slice(2);
const mode = args.includes("--ops") ? "ops" : "events";
const storageIndex = args.indexOf("--storage");
const storageKind = storageIndex < 0 ? "sqlite" : args[storageIndex + 1];
const prompt =
args.find((arg, index) => !arg.startsWith("--") && (storageIndex < 0 || index !== storageIndex + 1)) ??
"What is in this directory?";
let storage: Storage;
let location: string | undefined;
if (storageKind === "sqlite") {
location = join(tmpdir(), `pi-durable-json-${Date.now()}.sqlite`);
storage = await openNodeSqliteStorage(location);
} else if (storageKind === "jsonl") {
location = await mkdtemp(join(tmpdir(), "pi-durable-json-"));
storage = await openNodeJsonlStorage(location, context);
} else if (storageKind === "memory") {
storage = new MemoryStorage();
} else {
throw new Error(`Unknown --storage ${storageKind}; use sqlite, jsonl, or memory`);
}
const print = (value: unknown): void => void process.stdout.write(`${JSON.stringify(value)}\n`);
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
const faux = fauxProvider({ tokensPerSecond: 200 });
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
faux.setResponses([
fauxAssistantMessage([fauxToolCall("bash", { command: "ls" }, { id: "call-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("This directory holds the durable package sources, tests, and docs.")]),
]);
}
const registry = createRegistry();
registry.systemPrompt.section("preamble", () => "You are a concise coding assistant.", { tag: false });
registry.tools.add(createReadTool());
registry.tools.add(createBashTool());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const harness = await Harness.open(storage, { models, registry, env }, context);
const root = await harness.root(context);
await root.setModel(model, context);
// Attach before submitting, so the stream covers the whole run.
let stop: () => Promise<unknown>;
if (mode === "events") {
const stream = await watchEvents(harness, root.id, context);
print(stream.snapshot);
stream.start(async (events) => {
for (const event of events) print(event);
});
stop = () => stream.stop();
} else {
const watch = await root.watch(context);
print({ view: watch.value });
watch.start(async (_value, ops) => print({ ops }));
stop = () => watch.stop();
}
const submission = await root.submit({ type: "input", content: prompt }, context);
await submission.wait(context);
await harness.waitForIdle(context);
// Let the last batch reach the listener before stopping.
await new Promise((resolve) => setTimeout(resolve, 0));
await stop();
await harness.close(context);
if (location !== undefined) console.error(`${storageKind} storage: ${location}`);

View file

@ -0,0 +1,70 @@
// The inbox: what happens to submissions while a conversation is busy.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/20-inbox.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { FauxResponseStep } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import { createRegistry, Harness, InboxDoc, MemoryStorage, type Submission } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// The first answer waits until we let it go, so the conversation stays busy while we submit more.
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const slow: FauxResponseStep = async () => {
await held;
return fauxAssistantMessage("Answer to the first question.");
};
const faux = fauxProvider();
faux.setResponses([slow, fauxAssistantMessage("Answer to the follow-up and the steer.")]);
const models = createModels();
models.setProvider(faux.provider);
const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context);
const root = await harness.root(context);
await root.setModel({ provider: "faux", modelId: "faux-1" }, context);
// Place every queued follow-up at once instead of one per run.
await root.setFollowUpMode("all", context);
const first = await root.submit({ type: "input", content: "First question" }, context);
// While busy, input queues as a follow-up (the default) or a steer, and writes queue too.
const followUp = await root.submit({ type: "input", content: "A follow-up" }, context);
const steer = await root.submit({ type: "input", content: "A steer", whenBusy: "steer" }, context);
const note = await root.submit({ type: "write", entry: { kind: "app.note", data: "noted while busy" } }, context);
const withdrawn = await root.submit({ type: "input", content: "Never mind" }, context);
// whenBusy: "reject" refuses instead of queueing.
await root.submit({ type: "input", content: "Now or never", whenBusy: "reject" }, context).catch((error: Error) => {
console.log("rejected:", error.message);
});
// A queued submission can be withdrawn until a boundary places it.
console.log("withdraw:", await withdrawn.abort(context));
const inbox = await harness.snapshot(InboxDoc, root.id, context);
console.log(
"inbox:",
inbox?.items.map((item) => `${item.id} ${item.mode}`),
);
// The first answer ends the run at a final boundary: the write is placed first, then the steer and the
// follow-up, which start the next run together.
release();
const status = async (name: string, submission: Submission) => {
const record = await submission.wait(context);
console.log(`${name}:`, record.status, record.status === "unanswered" ? record.reason : "");
};
await status("first", first);
await status("follow-up", followUp);
await status("steer", steer);
await status("note", note);
await status("withdrawn", withdrawn);
const page = await root.entries({}, 20, undefined, context);
console.log(
"transcript:",
[...page.items].reverse().map((entry) => entry.kind),
);
await harness.close(context);

View file

@ -0,0 +1,92 @@
// Late join: a client attaches while a run is already underway. It gets the current state first, the conversation
// view or the snapshot event, and then only what changes after that.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/21-late-join.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import {
type AgentEvent,
createRegistry,
Harness,
type LiveState,
MemoryStorage,
watchEvents,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// A tool that prints a line every 100 ms, then a slowly streamed answer.
const registry = createRegistry();
registry.tools.add({
name: "count",
description: "Counts to ten",
parameters: Type.Object({}),
execute: async (_args, api) => {
for (let n = 1; n <= 10; n++) {
api.output(`${n}\n`);
await pause(100);
}
return {};
},
});
const faux = fauxProvider({ tokensPerSecond: 40, tokenSize: { min: 1, max: 1 } });
faux.setResponses([
fauxAssistantMessage([fauxToolCall("count", {}, { id: "call-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Counted to ten, and this answer streams slowly.")]),
]);
const models = createModels();
models.setProvider(faux.provider);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
const root = await harness.root(context);
await root.setModel({ provider: "faux", modelId: "faux-1" }, context);
const submission = await root.submit({ type: "input", content: "Count to ten, then tell me." }, context);
// Join while the tool is halfway through.
await pause(500);
// Structural client: the view holds the committed transcript and documents, including the running tool's output.
const view = await root.viewState(context);
const live = view.value.docs["pi.live"] as LiveState;
console.log(
"view entries:",
view.value.entries.map((entry) => entry.kind),
);
console.log("view tool slot:", live.tools?.[0]?.status, JSON.stringify(live.tools?.[0]?.output));
view.subscribe((value) => {
const slot = (value.docs["pi.live"] as LiveState).tools?.[0];
if (slot?.status === "running") console.log("view output now:", JSON.stringify(slot.output));
});
// Event client: the snapshot event carries the same state; later events apply on top of it.
const stream = await watchEvents(harness, root.id, context);
console.log(
"snapshot tools:",
stream.snapshot.tools.map((slot) => `${slot.name} ${slot.status}`),
);
let output = stream.snapshot.tools[0]?.output ?? "";
stream.start(async (events: readonly AgentEvent[]) => {
for (const event of events) {
if (event.type === "tool_execution_update" && event.output !== undefined) {
output =
"set" in event.output
? event.output.set
: output.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
console.log("event output now:", JSON.stringify(output));
} else if (event.type === "message_update") {
const deltas = event.changes.flatMap((change) => (change.type === "text_delta" ? [change.delta] : []));
console.log("event text delta:", JSON.stringify(deltas.join("")));
} else {
console.log("event:", event.type);
}
}
});
await submission.wait(context);
await harness.waitForIdle(context);
await pause(0);
await stream.stop();
view.dispose();
await harness.close(context);

View file

@ -76,12 +76,14 @@ describe("Harness root and conversations", () => {
});
expect(root.id).toBe(ROOT_CONVERSATION_ID);
expect(storage.commits).toHaveLength(1);
// Conversation, pi.live, configuration, and the init note.
// Conversation, pi.live, pi.inbox, pi.usage, configuration, and the init note.
expect(storage.commits[0]!.map((write) => write.type)).toEqual([
"conversation",
"document.create",
"document.create",
"document.create",
"document.create",
"document.create",
]);
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
expect(await root.getActiveTools(context)).toEqual(["read", "bash"]);

View file

@ -0,0 +1,602 @@
import type { Draft } from "@earendil-works/chord";
import {
type AssistantMessage,
type FauxResponseStep,
fauxAssistantMessage,
fauxText,
fauxThinking,
fauxToolCall,
Type,
} from "@earendil-works/pi-ai";
import {
type AgentEvent,
type AgentEventStream,
type Conversation,
ConversationConfig,
type Harness,
LiveDoc,
type LiveState,
MemoryStorage,
type MessageChange,
type SnapshotEvent,
UsageDoc,
watchEvents,
} from "@earendil-works/pi-durable";
import { describe, expect, it } from "vitest";
import { type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts";
import { context, documentChanges } from "./session-support.ts";
import { aborted, deferred } from "./task-support.ts";
/** Attach and start an event stream that records every delivered batch. */
async function listen(harness: Harness, conversation: Conversation) {
const stream = await watchEvents(harness, conversation.id, context);
const batches: AgentEvent[][] = [];
stream.start(async (events) => {
batches.push([...events]);
});
return { stream, batches, events: () => batches.flat() };
}
async function drained(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function slow(): ChatSetup {
return chatSetup({ tokensPerSecond: 400, tokenSize: { min: 1, max: 1 } });
}
/** Rebuild the streamed text of block 0 from `message_start` and the text deltas that follow it. */
function streamedText(events: readonly AgentEvent[]): string {
let text = "";
for (const event of events) {
if (event.type === "message_start" && event.message.role === "assistant") {
const block = event.message.content[0];
text = block?.type === "text" ? block.text : "";
}
if (event.type !== "message_update") continue;
for (const change of event.changes) {
if (change.type === "text_start" && change.contentIndex === 0 && change.block.type === "text") {
text = change.block.text;
}
if (change.type === "text_delta" && change.contentIndex === 0) text += change.delta;
}
}
return text;
}
type Mutable = { [key: string]: unknown };
/** Apply message changes to a copy of `message`, as an events-only consumer would. */
function applyChanges(message: AssistantMessage, changes: readonly MessageChange[]): AssistantMessage {
let next = structuredClone(message);
for (const change of changes) {
if (change.type === "message") next = structuredClone(change.message);
else if (change.type === "block") next.content[change.contentIndex] = structuredClone(change.block);
else if (change.type.endsWith("_start") && "block" in change) {
next.content.splice(change.contentIndex, 0, structuredClone(change.block));
} else if (change.type === "text_delta" || change.type === "thinking_delta") {
const block = next.content[change.contentIndex] as unknown as Mutable;
const field = change.type === "text_delta" ? "text" : "thinking";
block[field] = `${block[field] as string}${change.delta}`;
} else if (change.type === "toolcall_delta") {
let target = (next.content[change.contentIndex] as unknown as Mutable).arguments as Mutable;
for (const segment of change.path.slice(0, -1)) target = target[segment] as Mutable;
const last = change.path.at(-1)!;
target[last] = `${target[last] as string}${change.delta}`;
}
}
return next;
}
/** Committed partials of `conversation`'s generation, one per commit that has one. */
function partialsOf(harness: Harness): AssistantMessage[] {
const partials: AssistantMessage[] = [];
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication)) {
const message = (change.value as LiveState | null)?.generation?.message as AssistantMessage | undefined;
if (change.record.kind === "pi.live" && message !== undefined) partials.push(message);
}
});
return partials;
}
describe("agent events", () => {
it("streams a run as lifecycle events and text deltas that rebuild the answer", async () => {
const setup = slow();
const text = "streamed answer text ".repeat(20);
setup.faux.setResponses([fauxAssistantMessage([fauxText(text)])]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const { stream, events } = await listen(harness, root);
expect(stream.snapshot).toMatchObject({ type: "snapshot", entries: [], tools: [], inbox: [] });
const partials: string[] = [];
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication)) {
const message = (change.value as LiveState | null)?.generation?.message as AssistantMessage | undefined;
if (change.record.kind === "pi.live" && message !== undefined) partials.push(textOf(message) ?? "");
}
});
const submission = await root.submit({ type: "input", content: "hi" }, context);
await submission.wait(context);
await drained();
const all = events();
const types = all.map((event) => event.type).filter((type, index, list) => type !== list[index - 1]);
expect(types).toEqual([
"message_start",
"message_end",
"submission",
"run_start",
"turn_start",
"message_start",
"message_update",
"message_end",
"turn_end",
"run_end",
"submission",
"usage_changed",
]);
expect(all.find((event) => event.type === "run_start")).toEqual({ type: "run_start", inputs: [submission.id] });
// After each event, the rebuilt text equals the committed partial of that commit.
const rebuilt = all.flatMap((event, index) =>
(event.type === "message_start" && event.message.role === "assistant") || event.type === "message_update"
? [streamedText(all.slice(0, index + 1))]
: [],
);
expect(rebuilt).toEqual(partials);
expect(partials.length).toBeGreaterThan(1);
const end = all.filter((event) => event.type === "message_end").at(-1)!;
expect(end.type === "message_end" && textOf(end.entry!.model![0])).toBe(text);
await stream.stop();
await harness.close(context);
});
it("reports tool start, output appends, and the result entry", async () => {
const setup = chatSetup();
const gate = deferred();
setup.registry.tools.add({
name: "print",
description: "Prints",
parameters: Type.Object({ n: Type.Number() }),
execute: async (_args, api) => {
api.output("one\n");
await new Promise((resolve) => setTimeout(resolve, 150));
api.output("two\n");
await gate.promise;
return {};
},
});
setup.faux.setResponses([
fauxAssistantMessage([fauxToolCall("print", { n: 1 }, { id: "c1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("done")]),
]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const { stream, events } = await listen(harness, root);
const submission = await root.submit({ type: "input", content: "go" }, context);
// The output events rebuild the slot's retained window.
const output = (): string => {
let text = "";
for (const event of events()) {
if (event.type !== "tool_execution_update" || event.output === undefined) continue;
if ("set" in event.output) text = event.output.set;
else text = text.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
}
return text;
};
await waitFor(() => output() === "one\ntwo\n");
gate.resolve();
await submission.wait(context);
await drained();
const tool = events().filter((event) => event.type.startsWith("tool_execution"));
expect(tool[0]).toEqual({ type: "tool_execution_start", toolCallId: "c1", toolName: "print", args: { n: 1 } });
const end = tool.at(-1)!;
expect(end).toMatchObject({ type: "tool_execution_end", toolCallId: "c1", entry: { kind: "pi.tool-result" } });
// As in the coding agent, the tool ends directly before its result message.
const all = events();
const endIndex = all.indexOf(end);
expect(all.slice(endIndex, endIndex + 3).map((event) => event.type)).toEqual([
"tool_execution_end",
"message_start",
"message_end",
]);
expect(all[endIndex + 1]).toMatchObject({ message: { role: "toolResult", toolCallId: "c1" } });
// Two turns: the tool round, and the answer.
expect(events().filter((event) => event.type === "turn_start")).toHaveLength(2);
expect(events().filter((event) => event.type === "turn_end")).toHaveLength(2);
await stream.stop();
await harness.close(context);
});
it("reports queued submissions, inbox changes, and retries", async () => {
const setup = chatSetup();
const release = deferred();
const held: FauxResponseStep = async (_request, options) => {
await Promise.race([release.promise, aborted(options!.signal!)]);
return fauxAssistantMessage([fauxText("first")]);
};
const error = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" });
setup.faux.setResponses([held, error, fauxAssistantMessage([fauxText("second")])]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.setRetryPolicy({ enabled: true, maxRetries: 1, baseDelayMs: 1 }, context);
const { stream, events } = await listen(harness, root);
await root.submit({ type: "input", content: "a" }, context);
const followUp = await root.submit({ type: "input", content: "f" }, context);
await drained();
expect(events().filter((event) => event.type === "inbox_update")).toEqual([
{ type: "inbox_update", items: [{ id: followUp.id, mode: "followUp" }] },
]);
release.resolve();
await followUp.wait(context);
await drained();
const types = events().map((event) => event.type);
expect(types).toContain("auto_retry_start");
expect(types).toContain("auto_retry_end");
expect(types.filter((type) => type === "run_start")).toHaveLength(2);
expect(
events()
.filter((event) => event.type === "submission")
.map((event) => event.record.status),
).toEqual(["placed", "queued", "done", "placed", "done"]);
await stream.stop();
await harness.close(context);
});
it("replaces undelivered batches with one snapshot after 100 pending batches", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const stream = await watchEvents(harness, root.id, context);
for (let index = 0; index < 101; index++) {
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
}
const batches: AgentEvent[][] = [];
stream.start(async (events) => {
batches.push([...events]);
});
await drained();
expect(batches).toHaveLength(1);
expect(batches[0]).toHaveLength(1);
expect(batches[0]![0]).toMatchObject({ type: "snapshot" });
expect((batches[0]![0] as Extract<AgentEvent, { type: "snapshot" }>).entries).toHaveLength(101);
await stream.stop();
await harness.close(context);
});
it("rebuilds every committed partial of thinking, text, and tool-call arguments from message changes", async () => {
const setup = chatSetup({ tokensPerSecond: 150, tokenSize: { min: 1, max: 1 } });
const message = fauxAssistantMessage(
[
fauxThinking("thinking about it ".repeat(10)),
fauxText("some text ".repeat(10)),
fauxToolCall("missing", { path: "a/long/path/".repeat(10), note: "x".repeat(60) }, { id: "c1" }),
],
{ stopReason: "toolUse" },
);
setup.faux.setResponses([message, fauxAssistantMessage([fauxText("done")])]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const partials = partialsOf(harness);
const { stream, events } = await listen(harness, root);
await (await root.submit({ type: "input", content: "go" }, context)).wait(context);
await drained();
const rebuilt: AssistantMessage[] = [];
let current: AssistantMessage | undefined;
// The streamed tool-calling message, up to its end; the short final answer commits no partial.
for (const event of events()) {
if (event.type === "message_end" && event.entry?.kind === "pi.assistant") break;
if (event.type === "message_start" && event.message.role === "assistant") current = event.message;
else if (event.type === "message_update") current = applyChanges(current!, event.changes);
else continue;
rebuilt.push(current!);
}
expect(partials.length).toBeGreaterThan(2);
expect(rebuilt.map((partial) => partial.content)).toEqual(partials.map((partial) => partial.content));
const types = new Set(
events()
.flatMap((event) => (event.type === "message_update" ? event.changes : []))
.map((c) => c.type),
);
expect(types.has("thinking_delta") || types.has("text_delta")).toBe(true);
await stream.stop();
await harness.close(context);
});
it("rebuilds a sliding tail window from output trims and appends", async () => {
const setup = chatSetup();
const gate = deferred();
setup.registry.tools.add({
name: "tail",
description: "Prints lines",
parameters: Type.Object({}),
outputLimits: { maxLines: 3, retain: "tail" },
execute: async (_args, api) => {
for (let line = 0; line < 6; line++) {
api.output(`line ${line}\n`);
await new Promise((resolve) => setTimeout(resolve, 120));
}
await gate.promise;
return {};
},
});
setup.faux.setResponses([
fauxAssistantMessage([fauxToolCall("tail", {}, { id: "c1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("done")]),
]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const outputs: string[] = [];
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication)) {
const output = (change.value as LiveState | null)?.tools?.[0]?.output;
if (change.record.kind === "pi.live" && output !== undefined && output !== outputs.at(-1)) {
outputs.push(output);
}
}
});
const { stream, events } = await listen(harness, root);
const submission = await root.submit({ type: "input", content: "go" }, context);
await waitFor(() => outputs.at(-1)?.endsWith("line 5\n") === true);
gate.resolve();
await submission.wait(context);
await drained();
const rebuilt: string[] = [];
let text = "";
for (const event of events()) {
if (event.type !== "tool_execution_update" || event.output === undefined) continue;
if ("set" in event.output) text = event.output.set;
else text = text.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
rebuilt.push(text);
}
expect(rebuilt).toEqual(outputs);
expect(
events().some((event) => event.type === "tool_execution_update" && "trimStart" in (event.output ?? {})),
).toBe(true);
await stream.stop();
await harness.close(context);
});
it("emits one exact batch when a run ends and a queued follow-up starts the next", async () => {
const setup = chatSetup();
const release = deferred();
const held: FauxResponseStep = async (_request, options) => {
await Promise.race([release.promise, aborted(options!.signal!)]);
return fauxAssistantMessage([fauxText("first")]);
};
setup.faux.setResponses([held, fauxAssistantMessage([fauxText("second")])]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
const followUp = await root.submit({ type: "input", content: "f" }, context);
const { stream, batches } = await listen(harness, root);
release.resolve();
await followUp.wait(context);
await drained();
const boundary = batches.find((batch) => batch.some((event) => event.type === "run_end"))!;
expect(boundary.map((event) => event.type)).toEqual([
"message_start",
"message_end",
"message_start",
"message_end",
"turn_end",
"run_end",
"submission",
"submission",
"inbox_update",
"usage_changed",
"run_start",
"turn_start",
]);
expect(boundary.filter((event) => event.type === "submission").map((event) => event.record.id)).toEqual([
input.id,
followUp.id,
]);
await stream.stop();
await harness.close(context);
});
it("ends calls that never run and unfinished tools whose run ended", async () => {
const setup = chatSetup();
setup.registry.tools.add({
name: "wait",
description: "Waits until aborted",
parameters: Type.Object({}),
execute: (_args, _api, callContext) => aborted(callContext.abortSignal!),
});
setup.faux.setResponses([
fauxAssistantMessage([fauxToolCall("ghost", {}, { id: "c1" }), fauxToolCall("wait", {}, { id: "c2" })], {
stopReason: "toolUse",
}),
]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const { stream, events } = await listen(harness, root);
const submission = await root.submit({ type: "input", content: "go" }, context);
await waitFor(() => events().some((event) => event.type === "tool_execution_start"));
// Aborting post-tools alone ends the run while the tool still runs (spec §12).
await harness.abortTask((await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId, context);
await submission.wait(context);
await drained();
// The call not offered ends after its calling message and directly before its result message.
const round = events().map((event) =>
event.type === "message_end"
? `end:${event.entry.kind}`
: event.type === "message_start"
? `start:${event.message.role}`
: event.type,
);
const ghostEnd = round.indexOf("tool_execution_end");
expect(round.slice(ghostEnd - 1, ghostEnd + 3)).toEqual([
"end:pi.assistant",
"tool_execution_end",
"start:toolResult",
"end:pi.tool-result",
]);
const tool = events().filter((event) => event.type.startsWith("tool_execution"));
expect(tool.map((event) => [event.type, "toolCallId" in event && event.toolCallId, "entry" in event])).toEqual([
["tool_execution_end", "c1", true],
["tool_execution_start", "c2", false],
["tool_execution_end", "c2", false],
]);
await stream.stop();
await harness.close(context);
});
it("reports a steer without run events, a reset as an appended entry, and nothing for other conversations", async () => {
const setup = chatSetup();
const gate = deferred();
setup.registry.tools.add({
name: "hold",
description: "Waits",
parameters: Type.Object({}),
execute: async () => {
await gate.promise;
return {};
},
});
setup.faux.setResponses([
fauxAssistantMessage([fauxToolCall("hold", {}, { id: "c1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("done")]),
]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
const { stream, events, batches } = await listen(harness, root);
const input = await root.submit({ type: "input", content: "a" }, context);
await waitFor(() => events().some((event) => event.type === "tool_execution_start"));
await root.submit({ type: "input", content: "s", whenBusy: "steer" }, context);
const before = batches.length;
await other.commit((tx) => tx.appendEntry(other.id, { kind: "note" }), context);
await drained();
expect(batches.length).toBe(before);
gate.resolve();
await input.wait(context);
await root.reset(undefined, context);
await drained();
expect(events().filter((event) => event.type === "run_start")).toHaveLength(1);
expect(events().filter((event) => event.type === "run_end")).toHaveLength(1);
expect(batches.at(-1)!.map((event) => event.type)).toEqual(["entry_appended", "submission"]);
await stream.stop();
await harness.close(context);
});
it("rejects an attachment cancelled while it waits for the Session line", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const release = deferred();
const blocking = root.commit(async () => {
await release.promise;
}, context);
const controller = new AbortController();
const attaching = watchEvents(harness, root.id, { ...context, abortSignal: controller.signal });
controller.abort(new Error("cancelled"));
release.resolve();
await blocking;
await expect(attaching).rejects.toThrow("cancelled");
await harness.close(context);
});
it("applies deltas after an overflow snapshot that holds an in-flight partial", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const partial = fauxAssistantMessage([fauxText("hel")]);
const stream = await watchEvents(harness, root.id, context);
await root.commit(async (tx) => {
(await tx.doc(LiveDoc, root.id)).generation = {
attempt: 1,
message: JSON.parse(JSON.stringify(partial)),
};
}, context);
for (let index = 0; index < 101; index++) {
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
}
const batches: AgentEvent[][] = [];
stream.start(async (events) => {
batches.push([...events]);
});
await drained();
const snapshot = batches[0]![0] as SnapshotEvent;
expect(snapshot.type).toBe("snapshot");
await root.commit(async (tx) => {
const block = (await tx.doc(LiveDoc, root.id)).generation!.message!.content[0] as { text: string };
block.text += "lo";
}, context);
await drained();
const update = batches.at(-1)![0]!;
if (update.type !== "message_update") throw new Error(`Unexpected ${update.type}`);
expect(update.changes).toEqual([{ type: "text_delta", contentIndex: 0, delta: "lo" }]);
const rebuilt = applyChanges(snapshot.generation!.message!, update.changes);
expect(rebuilt.content).toEqual([{ type: "text", text: "hello" }]);
await stream.stop();
await harness.close(context);
});
it("reports usage-only updates, cleared tool progress, tools ending without entries, and deferred polls", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const { stream, batches } = await listen(harness, root);
const change = (edit: (live: Draft<LiveState>) => void) =>
root.commit(async (tx) => edit(await tx.doc(LiveDoc, root.id)), context);
const partial = JSON.parse(JSON.stringify(fauxAssistantMessage([fauxText("partial")])));
await change((live) => {
live.generation = { attempt: 1, message: partial };
});
// A usage-only change sends the usage and no changes.
await change((live) => {
live.generation!.message!.usage.input = 42;
});
await change((live) => {
live.generation = { attempt: 1, deferred: { pollAt: 1 } };
});
await change((live) => {
live.generation!.deferred!.pollAt = 2;
});
await change((live) => {
live.tools = [{ callId: "c1", name: "t", status: "running", details: { n: 1 }, diagnostics: [] }];
});
// A safe replay clears the running slot's progress.
await change((live) => {
delete live.tools![0]!.details;
delete live.tools![0]!.diagnostics;
});
// A fault marks the slot done without an entry.
await change((live) => {
live.tools![0]!.status = "done";
});
await root.commit((tx) => tx.retireDoc(UsageDoc, root.id), context);
await root.commit((tx) => tx.retireDoc(ConversationConfig, root.id), context);
await drained();
expect(batches.map((batch) => batch.filter((event) => event.type !== "task_failed"))).toEqual([
[{ type: "message_start", message: partial }],
[{ type: "message_update", usage: { ...partial.usage, input: 42 }, changes: [] }],
[{ type: "deferred_poll", pollAt: 1 }],
[{ type: "deferred_poll", pollAt: 2 }],
[{ type: "tool_execution_start", toolCallId: "c1", toolName: "t", args: {} }],
[{ type: "tool_execution_update", toolCallId: "c1", toolName: "t", details: null, diagnostics: [] }],
[{ type: "tool_execution_end", toolCallId: "c1", toolName: "t" }],
// Retired documents read as their initial values.
[{ type: "usage_changed", usage: { models: {}, tools: {} } }],
[{ type: "config_changed", config: { thinkingLevel: "off", activeTools: [] } }],
]);
await stream.stop();
await harness.close(context);
});
it("ends the stream with the Harness", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const stream: AgentEventStream = await watchEvents(harness, root.id, context);
await harness.close(context);
expect(await stream.closed).toEqual({ reason: "session_closed" });
});
it("starts a message at the first committed partial and ends it with the converted entry on abort", async () => {
const setup = chatSetup({ tokensPerSecond: 100, tokenSize: { min: 1, max: 1 } });
setup.faux.setResponses([fauxAssistantMessage([fauxText("x".repeat(400))])]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const { stream, events } = await listen(harness, root);
const submission = await root.submit({ type: "input", content: "hi" }, context);
await waitFor(() =>
events().some((event) => event.type === "message_start" && event.message.role === "assistant"),
);
const live = await harness.snapshot(LiveDoc, root.id, context);
await harness.abortTask(live!.run!.taskId, context);
await submission.wait(context);
await drained();
const assistantEnds = events().filter(
(event) => event.type === "message_end" && event.entry?.kind === "pi.assistant",
);
expect(assistantEnds).toHaveLength(1);
expect(
events().filter((event) => event.type === "message_start" && event.message.role === "assistant"),
).toHaveLength(1);
await stream.stop();
await harness.close(context);
});
});

View file

@ -435,7 +435,33 @@ describe("generation", () => {
await harness.close(context);
});
it("faults a run task, settling its inputs and discarding the committed partial", async () => {
it("commits no partial for a response that turns deferred after an empty start event", async () => {
const base = chatSetup();
const handle = { provider: "faux", modelId: "faux-1", api: "faux", id: "handle-1", pollAfterMs: 60_000 };
const deferredStream = (): ReturnType<Models["streamSimple"]> => {
const events = async function* () {
yield { type: "start", partial: fauxAssistantMessage([], { stopReason: "pending" }) };
// Longer than the partial throttle: an empty partial would be committed here.
await new Promise((resolve) => setTimeout(resolve, 300));
};
const final = fauxAssistantMessage([], { stopReason: "deferred", deferred: handle });
return { [Symbol.asyncIterator]: events, result: async () => final } as unknown as ReturnType<
Models["streamSimple"]
>;
};
const setup: ChatSetup = { ...base, models: withStream(base.models, deferredStream) };
const { harness, root } = await openChat(new MemoryStorage(), setup);
const values = livePublications(harness);
const submission = await root.submit({ type: "input", content: "hi" }, context);
await waitFor(async () => (await live(harness, root))?.generation?.deferred !== undefined);
expect(values.some((value) => value.generation?.message !== undefined)).toBe(false);
await harness.abortTask((await live(harness, root))!.run!.taskId, context);
expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user"]);
await harness.close(context);
});
it("faults a run task, settling its inputs and converting the committed partial", async () => {
const base = chatSetup();
const setup: ChatSetup = { ...base, models: withStream(base.models, invalidFinalStream) };
const { harness, root } = await openChat(new MemoryStorage(), setup);
@ -448,7 +474,10 @@ describe("generation", () => {
detail: expect.stringContaining("non-JSON function"),
});
expect(values.some((value) => textOf(value.generation?.message as Message) === "partial")).toBe(true);
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user"]);
const entries = await allEntries(root);
expect(entries.map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant"]);
expect(entries[1]!.model![0]).toMatchObject({ role: "assistant", stopReason: "aborted" });
expect(textOf(entries[1]!.model![0])).toBe("partial");
expect(await live(harness, root)).toEqual({});
const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 10), context);
expect(tasks.items[0]!.state).toMatchObject({ status: "terminal", outcome: { status: "faulted" } });
@ -478,7 +507,9 @@ describe("generation", () => {
return { taskId, submissionId: submission.id };
}, context);
harness.resume();
await expect(root.submit({ type: "input", content: "busy" }, context)).rejects.toThrow("is busy");
await expect(root.submit({ type: "input", content: "busy", whenBusy: "reject" }, context)).rejects.toThrow(
"is busy",
);
expect(await harness.abortTask(taskId, context)).toBe("marked");
expect((await harness.waitForTask(taskId, context)).state.outcome).toEqual({
status: "orphaned",

View file

@ -0,0 +1,882 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Op } from "@earendil-works/chord/delta";
import {
type AssistantMessage,
type FauxResponseStep,
fauxAssistantMessage,
fauxText,
fauxToolCall,
type ToolResultMessage,
Type,
type Usage,
} from "@earendil-works/pi-ai";
import {
type Conversation,
type EntryRecord,
GenerationTask,
type Harness,
InboxDoc,
type InboxState,
LiveDoc,
MemoryStorage,
ResetEntry,
type Submission,
type SubmissionRecord,
type ToolExecutionResult,
ToolTask,
UsageDoc,
} from "@earendil-works/pi-durable";
import { afterEach, describe, expect, it } from "vitest";
import { recordUsage } from "../src/harness/usage.ts";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { allEntries, type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts";
import { context, documentChanges } from "./session-support.ts";
import { aborted, type Deferred, deferred } from "./task-support.ts";
const directories = new Set<string>();
afterEach(async () => {
for (const directory of directories) await rm(directory, { recursive: true, force: true });
directories.clear();
});
function answer(text: string): AssistantMessage {
return fauxAssistantMessage([fauxText(text)]);
}
/** A faux response held until `release` or cancellation; `reached` resolves when the request is sent. */
function gated(message: AssistantMessage): { step: FauxResponseStep; reached: Promise<void>; release: () => void } {
const reached = deferred();
const gate = deferred();
const step: FauxResponseStep = async (_request, options) => {
reached.resolve();
await Promise.race([gate.promise, aborted(options!.signal!)]);
return message;
};
return { step, reached: reached.promise, release: () => gate.resolve() };
}
/** Register a `hold` tool whose calls wait for `gate` and then return `result`. */
function holdTool(setup: ChatSetup, gate: Deferred, result: ToolExecutionResult = { content: [] }): void {
setup.registry.tools.add({
name: "hold",
description: "Waits for the test",
parameters: Type.Object({}),
execute: async () => {
await gate.promise;
return result;
},
});
}
const HOLD = fauxAssistantMessage([fauxToolCall("hold", {}, { id: "c1" })], { stopReason: "toolUse" });
async function status(submission: Submission): Promise<SubmissionRecord> {
return submission.status(context);
}
/** Kind and text of each entry, skipping system entries. */
function transcript(entries: readonly EntryRecord[]): string[] {
return entries
.filter((entry) => entry.kind !== "pi.system")
.map((entry) => {
const message = entry.model?.[0];
const text = message?.role === "toolResult" ? undefined : textOf(message);
return text === undefined ? entry.kind : `${entry.kind}:${text}`;
});
}
async function inbox(harness: Harness, root: Conversation) {
return (await harness.snapshot(InboxDoc, root.id, context))!.items.map((item) => [item.id, item.mode]);
}
async function toolRunning(harness: Harness, root: Conversation): Promise<void> {
await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.tools?.[0]?.status === "running");
}
describe("inbox", () => {
it("queues busy submissions and places writes before user items at the final boundary, one follow-up per run", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("second"), answer("third")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const f1 = await root.submit({ type: "input", content: "f1" }, context);
const write = await root.submit({ type: "write", entry: { kind: "note", data: "w" } }, context);
const f2 = await root.submit({ type: "input", content: "f2", whenBusy: "followUp" }, context);
for (const submission of [f1, write, f2]) expect((await status(submission)).status).toBe("queued");
expect(await inbox(harness, root)).toEqual([
[f1.id, "followUp"],
[write.id, "write"],
[f2.id, "followUp"],
]);
expect(transcript(await allEntries(root))).toEqual(["pi.user:a"]);
first.release();
await f2.wait(context);
expect(transcript(await allEntries(root))).toEqual([
"pi.user:a",
"pi.assistant:first",
"note",
"pi.user:f1",
"pi.assistant:second",
"pi.user:f2",
"pi.assistant:third",
]);
const answers = (await allEntries(root)).filter((entry) => entry.kind === "pi.assistant");
expect(await status(input)).toMatchObject({ status: "done", answer: answers[0]!.id });
expect(await status(write)).toMatchObject({ status: "done" });
expect(await status(f1)).toMatchObject({ status: "done", answer: answers[1]!.id });
expect(await status(f2)).toMatchObject({ status: "done", answer: answers[2]!.id });
expect(await inbox(harness, root)).toEqual([]);
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
await harness.close(context);
});
it("places every follow-up in one successor run with followUpMode all", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("both")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.setFollowUpMode("all", context);
expect(await root.getFollowUpMode(context)).toBe("all");
expect(await root.getSteeringMode(context)).toBe("one-at-a-time");
await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const f1 = await root.submit({ type: "input", content: "f1" }, context);
const f2 = await root.submit({ type: "input", content: "f2" }, context);
first.release();
const settled = await f2.wait(context);
expect(await status(f1)).toEqual({ ...settled, id: f1.id, entry: expect.any(Number) });
expect(transcript(await allEntries(root))).toEqual([
"pi.user:a",
"pi.assistant:first",
"pi.user:f1",
"pi.user:f2",
"pi.assistant:both",
]);
expect(setup.faux.state.callCount).toBe(2);
await harness.close(context);
});
it("adds steers to the run at the post-tools boundary and holds follow-ups for the final boundary", async () => {
const setup = chatSetup();
const gate = deferred();
holdTool(setup, gate);
setup.faux.setResponses([HOLD, answer("after tools"), answer("follow-up")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await toolRunning(harness, root);
const steer = await root.submit({ type: "input", content: "s", whenBusy: "steer" }, context);
const followUp = await root.submit({ type: "input", content: "f" }, context);
gate.resolve();
await followUp.wait(context);
expect(transcript(await allEntries(root))).toEqual([
"pi.user:a",
"pi.assistant",
"pi.tool-result",
"pi.user:s",
"pi.assistant:after tools",
"pi.user:f",
"pi.assistant:follow-up",
]);
const answers = (await allEntries(root)).filter((entry) => entry.kind === "pi.assistant");
expect(await status(input)).toMatchObject({ status: "done", answer: answers[1]!.id });
expect(await status(steer)).toMatchObject({ status: "done", answer: answers[1]!.id });
expect(await status(followUp)).toMatchObject({ status: "done", answer: answers[2]!.id });
await harness.close(context);
});
it("ends the run at a queued reset after tools and runs earlier follow-ups in the new context", async () => {
const setup = chatSetup();
const gate = deferred();
holdTool(setup, gate);
const requests: string[][] = [];
const record: FauxResponseStep = (request) => {
requests.push(request.messages.map((message) => `${message.role}:${textOf(message) ?? ""}`));
return answer("fresh");
};
setup.faux.setResponses([HOLD, record]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await toolRunning(harness, root);
const followUp = await root.submit({ type: "input", content: "f" }, context);
await root.reset(undefined, context);
gate.resolve();
await followUp.wait(context);
expect(await status(input)).toMatchObject({ status: "unanswered", reason: "reset" });
expect(transcript(await allEntries(root))).toEqual([
"pi.user:a",
"pi.assistant",
"pi.tool-result",
"pi.reset",
"pi.user:f",
"pi.assistant:fresh",
]);
const reset = (await allEntries(root)).find((entry) => ResetEntry.is(entry))!;
expect(reset.head).toBe(reset.id);
// The follow-up's request starts at the reset: the follow-up, then the complete system baseline after the cut.
expect(requests).toEqual([["user:f", "system:"]]);
expect(setup.faux.state.callCount).toBe(2);
await harness.close(context);
});
it("places a queued reset after the answer at the final boundary", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
await root.reset("handoff", context);
first.release();
await input.wait(context);
await harness.waitForIdle(context);
expect(await status(input)).toMatchObject({ status: "done" });
expect(transcript(await allEntries(root))).toEqual(["pi.user:a", "pi.assistant:first", "pi.reset:handoff"]);
expect((await root.context(context)).messages).toEqual([
{ role: "user", content: "handoff", timestamp: expect.any(Number) },
]);
await harness.close(context);
});
it("resets an idle conversation at once, with or without handoff text", async () => {
const setup = chatSetup();
setup.now = () => 7;
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
await root.reset(undefined, context);
let view = await root.context(context);
expect(view.head?.kind).toBe("pi.reset");
expect(view.head?.model).toBeUndefined();
expect(view.messages).toEqual([]);
await root.reset("carry on", context);
view = await root.context(context);
expect(view.head?.head).toBe(view.head?.id);
expect(view.messages).toEqual([{ role: "user", content: "carry on", timestamp: 7 }]);
await harness.close(context);
});
it("makes a queued head write stale when it targets an entry before the active range", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("second")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const old = await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
await root.reset(undefined, context);
const reset = (await root.context(context)).head!;
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const stale = await root.submit({ type: "write", entry: { kind: "summary", head: old.id } }, context);
const fresh = await root.submit({ type: "write", entry: { kind: "summary", head: reset.id } }, context);
first.release();
await input.wait(context);
expect(await status(stale)).toMatchObject({ status: "unanswered", reason: "stale" });
expect(await status(fresh)).toMatchObject({ status: "done" });
expect(await inbox(harness, root)).toEqual([]);
// The fresh summary's marker starts the range at the reset: a target inside the range is not stale, even when
// it is older than the marker itself. A reset placed earlier in the same boundary makes it stale.
const inside = (await allEntries(root)).find((entry) => entry.kind === "pi.user")!;
const second = await root.submit({ type: "input", content: "b" }, context);
const kept = await root.submit({ type: "write", entry: { kind: "summary", head: inside.id } }, context);
await second.wait(context);
expect(await status(kept)).toMatchObject({ status: "done" });
await harness.close(context);
});
it("makes a head write stale behind a reset placed earlier in the same boundary", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const target = (await allEntries(root))[0]!;
await root.reset(undefined, context);
const summary = await root.submit({ type: "write", entry: { kind: "summary", head: target.id } }, context);
first.release();
await input.wait(context);
expect(await status(summary)).toMatchObject({ status: "unanswered", reason: "stale" });
await harness.close(context);
});
it("ends the run with a pi.reset entry when a tool requests a handoff", async () => {
const setup = chatSetup();
const gate = deferred();
gate.resolve();
holdTool(setup, gate, { content: [], control: { handoff: "continue here" } });
setup.faux.setResponses([HOLD]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const settled = await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
const entries = await allEntries(root);
const calling = entries.find((entry) => entry.kind === "pi.assistant")!;
expect(settled).toMatchObject({ status: "done", answer: calling.id });
expect(transcript(entries)).toEqual(["pi.user:a", "pi.assistant", "pi.tool-result", "pi.reset:continue here"]);
expect(entries.at(-1)!.head).toBe(entries.at(-1)!.id);
expect(setup.faux.state.callCount).toBe(1);
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
await harness.close(context);
});
it("drops an onYield continuation when the final boundary selects a follow-up", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("second")]);
let yields = 0;
setup.registry.hooks.add(GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) });
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const followUp = await root.submit({ type: "input", content: "f" }, context);
first.release();
await followUp.wait(context);
expect(await status(input)).toMatchObject({ status: "done" });
expect(transcript(await allEntries(root))).toEqual([
"pi.user:a",
"pi.assistant:first",
"pi.user:f",
"pi.assistant:second",
]);
await harness.close(context);
});
it("withdraws a queued submission and removes its item", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const kept = await root.submit({ type: "write", entry: { kind: "note" } }, context);
const withdrawn = await root.submit({ type: "input", content: "f" }, context);
expect(await withdrawn.abort(context)).toBe("aborted");
expect(await withdrawn.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
expect(await inbox(harness, root)).toEqual([[kept.id, "write"]]);
first.release();
await input.wait(context);
expect(await status(kept)).toMatchObject({ status: "done" });
expect(setup.faux.state.callCount).toBe(1);
await harness.close(context);
});
it("leaves queued items after a failed run until the next submission places them in order", async () => {
const setup = chatSetup();
const failing = gated(fauxAssistantMessage([], { stopReason: "error", errorMessage: "invalid request" }));
setup.faux.setResponses([failing.step, answer("for f"), answer("for g")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await failing.reached;
const f = await root.submit({ type: "input", content: "f" }, context);
failing.release();
expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error" });
await harness.waitForIdle(context);
expect((await status(f)).status).toBe("queued");
expect(await inbox(harness, root)).toEqual([[f.id, "followUp"]]);
// Idle with a queued item: the new input queues behind it, and a final boundary places the older one first.
const g = await root.submit({ type: "input", content: "g", whenBusy: "reject" }, context);
await g.wait(context);
expect(await status(f)).toMatchObject({ status: "done" });
expect(transcript(await allEntries(root)).slice(-4)).toEqual([
"pi.user:f",
"pi.assistant:for f",
"pi.user:g",
"pi.assistant:for g",
]);
await harness.close(context);
});
it("keeps queued submissions across reopen and settles them afterwards", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-durable-inbox-"));
directories.add(directory);
const path = join(directory, "session.sqlite");
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("first again"), answer("f")]);
let opened = await openChat(await openNodeSqliteStorage(path), setup);
await opened.root.submit({ type: "input", content: "a" }, context);
await first.reached;
const f = (await opened.root.submit({ type: "input", content: "f" }, context)).id;
await opened.harness.close(context);
opened = await openChat(await openNodeSqliteStorage(path), setup);
const settled = await (await opened.harness.submission(f, context))!.wait(context);
expect(settled).toMatchObject({ status: "done" });
expect(transcript(await allEntries(opened.root)).slice(-2)).toEqual(["pi.user:f", "pi.assistant:f"]);
await opened.harness.close(context);
});
it("commits inbox changes as positional Chord operations and a base when empty", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("second")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const ops: Op[][] = [];
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication)) {
if (change.record.kind === "pi.inbox" && change.ops.length > 0) ops.push([...change.ops]);
}
});
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const w1 = await root.submit({ type: "write", entry: { kind: "note" } }, context);
const f1 = await root.submit({ type: "input", content: "f1" }, context);
const w2 = await root.submit({ type: "write", entry: { kind: "note" } }, context);
const f2 = await root.submit({ type: "input", content: "f2" }, context);
const w3 = await root.submit({ type: "write", entry: { kind: "note" } }, context);
expect(ops).toEqual([
[["p", ["items"], 0, 0, [{ id: w1.id, mode: "write", entry: { kind: "note" } }]]],
[["p", ["items"], 1, 0, [{ id: f1.id, mode: "followUp", content: "f1" }]]],
[["p", ["items"], 2, 0, [{ id: w2.id, mode: "write", entry: { kind: "note" } }]]],
[["p", ["items"], 3, 0, [{ id: f2.id, mode: "followUp", content: "f2" }]]],
[["p", ["items"], 4, 0, [{ id: w3.id, mode: "write", entry: { kind: "note" } }]]],
]);
first.release();
await input.wait(context);
// Every write and the first follow-up leave; only f2 at index 3 remains. No retained value is carried.
expect(ops[5]!.every((op) => op[0] === "p" && (op[4] as unknown[]).length === 0)).toBe(true);
expect(ops[5]).toContainEqual(["p", ["items"], 4, 1, []]);
expect(JSON.stringify(ops[5])).not.toContain("f2");
await f2.wait(context);
await harness.close(context);
});
it("settles a stale write at once while idle, and a queued one behind waiting items", async () => {
const setup = chatSetup();
const failing = gated(fauxAssistantMessage([], { stopReason: "error", errorMessage: "invalid request" }));
setup.faux.setResponses([failing.step, answer("for f")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const old = await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
await root.reset(undefined, context);
const before = (await allEntries(root)).length;
const idle = await root.submit({ type: "write", entry: { kind: "summary", head: old.id } }, context);
expect(await idle.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" });
expect(await allEntries(root)).toHaveLength(before);
// After a failed run, a follow-up waits; a stale write queues behind it and the boundary rejects it.
const input = await root.submit({ type: "input", content: "a" }, context);
await failing.reached;
const f = await root.submit({ type: "input", content: "f" }, context);
failing.release();
await input.wait(context);
await harness.waitForIdle(context);
const queued = await root.submit({ type: "write", entry: { kind: "summary", head: old.id } }, context);
expect(await queued.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" });
expect(await f.wait(context)).toMatchObject({ status: "done" });
await harness.close(context);
});
it("keeps an onYield continuation across a queued plain write, with the run's original input", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("second")]);
let yields = 0;
setup.registry.hooks.add(GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) });
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const write = await root.submit({ type: "write", entry: { kind: "note" } }, context);
first.release();
await input.wait(context);
const entries = await allEntries(root);
expect(transcript(entries)).toEqual([
"pi.user:a",
"pi.assistant:first",
"note",
"pi.user:more",
"pi.assistant:second",
]);
expect(await status(input)).toMatchObject({ status: "done", answer: entries.at(-1)!.id });
expect(await status(write)).toMatchObject({ status: "done" });
await harness.close(context);
});
it("drops an onYield continuation for a queued reset", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step]);
setup.registry.hooks.add(GenerationTask, { onYield: () => ({ continue: "more" }) });
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
await root.reset(undefined, context);
first.release();
await input.wait(context);
await harness.waitForIdle(context);
expect(await status(input)).toMatchObject({ status: "done" });
expect(transcript(await allEntries(root))).toEqual(["pi.user:a", "pi.assistant:first", "pi.reset"]);
expect(setup.faux.state.callCount).toBe(1);
await harness.close(context);
});
it("adds every steer to the run at post-tools with steeringMode all", async () => {
const setup = chatSetup();
const gate = deferred();
holdTool(setup, gate);
setup.faux.setResponses([HOLD, answer("after tools"), answer("follow-up")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.setSteeringMode("all", context);
const input = await root.submit({ type: "input", content: "a" }, context);
await toolRunning(harness, root);
const s1 = await root.submit({ type: "input", content: "s1", whenBusy: "steer" }, context);
const f = await root.submit({ type: "input", content: "f" }, context);
const s2 = await root.submit({ type: "input", content: "s2", whenBusy: "steer" }, context);
gate.resolve();
await f.wait(context);
const entries = await allEntries(root);
expect(transcript(entries)).toEqual([
"pi.user:a",
"pi.assistant",
"pi.tool-result",
"pi.user:s1",
"pi.user:s2",
"pi.assistant:after tools",
"pi.user:f",
"pi.assistant:follow-up",
]);
const answers = entries.filter((entry) => entry.kind === "pi.assistant");
for (const submission of [input, s1, s2]) {
expect(await status(submission)).toMatchObject({ status: "done", answer: answers[1]!.id });
}
await harness.close(context);
});
it("queues an idle steer behind waiting items and places it with the first follow-up in ID order", async () => {
const setup = chatSetup();
const failing = gated(fauxAssistantMessage([], { stopReason: "error", errorMessage: "invalid request" }));
setup.faux.setResponses([failing.step, answer("both")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await failing.reached;
const f = await root.submit({ type: "input", content: "f" }, context);
failing.release();
await input.wait(context);
await harness.waitForIdle(context);
const steer = await root.submit({ type: "input", content: "s", whenBusy: "steer" }, context);
const settled = await steer.wait(context);
expect(await status(f)).toMatchObject({ status: "done", answer: settled.status === "done" && settled.answer });
expect(transcript(await allEntries(root)).slice(-3)).toEqual(["pi.user:f", "pi.user:s", "pi.assistant:both"]);
await harness.close(context);
});
it("starts a queued follow-up after a terminating round", async () => {
const setup = chatSetup();
const gate = deferred();
holdTool(setup, gate, { content: [], control: { terminate: true } });
setup.faux.setResponses([HOLD, answer("follow-up")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await toolRunning(harness, root);
const f = await root.submit({ type: "input", content: "f" }, context);
gate.resolve();
await f.wait(context);
const calling = (await allEntries(root)).find((entry) => entry.kind === "pi.assistant")!;
expect(await status(input)).toMatchObject({ status: "done", answer: calling.id });
expect(transcript(await allEntries(root))).toEqual([
"pi.user:a",
"pi.assistant",
"pi.tool-result",
"pi.user:f",
"pi.assistant:follow-up",
]);
await harness.close(context);
});
it("writes the last handoff in call order and then runs queued follow-ups in the new context", async () => {
const setup = chatSetup();
const firstGate = deferred();
const secondGate = deferred();
holdTool(setup, firstGate, { content: [], control: { handoff: "one" } });
setup.registry.tools.add({
name: "later",
description: "Finishes first",
parameters: Type.Object({}),
execute: async () => {
await secondGate.promise;
return { content: [], control: { handoff: "two" } };
},
});
const round = fauxAssistantMessage(
[fauxToolCall("hold", {}, { id: "c1" }), fauxToolCall("later", {}, { id: "c2" })],
{ stopReason: "toolUse" },
);
setup.faux.setResponses([round, answer("follow-up")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await toolRunning(harness, root);
const f = await root.submit({ type: "input", content: "f" }, context);
secondGate.resolve();
await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.tools?.[1]?.status === "done");
firstGate.resolve();
await f.wait(context);
expect(await status(input)).toMatchObject({ status: "done" });
expect(transcript(await allEntries(root)).slice(-4)).toEqual([
"pi.tool-result",
"pi.reset:two",
"pi.user:f",
"pi.assistant:follow-up",
]);
await harness.close(context);
});
it("leaves the inbox alone when the run's task is aborted", async () => {
const setup = chatSetup();
const first = gated(answer("never"));
setup.faux.setResponses([first.step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const f = await root.submit({ type: "input", content: "f" }, context);
const taskId = (await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId;
await harness.abortTask(taskId, context);
expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
await harness.waitForIdle(context);
expect((await status(f)).status).toBe("queued");
expect(await inbox(harness, root)).toEqual([[f.id, "followUp"]]);
await harness.close(context);
});
it("returns a queued submission for its repeated request ID without a second item", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const queued = await root.submit({ type: "input", content: "f", requestId: "r" }, context);
const again = await root.submit({ type: "input", content: "f", requestId: "r" }, context);
expect(again.id).toBe(queued.id);
expect(await inbox(harness, root)).toEqual([[queued.id, "followUp"]]);
await harness.close(context);
});
it("withdraws a middle item with one positional removal", async () => {
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.submit({ type: "input", content: "a" }, context);
await first.reached;
const items = [];
for (const text of ["x", "y", "z"]) items.push(await root.submit({ type: "input", content: text }, context));
const ops: Op[][] = [];
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication)) {
if (change.record.kind === "pi.inbox" && change.ops.length > 0) ops.push([...change.ops]);
}
});
await items[1]!.abort(context);
expect(ops).toEqual([[["p", ["items"], 1, 1, []]]]);
expect(await inbox(harness, root)).toEqual([
[items[0]!.id, "followUp"],
[items[2]!.id, "followUp"],
]);
await harness.close(context);
});
it("stores the inbox as a base exactly when it becomes empty", async () => {
const written: { kind: "base" | "delta"; empty: boolean }[] = [];
let inboxId: number | undefined;
class RecordingStorage extends MemoryStorage {
override async commit(
writes: Parameters<MemoryStorage["commit"]>[0],
callContext: Parameters<MemoryStorage["commit"]>[1],
) {
for (const write of writes) {
if (write.type !== "document.change" || write.id !== inboxId) continue;
const content = write.content;
const empty = content.kind === "base" && (content.value as InboxState).items.length === 0;
written.push({ kind: content.kind, empty });
}
return super.commit(writes, callContext);
}
}
const setup = chatSetup();
const first = gated(answer("first"));
setup.faux.setResponses([first.step, answer("second")]);
const { harness, root } = await openChat(new RecordingStorage(), setup);
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication))
if (change.record.kind === "pi.inbox") inboxId = change.record.id;
});
const input = await root.submit({ type: "input", content: "a" }, context);
await first.reached;
await root.submit({ type: "input", content: "f1" }, context);
const f2 = await root.submit({ type: "input", content: "f2" }, context);
first.release();
await input.wait(context);
await f2.wait(context);
// The inbox ID is learned from the f1 push; later writes: the f2 push, removing f1, and emptying the inbox.
expect(written).toEqual([
{ kind: "delta", empty: false },
{ kind: "delta", empty: false },
{ kind: "base", empty: true },
]);
await harness.close(context);
});
it("keeps a complete inbox base exactly while it is empty, and a usage base on every change", () => {
const info = { deltasSinceBase: 1000 } as never;
const item = { id: 1 as never, mode: "followUp", content: "x" } as const;
expect(InboxDoc.definition.checkpointWhen!({ items: [] }, [], info)).toBe(true);
expect(InboxDoc.definition.checkpointWhen!({ items: [item] }, [], info)).toBe(false);
expect(UsageDoc.definition.checkpointWhen!({ models: {}, tools: {} }, [], info)).toBe(true);
});
});
describe("usage", () => {
it("totals assistant usage per model and tool usage per tool, and sums the Session", async () => {
const setup = chatSetup();
const spent: Usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
const gate = deferred();
gate.resolve();
holdTool(setup, gate, { content: [], usage: spent });
setup.faux.setResponses([HOLD, answer("done"), answer("other")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
const entries = await allEntries(root);
const assistants = entries.flatMap((entry) =>
entry.kind === "pi.assistant" ? [entry.model![0] as AssistantMessage] : [],
);
const expected = assistants.reduce(
(sum, message) => ({
input: sum.input + message.usage.input,
output: sum.output + message.usage.output,
totalTokens: sum.totalTokens + message.usage.totalTokens,
}),
{ input: 0, output: 0, totalTokens: 0 },
);
const usage = (await harness.snapshot(UsageDoc, root.id, context))!;
expect(usage.models["faux/faux-1"]).toMatchObject(expected);
expect(usage.tools).toEqual({ hold: spent });
const result = entries.find((entry) => entry.kind === "pi.tool-result")!.model![0] as ToolResultMessage;
expect(result.usage).toEqual(spent);
// A fork starts at zero; the Session total adds every conversation once.
const fork = await root.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context);
expect(await harness.snapshot(UsageDoc, fork.id, context)).toEqual({ models: {}, tools: {} });
await (await fork.submit({ type: "input", content: "b" }, context)).wait(context);
const forkUsage = (await harness.snapshot(UsageDoc, fork.id, context))!.models["faux/faux-1"]!;
const total = await harness.usage(context);
expect(total.models["faux/faux-1"]!.output).toBe(expected.output + forkUsage.output);
expect(total.tools).toEqual({ hold: spent });
await harness.close(context);
});
it("counts failed attempts, converted partials, and tool usage replaced by afterTool", async () => {
const setup = chatSetup({ tokensPerSecond: 200, tokenSize: { min: 1, max: 1 } });
const spent: Usage = {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 5,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const gate = deferred();
gate.resolve();
holdTool(setup, gate);
setup.registry.hooks.add(ToolTask, { afterTool: (_call, result) => ({ ...result, usage: spent }) });
const retryable = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" });
setup.faux.setResponses([retryable, HOLD, answer("done"), answer("x".repeat(400))]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await root.setRetryPolicy({ enabled: true, maxRetries: 1, baseDelayMs: 1 }, context);
await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
expect((await harness.snapshot(UsageDoc, root.id, context))!.tools).toEqual({ hold: spent });
// A partial committed while streaming becomes an aborted entry on abort; its usage counts too.
const input = await root.submit({ type: "input", content: "b" }, context);
await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.generation?.message !== undefined);
await harness.abortTask((await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId, context);
await input.wait(context);
const assistants = (await allEntries(root)).flatMap((entry) =>
entry.kind === "pi.assistant" ? [entry.model![0] as AssistantMessage] : [],
);
expect(assistants.map((message) => message.stopReason)).toEqual(["error", "toolUse", "stop", "aborted"]);
const output = assistants.reduce((sum, message) => sum + message.usage.output, 0);
expect((await harness.snapshot(UsageDoc, root.id, context))!.models["faux/faux-1"]!.output).toBe(output);
await harness.close(context);
});
it("keeps tools named like object prototype keys in the ledger and the Session total", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const usage: Usage = {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const names = ["constructor", "__proto__", "toString"];
for (const name of names) {
await root.commit((tx) => recordUsage(tx, root.id, "tools", name, usage), context);
await root.commit((tx) => recordUsage(tx, root.id, "tools", name, usage), context);
}
const total = await harness.usage(context);
expect(Object.keys(total.tools)).toEqual(names);
for (const name of names) expect(Object.getOwnPropertyDescriptor(total.tools, name)?.value.output).toBe(2);
await harness.close(context);
});
it("keeps usage totals exact across reopen, counting a partial converted after reopen once", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-durable-usage-"));
directories.add(directory);
const path = join(directory, "session.sqlite");
const setup = chatSetup({ tokensPerSecond: 200, tokenSize: { min: 1, max: 1 } });
setup.faux.setResponses([answer("first"), answer("x".repeat(400)), answer("again")]);
let opened = await openChat(await openNodeSqliteStorage(path), setup);
await (await opened.root.submit({ type: "input", content: "a" }, context)).wait(context);
await opened.root.submit({ type: "input", content: "b" }, context);
const live = opened.harness;
await waitFor(
async () => (await live.snapshot(LiveDoc, opened.root.id, context))?.generation?.message !== undefined,
);
// Closing mid-stream keeps the committed partial; the reopened request converts it into an aborted entry.
await opened.harness.close(context);
opened = await openChat(await openNodeSqliteStorage(path), setup);
opened.harness.resume();
await opened.harness.waitForIdle(context);
const assistants = (await allEntries(opened.root)).flatMap((entry) =>
entry.kind === "pi.assistant" ? [entry.model![0] as AssistantMessage] : [],
);
expect(assistants.map((message) => message.stopReason)).toEqual(["stop", "aborted", "stop"]);
const total = (await opened.harness.usage(context)).models["faux/faux-1"]!;
const sum = (field: "input" | "output" | "totalTokens") =>
assistants.reduce((value, message) => value + message.usage[field], 0);
expect([total.input, total.output, total.totalTokens]).toEqual([sum("input"), sum("output"), sum("totalTokens")]);
await opened.harness.close(context);
});
it("records usage as numeric sets on the ledger in the entry's commit", async () => {
const setup = chatSetup();
setup.faux.setResponses([answer("one"), answer("two")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const commits: { entries: number; ops: readonly Op[] }[] = [];
harness.subscribeCommits((publication) => {
for (const change of documentChanges(publication)) {
if (change.record.kind !== "pi.usage") continue;
const entries = publication.changes.filter((other) => other.type === "entry").length;
commits.push({ entries, ops: change.ops });
}
});
await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
await (await root.submit({ type: "input", content: "b" }, context)).wait(context);
expect(commits).toHaveLength(2);
expect(commits[0]!.ops).toEqual([["s", ["models", "faux/faux-1"], expect.any(Object)]]);
expect(commits[1]!.ops.every((op) => op[0] === "s" && op[1][1] === "faux/faux-1")).toBe(true);
expect(commits.every((commit) => commit.entries >= 1)).toBe(true);
await harness.close(context);
});
});

View file

@ -54,7 +54,7 @@ describe("submissions", () => {
await harness.close(context);
});
it("places idle input and rejects every submission while busy without writing", async () => {
it("places idle input, and rejects busy input with whenBusy reject without writing", async () => {
const storage = new ControlledStorage();
const setup = chatSetup();
setup.now = () => 42;
@ -72,14 +72,9 @@ describe("submissions", () => {
expect((await harness.getTask(live!.run!.taskId, context))?.kind).toBe("pi.generation");
const commits = storage.commits.length;
for (const whenBusy of ["steer", "followUp", "reject"] as const) {
const busy = root.submit({ type: "input", content: "again", whenBusy }, context);
await expect(busy).rejects.toBeInstanceOf(ConversationBusy);
}
await expect(root.submit({ type: "write", entry: { kind: "note" } }, context)).rejects.toMatchObject({
name: "ConversationBusy",
conversationId: root.id,
});
const rejected = root.submit({ type: "input", content: "again", whenBusy: "reject" }, context);
await expect(rejected).rejects.toBeInstanceOf(ConversationBusy);
await expect(rejected).rejects.toMatchObject({ conversationId: root.id });
expect(storage.commits.length).toBe(commits);
await harness.close(context);
});

View file

@ -0,0 +1,352 @@
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
import { type AssistantMessage, type FauxResponseStep, fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai";
import {
type Conversation,
ConversationConfig,
type ConversationView,
defineDoc,
type Harness,
InboxDoc,
LiveDoc,
MemoryStorage,
UsageDoc,
} from "@earendil-works/pi-durable";
import { describe, expect, it } from "vitest";
import { allEntries, chatSetup, openChat, waitFor } from "./chat-support.ts";
import { context } from "./session-support.ts";
import { aborted, deferred } from "./task-support.ts";
type Frame = { value: ConversationView; ops: readonly Op[] };
const MOUNTED = ["pi.conversation.config", "pi.inbox", "pi.live", "pi.usage"];
/** Start a watch of `conversation` that records its acquisition revision and every delivered frame. */
async function record(conversation: Conversation) {
const watch = await conversation.watch(context);
const initial = watch.value;
const frames: Frame[] = [];
watch.start(async (value, ops) => {
frames.push({ value, ops });
});
return { initial, frames, stop: () => watch.stop() };
}
/** A freshly built view of `conversation`, for comparing with an advanced one. */
async function fresh(conversation: Conversation): Promise<ConversationView> {
const state = await conversation.viewState(context);
const value = state.value;
state.dispose();
return value;
}
/** The view as committed state defines it, read without any mount: the active entries and the built-in documents. */
async function committed(harness: Harness, conversation: Conversation, record: ConversationView["conversation"]) {
const docs: Record<string, unknown> = {};
for (const token of [ConversationConfig, LiveDoc, InboxDoc, UsageDoc] as const) {
const value = await harness.snapshot(token as typeof LiveDoc, conversation.id, context);
if (value !== undefined) docs[token.definition.kind] = value;
}
return { conversation: record, entries: (await conversation.context(context)).entries, docs };
}
/** Replay every frame's operations from `initial`, checking each delivered revision on the way. */
function replay(initial: ConversationView, frames: readonly Frame[]): ConversationView {
let value = initial;
for (const frame of frames) {
value = applyImmutable(value, frame.ops);
expect(value).toEqual(frame.value);
}
return value;
}
function answer(text: string): AssistantMessage {
return fauxAssistantMessage([fauxText(text)]);
}
/** Let watch callbacks, which run after the commit, catch up. */
async function drained(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function touches(harness: Harness, conversation: Conversation): { count: number } {
const counter = { count: 0 };
harness.subscribeCommits((publication) => {
const touched = publication.changes.some(
(change) =>
(change.type === "entry" && change.value.conversationId === conversation.id) ||
(change.type === "document" &&
change.conversationId === conversation.id &&
MOUNTED.includes(change.record.kind) &&
change.ops.length > 0),
);
if (touched) counter.count++;
});
return counter;
}
describe("conversation view", () => {
it("hydrates the active entries and the built-in documents", async () => {
const setup = chatSetup();
setup.faux.setResponses([answer("hello")]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
const view = await fresh(root);
expect(view.conversation).toEqual({ id: root.id });
expect(view.entries).toEqual(await allEntries(root));
expect(Object.keys(view.docs).sort()).toEqual(MOUNTED);
expect(view.docs["pi.live"]).toEqual({});
expect(view.docs["pi.inbox"]).toEqual({ items: [] });
await harness.close(context);
});
it("publishes one frame per touching commit, whose operations rebuild every revision", async () => {
const setup = chatSetup();
const release = deferred();
const step: FauxResponseStep = async (_request, options) => {
await Promise.race([release.promise, aborted(options!.signal!)]);
return answer("a longer answer");
};
setup.faux.setResponses([step]);
const { harness, root } = await openChat(new MemoryStorage(), setup);
const { initial, frames, stop } = await record(root);
const touching = touches(harness, root);
const submission = await root.submit({ type: "input", content: "hi" }, context);
await waitFor(() => frames.some((frame) => (frame.value.docs["pi.live"] as { generation?: unknown }).generation));
release.resolve();
await submission.wait(context);
await harness.waitForIdle(context);
await drained();
expect(frames).toHaveLength(touching.count);
expect(replay(initial, frames)).toEqual(await committed(harness, root, initial.conversation));
expect(frames[0]!.ops).toContainEqual(["p", ["entries"], 0, 0, [expect.objectContaining({ kind: "pi.user" })]]);
// Document operations keep their exact shape under the mount path.
expect(frames.flatMap((frame) => frame.ops)).toContainEqual([
"s",
["docs", "pi.live", "generation"],
{ attempt: 1 },
]);
await stop();
await harness.close(context);
});
it("shares unchanged parts between revisions and skips commits that touch nothing mounted", async () => {
const setup = chatSetup();
const { harness, root } = await openChat(new MemoryStorage(), setup);
const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
const { initial, frames, stop } = await record(root);
await other.commit((tx) => tx.appendEntry(other.id, { kind: "note" }), context);
await root.setThinkingLevel("high", context);
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
await drained();
expect(frames).toHaveLength(2);
expect(frames[0]!.ops).toEqual([["s", ["docs", "pi.conversation.config", "thinkingLevel"], "high"]]);
expect(frames[0]!.value.entries).toBe(initial.entries);
expect(frames[0]!.value.docs["pi.live"]).toBe(initial.docs["pi.live"]);
expect(frames[1]!.value.docs).toBe(frames[0]!.value.docs);
await stop();
await harness.close(context);
});
it("cuts the entries at a head marker, keeping the entries from its head", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const note = (kind: string) => root.commit((tx) => tx.appendEntry(root.id, { kind }), context);
await note("a");
const b = await note("b");
await note("c");
const { initial, frames, stop } = await record(root);
const summary = await root.commit((tx) => tx.appendEntry(root.id, { kind: "summary", head: b.id }), context);
await note("d");
await root.reset(undefined, context);
await drained();
const kinds = frames.map((frame) => frame.value.entries.map((entry) => entry.kind));
expect(kinds).toEqual([["summary", "b", "c"], ["summary", "b", "c", "d"], ["pi.reset"]]);
expect(frames[0]!.ops).toEqual([["p", ["entries"], 0, 1, [summary]]]);
expect(replay(initial, frames)).toEqual(await committed(harness, root, initial.conversation));
await stop();
await harness.close(context);
});
it("keeps only mounted entries for a raw head write that targets before the active range", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const old = await root.commit((tx) => tx.appendEntry(root.id, { kind: "old" }), context);
await root.reset(undefined, context);
const { frames, stop } = await record(root);
await root.commit((tx) => tx.appendEntry(root.id, { kind: "summary", head: old.id }), context);
await drained();
// Model context now starts at `old` again, but the mount never held it (spec §12); a rebuilt mount shows it.
expect(frames[0]!.value.entries.map((entry) => entry.kind)).toEqual(["summary"]);
await stop();
expect((await fresh(root)).entries.map((entry) => entry.kind)).toEqual(["summary", "old"]);
await harness.close(context);
});
it("cuts a fork's view into its inherited entries", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const a = await root.commit((tx) => tx.appendEntry(root.id, { kind: "a" }), context);
const b = await root.commit((tx) => tx.appendEntry(root.id, { kind: "b" }), context);
const fork = await root.fork(b.id, { ownership: { kind: "ownerless" } }, context);
const { initial, frames, stop } = await record(fork);
await fork.commit((tx) => tx.appendEntry(fork.id, { kind: "summary", head: b.id }), context);
await drained();
expect(initial.entries.map((entry) => entry.id)).toEqual([a.id, b.id]);
expect(frames[0]!.value.entries.map((entry) => entry.kind)).toEqual(["summary", "b"]);
expect(frames[0]!.value).toEqual(await committed(harness, fork, initial.conversation));
await stop();
await harness.close(context);
});
it("shows a fork's inherited entries and follows only the fork's own commits", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const first = await root.commit((tx) => tx.appendEntry(root.id, { kind: "first" }), context);
const fork = await root.fork(first.id, { ownership: { kind: "ownerless" } }, context);
const { initial, frames, stop } = await record(fork);
expect(initial.entries.map((entry) => entry.kind)).toEqual(["first"]);
expect(initial.conversation.parent).toEqual({ conversationId: root.id, at: first.id });
await root.commit((tx) => tx.appendEntry(root.id, { kind: "parent" }), context);
await fork.commit((tx) => tx.appendEntry(fork.id, { kind: "child" }), context);
await drained();
expect(frames.map((frame) => frame.value.entries.map((entry) => entry.kind))).toEqual([["first", "child"]]);
await stop();
await harness.close(context);
});
it("unmounts a retired document and mounts its recreation whole", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const { frames, stop } = await record(root);
await root.commit((tx) => tx.retireDoc(LiveDoc, root.id), context);
await root.commit(async (tx) => {
(await tx.doc(LiveDoc, root.id)).tools = [];
}, context);
await drained();
expect(frames.map((frame) => frame.ops)).toEqual([
[["d", ["docs", "pi.live"]]],
[["s", ["docs", "pi.live"], { tools: [] }]],
]);
expect(frames[0]!.value.docs["pi.live"]).toBeUndefined();
await stop();
await harness.close(context);
});
it("replaces undelivered frames with the newest view after 100 pending frames", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const watch = await root.watch(context);
for (let index = 0; index < 101; index++) {
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
}
const frames: Frame[] = [];
watch.start(async (value, ops) => {
frames.push({ value, ops });
});
await drained();
expect(frames).toHaveLength(1);
expect(frames[0]!.ops).toEqual([["r", frames[0]!.value]]);
expect(frames[0]!.value.entries).toHaveLength(101);
await watch.stop();
await harness.close(context);
});
it("keeps states and watches of one conversation independent and remounts after the last one detaches", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const state = await root.viewState(context);
const { frames, stop } = await record(root);
await root.commit((tx) => tx.appendEntry(root.id, { kind: "one" }), context);
await drained();
expect(state.value.entries.map((entry) => entry.kind)).toEqual(["one"]);
await stop();
expect(frames).toHaveLength(1);
await root.commit((tx) => tx.appendEntry(root.id, { kind: "two" }), context);
await drained();
expect(state.value.entries.map((entry) => entry.kind)).toEqual(["one", "two"]);
const last = state.value;
state.dispose();
// No observer is left, so the mount was dropped: a new observer builds a new revision from committed state.
const rebuilt = await fresh(root);
expect(rebuilt).toEqual(last);
expect(rebuilt).not.toBe(last);
await harness.close(context);
});
it("ends states and watches at close and rejects later acquisition", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const watch = await root.watch(context);
const state = await root.viewState(context);
await harness.close(context);
expect(await watch.closed).toEqual({ reason: "session_closed" });
expect(state.value.entries).toEqual([]);
await expect(root.watch(context)).rejects.toThrow();
await expect(root.viewState(context)).rejects.toThrow();
});
it("rejects an acquisition cancelled or closed while it waits for the Session line", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const hold = () => {
const release = deferred();
const blocking = root.commit(async () => {
await release.promise;
}, context);
return { release: () => release.resolve(), blocking };
};
let held = hold();
const controller = new AbortController();
const cancelled = root.watch({ ...context, abortSignal: controller.signal });
controller.abort(new Error("cancelled"));
held.release();
await held.blocking;
await expect(cancelled).rejects.toThrow("cancelled");
held = hold();
const closedWhileQueued = root.watch(context);
const closing = harness.close(context);
held.release();
await held.blocking;
await expect(closedWhileQueued).rejects.toThrow("Harness is closed");
await closing;
});
it("shares one mount between concurrent observers and isolates a failing listener", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const failing = await root.watch(context);
const { frames, stop } = await record(root);
const shared = await root.viewState(context);
expect(failing.value).toBe(shared.value);
shared.dispose();
failing.start(async () => {
throw new Error("listener failed");
});
await root.commit((tx) => tx.appendEntry(root.id, { kind: "one" }), context);
await root.commit((tx) => tx.appendEntry(root.id, { kind: "two" }), context);
await drained();
expect(await failing.closed).toMatchObject({ reason: "listener_error" });
expect(frames).toHaveLength(2);
await stop();
await harness.close(context);
});
it("publishes one frame for a commit that appends several entries and edits a document", async () => {
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
const Other = defineDoc<{ n: number }>({
kind: "app.other",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ n: 0 }),
});
const { initial, frames, stop } = await record(root);
await root.commit(async (tx) => {
await tx.appendEntry(root.id, { kind: "a" });
(await tx.doc(LiveDoc, root.id)).tools = [];
await tx.appendEntry(root.id, { kind: "b" });
}, context);
// A document that is not mounted publishes nothing.
await root.commit(async (tx) => {
(await tx.doc(Other, root.id)).n = 1;
}, context);
await drained();
expect(frames).toHaveLength(1);
expect(frames[0]!.value.entries.map((entry) => entry.kind)).toEqual(["a", "b"]);
expect(replay(initial, frames)).toEqual(await committed(harness, root, initial.conversation));
await stop();
await harness.close(context);
});
});

View file

@ -44,7 +44,7 @@ const BUDGETS = {
"packages/durable": {
".": {
// The built-in tool task validates arguments with pi-ai's TypeBox-based validation, so TypeBox is allowed.
maxFiles: 52,
maxFiles: 56,
forbid: ["packages/ai/src/index.ts", "packages/ai/src/utils/typebox-helpers.ts"],
},
},