Wave B: the native message record — input events, projection, adapter death

The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.

Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
  toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
  subagent-abort, plan-block), constructors, and toLlmMessages — the single
  model-facing seam (convertToLlm): strips ids, flattens markers to user
  text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
  dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
  projection (consecutive assistant+toolResult runs group into one
  assistant view message; toolResults attach backward by toolCallId;
  markers project to data parts; plan-blocks splice after the last
  successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
  continue), parseChatInput validation, and inputAppends (answer targets
  the dangling question call or 400s; verdict = optional note user
  message + native verdict marker).

Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
  persists input appends + claimed steers before the turn; claimSteers
  coalesces the whole queue with the handler's steerDelivered filter as
  the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
  placeholder toolResults (the record ends at the unanswered call),
  emits plan-block markers idempotently (plan-blocks.ts rewrite), and
  persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
  unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
  waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
  convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).

Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
  body after the turn settles — the event was never persisted
  pre-acceptance; the merge logic is gone). The transport consumes the
  stashed input event via prepareSendMessagesRequest and throws loudly on
  a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
  (text, answer, verdict, continue) before invoking the SDK send.

Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
This commit is contained in:
amosblomqvist
2026-09-26 00:37:46 +03:00
parent b87a6925af
commit 7ab47d4fcb
36 changed files with 2410 additions and 1447 deletions
+10 -6
View File
@@ -28,6 +28,7 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../
import type { GraphStore } from "../../core/ports.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/coverage.ts";
import { projectNative } from "../../core/projection.ts";
/**
* The extraction pipeline (vision §1 construction, 2026-09-02 redesign) — the
@@ -92,10 +93,12 @@ import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/co
export function transcriptView(record: ChatRecord): string {
// The shared seam (core/coverage.ts messageViewLines): the LLM's prompt
// view and the coverage fingerprint use ONE line builder, so "covered"
// means — exactly — what the extractor saw.
// means — exactly — what the extractor saw. Wave B: the record is native
// history — the extraction view reads through the one-way projection.
const lines = [`SESSION: ${record.title}`];
record.messages.forEach((message, index) => {
lines.push(...messageViewLines(message as UIMessage, (message as UIMessage).id || `m${index}`));
const projected = projectNative(record.messages);
projected.forEach((message, index) => {
lines.push(...messageViewLines(message, message.id || `m${index}`));
});
return lines.join("\n");
}
@@ -868,7 +871,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// engines AND their providers' keys; a refusal lands BEFORE any
// stage runs (typed — the trigger skips, routes surface 503).
refuseIfStagesUnready();
const covered = coverageOf(record.messages as UIMessage[], graphStore.readCoverage(), chatId);
const projected = projectNative(record.messages);
const covered = coverageOf(projected, graphStore.readCoverage(), chatId);
if (covered.uncovered.length === 0) {
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
return { nodes: 0, edges: 0, events: 0 };
@@ -883,11 +887,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// The batch's prompt view: SESSION header + the batch's lines,
// same provenance [id] tags the fingerprints used (coverage
// and prompt share ONE line builder — core/coverage.ts).
const mids = record.messages.map((m, i) => ((m as UIMessage).id || `m${i}`) as string);
const mids = projected.map((m, i) => (m.id || `m${i}`) as string);
const view = [
`SESSION: ${record.title}`,
...batch.flatMap((slot) => {
const idx = record.messages.indexOf(slot.message);
const idx = projected.indexOf(slot.message);
return messageViewLines(slot.message, mids[idx] ?? slot.message.id);
}),
].join("\n");
+10 -4
View File
@@ -1,4 +1,4 @@
import { isToolUIPart, type UIMessage } from "ai";
import { isToolUIPart } from "ai";
import type { PiEngine } from "../models.ts";
import type { SearchProvider } from "../../core/search.ts";
import {
@@ -14,7 +14,7 @@ import {
type PdfToolInput,
type ResearchResult,
type ResearchToolInput,
type SubagentSteerMessage,
type SteerMessage,
type UsageStepLike,
type VisualResult,
type VisualToolInput,
@@ -23,6 +23,8 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
import type { ModelRole } from "../../../config.ts";
import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts";
import { projectNative } from "../../core/projection.ts";
import type { UIMessage } from "ai";
import { runDemoBuilder } from "./demo-builder.ts";
import { runPdfReaderAgent } from "./pdf-reader.ts";
import { runResearcherAgent } from "./researcher.ts";
@@ -69,7 +71,7 @@ export interface JobRunnerDeps {
* steer is delivered (the wake turn completed and the job status
* flipped); rejects when delivery fails unrecoverably.
*/
deliver: (steer: SubagentSteerMessage) => Promise<void>;
deliver: (steer: SteerMessage) => Promise<void>;
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
* the role-override → slot chain, read FRESH per job (the server
* wiring passes effectiveSettings-based resolution). Test overrides
@@ -274,7 +276,11 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
if (input) return input;
const chat = chatStore.getChat(job.chatId);
if (!chat) return undefined;
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as JobToolInput | undefined;
// Wave B: the record is native history — the transcript scan reads
// through the projection (same shape the old record held).
return (findToolPart(projectNative(chat.messages), job.toolCallId)?.input ?? undefined) as
| JobToolInput
| undefined;
}
// ─── shared plumbing ────────────────────────────────────────────────────
+9 -1
View File
@@ -15,6 +15,7 @@ import {
} from "./tools.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages } from "../core/native-messages.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
@@ -89,7 +90,7 @@ export type StepUsageCollector = (usage: Usage) => void;
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
/** The initial transcript, as pi messages (recordToPiMessages). */
/** The initial transcript, as pi messages (the record IS agent state). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
onUsage?: StepUsageCollector;
@@ -164,6 +165,13 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
},
streamFn: engine.streamFn,
shouldStopAfterTurn: stopper,
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
// THE app seam — markers flatten to their canonical user text
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced),
// UI-only markers (plan-block) drop, the app's id fields strip, and
// an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract).
convertToLlm: toLlmMessages,
});
if (opts.onUsage) {
agent.subscribe((event: AgentEvent) => {
+9 -11
View File
@@ -1,10 +1,9 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { serverExecute } from "../tool-execute.ts";
import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts";
import type { UIMessage } from "ai";
import { findPendingToolCall } from "../../core/pending.ts";
import { danglingQuestionOf, hasProbeDone, type LhMessage } from "../../core/native-messages.ts";
import type { ChatStore } from "../../core/ports.ts";
import { buildProbeHandoffSteer, type ProbeHandoffSteerMessage } from "../../core/contracts.ts";
import { buildProbeHandoffSteer, type SteerMessage } from "../../core/contracts.ts";
/** What the probe_done tool closes over. */
export interface ProbeDoneContext {
@@ -13,15 +12,14 @@ export interface ProbeDoneContext {
chatStore: ChatStore;
/** Queue the handoff steer (the wake pipeline — src/server/jobs.ts).
* Injected so this module never imports the server seam. */
deliver: (steer: ProbeHandoffSteerMessage) => void;
deliver: (steer: SteerMessage) => void;
}
/** Does the transcript already carry a concluded probe (any state)? */
function probeConcluded(messages: UIMessage[]): boolean {
for (const m of messages) {
if (m.parts.some((p) => p.type === "tool-probe_done")) return true;
}
return false;
/** Does the transcript already carry a concluded probe (any state)?
* Wave B: the native scan (any probe_done tool call, any state) — same
* semantics the UIMessage part scan had. */
function probeConcluded(messages: readonly LhMessage[]): boolean {
return hasProbeDone(messages);
}
/**
@@ -64,7 +62,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<a
const record = ctx.chatStore.getChat(ctx.chatId);
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
if (findPendingToolCall(record.messages) !== null) {
if (danglingQuestionOf(record.messages) !== null) {
return {
ok: false,
error:
+12 -7
View File
@@ -8,22 +8,27 @@
*/
import type { UIMessage } from "ai";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { LhMessage } from "../native-messages.ts";
import type { Phase3 } from "../phases.ts";
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
import type { EmissionStats, UnderstandingLevel } from "./graph.ts";
// ─── Chat records (the session store's on-disk format) ───────────────────────
/** One persisted session: data/chats/<id>.json. The stored messages are full
* AI SDK UIMessages (parts included) — the canonical session record: a
* pending quiz is a tool part in `input-available` state, which is exactly
* what makes resume-mid-question free. */
/** One persisted session: data/chats/<id>.json. Wave B (owner-approved
* 2026-09-26): the stored messages are pi-NATIVE history (LhMessage — the
* agent loop's own shapes, provider/model/usage/thinkingSignature stamped
* by pi) — the canonical session record IS the agent state; replay is
* `state.messages = record.messages` + continue(). The client-shaped view
* is the one-way projection (core/projection.ts) — a pending quiz is an
* unanswered question tool call, which is exactly what makes
* resume-mid-question free. */
export interface ChatRecord {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messages: UIMessage[];
messages: LhMessage[];
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
tree?: PlanTree;
/** Append-only usage log (observability, 2026-09): one entry per model
@@ -152,8 +157,8 @@ export interface TurnUsage {
* both verdicts are real transcript events: accept sanctions teaching,
* decline asks for revision. The part is the message TYPE (the "custom
* message" scheme, 2026-09-12): the message carries no human-authored text,
* the model-facing wording is owned by the adapter (record-adapter.ts
* flattens it to VERDICT_TEXT below), and the UI renders the verdict
* the model-facing wording is owned by toLlmMessages (native-messages.ts
* flattens it to verdictText below), and the UI renders the verdict
* marker — never a user bubble. A learner who also typed a note sends the
* note as its own real user message first; the verdict message follows. */
export interface PlanVerdictData {
+44 -73
View File
@@ -6,8 +6,9 @@
* master. Pure types + protocol docs + tiny pure helpers; type-only imports
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
*/
import type { UIMessage } from "ai";
import type { CallUsage } from "./chat.ts";
import type { LhMessage, LhMarkerMessage } from "../native-messages.ts";
import { newHandoffMessage } from "../native-messages.ts";
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
@@ -141,7 +142,7 @@ export function accumulateJobUsage(
*
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
* UIMessage carrying the result — a text part with what the MODEL needs
* result-carrying native marker — the content is what the MODEL needs
* (the research brief, the builder's notes), plus a data-subagent-artifact
* part with what the UI renders (the full result: brief + sources /
* SVG / demo HTML). Provider conversion drops data parts, so artifact
@@ -245,6 +246,14 @@ export interface PdfResult {
}
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
//
// Wave B (owner-approved 2026-09-26): steers are NATIVE record messages —
// LhMarkerMessage customs (role "custom"), the custom-message taxonomy's
// wake members. The model-facing text rides `content` (single-sourced
// below); the UI payload (the artifact, the abort badge) rides `details`
// and never reaches the model (toLlmMessages maps the marker to a user
// message carrying `content` only). The deterministic id (`steer_<jobId>`)
// IS the idempotency key — steerDelivered matches on it.
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
export interface SubagentResultMeta {
@@ -264,40 +273,14 @@ export interface SubagentArtifactData {
result: ResearchResult | DemoResult | VisualResult | PdfResult;
}
/**
* The steer message: user-role on the wire (UIMessage roles are fixed), but
* NEVER a learner interjection — metadata.subagentResult is the marker the
* UI branches on, and the artifact rides the data part (stripped by provider
* conversion, so the model sees only the text part).
*/
export interface SubagentSteerMessage extends UIMessage {
role: "user";
metadata: { subagentResult: SubagentResultMeta };
parts: [
{ type: "text"; text: string },
{ type: "data-subagent-artifact"; data: SubagentArtifactData },
];
/** A result steer's marker details: the UI's meta + artifact in one
* payload (the projection splits it into metadata + data part). */
export interface SteerDetails extends SubagentResultMeta {
result: ResearchResult | DemoResult | VisualResult | PdfResult;
}
/** The abort steer: user-role like any steer, but NO artifact part —
* there is no result to render, only the teacher-facing text. Shares the
* result steer's deterministic id space (`steer_${jobId}`): steerDelivered
* matches on metadata jobId, so an abort and a (racing) result steer
* can never BOTH deliver — exactly one wakes the teacher. */
export interface AbortSteerMessage extends UIMessage {
role: "user";
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
parts: [
{ type: "text"; text: string },
{ type: "data-subagent-aborted"; data: SubagentResultMeta & { aborted: true } },
];
}
/** Anything the wake pipeline may queue and deliver. */
export type SteerMessage =
| SubagentSteerMessage
| AbortSteerMessage
| ProbeHandoffSteerMessage;
/** Anything the wake pipeline may queue and deliver — a native marker. */
export type SteerMessage = LhMarkerMessage;
/** Whether a result is the failure shape (html/svg empty, failure brief). */
export function isSubagentFailure(
@@ -354,35 +337,38 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe
export function buildSubagentSteer(
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
result: ResearchResult | DemoResult | VisualResult | PdfResult,
): SubagentSteerMessage {
): SteerMessage {
return {
id: `steer_${job.id}`,
role: "user",
metadata: { subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId } },
parts: [
{ type: "text", text: steerText(job.kind, result) },
{ type: "data-subagent-artifact", data: { kind: job.kind, result } },
],
role: "custom",
customType: "subagent-steer",
content: steerText(job.kind, result),
display: true,
details: {
jobId: job.id,
kind: job.kind,
toolCallId: job.toolCallId,
result,
},
timestamp: Date.now(),
};
}
/** Build the abort steer for an aborted job (pure — shared by the
* abort path in src/server/jobs.ts and tests). Same id space as the result
* steer; see AbortSteerMessage. */
* steer (same id space — exactly one delivers). */
export function buildAbortSteer(
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
): AbortSteerMessage {
): SteerMessage {
const meta = { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true } as const;
return {
id: `steer_${job.id}`,
role: "user",
metadata: { subagentResult: meta },
parts: [
{ type: "text", text: abortText(job.kind) },
// The message-type marker (the custom-message scheme): the UI
// renders the abort badge off this part, not off metadata.
{ type: "data-subagent-aborted", data: meta },
],
role: "custom",
customType: "subagent-abort",
content: abortText(job.kind),
display: true,
details: meta,
timestamp: Date.now(),
};
}
@@ -403,34 +389,19 @@ function abortText(kind: JobKind): string {
// probe_done's execute queues this through the same wake pipeline; the
// pump fires it once the prober's turn ends (gates + grace). It carries no
// artifact — the assessment it references is durable in the transcript as
// the probe_done tool part. Deterministic id per chat: exactly one
// handoff, idempotent re-delivery.
// the probe_done tool result. Deterministic id per chat: exactly one
// handoff, idempotent re-delivery. The marker's content is HANDOFF_TEXT
// (core/contracts/chat.ts — single source); the UI renders the quiet
// system line off the customType. Nothing human-looking is persisted, ever.
export interface ProbeHandoffSteerMessage extends UIMessage {
id: `steer_probe_${string}`;
role: "user";
metadata: { probeHandoff: { chatId: string } };
/** The custom-message scheme (2026-09-12): NO text part — the wake is
* scaffolding, not speech. The model-facing instruction is owned by the
* adapter (record-adapter.ts flattens this part to HANDOFF_TEXT in
* core/contracts/chat.ts); the UI renders the quiet system line off the
* part. Nothing human-looking is persisted, ever. */
parts: [{ type: "data-probe-handoff"; data: { chatId: string } }];
}
export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage {
return {
id: `steer_probe_${chatId}`,
role: "user",
metadata: { probeHandoff: { chatId } },
parts: [{ type: "data-probe-handoff", data: { chatId } }],
};
export function buildProbeHandoffSteer(chatId: string): SteerMessage {
return newHandoffMessage(chatId);
}
/** True when the transcript already carries this steer — idempotent
* delivery for ANY steer kind: every steer id is deterministic per its
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
export function steerDelivered(messages: UIMessage[], steerId: string): boolean {
export function steerDelivered(messages: LhMessage[], steerId: string): boolean {
return messages.some((m) => m.id === steerId);
}
+6
View File
@@ -32,6 +32,12 @@ export * from "./pending.ts";
export * from "./prompt-segments.ts";
export * from "./math-delims.ts";
export * from "./ui-message.ts";
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
// one-way client-view projection, and the input-events contract — all
// pure logic, browser-safe.
export * from "./native-messages.ts";
export * from "./projection.ts";
export * from "./input-events.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
export * from "./cost-summary.ts";
+121
View File
@@ -0,0 +1,121 @@
/**
* THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26: "input
* events now, in the same pass"): POST /api/chat carries `{chatId, input}`
* — ONE explicit event, never a client transcript. The server owns the
* record; a send tells it what HAPPENED, not what the client's list looks
* like. The 409 merge-retry died with the full-list body (an input event
* retries idempotently: nothing was appended when a POST is refused).
*
* The four events (the complete client-originated input taxonomy):
* text — a learner-authored user message (composer send, initial
* topic, the demo error-fix loop)
* answer — the pending question's graded result (QuestionAnswerData,
* built client-side exactly as today; a dismissal is an answer
* carrying its flag). Server-side this becomes the call's
* native tool result — fork 1's normal agent history.
* verdict — the plan verdict (accept/decline); an optional note is the
* learner's own real user message, sent first, exactly the
* two-real-messages scheme.
* continue — run the next turn on the current record (mount
* continuations, job-settle re-syncs: the record ends at an
* answered question with no teacher reaction yet).
*
* Server-originated delivery (the wake pipeline's steers) is NOT a client
* input — the handler accepts it on the same path (TurnInput) so every
* turn rides one composition.
*/
import type { QuestionAnswerData } from "./question-core.ts";
import type { LhMessage } from "./native-messages.ts";
import {
danglingQuestionOf,
newAnswerResult,
newUserMessage,
newVerdictMessage,
} from "./native-messages.ts";
import { QUESTION_TOOLS } from "./pending.ts";
/** One client-originated input event (the POST body's `input`). */
export type ChatInput =
| { type: "text"; text: string }
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
| { type: "verdict"; accepted: boolean; note?: string }
| { type: "continue" };
/** A wake-pipeline steer delivery — same turn composition, server-origin. */
export type SteerInput = { type: "steer"; steer: LhMessage };
/** Every input the turn composition accepts. */
export type TurnInput = ChatInput | SteerInput;
/** A malformed input event (the honest 400 — the orphan-trap discipline). */
export class ChatInputError extends Error {}
/** Parse and validate an input event off a request body. */
export function parseChatInput(input: unknown): ChatInput {
if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object");
const i = input as Record<string, unknown>;
switch (i.type) {
case "text":
if (typeof i.text !== "string" || (i.text as string).trim().length === 0) {
throw new ChatInputError("text input requires non-empty text");
}
return { type: "text", text: i.text };
case "answer":
if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) {
throw new ChatInputError("answer input requires toolCallId");
}
if (typeof i.answer !== "object" || i.answer === null) {
throw new ChatInputError("answer input requires the graded answer object");
}
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
case "verdict":
if (typeof i.accepted !== "boolean") throw new ChatInputError("verdict input requires accepted");
if (i.note !== undefined && typeof i.note !== "string") {
throw new ChatInputError("verdict note must be a string");
}
return { type: "verdict", accepted: i.accepted, note: i.note as string | undefined };
case "continue":
return { type: "continue" };
default:
throw new ChatInputError(`unknown input type: ${String(i.type)}`);
}
}
/**
* The native appends an input event makes to the record (pure). The caller
* persists them write-ahead; the agent's context is the record plus these.
*
* Answer validation is the orphan-400 discipline: the call must be a
* DANGLING QUESTION call in the record (posed, never answered) — anything
* else 400s honestly instead of silently interrupting a question or
* double-answering.
*/
export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] {
switch (input.type) {
case "text":
return [newUserMessage(input.text)];
case "answer": {
const pending = danglingQuestionOf(record.messages);
if (!pending || pending.toolCallId !== input.toolCallId) {
throw new ChatInputError("answer input targets no pending question call");
}
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
}
case "verdict": {
const appends: LhMessage[] = [];
if (input.note && input.note.trim().length > 0) {
appends.push(newUserMessage(input.note));
}
appends.push(newVerdictMessage(input.accepted, new Date().toISOString()));
return appends;
}
case "continue":
return [];
}
}
/** Is this tool name a question tool (dock-owned, answer-gated)? */
export function isQuestionTool(name: string): boolean {
return (QUESTION_TOOLS as readonly string[]).includes(name);
}
+284
View File
@@ -0,0 +1,284 @@
/**
* THE NATIVE MESSAGE RECORD (Wave B, owner-approved 2026-09-26): chat
* records store pi's own message shapes — the exact history the agent
* loop builds — instead of AI-SDK UIMessages. Provider, model, usage,
* stopReason, and thinking signatures are stamped by pi itself; replay is
* `state.messages = record.messages` + `continue()` — the record IS the
* agent state.
*
* WHY pi MESSAGES LACK OUR `id`: pi's session format gives every ENTRY an
* id, never the message. This app needs a per-message id for client
* identity (React keys, append-only adoption, the continuation seam), so
* every record message carries ONE extra field — `id` — alongside the
* native shape. `toLlmMessages` strips it at the wire: the provider never
* sees it (verified single site, below).
*
* UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message
* taxonomy): verdict markers, the probe→teach handoff, sub-agent steers,
* and plan blocks are LhMarkerMessage — this app's declaration-merged
* member of pi-agent-core's CustomAgentMessages extension point, shaped
* exactly like pi's own CustomMessage ({role:"custom", customType,
* content, display, details}). ONE seam — toLlmMessages, wired as the
* agent's convertToLlm — owns every model-facing decision: markers
* flatten to their canonical user text (single-sourced in
* core/contracts), plan blocks are UI-only and filtered, and dangling
* tool calls (an aborted turn) get the interrupted-result synthesis the
* provider protocol demands.
*
* Answers (fork 1, owner 2026-09-26): the learner's answer IS the tool
* call's ToolResultMessage — appended natively at answer time. The
* synthetic "question posed" placeholder is an in-memory loop device
* (Wave A's terminate) and is never persisted: the record ends at the
* unanswered call in the pose→answer window, where no turn can run (the
* question gate holds steers; a POST would 409).
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import { QUESTION_TOOLS } from "./pending.ts";
import { HANDOFF_TEXT, verdictText } from "./contracts/chat.ts";
/** The hard-invariant wording for an aborted turn's unanswered calls —
* carried over verbatim from the pre-native era so the model's view
* never drifted. */
export const INTERRUPTED_TEXT =
"The question was interrupted — the learner never answered; continue without holding a result for it.";
// ─── The record's message type ───────────────────────────────────────────────
/**
* The app's custom message (declaration-merged into pi-agent-core's
* CustomAgentMessages below). Shaped like pi's own CustomMessage —
* `content` is the MODEL-facing text (single-sourced in core/contracts,
* never fake human speech), `details` is the UI payload (never
* model-visible), `display` marks messages the UI renders (our projection
* keys rendering on customType; the flag documents intent).
*/
export interface LhMarkerMessage {
/** Record identity (pi messages carry no id — see the file header). */
id: string;
role: "custom";
customType: "verdict" | "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
/** The model-facing text ("" for plan blocks — UI-only). */
content: string;
display: boolean;
/** The UI payload — per-customType, never model-visible. */
details: unknown;
timestamp: number;
}
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
lhMarker: LhMarkerMessage;
}
}
/** A record message: a native pi message (or our marker) plus the record id. */
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string };
/** A record message list is assignable to the agent's context — that IS
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[] {
return messages as unknown as AgentMessage[];
}
// ─── id minting ──────────────────────────────────────────────────────────────
/** The app-wide message id shape (same as the UIMessage-era mint). */
export function newMessageId(): string {
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
}
// ─── constructors (the input-event → native append mapping lives in
// core/input-events.ts; steers in core/contracts/jobs.ts) ─────────────────
/** A learner-authored user message (text input, verdict note). */
export function newUserMessage(text: string): LhMessage & UserMessage {
return {
id: newMessageId(),
role: "user",
content: text,
timestamp: Date.now(),
};
}
/** The learner's answer — the question call's tool result (fork 1).
* `answer` is the structured QuestionAnswerData the client graded; the
* MODEL sees its JSON encoding (the pre-native wire shape),
* the UI sees the structured payload via `details`. */
export function newAnswerResult(
toolCallId: string,
toolName: string,
answer: unknown,
): LhMessage & ToolResultMessage {
return {
id: newMessageId(),
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text: JSON.stringify(answer) }],
details: answer,
isError: false,
timestamp: Date.now(),
};
}
/** The plan verdict (the plan→teach seam): marker only — the model-facing
* wording is owned by verdictText (core/contracts/chat.ts), the UI
* renders the quiet accept/decline line off details. */
export function newVerdictMessage(accepted: boolean, at: string): LhMarkerMessage {
return {
id: newMessageId(),
role: "custom",
customType: "verdict",
content: verdictText(accepted),
display: true,
details: { accepted, at },
timestamp: Date.now(),
};
}
/** The probe→teach handoff wake: scaffolding, not speech — the model reads
* HANDOFF_TEXT, the UI renders the quiet system line. */
export function newHandoffMessage(chatId: string): LhMarkerMessage {
return {
id: `steer_probe_${chatId}`,
role: "custom",
customType: "probe-handoff",
content: HANDOFF_TEXT,
display: true,
details: { chatId },
timestamp: Date.now(),
};
}
/** The plan presentation block: pure UI history (a frozen snapshot), no
* model face — toLlmMessages filters it. `planCallId` keys idempotent
* emission (one block per successful plan_init call). */
export function newPlanBlockMessage(
planCallId: string,
block: unknown,
): LhMarkerMessage {
return {
id: `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
role: "custom",
customType: "plan-block",
content: "",
display: true,
details: { planCallId, block },
timestamp: Date.now(),
};
}
// ─── the one model-facing seam (the agent's convertToLlm) ────────────────────
/** The ids of every toolResult in the tail (for the dangling-call scan). */
function resultIdsFrom(messages: readonly AgentMessage[]): Set<string> {
const ids = new Set<string>();
for (const m of messages) {
if ((m as { role?: string }).role === "toolResult") {
ids.add((m as ToolResultMessage).toolCallId);
}
}
return ids;
}
/**
* AgentMessage[] → the provider wire (the agent's convertToLlm — required
* by pi-agent-core's loop config, the ONE place model-facing decisions
* live):
*
* - user / assistant / toolResult pass through VERBATIM — signatures,
* usage, provider stamps all native — minus our record-`id` field.
* - markers flatten to a user message carrying their content (the
* canonical text single-sourced in core/contracts); plan blocks are
* UI-only and filtered.
* - dangling tool calls (an aborted turn's unanswered questions) get the
* synthesized interrupted result — the provider protocol's hard
* invariant: a tool call must be followed by its result.
*
* Contract (pi-agent-core): never throws — an unknown shape passes
* through untouched rather than killing the loop.
*/
export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
const out: Message[] = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i]!;
const role = (m as { role?: string }).role;
if (role === "custom") {
const marker = m as LhMarkerMessage;
if (marker.customType === "plan-block") continue; // UI-only
out.push({
role: "user",
content: [{ type: "text", text: marker.content }],
timestamp: marker.timestamp,
});
continue;
}
// Strip the record id: the wire carries the native shape only.
const { id: _id, ...native } = m as LhMessage;
out.push(native as Message);
// Dangling-call synthesis: an assistant message whose toolCalls have
// no results in the remaining tail (abort mid-question) — the
// provider rejects unanswered calls outright.
if (role === "assistant") {
const assistant = m as AssistantMessage;
const calls = assistant.content.filter((b) => b.type === "toolCall");
if (calls.length === 0) continue;
const answered = resultIdsFrom(messages.slice(i + 1));
for (const call of calls) {
if (answered.has(call.id)) continue;
out.push({
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [{ type: "text", text: INTERRUPTED_TEXT }],
isError: false,
timestamp: assistant.timestamp,
});
}
}
}
return out;
}
// ─── native scans (the UIMessage-era pending/phase seams, natively) ──────────
/** The pending-question gate (findPendingToolCall's native twin): the
* NEWEST assistant message's FIRST question-tool call with no result in
* the tail — one question at a time, the dock's subject. */
export function danglingQuestionOf(
messages: readonly LhMessage[],
): { toolCallId: string; tool: string; input: unknown } | null {
for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]!;
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage & { id: string };
const answered = resultIdsFrom(messages.slice(mi + 1));
for (const block of assistant.content) {
if (block.type !== "toolCall") continue;
if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue;
if (answered.has(block.id)) continue;
return { toolCallId: block.id, tool: block.name, input: block.arguments };
}
return null; // the newest assistant message's questions are all answered
}
return null;
}
/** The probe→teach construction seam (chat-handler): a probe_done call in
* the transcript — any state — means the probe is concluded: teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
}
return false;
}
/** Every toolResult id in the record — answer-event validation (the call
* must be a DANGLING question call, not an already-answered one). */
export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
return resultIdsFrom(messages);
}
+58 -53
View File
@@ -1,44 +1,38 @@
/**
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
* presentation seam. The plan renders IN FLOW once, at authoring — a full
* `data-plan-block` attached to the assistant message that carries the
* presentation seam. The plan renders IN FLOW once, at authoring — a
* frozen snapshot attached right after the assistant group carrying the
* successful plan_init call. It is the presentation moment for the plan
* (and the popup's entry point) — statuses on it are accurate at that
* instant (current = null, everything upcoming — unless the authoring is a
* mid-lesson rewrite, in which case the snapshot shows what was true then).
* instant (current = null, everything upcoming — unless the authoring is
* a mid-lesson rewrite, in which case the snapshot shows what was true
* then).
*
* The neighborhood-block machinery (frozen fragments on plan movement) is
* RETIRED: position derives live from the transcript (derivePlanStatuses —
* calls, not results), and frozen snapshots can't represent live movement.
* The persistent DAG window (the UI wave) is the successor — always
* present, auto-moving with the lesson.
*
* Idempotent by construction: a message that already carries a plan-block
* part is never given another (persist replays / retry paths are safe).
* Wave B (owner-approved 2026-09-26): the block is a native plan-block
* MARKER appended at the turn's persist (idempotent per plan_init call);
* the projection splices its data part into the assistant group IN FLOW,
* exactly where the UIMessage-era attach put it. The neighborhood-block
* machinery stays retired: position derives live from the transcript.
*/
import type { PlanBlockData, PlanTree } from "./contracts.ts";
import { derivePlanStatuses } from "./contracts.ts";
import type { UIMessage } from "ai";
import type { LhMessage } from "./native-messages.ts";
import { newPlanBlockMessage } from "./native-messages.ts";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
/** The message part the chat renderer consumes (TeacherUIDataParts key
* "plan-block" — the SDK strips the `data-` prefix from the name). */
* "plan-block" — the SDK strips the `data-` prefix). Kept for the client
* renderers; the projection emits this shape. */
export interface PlanBlockPart {
type: "data-plan-block";
id: string;
data: PlanBlockData;
}
/** Does this message carry a successful plan_init (the trigger)? */
function hasSuccessfulPlanInit(message: UIMessage): boolean {
for (const part of message.parts) {
if (part.type !== "tool-plan_init") continue;
const ok = (part as { output?: { ok?: unknown } }).output?.ok;
if (ok === true) return true;
}
return false;
}
/** The snapshot payload (unchanged since wave 4): statuses derived AT
* EMISSION time from the transcript — the block never re-derives. */
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
const statuses = derivePlanStatuses(tree, messages);
return {
@@ -54,36 +48,47 @@ function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData
};
}
/**
* Enrich a message list with plan-presentation blocks — THE emission
* seam's pure half. `messages` is the deduped list persistTurn is about to
* write; `tree` is the record's current tree (the plan tool saved it during
* the turn). Messages already carrying a block are never touched.
*/
export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] {
if (!tree || tree.nodes.length === 0 || messages.length === 0) return messages;
const blockId = `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
return messages.map((m) => {
if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m;
if (!hasSuccessfulPlanInit(m)) return m;
// POSITION, not append: emission runs at persistTurn — after the whole
// turn is composed — so appending would bury the block under every
// teaching note and quiz of the same turn. The block renders IN FLOW
// immediately after the plan_init call it presents (after the LAST
// one if a turn carried multiple — a rewrite).
const idx = lastIndexOfSuccessfulPlanInit(m.parts);
const parts = m.parts.slice();
parts.splice(idx + 1, 0, { type: "data-plan-block", id: blockId, data: snapshot(tree, messages) } satisfies PlanBlockPart);
return { ...m, parts };
});
/** The record's plan-block markers, keyed by the plan_init call they
* present — the idempotency set for emission. */
export function emittedPlanBlockCalls(messages: readonly LhMessage[]): Set<string> {
const out = new Set<string>();
for (const m of messages) {
if ((m as { role?: string }).role !== "custom") continue;
const marker = m as { customType?: string; details?: { planCallId?: unknown } };
if (marker.customType === "plan-block" && typeof marker.details?.planCallId === "string") {
out.add(marker.details.planCallId);
}
}
return out;
}
/** Index of the last tool-plan_init part with an ok output in this message. */
function lastIndexOfSuccessfulPlanInit(parts: ReadonlyArray<{ type: string }>): number {
let idx = -1;
parts.forEach((p, i) => {
if (p.type !== "tool-plan_init") return;
if ((p as { output?: { ok?: unknown } }).output?.ok === true) idx = i;
});
return idx;
/**
* The native emission seam (pure): ONE plan-block marker per TURN — for
* the turn's LAST successful plan_init call (a same-turn rewrite presents
* once; a later turn's rewrite gets its own block — the golden-master
* rule the UIMessage-era attach had) that the record hasn't already
* emitted. `tree` is the record's current tree (the plan tool saved it
* during the turn); `transcript` is the full PROJECTED transcript the
* status derivation reads.
*/
export function planBlocksFor(
record: readonly LhMessage[],
newMessages: readonly LhMessage[],
tree: PlanTree | null | undefined,
transcript: readonly UIMessage[],
): LhMessage[] {
if (!tree || tree.nodes.length === 0) return [];
const emitted = emittedPlanBlockCalls(record);
let last: { toolCallId: string } | null = null;
for (const m of newMessages) {
if ((m as { role?: string }).role !== "toolResult") continue;
const result = m as ToolResultMessage & { id: string };
if (result.toolName !== "plan_init") continue;
const ok = (result.details as { ok?: unknown } | undefined)?.ok;
if (ok !== true) continue;
if (emitted.has(result.toolCallId)) continue;
last = { toolCallId: result.toolCallId };
}
if (!last) return [];
return [newPlanBlockMessage(last.toolCallId, snapshot(tree, transcript))];
}
+4 -3
View File
@@ -9,7 +9,7 @@
* adapters may import core; core NEVER imports an adapter.
*/
import type { UIMessage } from "ai";
import type { LhMessage } from "./native-messages.ts";
import type {
ChatRecord,
ChatSettings,
@@ -39,8 +39,9 @@ export interface ChatStore {
getChat(id: string): ChatRecord | null;
/** All sessions, newest activity first (the sidebar's list view). */
listChats(): ChatSummary[];
/** Persist the full message list (client state + new response) after a turn. */
saveMessages(id: string, messages: UIMessage[]): void;
/** Persist the full native message list (the record IS the agent
* state — Wave B, owner-approved 2026-09-26). */
saveMessages(id: string, messages: LhMessage[]): void;
/** Persist the session plan tree (plan tools call this in execute). */
saveTree(id: string, tree: PlanTree): void;
/** Append one usage entry to the record's turn log (the money channel,
+199
View File
@@ -0,0 +1,199 @@
/**
* THE ONE-WAY PROJECTION (Wave B, owner-approved 2026-09-26): native record
* messages → AI-SDK UIMessages. The record is pi-native history; every
* existing consumer — GET /api/chats/[id], the client's renderer, phase/
* pending/coverage/extraction scanners — reads THIS projection until Wave
* D replaces the client dialect. One direction only: nothing ever converts
* UIMessages back into native messages (the lossy record-adapter direction
* is deleted wholesale).
*
* GROUPING RULE (the golden master): a turn's UIMessage shape is one
* assistant message per GROUP — consecutive assistant + toolResult
* messages, bounded by user/marker messages; toolResults attach to the
* tool part of their call (searching backward — an answered quiz's result
* lands on the part that asked it, even turns later); a group's id is its
* FIRST assistant message's id. This reproduces the client's live view
* exactly: the continuation merge (start-chunk id = the group's id) and
* the settled record converge to the same shape, so the settle sync never
* re-renders what streamed.
*
* Plan blocks (plan-blocks.ts's splice, moved here): a plan-block marker
* has no message of its own — its data part splices into the preceding
* assistant group, IN FLOW right after the last successful plan_init call
* (the presentation moment).
*/
import type { UIMessage } from "ai";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts";
/** Loose part shapes the projection emits (the client's part shapes). */
type Part = Record<string, unknown> & { type: string };
type LooseMessage = { id: string; role: "user" | "assistant"; parts: Part[]; metadata?: unknown };
function textPart(text: string): Part {
return { type: "text", text, state: "done" };
}
/** The result a tool part should show, from a native toolResult. */
function applyResult(part: Part, result: ToolResultMessage): void {
if (result.isError) {
part.state = "output-error";
part.rawInput = part.input;
const text = result.content.find((c) => c.type === "text");
part.errorText = text && text.type === "text" ? text.text : "The tool call failed.";
} else {
part.state = "output-available";
part.output = result.details;
}
}
/** Does this assistant group carry a successful plan_init (the splice target)? */
function lastSuccessfulPlanInit(parts: Part[]): number {
let idx = -1;
parts.forEach((p, i) => {
if (p.type !== "tool-plan_init") return;
const ok = (p.output as { ok?: unknown } | undefined)?.ok;
if (ok === true) idx = i;
});
return idx;
}
/** Project the native record into client-shaped UIMessages. Pure and
* deterministic: the same record always projects to the same list. */
export function projectNative(messages: readonly LhMessage[]): UIMessage[] {
const out: LooseMessage[] = [];
const lastAssistant = (): LooseMessage | undefined => {
const last = out[out.length - 1];
return last && last.role === "assistant" ? last : undefined;
};
const findToolPart = (toolCallId: string): Part | undefined => {
for (let i = out.length - 1; i >= 0; i--) {
for (const part of out[i]!.parts) {
if (part.type.startsWith("tool-") && part.toolCallId === toolCallId) return part;
}
}
return undefined;
};
for (const m of messages) {
const role = (m as { role?: string }).role;
if (role === "user") {
const content = m.content as string | Array<{ type: string; text?: string }>;
const texts = typeof content === "string" ? [content] : content.filter((c) => c.type === "text").map((c) => (c as { text: string }).text);
out.push({ id: m.id, role: "user", parts: texts.map(textPart) });
continue;
}
if (role === "custom") {
const marker = m as LhMarkerMessage;
projectMarker(marker, out);
continue;
}
if (role === "toolResult") {
const result = m as ToolResultMessage;
const part = findToolPart(result.toolCallId);
if (part) applyResult(part, result);
continue;
}
if (role === "assistant") {
const assistant = m as AssistantMessage;
const parts: Part[] = [];
let textN = 0;
let reasoningN = 0;
for (const block of assistant.content) {
if (block.type === "text") {
parts.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
} else if (block.type === "thinking") {
parts.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
} else if (block.type === "toolCall") {
parts.push({
type: `tool-${block.name}`,
toolCallId: block.id,
state: "input-available",
input: block.arguments,
});
}
}
if (parts.length === 0) continue; // presentation-only (nothing renderable)
const group = lastAssistant();
if (group) {
// Same group: the step separator renders between the steps.
group.parts.push({ type: "step-start" }, ...parts);
} else {
out.push({ id: m.id, role: "assistant", parts: [{ type: "step-start" }, ...parts] });
}
continue;
}
}
return out as unknown as UIMessage[];
}
/** A marker's projected message (verdict/handoff/steers) or splice (plan block). */
function projectMarker(marker: LhMarkerMessage, out: LooseMessage[]): void {
switch (marker.customType) {
case "verdict": {
const data = marker.details as PlanVerdictData;
out.push({
id: marker.id,
role: "user",
parts: [{ type: "data-verdict", id: `vd_${marker.id}`, data }],
});
return;
}
case "probe-handoff": {
out.push({
id: marker.id,
role: "user",
parts: [{ type: "data-probe-handoff", id: `ph_${marker.id}`, data: marker.details }],
});
return;
}
case "subagent-steer": {
const meta = marker.details as SubagentResultMeta;
const artifact = marker.details as { kind: SubagentArtifactData["kind"]; result: SubagentArtifactData["result"] };
out.push({
id: marker.id,
role: "user",
metadata: { subagentResult: meta },
parts: [
textPart(marker.content),
{ type: "data-subagent-artifact", id: `sa_${marker.id}`, data: { kind: artifact.kind, result: artifact.result } },
],
});
return;
}
case "subagent-abort": {
const meta = marker.details as SubagentResultMeta & { aborted: true };
out.push({
id: marker.id,
role: "user",
metadata: { subagentResult: meta },
parts: [
textPart(marker.content),
{ type: "data-subagent-aborted", id: `sa_${marker.id}`, data: meta },
],
});
return;
}
case "plan-block": {
// No message of its own: splice into the preceding assistant
// group, IN FLOW after the last successful plan_init call.
const group = out[out.length - 1];
if (!group || group.role !== "assistant") return;
const idx = lastSuccessfulPlanInit(group.parts);
if (idx === -1) return;
const { block } = marker.details as { planCallId: string; block: PlanBlockData };
group.parts.splice(idx + 1, 0, { type: "data-plan-block", id: marker.id, data: block });
return;
}
}
}
+108 -106
View File
@@ -1,4 +1,3 @@
import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-ai";
import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts";
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
@@ -6,7 +5,6 @@ import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { recordToPiMessages } from "./pi/record-adapter.ts";
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
@@ -20,26 +18,35 @@ import {
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { hasProbeDone } from "../core/native-messages.ts";
import { asAgentMessages } from "../core/native-messages.ts";
import {
ChatInputError,
inputAppends,
parseChatInput,
type TurnInput,
} from "../core/input-events.ts";
import { steerDelivered } from "../core/contracts.ts";
import type { CallUsage } from "../core/contracts.ts";
/**
* One chat turn: run the teaching agent over the client's message list and
* stream the UI message stream back. Framework-free (plain Request/Response)
* so tests can drive it directly with a scripted faux engine — the Next route
* handler in app/api/chat is a one-line wrapper.
* One chat turn over the server-owned native record. Framework-free (plain
* Request/Response) so tests can drive it directly with a scripted faux
* engine — the Next route handler in app/api/chat is a one-line wrapper.
*
* THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26): POST /api/chat
* carries {chatId, input} — one explicit event (text / answer / verdict /
* continue), never a client transcript. The server owns the record; this
* module is the TURN COMPOSITION ROOT: it turns the event into native
* record appends (core/input-events.ts), builds the agent from the record
* (the record IS the agent state — state.messages = record.messages,
* continue()), and runs the turn as a background object (pi/ui-stream.ts).
* Wake-pipeline steers ride the same composition (TurnInput's steer kind).
*
* This module is the TURN COMPOSITION ROOT: it wires the storage ports, the
* job runner + wake pipeline, the teacher tools, and the turn services
* (turn-service.ts) together — all actual work lives behind those seams.
* THE SAME COMPOSITION serves both entry points: client requests and the
* wake pipeline's server-side steers (src/server/jobs.ts) — one code path
* for write-ahead persistence, turn liveness, and the turn services.
*
* Engine note (docs/pi-engine-swap.md): the agent is STATELESS per turn —
* built fresh from the persisted record (recordToPiMessages), run by the
* bridge (pi/ui-stream.ts), discarded at turn end. The record is the only
* source of truth.
*/
const jobRunner = createJobRunner({
@@ -82,18 +89,12 @@ export const extraction = createExtractionPipeline({
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
// fires its server-side teacher turns through THIS composition root, and
// crash recovery re-spawns orphaned sub-agent loops through THIS runner.
setTurnRunner(async (chatId, messages) => {
const res = await handleChatRequest(
new Request("http://localhost/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, messages }),
}),
);
// delivers its steers through THIS composition root (one event: the steer
// marker, appended natively under the turn claim), and crash recovery
// re-spawns orphaned sub-agent loops through THIS runner.
setTurnRunner(async (chatId, input) => {
const res = await handleChatInput(chatId, input);
// Drive the stream to completion server-side — nobody else will read it,
// and onFinish (persist, extraction, endTurn) only runs when it drains.
await res.arrayBuffer().catch(() => undefined);
@@ -110,39 +111,44 @@ function resolveEngine(role: ModelRole): PiEngine | undefined {
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
/** The message-shape gate the SDK's UIMessageValidator owned: a malformed
* body must fail BEFORE the stream exists — the path where no onFinish will
* ever run (the catch clears the turn registry so the chat never locks). */
function assertValidUiMessages(messages: UIMessage[]): void {
for (const m of messages) {
if (m.role !== "user" && m.role !== "assistant" && m.role !== "system") {
throw new Error(`invalid message role: ${String((m as { role?: unknown }).role)}`);
}
if (!Array.isArray(m.parts)) throw new Error("message has no parts array");
for (const p of m.parts) {
if (p.type === "text" && typeof (p as { text?: unknown }).text !== "string") {
throw new Error("text part has no text");
}
}
}
}
/** The route-facing entry: parse {chatId, input} and run one turn. */
export async function handleChatRequest(
req: Request,
buildAgent?: (outgoing: UIMessage[]) => TeachingAgent,
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
): Promise<Response> {
let body: { chatId?: unknown; messages?: unknown };
let body: { chatId?: unknown; input?: unknown };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const chatId = typeof body.chatId === "string" ? body.chatId : "";
if (!chatId || !stores.chatStore.getChat(chatId)) {
return Response.json({ error: "Unknown chat" }, { status: 404 });
if (!chatId) return Response.json({ error: "Unknown chat" }, { status: 404 });
let input: TurnInput;
try {
input = parseChatInput(body.input);
} catch (err) {
if (err instanceof ChatInputError) {
return Response.json({ error: err.message }, { status: 400 });
}
throw err;
}
if (!Array.isArray(body.messages)) {
return Response.json({ error: "messages must be an array" }, { status: 400 });
return handleChatInput(chatId, input, buildAgent);
}
/**
* The turn composition: one input event → native record appends → one
* agent turn as a background object. Also the wake pipeline's entry
* (TurnInput's steer kind appends the marker under the same claim).
*/
export async function handleChatInput(
chatId: string,
input: TurnInput,
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
): Promise<Response> {
const record = stores.chatStore.getChat(chatId);
if (!record) {
return Response.json({ error: "Unknown chat" }, { status: 404 });
}
// THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the
@@ -154,10 +160,7 @@ export async function handleChatRequest(
// write-ahead persist (nothing durable changes), and every downstream
// resolution (jobs/extraction gate per-need on their own tiers).
const effective = effectiveSettings();
const conversation = resolveConversationSpec(
stores.chatStore.getChat(chatId)?.settings,
effective.models.strong,
);
const conversation = resolveConversationSpec(record.settings, effective.models.strong);
if (!conversation) {
const envelope = modelsNotConfigured(["strong"]);
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
@@ -179,10 +182,27 @@ export async function handleChatRequest(
return Response.json(envelope, { status: 503 });
}
// The input's native appends (pure; the honest 400 for an answer with no
// pending question call). Computed BEFORE beginTurn so a refused input
// leaves no liveness effects; read against the CURRENT record.
let appends;
try {
appends =
input.type === "steer"
? steerDelivered(record.messages, input.steer.id)
? []
: [input.steer]
: inputAppends(record, input);
} catch (err) {
if (err instanceof ChatInputError) {
return Response.json({ error: err.message }, { status: 400 });
}
throw err;
}
// One turn per chat, ever. A second request while a turn runs means a
// stale/remounted client raced a live one — its message list is outdated,
// and letting it through would double-run the teacher and clobber the
// first turn's persist (saveMessages is last-writer-wins).
// stale/remounted client raced a live one — letting it through would
// double-run the teacher and clobber the first turn's persist.
if (!beginTurn(chatId)) {
console.info(`[chat ${chatId}] turn already in flight — request rejected`);
return Response.json({ error: "A turn is already running for this session" }, { status: 409 });
@@ -190,40 +210,27 @@ export async function handleChatRequest(
// CONTINUATION COALESCING (async protocol §4, the delivery-starvation fix):
// steers finished while a quiz chain held the wake gates ride THIS turn —
// appended after the learner's answer (conversion-safe: the answer carries
// its output). Claiming happens after beginTurn, so this turn owns the
// registry and the write-ahead persist below makes the claim durable.
// The incoming body is passed too: a pump-fired wake turn ALREADY CARRIES
// its steer, so claimSteers finalizes that entry instead of appending a
// second copy (duplicate-key bug, 2026-09-18).
const steers = claimSteers(chatId, body.messages as UIMessage[]);
const outgoing = (
steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages
) as UIMessage[];
// appended after the learner's answer (conversion-safe: the answer is the
// question call's tool result now). Claiming happens after beginTurn, so
// this turn owns the registry and the write-ahead persist below makes the
// claim durable. Steers already in the transcript (a wake raced in) are
// finalized by the claim itself — the append filter keeps exactly one copy.
const steers = claimSteers(chatId).filter((s) => !steerDelivered(record.messages, s.id));
// THE SETTINGS VIEW (settings-storage wave, 2026-09-19): resolved fresh
// per turn (the gate above owns the read) — graph gates, tool toggles,
// and the record's chat defaults all read from ONE small file.
// Write-ahead persistence: the request body IS the client's canonical
// state, so it goes on disk BEFORE the stream starts. The learner's answer
// is durable the instant the request arrives — any remount, second tab, or
// server crash sees truth instead of the pre-answer record. (A client that
// returns mid-turn reads turnInFlight from the registry and re-attaches by
// polling instead of resurrecting the stale transcript.)
// Write-ahead persistence: the input's native appends go on disk BEFORE
// the turn starts — the learner's answer is durable the instant the
// request arrives, and any remount, second tab, or server crash reads
// truth instead of the pre-answer record.
const merged = [...record.messages, ...appends, ...steers];
try {
stores.chatStore.saveMessages(chatId, outgoing);
stores.chatStore.saveMessages(chatId, merged);
} catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
}
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
// message_end events. Every model call's usage lands in the chat record's
// append-only turns log at onFinish — idempotent per turn id, so a
// replayed persist appends once while continuation turns (which share a
// response message id) each keep their usage.
// append-only turns log at onFinish — idempotent per turn id.
const turnUsageId = crypto.randomUUID();
const steps: CallUsage[] = [];
const collectUsage = (usage: Usage) => {
@@ -238,13 +245,12 @@ export async function handleChatRequest(
};
// AGENT CONSTRUCTION BRANCH (docs/probe-teach-split.md §2): the transcript
// is the seam — a probe_done tool part (any state) means the probe is
// is the seam — a probe_done call in the record means the probe is
// concluded: build the TEACHER (planning + the full toolset). Otherwise
// the session is still probing: build the PROBER (quiz/ask + probe_done).
// Both share this whole composition (write-ahead persist, liveness,
// steers, usage, extraction) — only the agent differs.
const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done"));
const role: ModelRole = teaching ? "teacher" : "prober";
const teaching = hasProbeDone(merged);
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
// turn gate above (chat.model override → strong slot → refused) — the
// engine build is pure from here; thinking levels clamp natively.
@@ -252,19 +258,18 @@ export async function handleChatRequest(
// Provider-qualified stamp (model slots wave): matches the price table's
// provider:id keys.
const modelId = `${engine.model.provider}:${engine.model.id}`;
// Tests inject a factory: the agent is stateless per turn (docs/
// pi-engine-swap.md §2), so each request builds a fresh one off the
// transcript — the factory mirrors that seam exactly.
// Tests inject a factory: the agent is stateless per turn, so each
// request builds a fresh one off the record — the factory mirrors that
// seam exactly. Wave B: the record IS the agent state (native messages;
// the record's id field strips at the wire in toLlmMessages).
// CROSS-STREAM SEAM (P2 reconciliation, 2026-09-19): `toolToggles`
// rides the build opts as the user-level DELTA map (absent key =
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// user: quiz/ask/fs/plan/probe_done are always-on). First-class on
// AgentBuildOptions; the factories drop anything explicitly false and
// the settings write side rejects unknown keys.
// user: quiz/ask/fs/plan/probe_done are always-on).
const agentOpts: AgentBuildOptions = {
engine,
messages: recordToPiMessages(outgoing),
messages: asAgentMessages(merged),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
// The prompt-override switches (P2, 2026-09-19): same resolution
@@ -273,7 +278,7 @@ export async function handleChatRequest(
promptSwitches: effective.agent.prompts,
};
const constructed: TeachingAgent = buildAgent
? buildAgent(outgoing)
? buildAgent({ ...agentOpts, teaching })
: teaching
? createTeacherAgent(
{
@@ -310,7 +315,6 @@ export async function handleChatRequest(
// transcript, not the money channel); when it's ours, usage flows through
// the factory's subscription (wired above via onUsage).
try {
assertValidUiMessages(outgoing);
// THE TURN AS A BACKGROUND OBJECT (owner ruling, 2026-09-25):
// runPiTurn executes decoupled from this response — the POST stream
// is just the FIRST subscriber (a view). A client disconnect can no
@@ -318,7 +322,7 @@ export async function handleChatRequest(
// late views reattach via GET /api/chats/[id]/stream.
const handle = runPiTurn({
agent: constructed,
uiMessages: outgoing,
base: merged,
// Stream-level failures are provider/transport internals — masked,
// like the SDK defaults. Tool-input validation messages never
// route through here: they ride the error-result path and reach the
@@ -327,14 +331,14 @@ export async function handleChatRequest(
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onFinish: (messages, isAborted) => {
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
onFinish: (turnMessages, isAborted) => {
// Wave B: the record write is the agent's own final state —
// the new messages are the slice past the merged base.
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
// usage log append (idempotent per turn id) — the stats bar
// refolds this on the client after the turn settles. messageId
// comes from the id-FILLED list: the bridge's response message
// already carries its id; the re-fill here is idempotent.
const filled = withMessageIds(messages as UIMessage[]);
const response = [...filled].reverse().find((m) => m.role === "assistant");
// refolds this on the client after the turn settles. The
// provenance message is the turn's FIRST new assistant message.
const response = stamped.find((m) => (m as { role?: string }).role === "assistant");
if (response && steps.length > 0) {
stores.chatStore.appendTurnUsage(chatId, {
id: turnUsageId,
@@ -345,9 +349,7 @@ export async function handleChatRequest(
});
}
// GRAPH GATE (settings-storage wave, 2026-09-19): the write channel —
// extraction, its auditor — dies with the master switch. (The deeper
// pipeline knobs — embedding/clustering/labeling — gate inside the
// pipeline and its consumers, later waves, via the same resolved view.)
// extraction, its auditor — dies with the master switch.
if (extractionEnabled(effective.graph)) {
// Fresh per-turn read of the graph section's chunk budget
// (extraction-UX redesign, 2026-09-24): the trigger compares
@@ -380,4 +382,4 @@ export async function handleChatRequest(
console.error(`[chat ${chatId}] turn failed to start:`, err);
return Response.json({ error: "Failed to start the turn" }, { status: 500 });
}
}
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
import { coverageOf } from "../core/coverage.ts";
import { projectNative } from "../core/projection.ts";
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
import { titleFromPrompt } from "../core/contracts.ts";
import type { UIMessage } from "ai";
@@ -141,7 +142,9 @@ export function sessionCoverage(chatId: string): SessionCoverage | null {
const record = stores.chatStore.getChat(chatId);
if (!record) return null;
const entries = stores.graphStore.readCoverage();
const cov = coverageOf(record.messages as UIMessage[], entries, chatId);
// Wave B: the record is native history — coverage reads through the
// projection (same view the extraction pipeline folds).
const cov = coverageOf(projectNative(record.messages), entries, chatId);
let lastFoldedAt: string | null = null;
for (const entry of entries) {
if (entry.chatId !== chatId) continue;
+37 -40
View File
@@ -31,7 +31,6 @@
* be a different object in each).
*/
import type { UIMessage } from "ai";
import {
buildAbortSteer,
buildSubagentSteer,
@@ -40,13 +39,16 @@ import {
type JobRecord,
type SteerMessage,
} from "../core/contracts.ts";
import { findPendingToolCall } from "../core/pending.ts";
import type { TurnInput } from "../core/input-events.ts";
import { danglingQuestionOf } from "../core/native-messages.ts";
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
/** Fires one server-side teacher turn: (chatId, messages) → HTTP-ish status. */
export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<number>;
/** Fires one server-side teacher turn: (chatId, input) → HTTP-ish status.
* Wave B: the input is the STEER EVENT — the handler appends the marker
* natively under its own turn claim (one composition for every turn). */
export type TurnRunner = (chatId: string, input: TurnInput) => Promise<number>;
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
export type JobRespawn = (jobId: string) => void;
@@ -110,10 +112,11 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** The job a steer belongs to, if it is a job steer (result/abort/context);
/** The job a steer belongs to, if it is a job steer (result/abort);
* probe-handoff steers have no job — null. */
function jobIdOf(steer: SteerMessage): string | null {
return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null;
const details = steer.details as { jobId?: string } | undefined;
return typeof details?.jobId === "string" ? details.jobId : null;
}
/** Finalize a delivered steer's job bookkeeping — job steers only. */
@@ -167,32 +170,22 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
* copies of the same steer id (a live React duplicate-key warning the
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
*/
export function claimSteers(
chatId: string,
incoming?: ReadonlyArray<UIMessage>,
): SteerMessage[] {
export function claimSteers(chatId: string): SteerMessage[] {
const s = state();
const queue = s.queues.get(chatId);
if (!queue || queue.length === 0) return [];
const record = stores.chatStore.getChat(chatId);
if (!record) return []; // the handler validated the chat; defensive only
const claimed: SteerMessage[] = [];
s.queues.delete(chatId);
// The caller holds the turn registry (chat-handler claims after
// beginTurn), so the write-ahead persist is imminent and durable:
// every queued steer is claimed — its delivery is THIS turn's job.
// Steers already in the transcript (a wake raced in) still finalize
// here; the handler's append filter (steerDelivered) keeps exactly
// one copy in the record.
const claimed = queue.map((entry) => entry.steer);
for (const entry of queue) {
if (
steerDelivered(record.messages, entry.steer.id) ||
incoming?.some((m) => m.id === entry.steer.id)
) {
// A wake already landed it — or this turn's own request already
// carries it (the pump path): the delivery is done either way,
// just finish the bookkeeping. NEVER append twice.
finalizeSteer(entry.steer);
} else {
claimed.push(entry.steer);
finalizeSteer(entry.steer); // delivery is now the client turn's job
}
finalizeSteer(entry.steer);
entry.resolve();
}
s.queues.delete(chatId);
return claimed;
}
@@ -256,12 +249,13 @@ export function abortJob(jobId: string): AbortOutcome {
}
function chatIdOf(steer: SteerMessage): string {
// The probe handoff carries its chat directly (the job record would
// say it too, but the metadata is the cheap path).
const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff;
if (handoff) return handoff.chatId;
// The chat is resolvable from the job record; the steer's metadata
// carries jobId, which indexes the store.
// The probe handoff carries its chat directly in the marker details (the
// job record would say it too, but the details are the cheap path).
if (steer.customType === "probe-handoff") {
return (steer.details as { chatId: string }).chatId;
}
// The chat is resolvable from the job record; the steer's details
// carry jobId, which indexes the store.
const jobId = jobIdOf(steer);
const job = jobId ? stores.jobStore.getJob(jobId) : undefined;
return job?.chatId ?? "";
@@ -334,9 +328,11 @@ async function pump(chatId: string): Promise<void> {
continue;
}
// Fire the server-side teacher turn. A 409 = a client turn raced
// in between idle and firing — back off and retry the same steer.
const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => {
// Fire the server-side teacher turn (the steer EVENT: the handler
// appends the marker natively under its own turn claim). A 409 = a
// client turn raced in between idle and firing — back off and
// retry the same steer.
const status = await s.turnRunner(chatId, { type: "steer", steer: entry.steer }).catch((err: unknown) => {
console.error(`[steer ${steerId}] wake turn crashed:`, err);
return -1;
});
@@ -387,22 +383,23 @@ function failJob(jobId: string, err: Error): void {
/**
* Wait until the chat can take a wake turn: no turn in flight AND no
* unanswered question part (provider conversion would break). Poll; the
* learner may take arbitrarily long to answer, and that is correct — the
* steer fires right after their continuation completes.
* unanswered question call (the record's dangling question — the answer
* arrives as its tool result, and no turn may run in the window). Poll;
* the learner may take arbitrarily long to answer, and that is correct —
* the steer fires right after their answer turn completes.
*/
async function waitForIdle(chatId: string): Promise<void> {
for (;;) {
const record = stores.chatStore.getChat(chatId);
if (!record) return;
const blocked = isTurnRunning(chatId) || findPendingToolCall(record.messages) !== null;
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
if (!blocked) {
await delay(GRACE_MS);
const recheck = stores.chatStore.getChat(chatId);
if (
!isTurnRunning(chatId) &&
recheck &&
findPendingToolCall(recheck.messages) === null
danglingQuestionOf(recheck.messages) === null
) {
return;
}
-190
View File
@@ -1,190 +0,0 @@
import type { UIMessage } from "ai";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
/**
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The
* persisted UIMessage record IS the source of truth; every turn builds a
* fresh agent from it (the stateless per-turn decision). This replaces the
* SDK's convertToModelMessages with an owned, explicit mapping:
*
* - user text parts → UserMessage text content.
* - SYSTEM DATA PARTS (the custom-message scheme, 2026-09-12): the wake and
* verdict messages carry ONLY a marker part — no human text exists to
* drop, so the ADAPTER flattens them: data-verdict → verdictText(),
* data-probe-handoff → HANDOFF_TEXT (core/contracts/chat.ts owns both —
* single source, so the model-facing wording can't drift from the seam).
* Other data parts (artifacts, plan blocks) are dropped as before: they
* are UI history; the steer briefs ride their text twin (payload, not
* boilerplate — those texts are content the sub-agent produced).
* - assistant text/reasoning/tool parts → one AssistantMessage whose
* content blocks mirror the part order; tool parts become ToolCall
* blocks (output-error parts keep their rawInput).
* - answered tool parts (output-available) → ToolResultMessage after the
* assistant message, JSON-encoded like the model saw it live.
* - errored tool parts (output-error) → an isError ToolResultMessage
* carrying the validation message (the model's retry context).
* - DANGLING calls (input-available, never answered — an aborted turn)
* → a synthesized interrupted ToolResultMessage. THE HARD INVARIANT:
* an assistant message with tool calls must be followed by tool
* results, or providers reject the request outright.
*/
const INTERRUPTED = "The question was interrupted — the learner never answered; continue without holding a result for it.";
function textOf(part: { text?: string }): string {
return typeof part.text === "string" ? part.text : "";
}
/** Encode a tool output the way the model saw it live (string verbatim, object JSON). */
function toolResultText(output: unknown): string {
return typeof output === "string" ? output : JSON.stringify(output);
}
function zeroUsage() {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
/** The model-facing text of a marker-only user message, or null when the
* message has no model face at all (UI-only data parts). */
function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string | null {
for (const part of message.parts) {
if (part.type === "data-verdict") {
return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true);
}
if (part.type === "data-probe-handoff") return HANDOFF_TEXT;
}
return null;
}
export function recordToPiMessages(messages: UIMessage[]): Message[] {
const out: AgentMessage[] = [];
for (const message of messages) {
if (message.role === "user") {
const texts = message.parts
.filter((p) => p.type === "text")
.map((p) => textOf(p as { text?: string }))
.filter((t) => t.length > 0);
// The custom-message scheme: a marker-only message (verdict,
// handoff) flattens to its canonical model-facing text — the
// wording lives in the adapter's reach (core contracts), never
// authored as fake human speech in the transcript.
if (texts.length === 0) {
const flat = systemTextOf(message);
if (flat === null) continue; // pure UI data part, no model face
texts.push(flat);
}
out.push({
role: "user",
content: texts.map((text) => ({ type: "text" as const, text })),
timestamp: Date.now(),
});
} else if (message.role === "assistant") {
const content: AssistantMessage["content"] = [];
const results: ToolResultMessage[] = [];
let answered = false;
for (const part of message.parts) {
if (part.type === "text") {
const text = textOf(part);
if (text) content.push({ type: "text", text });
} else if (part.type === "reasoning") {
const text = textOf(part);
if (text) content.push({ type: "thinking", thinking: text });
} else if (part.type === "step-start") {
// presentation-only; the pi loop re-derives turn boundaries
} else if (part.type.startsWith("tool-")) {
const tool = part.type.slice("tool-".length);
const p = part as unknown as {
toolCallId: string;
state: string;
input?: unknown;
rawInput?: unknown;
output?: unknown;
errorText?: string;
};
// The call block (any state — the model authored it).
if (p.state === "input-available" || p.state === "input-streaming" || p.state === "output-available") {
content.push({
type: "toolCall",
id: p.toolCallId,
name: tool,
arguments: (p.input ?? {}) as Record<string, unknown>,
});
} else if (p.state === "output-error") {
content.push({
type: "toolCall",
id: p.toolCallId,
name: tool,
arguments: (p.rawInput ?? p.input ?? {}) as Record<string, unknown>,
});
}
// The result, if any.
if (p.state === "output-available") {
results.push({
role: "toolResult",
toolCallId: p.toolCallId,
toolName: tool,
content: [{ type: "text", text: toolResultText(p.output) }],
isError: false,
timestamp: Date.now(),
});
answered = true;
} else if (p.state === "output-error") {
results.push({
role: "toolResult",
toolCallId: p.toolCallId,
toolName: tool,
content: [{ type: "text", text: p.errorText ?? "The tool call failed validation." }],
isError: true,
timestamp: Date.now(),
});
answered = true;
}
}
// data-* parts (plan blocks, artifacts, markers) never reach the model.
}
if (content.length === 0) continue; // presentation-only message
out.push({
role: "assistant",
content,
api: "openai-completions",
provider: "openrouter",
model: "replayed",
usage: zeroUsage(),
stopReason: content[content.length - 1]!.type === "toolCall" ? "toolUse" : "stop",
timestamp: Date.now(),
});
// Dangling calls: synthesize the interrupted result so providers
// never see an unanswered tool call (the hard invariant).
if (!answered) {
for (const block of content) {
if (block.type === "toolCall" && !results.some((r) => r.toolCallId === block.id)) {
results.push({
role: "toolResult",
toolCallId: block.id,
toolName: block.name,
content: [{ type: "text", text: INTERRUPTED }],
isError: false,
timestamp: Date.now(),
});
}
}
}
out.push(...results);
}
// role "system" never appears in records (system prompt is composed separately).
}
return out as Message[];
}
+33 -18
View File
@@ -1,6 +1,8 @@
import type { Agent, AgentEvent } from "@earendil-works/pi-agent-core";
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
import type { UIMessage } from "ai";
import { QUESTION_TOOLS } from "../../core/pending.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { projectNative } from "../../core/projection.ts";
/**
* The UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and
@@ -23,9 +25,10 @@ import { QUESTION_TOOLS } from "../../core/pending.ts";
* tool-input-error instead and NEVER becomes input-available, so a
* malformed question can never mount the dock (the live modal-realism
* crash: GLM double-encoded `options` as a JSON string).
* - The persisted message is assembled from the SAME parts the client
* assembles (the processUIMessageStream shapes), so every downstream
* consumer (phases, plan blocks, pending, extraction) is unchanged.
* - The WIRE assembly (below) is the client view only (Wave B): the
* record write is the agent's own native final state — the projection
* (core/projection.ts) reproduces the client's part shapes for every
* consumer (phases, plan blocks, pending, extraction) until Wave D.
*
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
* server-side background object decoupled from any single HTTP response —
@@ -42,12 +45,17 @@ type Chunk = Record<string, unknown> & { type: string };
export interface PiTurnSpec {
/** The per-turn agent (stateless engine — built from the record). */
agent: Agent;
/** The outgoing request messages (write-ahead persisted already). */
uiMessages: UIMessage[];
/** The turn's base: the native record + this turn's appends
* (write-ahead persisted already). Wave B: the agent's context and
* the record share one shape; onFinish receives the full agent state
* and the caller persists the slice past base.length. */
base: LhMessage[];
/** The handler's error-unmasking policy (validation messages stay readable). */
onError?: (error: unknown) => string;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn. */
onFinish: (messages: UIMessage[], isAborted: boolean) => void;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
* Receives the agent's FULL final state (native messages) — the turn's
* new messages are the slice past base.length. */
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
}
/** One view onto a running turn: push() replays/streams a chunk, done()
@@ -117,15 +125,22 @@ interface ToolPart {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
const { agent, uiMessages, onFinish } = spec;
const { agent, base, onFinish } = spec;
// The streaming target: continuation (same id, parts appended to a copy
// of the existing message) or a fresh assistant message.
const last = uiMessages[uiMessages.length - 1];
const continuing = last?.role === "assistant";
// of the existing message) or a fresh assistant message. The continuation
// rule is the CLIENT's: it keys on the last UIMessage's id — read through
// the projection (the record is native; a turn after an answer continues
// the group that carries the answered quiz, which is the projection's
// last message).
const projected = projectNative(base);
const last = projected[projected.length - 1] as
| { id?: string; role?: string; parts?: UIMessage["parts"] }
| undefined;
const continuing = last?.role === "assistant" && typeof last.id === "string";
const messageId =
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
const baseParts: UIMessage["parts"] = continuing ? [...last.parts] : [];
const baseParts: UIMessage["parts"] = continuing && last.parts ? [...last.parts] : [];
const parts: UIMessage["parts"] = [...baseParts];
// Stable, collision-free part ids across continuation runs (reasoning
@@ -174,11 +189,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
send({ type: "start", messageId });
const settleWith = () => {
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
// The SDK's onFinish shape: the input copy stays, the extended
// response copy appends (persistTurn's withMessageIds dedupe
// keeps the last occurrence).
onFinish([...uiMessages, assembled], sawAbort);
// Wave B: the record write is the agent's OWN final state (native
// history — signatures, usage, provider stamps all real); the wire
// assembly above served the client view only. Copy the array: the
// agent may outlive this call's read.
onFinish([...agent.state.messages], sawAbort);
};
const handle = (event: AgentEvent) => {
+68 -57
View File
@@ -4,69 +4,85 @@
* implementing. Each takes its dependencies explicitly (storage ports, the
* job runner) — no module here imports an adapter.
*
* persistTurn — the canonical state write (client + agent messages)
* persistTurn — the canonical state write (Wave B: native history;
* id stamping, placeholder filtering, plan-block
* emission, abort-partial persistence)
* triggerExtraction — the graph write channel: fires the extraction
* pipeline (the sole graph writer) once a chat
* crosses the v1 quiz-answer threshold. The trigger
* mechanism is a v1 placeholder — this seam is the
* ONLY thing to swap when a better one lands.
* crosses the token-budget threshold.
*
* Logs are the minimal-observability baseline: one line per failure, tagged
* [chat <id>] — structured logging lands behind these seams later.
*/
import type { UIMessage } from "ai";
import { withPlanBlocks } from "../core/plan-blocks.ts";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMessage } from "../core/native-messages.ts";
import { newMessageId } from "../core/native-messages.ts";
import { QUESTION_TOOLS } from "../core/pending.ts";
import { planBlocksFor } from "../core/plan-blocks.ts";
import { projectNative } from "../core/projection.ts";
import { coverageOf } from "../core/coverage.ts";
import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
/**
* The canonical state write: the client owns the message list between turns
* (useChat); this persists client messages + the agent response. Persists
* even when aborted — the partial transcript (including any pending
* question part) is what makes resume work.
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
* turn's own messages — the agent loop's native history, provider/model/
* usage/thinkingSignature stamped by pi itself. Persists even when aborted
* — the partial transcript (including a pending question's unanswered
* call) is what makes resume work.
*
* - Id stamping: pi messages carry no id; the record's identity needs one
* per message (client keys, append-only adoption, the continuation
* seam). Minted here, at the single canonical write.
* - Placeholder filtering: a cleanly-posed question's synthetic
* "question posed" result is an in-memory loop device (Wave A's
* terminate) — NEVER persisted (fork 1: the record ends at the
* unanswered call; the learner's answer arrives as the real tool
* result). Errored question calls DO persist — the model's retry
* context.
* - Plan blocks: one frozen snapshot per successful plan_init call,
* idempotent (persist replays are safe).
*
* Returns the stamped new messages (usage provenance reads them).
*/
/**
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
* would otherwise land in the record with id "" — and two of those in one
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
* missing id here, at the single canonical write: client-authored ids are
* NEVER touched (the client's next POST still carries them verbatim).
*/
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
const filled = messages.map((m) =>
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
);
// Same id twice = the SDK's continuation merge couldn't happen in place
// (continuation coalescing appends a user-role steer AFTER the assistant
// message being continued, so onFinish carries the input copy AND the
// extended response copy under one id). Keep the LAST occurrence — it is
// the SDK's final merge state (a superset of the earlier copy).
const seen = new Set<string>();
const deduped: UIMessage[] = [];
for (let i = filled.length - 1; i >= 0; i--) {
const m = filled[i]!;
if (seen.has(m.id)) continue;
seen.add(m.id);
deduped.unshift(m);
}
return deduped;
}
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
export function persistTurn(
chatStore: ChatStore,
chatId: string,
baseCount: number,
turnMessages: readonly AgentMessage[],
isAborted: boolean,
): LhMessage[] {
try {
const deduped = withMessageIds(messages);
// Plan-block emission (wave 4): blocks are frozen transcript content —
// attached here, once, at the canonical state write. The tree was
// saved by the plan tool during the turn; snapshots derive now.
const tree = chatStore.getChat(chatId)?.tree;
chatStore.saveMessages(chatId, withPlanBlocks(deduped, tree));
const record = chatStore.getChat(chatId);
if (!record) return [];
const stamped: LhMessage[] = [];
for (const m of turnMessages.slice(baseCount)) {
const message = m as LhMessage;
// The placeholder question result: loop device, never a record fact.
if (
(message as { role?: string }).role === "toolResult" &&
(QUESTION_TOOLS as readonly string[]).includes((message as ToolResultMessage).toolName) &&
!(message as ToolResultMessage).isError
) {
continue;
}
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
}
const tree = record.tree;
const blocks = planBlocksFor(
record.messages,
stamped,
tree,
projectNative([...record.messages, ...stamped]),
);
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
return stamped;
} catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
return [];
}
}
@@ -74,15 +90,11 @@ export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMe
* The graph write channel (vision §1 construction): the extraction pipeline
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
* chat's UNCOVERED transcript tokens reach the chunk budget
* (vision-extraction-coverage.md, 2026-09-15 — replacing the old
* every-3-quiz-answers count: content size is the honest signal, and
* quiz-less sessions extract too). The budget is the graph section's
* extractionChunkTokens setting (extraction-UX redesign, 2026-09-24) —
* the composition root passes the per-turn effective read (chat-handler
* resolves fresh per turn, same as every other gate). The pipeline
* itself extracts only the uncovered batches. Fire-and-forget — never blocks the turn; failures are
* logged by the pipeline and leave coverage untouched, so the next trigger
* (or the manual extract button) re-runs what's left.
* (vision-extraction-coverage.md, 2026-09-15). Coverage reads through the
* projection (the record is native history; the extraction view stays
* client-shaped until Wave D). Fire-and-forget — never blocks the turn;
* failures are logged by the pipeline and leave coverage untouched, so the
* next trigger (or the manual extract button) re-runs what's left.
*
* Deliberately isolated in this one seam — the pipeline is
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
@@ -98,7 +110,7 @@ export function triggerExtraction(
try {
const record = deps.chatStore.getChat(chatId);
if (!record) return;
const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId);
const covered = coverageOf(projectNative(record.messages), deps.graphStore.readCoverage(), chatId);
if (covered.uncoveredTokens < chunkTokens) return;
void extraction.runExtraction(chatId).catch((err) => {
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
@@ -119,4 +131,3 @@ export function triggerExtraction(
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
}
}
+33 -17
View File
@@ -12,9 +12,10 @@ import * as path from "node:path";
import { PATHS } from "../../config.ts";
import type { ChatStore } from "../core/ports.ts";
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
import type { UIMessage } from "ai";
import type { LhMessage } from "../core/native-messages.ts";
import { findPendingToolCall } from "../core/pending.ts";
import { phaseAt3 } from "../core/phases.ts";
import { projectNative } from "../core/projection.ts";
function chatFile(id: string): string {
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
@@ -51,28 +52,43 @@ function listChats(): ChatSummary[] {
.filter((f) => f.endsWith(".json"))
.map((f) => getChat(f.slice(0, -5)))
.filter((r): r is ChatRecord => r !== null)
.map(({ messages, tree, turns, extractions, ...summary }) => ({
// turns and extractions stay out of the list payload (list =
// summaries; usage aggregates ride the full record, the picker
// never needs them).
...summary,
messageCount: messages.length,
// Record-derived status for the session picker: phase from the
// transcript's explicit markers (probe_done / plan_init — the
// event-driven phases, docs/probe-teach-split.md §4), pending
// question from the last unanswered quiz/ask part. Liveness (busy)
// is NOT record-derived — the API route merges it (ChatListItem).
phase: phaseAt3(messages),
pendingQuestion: findPendingToolCall(messages) !== null,
}))
.map(({ messages, tree, turns, extractions, ...summary }) => {
// Per-record isolation (Wave B): a record whose messages fail the
// projection (e.g. pre-Wave-B UIMessage-format scratch — the
// zero-back-compat wipe makes these "corrupt") must not blank the
// whole sidebar — same isolation getChat's per-file catch gives.
try {
const projected = projectNative(messages);
return {
// turns and extractions stay out of the list payload (list =
// summaries; usage aggregates ride the full record, the picker
// never needs them).
...summary,
messageCount: messages.length,
// Record-derived status for the session picker: phase from the
// transcript's explicit markers (probe_done / plan_init — the
// event-driven phases, docs/probe-teach-split.md §4), pending
// question from the last unanswered quiz/ask part — both read
// through the one-way projection. Liveness (busy) is NOT
// record-derived — the API route merges it (ChatListItem).
phase: phaseAt3(projected),
pendingQuestion: findPendingToolCall(projected) !== null,
};
} catch (err) {
console.error(`[chat ${summary.id}] summary projection failed:`, err);
return null;
}
})
.filter((s): s is ChatSummary => s !== null)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} catch {
return [];
}
}
/** Persist the full message list (client state + new response) after a turn. */
function saveMessages(id: string, messages: UIMessage[]): void {
/** Persist the full native message list (the record IS the agent state —
* Wave B: replay is state.messages = record.messages). */
function saveMessages(id: string, messages: LhMessage[]): void {
const record = getChat(id);
if (!record) throw new Error(`Chat not found: ${id}`);
record.messages = messages;