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:
+58
-53
@@ -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))];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user