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 { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context"; import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar"; 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 { PlanWindow } from "@/components/plan-window";
import type { PlanBlockData } from "@/src/core"; import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock"; 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. // The live plan popup (wave 4): opened by any transcript plan block.
const [planOpen, setPlanOpen] = useState(false); 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) ── // ── Session usage (wave-4 stats bar) ──
// The money channel: the record's turns log, folded client-side by // 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), // core/prices.ts. Seeded once on mount (the page doesn't carry turns),
@@ -489,6 +520,14 @@ export function Chat({
onSend={onSend} onSend={onSend}
onStop={() => stop()} onStop={() => stop()}
canSend={canSend} canSend={canSend}
verdict={
planVerdict
? {
onAccept: () => sendVerdict(true),
onDecline: () => sendVerdict(false),
}
: undefined
}
/> />
} }
onSubmitAnswer={(output) => { onSubmitAnswer={(output) => {
+29 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
@@ -19,6 +19,7 @@ export function Composer({
onStop, onStop,
canSend, canSend,
autoFocus, autoFocus,
verdict,
}: { }: {
busy: boolean; busy: boolean;
value: string; value: string;
@@ -27,6 +28,10 @@ export function Composer({
onStop: () => void; onStop: () => void;
canSend: boolean; canSend: boolean;
autoFocus?: 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 ( return (
<form <form
@@ -56,6 +61,28 @@ export function Composer({
rows={1} 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" 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"
/> />
{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 <button
type="submit" type="submit"
aria-label={busy ? "Stop" : "Send"} aria-label={busy ? "Stop" : "Send"}
@@ -69,6 +96,7 @@ export function Composer({
> >
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />} {busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
</button> </button>
)}
</form> </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` - The broken derived-occupancy seam (phase-of-tree via `currentNodeId`
nothing ever sets — the stuck-on-plan bug) is **replaced, not patched**. nothing ever sets — the stuck-on-plan bug) is **replaced, not patched**.
- Interim rule until the accept-plan UI lands: a successful `plan_init` - The verdict gate is LIVE (landed 2026-09-11, same day): presenting the
marks plan→teach. (The accept gate is designed but deferred — §7.) 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. - The session picker phase derives from transcript markers the same way.
## 5. No global-graph context in sessions (for now) ## 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 as the lesson advances; click → full plan (the existing popup survives as
the expanded view). Exact layout/design is refined in the UI session. 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 The window is LIVE (landed 2026-09-11, one day after the design): bottom-left,
plan authoring (plan presentation moment; statuses are accurate at that 2:1 zoomed viewBox centered on the current node, clamped to the layout, click
instant) and remains the popup's entry point. The neighborhood-block 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 machinery (frozen fragments) is retired — snapshots can't represent
live-derived movement. live-derived movement.
## 7. Deferred / later waves (designed, not now) ## 7. Deferred / later waves (designed, not now)
- **Accept-plan UI** — when the teacher presents the plan, the composer - ~~**Accept-plan UI**~~ — **landed 2026-09-11** (see §4): stacked
changes to a formal accept control (two send buttons: green accept-plan / Accept/Decline buttons; the `data-plan-accepted` part is the seam.
continue; red decline) — first `plan_init` only, never re-authorings.
That acceptance is the explicit planning→teach signal. Ideated in the UI
session.
- **Assessment viewer** — the strand assessments displayed to the learner - **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 - **Model / thinking control** — currently hardcoded per role in
`config.ts` MODELS. The split gives natural `MODELS.prober` / `config.ts` MODELS. The split gives natural `MODELS.prober` /
`MODELS.teacher` entries; runtime control comes after. `MODELS.teacher` entries; runtime control comes after.
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
import "./.next/types/root-params.d.ts"; import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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. **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 * block renders current node + direct parents + direct children in its own
* small container, the current node glowing — clearly a fragment, not the * small container, the current node glowing — clearly a fragment, not the
* whole plan. Clicking either opens the full plan in a popup. */ * 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 { export interface PlanBlockData {
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN /** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
* FLOW once, at authoring. The neighborhood fragments are retired — * 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 * probe — until the first message containing a `tool-probe_done` part
* (the prober concludes; the assessment rides the tool part) * (the prober concludes; the assessment rides the tool part)
* plan — probe concluded, until the first successful `plan_init` * plan — probe concluded, until the learner ACCEPTS the presented
* (the teacher presents the map; interim rule until the * plan (a `data-plan-accepted` part on the accept button's
* accept-plan UI lands — then acceptance becomes the seam) * user message — the explicit verdict gate; plan_init itself
* teach — a plan exists * is only the PRESENTATION and moves no phase)
* teach — teaching is sanctioned
* *
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`) * The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a * 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. */ /** The three phases of a session, as the picker / header / dividers see them. */
export type Phase3 = "probe" | "plan" | "teach"; 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 * Minimal structural slice of a UI message for the phase scan: parts with
* a state and (for concluded calls) an output. * a state and (for concluded calls) an output.
@@ -33,10 +31,6 @@ export interface PhaseMessage {
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>; parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
} }
interface PlanOutput {
tree?: { nodes?: unknown };
}
/** The phase at `upToIndex` (inclusive; default: the last message). */ /** The phase at `upToIndex` (inclusive; default: the last message). */
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 { export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe"; let phase: Phase3 = "probe";
@@ -55,15 +49,15 @@ function advance(
if (!parts) return phase; if (!parts) return phase;
for (const part of parts) { for (const part of parts) {
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done, // STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
// plan → teach only via a successful plan_init. A marker out of order // plan → teach only via the learner's plan acceptance
// (e.g. a legacy plan_init with no probe) advances nothing — the chain // (data-plan-accepted). A marker out of order advances nothing — the
// never skips, and the rank table below only guards regressions. // 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") { if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
phase = "plan"; phase = "plan";
} }
if (phase === "plan" && part.type === "tool-plan_init" && part.state === "output-available") { if (phase === "plan" && part.type === "data-plan-accepted") {
const tree = (part.output as PlanOutput | undefined)?.tree; phase = "teach";
if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) phase = "teach";
} }
} }
return phase; return phase;
+4
View File
@@ -1,6 +1,7 @@
import type { UIMessage } from "ai"; import type { UIMessage } from "ai";
import type { import type {
DemoToolInput, DemoToolInput,
PlanAcceptedData,
PlanBlockData, PlanBlockData,
ProbeDoneInput, ProbeDoneInput,
ProbeDoneResult, ProbeDoneResult,
@@ -37,6 +38,9 @@ export type TeacherUIDataParts = {
"subagent-artifact": SubagentArtifactData; "subagent-artifact": SubagentArtifactData;
/** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */ /** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */
"plan-block": PlanBlockData; "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>; 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). * phases — the event-driven phase chain (docs/probe-teach-split.md §4).
* *
* probe ends at the first CONCLUDED probe_done (output-available); planning * 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 * ends at the LEARNER'S plan acceptance (a data-plan-accepted part on the
* seam: the phase is a pure fold over the transcript, so it can never stick * accept button's user message — plan_init only PRESENTS the map). No
* (the old currentNodeId-based phaseOfTree read a field nothing ever set). * 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 assert from "node:assert";
import test from "node:test"; import test from "node:test";
@@ -32,7 +34,19 @@ function assistantTool(
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } }; const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } }; 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")]); const probe = phaseAt3([user("teach me calculus"), assistantTool("quiz", "output-available")]);
assert.equal(probe, "probe", "quiz answers alone never end the probe"); 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)"); assert.equal(concluded, "plan", "a concluded probe_done ends the probe (planning begins)");
const plan = phaseAt3([ const presented = phaseAt3([
user("teach me"), user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT), assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK), 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([ const failedPlan = phaseAt3([
user("teach me"), user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT), assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-error", { ok: false, error: "bad mermaid" }), 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", () => { 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("quiz", "output-available"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT), assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK), assistantTool("plan_init", "output-available", PLAN_OK),
user("what does node 1 mean?"), // side question — still planning
planAccepted(),
user("go on"), user("go on"),
]); ]);
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "teach", "teach"]); assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "teach", "teach"]);
assert.equal(timeline.length, 5); assert.equal(timeline.length, 7);
}); });
test("phaseAt3: empty transcript is probe", () => { test("phaseAt3: empty transcript is probe", () => {