Emission runs at persistTurn — after the whole turn is composed — so appending the block buried it under the same turn's teaching prose and quiz calls (user-observed in chat_mtwgb9lj: the DAG appeared only after the first check). Splice it in immediately after the (last) successful plan_init part instead. Two positional tests added.
90 lines
3.8 KiB
TypeScript
90 lines
3.8 KiB
TypeScript
/**
|
|
* 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
|
|
* 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).
|
|
*
|
|
* 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).
|
|
*/
|
|
|
|
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
|
import { derivePlanStatuses } from "./contracts.ts";
|
|
import type { UIMessage } from "ai";
|
|
|
|
/** The message part the chat renderer consumes (TeacherUIDataParts key
|
|
* "plan-block" — the SDK strips the `data-` prefix from the name). */
|
|
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;
|
|
}
|
|
|
|
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
|
const statuses = derivePlanStatuses(tree, messages);
|
|
return {
|
|
kind: "full",
|
|
at: new Date().toISOString(),
|
|
current: statuses.current,
|
|
nodes: tree.nodes.map((n) => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
status: statuses.current === n.id ? "current" : statuses.visited.has(n.id) ? "visited" : "upcoming",
|
|
})),
|
|
edges: tree.edges.map((e) => ({ from: e.from, to: e.to })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
});
|
|
}
|
|
|
|
/** 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;
|
|
}
|