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