Files
learnharness/src/core/plan-blocks.ts
T
amosblomqvist f3003d357a Wave D: native AgentEvent wire + client session rewrite (SDK purged)
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).

Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).

Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.

Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).

Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
2026-09-26 01:52:47 +03:00

96 lines
4.0 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
* 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).
*
* 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 { derivePlanStatusesLh } from "./contracts.ts";
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). Kept for the client
* renderers; the projection emits this shape. */
export interface PlanBlockPart {
type: "data-plan-block";
id: string;
data: PlanBlockData;
}
/** The snapshot payload (unchanged since wave 4): statuses derived AT
* EMISSION time from the transcript — the block never re-derives. Wave D:
* the derivation reads the NATIVE transcript (quiz CALLS only — results
* feed nothing, contracts/plan.ts derivePlanStatusesLh). */
function snapshot(tree: PlanTree, messages: readonly LhMessage[]): PlanBlockData {
const statuses = derivePlanStatusesLh(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 })),
};
}
/** 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;
}
/**
* 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 NATIVE transcript the status
* derivation reads.
*/
export function planBlocksFor(
record: readonly LhMessage[],
newMessages: readonly LhMessage[],
tree: PlanTree | null | undefined,
transcript: readonly LhMessage[],
): 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))];
}