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