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
+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);
}