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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user