From 47f65f1a3a58caf17ff73aff9f8dcb2fb90d157f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 29 Sep 2026 21:23:46 +0200 Subject: [PATCH] feat(durable): inbox, reset, usage, conversation view, and experimental events (Package 17) --- packages/durable/CHANGELOG.md | 10 + packages/durable/src/entries.ts | 5 + packages/durable/src/harness/config.ts | 6 +- packages/durable/src/harness/context.ts | 58 +- packages/durable/src/harness/events.ts | 385 ++++++++ packages/durable/src/harness/generation.ts | 61 +- packages/durable/src/harness/harness.ts | 63 +- packages/durable/src/harness/inbox.ts | 117 +++ packages/durable/src/harness/live.ts | 10 +- packages/durable/src/harness/post-tools.ts | 32 +- packages/durable/src/harness/registry.ts | 9 +- packages/durable/src/harness/submissions.ts | 59 +- packages/durable/src/harness/tool.ts | 5 +- packages/durable/src/harness/types.ts | 35 +- packages/durable/src/harness/usage.ts | 72 ++ packages/durable/src/harness/view.ts | 241 +++++ packages/durable/src/index.ts | 13 +- packages/durable/src/session/observation.ts | 82 +- packages/durable/src/session/session.ts | 34 +- packages/durable/src/session/transaction.ts | 44 +- packages/durable/src/types.ts | 7 + packages/durable/test/examples/18-print.ts | 50 + packages/durable/test/examples/19-json.ts | 92 ++ packages/durable/test/examples/20-inbox.ts | 70 ++ .../durable/test/examples/21-late-join.ts | 92 ++ .../test/harness-conversations.test.ts | 4 +- packages/durable/test/harness-events.test.ts | 602 ++++++++++++ .../durable/test/harness-generation.test.ts | 37 +- packages/durable/test/harness-inbox.test.ts | 882 ++++++++++++++++++ .../durable/test/harness-submissions.test.ts | 13 +- packages/durable/test/harness-view.test.ts | 352 +++++++ scripts/check-entry-graphs.mjs | 2 +- 32 files changed, 3424 insertions(+), 120 deletions(-) create mode 100644 packages/durable/src/harness/events.ts create mode 100644 packages/durable/src/harness/inbox.ts create mode 100644 packages/durable/src/harness/usage.ts create mode 100644 packages/durable/src/harness/view.ts create mode 100644 packages/durable/test/examples/18-print.ts create mode 100644 packages/durable/test/examples/19-json.ts create mode 100644 packages/durable/test/examples/20-inbox.ts create mode 100644 packages/durable/test/examples/21-late-join.ts create mode 100644 packages/durable/test/harness-events.test.ts create mode 100644 packages/durable/test/harness-inbox.test.ts create mode 100644 packages/durable/test/harness-view.test.ts diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 67af05aae..4e4fedde9 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -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 diff --git a/packages/durable/src/entries.ts b/packages/durable/src/entries.ts index f17f3aa29..17f5632c3 100644 --- a/packages/durable/src/entries.ts +++ b/packages/durable/src/entries.ts @@ -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"); diff --git a/packages/durable/src/harness/config.ts b/packages/durable/src/harness/config.ts index fb5faea54..c01c87e71 100644 --- a/packages/durable/src/harness/config.ts +++ b/packages/durable/src/harness/config.ts @@ -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 = { diff --git a/packages/durable/src/harness/context.ts b/packages/durable/src/harness/context.ts index 60ccb1751..507572e23 100644 --- a/packages/durable/src/harness/context.ts +++ b/packages/durable/src/harness/context.ts @@ -63,6 +63,43 @@ export async function deriveContext( context: Context, ): Promise { if (bounds === undefined) return { head: undefined, entries: [], messages: [] }; + const head = bounds.head; + const range = await scanRange(storage, conversationId, bounds, context); + const edits = new Map(); + 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 { + 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 { 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(); - 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)]; } /** diff --git a/packages/durable/src/harness/events.ts b/packages/durable/src/harness/events.ts new file mode 100644 index 000000000..e5adea3d3 --- /dev/null +++ b/packages/durable/src/harness/events.ts @@ -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; + stop(): Promise; + readonly closed: Promise; +} + +/** 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 { + let watch!: CommittedWatch; + 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([], 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; + +/** 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(); + 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[] = []; + 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(); + 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, "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); +} diff --git a/packages/durable/src/harness/generation.ts b/packages/durable/src/harness/generation.ts index d2715d2b9..956831636 100644 --- a/packages/durable/src/harness/generation.ts +++ b/packages/durable/src/harness/generation.ts @@ -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; @@ -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, conversationId: ConversationId): Promise { +/** + * 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, conversationId: ConversationId): Promise { 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 { 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 => { + 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> { + 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, + inputs: SubmissionId[], +): Promise { + 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, from: TaskId, to: TaskId): void { if (live.run?.taskId === from) live.run.taskId = to; diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index 7ba865ac3..08d201cb7 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -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 = { readonly registry: RegistryReader; readonly tasks: TaskScheduler; readonly submissions: Submissions; + readonly views: ConversationViews; + readonly now: () => number; create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise; }; @@ -142,10 +149,41 @@ class ConversationImpl implements Conversation { }, context); } + async getSteeringMode(context: Context): Promise { + return (await this.#config(context)).steeringMode ?? "one-at-a-time"; + } + + setSteeringMode(mode: QueueMode | undefined, context: Context): Promise { + return this.#editConfig((config) => { + if (mode === undefined) delete config.steeringMode; + else config.steeringMode = mode; + }, context); + } + + async getFollowUpMode(context: Context): Promise { + return (await this.#config(context)).followUpMode ?? "one-at-a-time"; + } + + setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise { + return this.#editConfig((config) => { + if (mode === undefined) delete config.followUpMode; + else config.followUpMode = mode; + }, context); + } + submit(submission: SubmissionDraft, context: Context): Promise { return this.#host.submissions.submit(this.id, submission, context); } + async reset(handoff: string | undefined, context: Context): Promise { + 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(change: (tx: Tx) => T | Promise, context: Context): Promise { return this.#host.harness.commitWith(change, context, { conversationId: this.id }); } @@ -181,6 +219,14 @@ class ConversationImpl implements Conversation { return this.#host.tasks.waitForIdle(this.id, context); } + viewState(context: Context): Promise> { + return this.#host.views.state(this.id, context); + } + + watch(context: Context): Promise> { + return this.#host.views.watch(this.id, context); + } + async #config(context: Context): Promise> { return ( (await this.#host.harness.snapshot(ConversationConfig, this.id, context)) ?? @@ -228,6 +274,8 @@ class HarnessImpl 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 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 { + 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 { return this.#create({ kind: "root" }, options?.init, context); } diff --git a/packages/durable/src/harness/inbox.ts b/packages/durable/src/harness/inbox.ts new file mode 100644 index 000000000..0cc05e3d6 --- /dev/null +++ b/packages/durable/src/harness/inbox.ts @@ -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 } + /** `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({ + 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; + 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 { + 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 { + 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>; + 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>; + 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 { + const items = (await tx.doc(InboxDoc, conversationId)).items; + const index = items.findIndex((item) => item.id === id); + if (index >= 0) items.splice(index, 1); +} diff --git a/packages/durable/src/harness/live.ts b/packages/durable/src/harness/live.ts index 44fb832ec..8003c0d41 100644 --- a/packages/durable/src/harness/live.ts +++ b/packages/durable/src/harness/live.ts @@ -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): 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, diff --git a/packages/durable/src/harness/post-tools.ts b/packages/durable/src/harness/post-tools.ts index 1e2aedf7c..9416eb60e 100644 --- a/packages/durable/src/harness/post-tools.ts +++ b/packages/durable/src/harness/post-tools.ts @@ -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; /** * 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({ name: "pi.post-tools", @@ -36,17 +39,36 @@ export const PostToolsTask = defineTask 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); diff --git a/packages/durable/src/harness/registry.ts b/packages/durable/src/harness/registry.ts index 164cb9a03..7f8fb42ed 100644 --- a/packages/durable/src/harness/registry.ts +++ b/packages/durable/src/harness/registry.ts @@ -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 { 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()]; } diff --git a/packages/durable/src/harness/submissions.ts b/packages/durable/src/harness/submissions.ts index 8fae06c3d..67155fd3a 100644 --- a/packages/durable/src/harness/submissions.ts +++ b/packages/durable/src/harness/submissions.ts @@ -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 { 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 }); + } + 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 { 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"; diff --git a/packages/durable/src/harness/tool.ts b/packages/durable/src/harness/tool.ts index 1445be0a9..b7029ca6c 100644 --- a/packages/durable/src/harness/tool.ts +++ b/packages/durable/src/harness/tool.ts @@ -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 } }); } diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index 7a9a89285..481cc18ab 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -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; /** `undefined` removes the configured mode. */ setToolExecution(mode: ToolExecutionMode | undefined, context: Context): Promise; + /** `one-at-a-time` when unset. */ + getSteeringMode(context: Context): Promise; + /** `undefined` removes the configured mode. */ + setSteeringMode(mode: QueueMode | undefined, context: Context): Promise; + /** `one-at-a-time` when unset. */ + getFollowUpMode(context: Context): Promise; + /** `undefined` removes the configured mode. */ + setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise; /** - * 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; + /** + * 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; /** Session commit whose `tx.createTask()` defaults to this conversation. */ commit(change: (tx: Tx) => T | Promise, context: Context): Promise; @@ -428,6 +450,10 @@ export interface Conversation { fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise; /** Resolve when no live non-background task belongs to this conversation. */ waitForIdle(context: Context): Promise; + /** The structural view (spec §9.3) as a disposable read-only Chord state. */ + viewState(context: Context): Promise>; + /** The structural view as a serialized exact-frame watch with bounded pending frames. */ + watch(context: Context): Promise>; } // 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 activity (active/idle notifications) is specified with run control in Package 17. conversation(id: ConversationId, context: Context): Promise; createConversation(options: ConversationCreateOptions, context: Context): Promise; @@ -466,6 +491,8 @@ export interface Harness extends Session { waitForTask(id: TaskId, context: Context): Promise>; /** Resolve when no live non-background task exists. */ waitForIdle(context: Context): Promise; + /** Session total: every conversation's `pi.usage` summed. */ + usage(context: Context): Promise; } /** What a hook may use: committed reads and the asking task's memos, which hooks and the task share. */ diff --git a/packages/durable/src/harness/usage.ts b/packages/durable/src/harness/usage.ts new file mode 100644 index 000000000..323bd7702 --- /dev/null +++ b/packages/durable/src/harness/usage.ts @@ -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>; + /** Tool results, keyed by tool name; their usage has no model identity. */ + tools: Record>; +}; + +export const UsageDoc = defineDoc({ + 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 { + 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; + 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): 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): 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, + }); + } + } +} diff --git a/packages/durable/src/harness/view.ts b/packages/durable/src/harness/view.ts new file mode 100644 index 000000000..92f61398a --- /dev/null +++ b/packages/durable/src/harness/view.ts @@ -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>; +}; + +/** 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[]; +const MOUNTED_KINDS: ReadonlySet = 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; + readonly observers: Set; +}; + +const VIEWS = new WeakMap(); + +/** 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(); + #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> { + const { observer, detach } = await this.attach( + id, + (value, release) => new CommittedStateSource(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> { + const { observer } = await this.attach( + id, + (value, release) => new CommittedWatch(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( + 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 { + 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 = {}; + const incarnations = new Map(); + 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]]; + } +} diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index 8a0e19483..244a57973 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -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"; diff --git a/packages/durable/src/session/observation.ts b/packages/durable/src/session/observation.ts index bf2df6d09..ac74d438a 100644 --- a/packages/durable/src/session/observation.ts +++ b/packages/durable/src/session/observation.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 { - readonly #attachments = new Set(); +/** + * 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 implements ReplicatedStateSource { + readonly #attachments = new Set>(); #release: (() => void) | undefined; - #value: ObservedDocumentValue; + /** Released on disposal. */ + #value: T | undefined; #cursor = 0; #retired = false; #closed = false; - constructor(value: Readonly, release: () => void) { + constructor(value: T, release: () => void) { this.#value = value; this.#release = release; } - attach(): ReplicatedStateSourceAttachment { - if (this.#closed) throw new Error("Document state source is closed"); - const attachment = new SessionSourceAttachment({ value: this.#value, cursor: this.#cursor }, () => { + attach(): ReplicatedStateSourceAttachment { + 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 = { + const frame: ReplicatedStateSourceFrame = { cursor: this.#cursor, value, ops, @@ -63,37 +68,37 @@ export class SessionDocumentSource implements ReplicatedStateSource { - readonly snapshot: { readonly value: ObservedDocumentValue; readonly cursor: number }; +class SessionSourceAttachment implements ReplicatedStateSourceAttachment { + readonly snapshot: { readonly value: T; readonly cursor: number }; #release: (() => void) | undefined; - readonly #frames: ReplicatedStateSourceFrame[] = []; - #listener: ((frame: ReplicatedStateSourceFrame) => void) | undefined; + readonly #frames: ReplicatedStateSourceFrame[] = []; + #listener: ((frame: ReplicatedStateSourceFrame) => 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) => 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) => 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): void { + publish(frame: ReplicatedStateSourceFrame): 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 = { + 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 { +/** + * Serialized exact-frame watch bound to one document incarnation or conversation view. A `null` value retires it. + * @internal + */ +export class CommittedWatch implements WatchHandle { readonly #detach: () => void; + readonly #replace: (() => T) | undefined; readonly #closedPromise: Promise; - readonly #pending: WatchFrame[] = []; + readonly #pending: WatchFrame[] = []; #resolveClosed!: (end: WatchEnd) => void; - #value: ObservedDocumentValue; - #listener: ((value: ObservedDocumentValue, ops: readonly Op[], context: Context) => Promise) | undefined; + #value: T; + #listener: ((value: T, ops: readonly Op[], context: Context) => Promise) | undefined; #started = false; #scheduled = false; #running = false; @@ -161,15 +170,17 @@ export class SessionDocumentWatch implements WatchHandle #cancellationSignal: AbortSignal | undefined; #cancellationListener: (() => void) | undefined; - constructor(value: Readonly, 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 return this.#closedPromise; } - start(listener: (value: ObservedDocumentValue, ops: readonly Op[], context: Context) => Promise): 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 { + 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 } 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 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 }); } diff --git a/packages/durable/src/session/session.ts b/packages/durable/src/session/session.ts index 624d025f1..960b4d8c0 100644 --- a/packages/durable/src/session/session.ts +++ b/packages/durable/src/session/session.ts @@ -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, + 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(token: SessionDocToken, context: Context): Promise | undefined>; snapshot( token: ConversationDocToken, @@ -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(value, release), ); try { return replicatedState(source) as DocumentState; @@ -261,7 +285,7 @@ export class SessionImpl implements Session { return this.#attachDocument( definition, loaded, - (value, release) => new SessionDocumentWatch(value, release), + (value, release) => new CommittedWatch(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( + #attachDocument( 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); } }); diff --git a/packages/durable/src/session/transaction.ts b/packages/durable/src/session/transaction.ts index 70d3bb0bf..e3fd03f8d 100644 --- a/packages/durable/src/session/transaction.ts +++ b/packages/durable/src/session/transaction.ts @@ -57,14 +57,25 @@ import { prepareForkDocumentCopies } from "./forks.ts"; type AnyTaskRecord = TaskRecord; -/** 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(); /** Submissions created by this transaction, by ID. */ readonly #submissions = new Map(); - /** 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; diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index a02737ede..7b4003fd4 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -709,6 +709,8 @@ export interface Tx { cursor?: Cursor, ): Promise>; scanEntries(query: EntryQuery, limit: number, cursor?: Cursor): Promise>; + /** 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(token: SessionDocToken): Promise>; doc(token: ConversationDocToken, conversationId: ConversationId): Promise>; diff --git a/packages/durable/test/examples/18-print.ts b/packages/durable/test/examples/18-print.ts new file mode 100644 index 000000000..ed0be8b9a --- /dev/null +++ b/packages/durable/test/examples/18-print.ts @@ -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); diff --git a/packages/durable/test/examples/19-json.ts b/packages/durable/test/examples/19-json.ts new file mode 100644 index 000000000..7addf76d2 --- /dev/null +++ b/packages/durable/test/examples/19-json.ts @@ -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; +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}`); diff --git a/packages/durable/test/examples/20-inbox.ts b/packages/durable/test/examples/20-inbox.ts new file mode 100644 index 000000000..8ee01d4a6 --- /dev/null +++ b/packages/durable/test/examples/20-inbox.ts @@ -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((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); diff --git a/packages/durable/test/examples/21-late-join.ts b/packages/durable/test/examples/21-late-join.ts new file mode 100644 index 000000000..209caa800 --- /dev/null +++ b/packages/durable/test/examples/21-late-join.ts @@ -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); diff --git a/packages/durable/test/harness-conversations.test.ts b/packages/durable/test/harness-conversations.test.ts index 6160d095d..4f0f2404c 100644 --- a/packages/durable/test/harness-conversations.test.ts +++ b/packages/durable/test/harness-conversations.test.ts @@ -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"]); diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts new file mode 100644 index 000000000..4c1a4be4f --- /dev/null +++ b/packages/durable/test/harness-events.test.ts @@ -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 { + 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).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) => 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); + }); +}); diff --git a/packages/durable/test/harness-generation.test.ts b/packages/durable/test/harness-generation.test.ts index 9b05948e0..ef179dc06 100644 --- a/packages/durable/test/harness-generation.test.ts +++ b/packages/durable/test/harness-generation.test.ts @@ -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 => { + 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", diff --git a/packages/durable/test/harness-inbox.test.ts b/packages/durable/test/harness-inbox.test.ts new file mode 100644 index 000000000..3f3a16651 --- /dev/null +++ b/packages/durable/test/harness-inbox.test.ts @@ -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(); + +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; 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 { + 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 { + 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[0], + callContext: Parameters[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); + }); +}); diff --git a/packages/durable/test/harness-submissions.test.ts b/packages/durable/test/harness-submissions.test.ts index 21222dcbe..3d3af830b 100644 --- a/packages/durable/test/harness-submissions.test.ts +++ b/packages/durable/test/harness-submissions.test.ts @@ -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); }); diff --git a/packages/durable/test/harness-view.test.ts b/packages/durable/test/harness-view.test.ts new file mode 100644 index 000000000..2e6719d39 --- /dev/null +++ b/packages/durable/test/harness-view.test.ts @@ -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 { + 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 = {}; + 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 { + 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); + }); +}); diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index dbde063c4..bcd774fdf 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -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"], }, },