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", () => {