diff --git a/components/chat.tsx b/components/chat.tsx
index 834b439..e928386 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -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) => {
diff --git a/components/composer.tsx b/components/composer.tsx
index df58d36..2192d9b 100644
--- a/components/composer.tsx
+++ b/components/composer.tsx
@@ -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 (
-
+ {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.
+
+
+
+
+ ) : (
+
+ )}
);
}
diff --git a/docs/probe-teach-split.md b/docs/probe-teach-split.md
index 7bf09b3..b5ba2a6 100644
--- a/docs/probe-teach-split.md
+++ b/docs/probe-teach-split.md
@@ -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.
diff --git a/next-env.d.ts b/next-env.d.ts
index ce4e94a..a419cbe 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-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.
diff --git a/src/agent/prompts/teacher/10-planning.md b/src/agent/prompts/teacher/10-planning.md
index a480e2e..20539c1 100644
--- a/src/agent/prompts/teacher/10-planning.md
+++ b/src/agent/prompts/teacher/10-planning.md
@@ -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.
\ No newline at end of file
+**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.
\ No newline at end of file
diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts
index ea41c47..e13ee57 100644
--- a/src/core/contracts/chat.ts
+++ b/src/core/contracts/chat.ts
@@ -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 —
diff --git a/src/core/phases.ts b/src/core/phases.ts
index 5b0c3ad..6eefa9f 100644
--- a/src/core/phases.ts
+++ b/src/core/phases.ts
@@ -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 = { 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;
diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts
index dbeff31..29ae7f0 100644
--- a/src/core/ui-message.ts
+++ b/src/core/ui-message.ts
@@ -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;
diff --git a/tests/composer-verdict.test.tsx b/tests/composer-verdict.test.tsx
new file mode 100644
index 0000000..dd13f97
--- /dev/null
+++ b/tests/composer-verdict.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ assert.ok(html.includes('aria-label="Stop"'));
+ assert.ok(!html.includes("plan-verdict-buttons"));
+ });
+});
\ No newline at end of file
diff --git a/tests/phases.test.ts b/tests/phases.test.ts
index 228430d..8931ff2 100644
--- a/tests/phases.test.ts
+++ b/tests/phases.test.ts
@@ -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", () => {