feat: the plan verdict gate — acceptance is the plan→teach seam

The accept-plan UI lands (docs/probe-teach-split.md §4, formerly the
interim rule): presenting the plan no longer moves the phase — the
learner's ACCEPT does. While the presented plan awaits its verdict, the
composer's send square becomes two stacked buttons (green Accept plan /
red Decline); typed text rides the verdict as his note, Enter keeps
sending plain side questions (phase unmoved), decline asks for a
revision and the buttons stay.

The seam: accept sends a user message carrying a data-plan-accepted
part (PlanAcceptedData in contracts/chat.ts, TeacherUIDataParts
'plan-accepted'); phaseAt3 reads it as the plan→teach event (plan_init
demoted to presentation-only); convertToModelMessages drops data parts,
so the teacher sees only the text. Mid-lesson rewrites need no fresh
verdict — acceptance happens once, at the first presentation.

The teacher's planning prompt now ends the turn at presentation and
waits for the verdict (never teaches into an unaccepted plan).

Gates: tsc clean, 304/304 (phases tests rewritten to the acceptance
seam incl. decline/side-question/failed-init cases; composer verdict
render tests), lint, build. Live GLM smoke ALL PASS: phase pinned at
'plan' after plan_init, presentation turn carried NO check, accept
click flipped the summary to 'teach', then tagged checks moved the map
(current=rates, visited=composite); zero console errors.
This commit is contained in:
amosblomqvist
2026-09-11 21:26:29 +03:00
parent 41d6ae531c
commit b2c89b8c91
10 changed files with 230 additions and 55 deletions
+10
View File
@@ -123,6 +123,16 @@ export interface TurnUsage {
* block renders current node + direct parents + direct children in its own
* small container, the current node glowing — clearly a fragment, not the
* whole plan. Clicking either opens the full plan in a popup. */
/** The learner's plan-acceptance marker (docs/probe-teach-split.md §4): a
* data part riding the accept-click user message. It is THE plan→teach
* phase seam — phaseAt3 reads it from the transcript;
* convertToModelMessages drops data parts, so the teacher only sees the
* message's text; the marker is for derivation and the UI. */
export interface PlanAcceptedData {
/** ISO timestamp of the accept click. */
at: string;
}
export interface PlanBlockData {
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
* FLOW once, at authoring. The neighborhood fragments are retired —
+11 -17
View File
@@ -5,10 +5,11 @@
*
* probe — until the first message containing a `tool-probe_done` part
* (the prober concludes; the assessment rides the tool part)
* plan — probe concluded, until the first successful `plan_init`
* (the teacher presents the map; interim rule until the
* accept-plan UI lands — then acceptance becomes the seam)
* teach — a plan exists
* plan — probe concluded, until the learner ACCEPTS the presented
* plan (a `data-plan-accepted` part on the accept button's
* user message — the explicit verdict gate; plan_init itself
* is only the PRESENTATION and moves no phase)
* teach — teaching is sanctioned
*
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a
@@ -22,9 +23,6 @@
/** The three phases of a session, as the picker / header / dividers see them. */
export type Phase3 = "probe" | "plan" | "teach";
/** The advance-only order of phases: a session never visibly regresses. */
const PHASE3_RANK: Record<Phase3, number> = { probe: 0, plan: 1, teach: 2 };
/**
* Minimal structural slice of a UI message for the phase scan: parts with
* a state and (for concluded calls) an output.
@@ -33,10 +31,6 @@ export interface PhaseMessage {
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
}
interface PlanOutput {
tree?: { nodes?: unknown };
}
/** The phase at `upToIndex` (inclusive; default: the last message). */
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
@@ -55,15 +49,15 @@ function advance(
if (!parts) return phase;
for (const part of parts) {
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
// plan → teach only via a successful plan_init. A marker out of order
// (e.g. a legacy plan_init with no probe) advances nothing — the chain
// never skips, and the rank table below only guards regressions.
// plan → teach only via the learner's plan acceptance
// (data-plan-accepted). A marker out of order advances nothing — the
// chain never skips. plan_init PRESENTS the map; only the verdict
// (the accept button's marker part) sanctions teaching.
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
phase = "plan";
}
if (phase === "plan" && part.type === "tool-plan_init" && part.state === "output-available") {
const tree = (part.output as PlanOutput | undefined)?.tree;
if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) phase = "teach";
if (phase === "plan" && part.type === "data-plan-accepted") {
phase = "teach";
}
}
return phase;
+4
View File
@@ -1,6 +1,7 @@
import type { UIMessage } from "ai";
import type {
DemoToolInput,
PlanAcceptedData,
PlanBlockData,
ProbeDoneInput,
ProbeDoneResult,
@@ -37,6 +38,9 @@ export type TeacherUIDataParts = {
"subagent-artifact": SubagentArtifactData;
/** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */
"plan-block": PlanBlockData;
/** The plan-acceptance marker: the accept button's user message carries
* it — the plan→teach phase seam (docs/probe-teach-split.md §4). */
"plan-accepted": PlanAcceptedData;
};
export type TeacherUIMessage = UIMessage<unknown, TeacherUIDataParts, TeacherUITools>;