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:
+40
-1
@@ -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) => {
|
||||||
|
|||||||
+42
-14
@@ -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,19 +61,42 @@ 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"
|
||||||
/>
|
/>
|
||||||
<button
|
{verdict && !busy ? (
|
||||||
type="submit"
|
// The plan verdict (docs/probe-teach-split.md §4): accept
|
||||||
aria-label={busy ? "Stop" : "Send"}
|
// sanctions teaching (phase plan→teach); decline sends the
|
||||||
disabled={!busy && !canSend}
|
// revision request. Enter keeps sending a plain message —
|
||||||
className={cn(
|
// side questions stay possible while the verdict pends.
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
|
||||||
busy
|
<button
|
||||||
? "bg-secondary text-foreground hover:bg-accent"
|
type="button"
|
||||||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
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"
|
||||||
>
|
>
|
||||||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
<CheckIcon className="size-3" /> Accept plan
|
||||||
</button>
|
</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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-11
@@ -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.
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user