2026-09-11 04:07:12 +03:00
|
|
|
/**
|
2026-09-11 06:44:20 +03:00
|
|
|
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
|
2026-09-26 00:37:46 +03:00
|
|
|
* presentation seam. The plan renders IN FLOW once, at authoring — a
|
|
|
|
|
* frozen snapshot attached right after the assistant group carrying the
|
2026-09-11 06:44:20 +03:00
|
|
|
* 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
|
2026-09-26 00:37:46 +03:00
|
|
|
* instant (current = null, everything upcoming — unless the authoring is
|
|
|
|
|
* a mid-lesson rewrite, in which case the snapshot shows what was true
|
|
|
|
|
* then).
|
2026-09-11 04:07:12 +03:00
|
|
|
*
|
2026-09-26 00:37:46 +03:00
|
|
|
* 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.
|
2026-09-11 04:07:12 +03:00
|
|
|
*/
|
|
|
|
|
|
2026-09-11 06:44:20 +03:00
|
|
|
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
2026-09-11 04:07:12 +03:00
|
|
|
import { derivePlanStatuses } from "./contracts.ts";
|
|
|
|
|
import type { UIMessage } from "ai";
|
2026-09-26 00:37:46 +03:00
|
|
|
import type { LhMessage } from "./native-messages.ts";
|
|
|
|
|
import { newPlanBlockMessage } from "./native-messages.ts";
|
|
|
|
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
2026-09-11 04:07:12 +03:00
|
|
|
|
|
|
|
|
/** The message part the chat renderer consumes (TeacherUIDataParts key
|
2026-09-26 00:37:46 +03:00
|
|
|
* "plan-block" — the SDK strips the `data-` prefix). Kept for the client
|
|
|
|
|
* renderers; the projection emits this shape. */
|
2026-09-11 04:07:12 +03:00
|
|
|
export interface PlanBlockPart {
|
|
|
|
|
type: "data-plan-block";
|
|
|
|
|
id: string;
|
|
|
|
|
data: PlanBlockData;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 00:37:46 +03:00
|
|
|
/** The snapshot payload (unchanged since wave 4): statuses derived AT
|
|
|
|
|
* EMISSION time from the transcript — the block never re-derives. */
|
2026-09-11 06:44:20 +03:00
|
|
|
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
2026-09-11 04:07:12 +03:00
|
|
|
const statuses = derivePlanStatuses(tree, messages);
|
|
|
|
|
return {
|
2026-09-11 06:44:20 +03:00
|
|
|
kind: "full",
|
2026-09-11 04:07:12 +03:00
|
|
|
at: new Date().toISOString(),
|
|
|
|
|
current: statuses.current,
|
2026-09-11 06:44:20 +03:00
|
|
|
nodes: tree.nodes.map((n) => ({
|
2026-09-11 04:07:12 +03:00
|
|
|
id: n.id,
|
|
|
|
|
title: n.title,
|
2026-09-11 06:44:20 +03:00
|
|
|
status: statuses.current === n.id ? "current" : statuses.visited.has(n.id) ? "visited" : "upcoming",
|
2026-09-11 04:07:12 +03:00
|
|
|
})),
|
2026-09-11 06:44:20 +03:00
|
|
|
edges: tree.edges.map((e) => ({ from: e.from, to: e.to })),
|
2026-09-11 04:07:12 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 00:37:46 +03:00
|
|
|
/** 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;
|
2026-09-11 04:07:12 +03:00
|
|
|
}
|
2026-09-11 21:19:45 +03:00
|
|
|
|
2026-09-26 00:37:46 +03:00
|
|
|
/**
|
|
|
|
|
* 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))];
|
2026-09-11 21:19:45 +03:00
|
|
|
}
|