mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(durable): inbox, reset, usage, conversation view, and experimental events (Package 17)
This commit is contained in:
parent
749e45ae69
commit
47f65f1a3a
32 changed files with 3424 additions and 120 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
385
packages/durable/src/harness/events.ts
Normal file
385
packages/durable/src/harness/events.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
117
packages/durable/src/harness/inbox.ts
Normal file
117
packages/durable/src/harness/inbox.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
72
packages/durable/src/harness/usage.ts
Normal file
72
packages/durable/src/harness/usage.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
241
packages/durable/src/harness/view.ts
Normal file
241
packages/durable/src/harness/view.ts
Normal 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]];
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
50
packages/durable/test/examples/18-print.ts
Normal file
50
packages/durable/test/examples/18-print.ts
Normal 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);
|
||||
92
packages/durable/test/examples/19-json.ts
Normal file
92
packages/durable/test/examples/19-json.ts
Normal 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}`);
|
||||
70
packages/durable/test/examples/20-inbox.ts
Normal file
70
packages/durable/test/examples/20-inbox.ts
Normal 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);
|
||||
92
packages/durable/test/examples/21-late-join.ts
Normal file
92
packages/durable/test/examples/21-late-join.ts
Normal 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);
|
||||
|
|
@ -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"]);
|
||||
|
|
|
|||
602
packages/durable/test/harness-events.test.ts
Normal file
602
packages/durable/test/harness-events.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
882
packages/durable/test/harness-inbox.test.ts
Normal file
882
packages/durable/test/harness-inbox.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
352
packages/durable/test/harness-view.test.ts
Normal file
352
packages/durable/test/harness-view.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue