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
+40 -1
View File
@@ -21,7 +21,7 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import { PlanBlock, PlanPopup } from "@/components/plan-block";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanWindow } from "@/components/plan-window";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
@@ -181,6 +181,37 @@ export function Chat({
// The live plan popup (wave 4): opened by any transcript plan block.
const [planOpen, setPlanOpen] = useState(false);
// ── The plan verdict gate (docs/probe-teach-split.md §4) ──
// The plan has been presented (a tree exists) and the session still sits in
// the planning phase → the composer's send square becomes the stacked
// Accept / Decline buttons. Acceptance is the explicit plan→teach seam
// (a data-plan-accepted part on the message); decline asks for revision.
const planVerdict = useMemo(
() => phaseAt3(messages) === "plan" && latestTree(messages) !== null,
[messages],
);
const sendVerdict = useCallback(
(accepted: boolean) => {
const note = text.trim();
setText("");
if (accepted) {
void sendMessage({
parts: [
{ type: "text", text: note || "The plan is accepted — begin teaching." },
{
type: "data-plan-accepted",
id: `pa_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
data: { at: new Date().toISOString() },
},
],
});
} else {
void sendMessage({ text: note || "I'd like the plan revised before we begin." });
}
},
[text, sendMessage],
);
// ── Session usage (wave-4 stats bar) ──
// The money channel: the record's turns log, folded client-side by
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
@@ -489,6 +520,14 @@ export function Chat({
onSend={onSend}
onStop={() => stop()}
canSend={canSend}
verdict={
planVerdict
? {
onAccept: () => sendVerdict(true),
onDecline: () => sendVerdict(false),
}
: undefined
}
/>
}
onSubmitAnswer={(output) => {
+42 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
@@ -19,6 +19,7 @@ export function Composer({
onStop,
canSend,
autoFocus,
verdict,
}: {
busy: boolean;
value: string;
@@ -27,6 +28,10 @@ export function Composer({
onStop: () => void;
canSend: boolean;
autoFocus?: boolean;
/** The plan verdict gate: while the presented plan awaits the learner's
* word, the send square becomes two stacked buttons — green accept,
* red decline. Typed text rides the verdict as his note. */
verdict?: { onAccept: () => void; onDecline: () => void };
}) {
return (
<form
@@ -56,19 +61,42 @@ export function Composer({
rows={1}
className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="submit"
aria-label={busy ? "Stop" : "Send"}
disabled={!busy && !canSend}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
busy
? "bg-secondary text-foreground hover:bg-accent"
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
)}
>
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
</button>
{verdict && !busy ? (
// The plan verdict (docs/probe-teach-split.md §4): accept
// sanctions teaching (phase plan→teach); decline sends the
// revision request. Enter keeps sending a plain message —
// side questions stay possible while the verdict pends.
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
<button
type="button"
onClick={verdict.onAccept}
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white transition-colors hover:bg-emerald-500"
>
<CheckIcon className="size-3" /> Accept plan
</button>
<button
type="button"
onClick={verdict.onDecline}
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-[11px] font-medium text-destructive-foreground transition-colors hover:opacity-90"
>
<XIcon className="size-3" /> Decline
</button>
</div>
) : (
<button
type="submit"
aria-label={busy ? "Stop" : "Send"}
disabled={!busy && !canSend}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
busy
? "bg-secondary text-foreground hover:bg-accent"
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
)}
>
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
</button>
)}
</form>
);
}
+18 -11
View File
@@ -100,8 +100,15 @@ presents the plan → **accept** → **teach (teacher)**
- The broken derived-occupancy seam (phase-of-tree via `currentNodeId`
nothing ever sets — the stuck-on-plan bug) is **replaced, not patched**.
- Interim rule until the accept-plan UI lands: a successful `plan_init`
marks plan→teach. (The accept gate is designed but deferred — §7.)
- The verdict gate is LIVE (landed 2026-09-11, same day): presenting the
plan (`plan_init`) does NOT move the phase — the learner's **accept**
does. The composer's send square becomes two stacked buttons (green
Accept plan / red Decline) while the verdict pends; accept sends a user
message carrying a `data-plan-accepted` data part — THE plan→teach seam
(`phaseAt3` reads it; `convertToModelMessages` drops data parts, so the
teacher sees only the text). Decline asks for a revision; side questions
stay possible (Enter sends a plain message, phase unmoved). Mid-lesson
rewrites need no fresh verdict: acceptance happens once.
- The session picker phase derives from transcript markers the same way.
## 5. No global-graph context in sessions (for now)
@@ -120,21 +127,21 @@ processes dock): always present, zoomed on the current node, auto-moving
as the lesson advances; click → full plan (the existing popup survives as
the expanded view). Exact layout/design is refined in the UI session.
Interim until that wave: the full plan block still renders in the flow at
plan authoring (plan presentation moment; statuses are accurate at that
instant) and remains the popup's entry point. The neighborhood-block
The window is LIVE (landed 2026-09-11, one day after the design): bottom-left,
2:1 zoomed viewBox centered on the current node, clamped to the layout, click
opens the popup. The full plan block also still renders in the flow at plan
authoring (the presentation moment; the block sits immediately after the
`plan_init` call) as a second popup entry point. The neighborhood-block
machinery (frozen fragments) is retired — snapshots can't represent
live-derived movement.
## 7. Deferred / later waves (designed, not now)
- **Accept-plan UI** — when the teacher presents the plan, the composer
changes to a formal accept control (two send buttons: green accept-plan /
continue; red decline) — first `plan_init` only, never re-authorings.
That acceptance is the explicit planning→teach signal. Ideated in the UI
session.
- ~~**Accept-plan UI**~~ — **landed 2026-09-11** (see §4): stacked
Accept/Decline buttons; the `data-plan-accepted` part is the seam.
- **Assessment viewer** — the strand assessments displayed to the learner
as a proper card (data already durable in the transcript).
as a proper card (data already durable in the transcript; the plain v1
probe-done card renders the assessment meanwhile).
- **Model / thinking control** — currently hardcoded per role in
`config.ts` MODELS. The split gives natural `MODELS.prober` /
`MODELS.teacher` entries; runtime control comes after.
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6 -1
View File
@@ -51,4 +51,9 @@ Two parts:
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
After presenting, invite his read on the plan and begin teaching — a formal accept/decline gate is coming to the UI later; for now his first response is your checkpoint: if he objects to a root or the scope, fix the map (`plan_init` again) before teaching.
**Presenting ENDS your turn.** The moment the plan is presented, his interface shows two verdict buttons — Accept plan / Decline — and the session waits for his word before any teaching is sanctioned. Do not ask a check or teach before it arrives.
- **Acceptance** arrives as a message like "[Plan accepted] — begin teaching." Begin the first node.
- **Decline** arrives as a revision request. Ask what he'd like changed if it isn't clear, fix the map (`plan_init` again — a wholesale rewrite), present again, and wait once more.
The verdict is his alone to give — never ask a question whose answer would assume it, and never start teaching into an unaccepted plan. A plan he interacts with mid-lesson (a `plan_init` rewrite during teaching) needs no fresh verdict: acceptance happens once, at the first presentation.
+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>;
+54
View File
@@ -0,0 +1,54 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { Composer } from "../components/composer.tsx";
// The plan verdict gate (docs/probe-teach-split.md §4): while the presented
// plan awaits the learner's word, the composer's send square becomes two
// stacked buttons — green Accept plan, red Decline.
describe("Composer verdict buttons", () => {
const noop = () => {};
it("no verdict: the normal send square renders", () => {
const html = renderToStaticMarkup(
<Composer busy={false} value="" onChange={noop} onSend={noop} onStop={noop} canSend />,
);
assert.ok(!html.includes("plan-verdict-buttons"));
assert.ok(html.includes('aria-label="Send"'));
});
it("verdict pending: two stacked buttons replace the send square; stop square gone", () => {
const html = renderToStaticMarkup(
<Composer
busy={false}
value=""
onChange={noop}
onSend={noop}
onStop={noop}
canSend
verdict={{ onAccept: noop, onDecline: noop }}
/>,
);
assert.ok(html.includes('data-testid="plan-verdict-buttons"'));
assert.ok(html.includes("Accept plan"));
assert.ok(html.includes("Decline"));
assert.ok(!html.includes('aria-label="Send"'), "no send square while the verdict pends");
});
it("busy: the stop square wins even while a verdict pends (no verdict mid-turn)", () => {
const html = renderToStaticMarkup(
<Composer
busy
value=""
onChange={noop}
onSend={noop}
onStop={noop}
canSend={false}
verdict={{ onAccept: noop, onDecline: noop }}
/>,
);
assert.ok(html.includes('aria-label="Stop"'));
assert.ok(!html.includes("plan-verdict-buttons"));
});
});
+43 -9
View File
@@ -2,9 +2,11 @@
* phases — the event-driven phase chain (docs/probe-teach-split.md §4).
*
* probe ends at the first CONCLUDED probe_done (output-available); planning
* ends at the first successful plan_init (a tree with nodes). No stored-tree
* seam: the phase is a pure fold over the transcript, so it can never stick
* (the old currentNodeId-based phaseOfTree read a field nothing ever set).
* ends at the LEARNER'S plan acceptance (a data-plan-accepted part on the
* accept button's user message — plan_init only PRESENTS the map). No
* stored-tree seam: the phase is a pure fold over the transcript, so it can
* never stick (the old currentNodeId-based phaseOfTree read a field nothing
* ever set).
*/
import assert from "node:assert";
import test from "node:test";
@@ -32,7 +34,19 @@ function assistantTool(
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
test("phaseAt3: probe until a concluded probe_done; plan until a successful plan_init; teach after", () => {
/** The accept button's user message (text + marker data part). */
function planAccepted(): UIMessage {
return {
id: `u_accept_${Math.random().toString(36).slice(2, 6)}`,
role: "user",
parts: [
{ type: "text", text: "The plan is accepted — begin teaching." },
{ type: "data-plan-accepted", id: "pa_1", data: { at: "2026-09-11T00:00:00Z" } } as never,
],
};
}
test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEPTS; teach after", () => {
const probe = phaseAt3([user("teach me calculus"), assistantTool("quiz", "output-available")]);
assert.equal(probe, "probe", "quiz answers alone never end the probe");
@@ -42,19 +56,37 @@ test("phaseAt3: probe until a concluded probe_done; plan until a successful plan
]);
assert.equal(concluded, "plan", "a concluded probe_done ends the probe (planning begins)");
const plan = phaseAt3([
const presented = phaseAt3([
user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK),
]);
assert.equal(plan, "teach", "a successful plan_init ends planning");
assert.equal(presented, "plan", "a successful plan_init PRESENTS the map — planning continues until the verdict");
const accepted = phaseAt3([
user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK),
planAccepted(),
]);
assert.equal(accepted, "teach", "the learner's acceptance ends planning");
const declined = phaseAt3([
user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK),
user("revise it"),
assistantTool("plan_init", "output-available", PLAN_OK),
]);
assert.equal(declined, "plan", "a declined plan stays in planning until a later acceptance");
const failedPlan = phaseAt3([
user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-error", { ok: false, error: "bad mermaid" }),
planAccepted(),
]);
assert.equal(failedPlan, "plan", "a failed plan_init does not end planning");
assert.equal(failedPlan, "teach", "acceptance sanctions teaching even after a failed init attempt — the verdict is his");
});
test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keeps the probe open", () => {
@@ -73,10 +105,12 @@ test("phaseTimeline: folds messages into phase spans for the dividers", () => {
assistantTool("quiz", "output-available"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK),
user("what does node 1 mean?"), // side question — still planning
planAccepted(),
user("go on"),
]);
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "teach", "teach"]);
assert.equal(timeline.length, 5);
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "teach", "teach"]);
assert.equal(timeline.length, 7);
});
test("phaseAt3: empty transcript is probe", () => {