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 { JobsContext } from "@/components/jobs-context";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import { PlanBlock, PlanPopup } from "@/components/plan-block";
|
||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||
import { PlanWindow } from "@/components/plan-window";
|
||||
import type { PlanBlockData } from "@/src/core";
|
||||
import { QuestionDock } from "@/components/question-dock";
|
||||
@@ -181,6 +181,37 @@ export function Chat({
|
||||
// The live plan popup (wave 4): opened by any transcript plan block.
|
||||
const [planOpen, setPlanOpen] = useState(false);
|
||||
|
||||
// ── The plan verdict gate (docs/probe-teach-split.md §4) ──
|
||||
// The plan has been presented (a tree exists) and the session still sits in
|
||||
// the planning phase → the composer's send square becomes the stacked
|
||||
// Accept / Decline buttons. Acceptance is the explicit plan→teach seam
|
||||
// (a data-plan-accepted part on the message); decline asks for revision.
|
||||
const planVerdict = useMemo(
|
||||
() => phaseAt3(messages) === "plan" && latestTree(messages) !== null,
|
||||
[messages],
|
||||
);
|
||||
const sendVerdict = useCallback(
|
||||
(accepted: boolean) => {
|
||||
const note = text.trim();
|
||||
setText("");
|
||||
if (accepted) {
|
||||
void sendMessage({
|
||||
parts: [
|
||||
{ type: "text", text: note || "The plan is accepted — begin teaching." },
|
||||
{
|
||||
type: "data-plan-accepted",
|
||||
id: `pa_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||
data: { at: new Date().toISOString() },
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
void sendMessage({ text: note || "I'd like the plan revised before we begin." });
|
||||
}
|
||||
},
|
||||
[text, sendMessage],
|
||||
);
|
||||
|
||||
// ── Session usage (wave-4 stats bar) ──
|
||||
// The money channel: the record's turns log, folded client-side by
|
||||
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
||||
@@ -489,6 +520,14 @@ export function Chat({
|
||||
onSend={onSend}
|
||||
onStop={() => stop()}
|
||||
canSend={canSend}
|
||||
verdict={
|
||||
planVerdict
|
||||
? {
|
||||
onAccept: () => sendVerdict(true),
|
||||
onDecline: () => sendVerdict(false),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
onSubmitAnswer={(output) => {
|
||||
|
||||
+42
-14
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -19,6 +19,7 @@ export function Composer({
|
||||
onStop,
|
||||
canSend,
|
||||
autoFocus,
|
||||
verdict,
|
||||
}: {
|
||||
busy: boolean;
|
||||
value: string;
|
||||
@@ -27,6 +28,10 @@ export function Composer({
|
||||
onStop: () => void;
|
||||
canSend: boolean;
|
||||
autoFocus?: boolean;
|
||||
/** The plan verdict gate: while the presented plan awaits the learner's
|
||||
* word, the send square becomes two stacked buttons — green accept,
|
||||
* red decline. Typed text rides the verdict as his note. */
|
||||
verdict?: { onAccept: () => void; onDecline: () => void };
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
@@ -56,19 +61,42 @@ export function Composer({
|
||||
rows={1}
|
||||
className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={busy ? "Stop" : "Send"}
|
||||
disabled={!busy && !canSend}
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||
busy
|
||||
? "bg-secondary text-foreground hover:bg-accent"
|
||||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||
</button>
|
||||
{verdict && !busy ? (
|
||||
// The plan verdict (docs/probe-teach-split.md §4): accept
|
||||
// sanctions teaching (phase plan→teach); decline sends the
|
||||
// revision request. Enter keeps sending a plain message —
|
||||
// side questions stay possible while the verdict pends.
|
||||
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
|
||||
<button
|
||||
type="button"
|
||||
onClick={verdict.onAccept}
|
||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<CheckIcon className="size-3" /> Accept plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={verdict.onDecline}
|
||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-[11px] font-medium text-destructive-foreground transition-colors hover:opacity-90"
|
||||
>
|
||||
<XIcon className="size-3" /> Decline
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={busy ? "Stop" : "Send"}
|
||||
disabled={!busy && !canSend}
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
|
||||
busy
|
||||
? "bg-secondary text-foreground hover:bg-accent"
|
||||
: "bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-11
@@ -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.
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -51,4 +51,9 @@ Two parts:
|
||||
|
||||
**Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.
|
||||
|
||||
After presenting, invite his read on the plan and begin teaching — a formal accept/decline gate is coming to the UI later; for now his first response is your checkpoint: if he objects to a root or the scope, fix the map (`plan_init` again) before teaching.
|
||||
**Presenting ENDS your turn.** The moment the plan is presented, his interface shows two verdict buttons — Accept plan / Decline — and the session waits for his word before any teaching is sanctioned. Do not ask a check or teach before it arrives.
|
||||
|
||||
- **Acceptance** arrives as a message like "[Plan accepted] — begin teaching." Begin the first node.
|
||||
- **Decline** arrives as a revision request. Ask what he'd like changed if it isn't clear, fix the map (`plan_init` again — a wholesale rewrite), present again, and wait once more.
|
||||
|
||||
The verdict is his alone to give — never ask a question whose answer would assume it, and never start teaching into an unaccepted plan. A plan he interacts with mid-lesson (a `plan_init` rewrite during teaching) needs no fresh verdict: acceptance happens once, at the first presentation.
|
||||
@@ -123,6 +123,16 @@ export interface TurnUsage {
|
||||
* block renders current node + direct parents + direct children in its own
|
||||
* small container, the current node glowing — clearly a fragment, not the
|
||||
* whole plan. Clicking either opens the full plan in a popup. */
|
||||
/** The learner's plan-acceptance marker (docs/probe-teach-split.md §4): a
|
||||
* data part riding the accept-click user message. It is THE plan→teach
|
||||
* phase seam — phaseAt3 reads it from the transcript;
|
||||
* convertToModelMessages drops data parts, so the teacher only sees the
|
||||
* message's text; the marker is for derivation and the UI. */
|
||||
export interface PlanAcceptedData {
|
||||
/** ISO timestamp of the accept click. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface PlanBlockData {
|
||||
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
|
||||
* FLOW once, at authoring. The neighborhood fragments are retired —
|
||||
|
||||
+11
-17
@@ -5,10 +5,11 @@
|
||||
*
|
||||
* probe — until the first message containing a `tool-probe_done` part
|
||||
* (the prober concludes; the assessment rides the tool part)
|
||||
* plan — probe concluded, until the first successful `plan_init`
|
||||
* (the teacher presents the map; interim rule until the
|
||||
* accept-plan UI lands — then acceptance becomes the seam)
|
||||
* teach — a plan exists
|
||||
* plan — probe concluded, until the learner ACCEPTS the presented
|
||||
* plan (a `data-plan-accepted` part on the accept button's
|
||||
* user message — the explicit verdict gate; plan_init itself
|
||||
* is only the PRESENTATION and moves no phase)
|
||||
* teach — teaching is sanctioned
|
||||
*
|
||||
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
|
||||
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a
|
||||
@@ -22,9 +23,6 @@
|
||||
/** The three phases of a session, as the picker / header / dividers see them. */
|
||||
export type Phase3 = "probe" | "plan" | "teach";
|
||||
|
||||
/** The advance-only order of phases: a session never visibly regresses. */
|
||||
const PHASE3_RANK: Record<Phase3, number> = { probe: 0, plan: 1, teach: 2 };
|
||||
|
||||
/**
|
||||
* Minimal structural slice of a UI message for the phase scan: parts with
|
||||
* a state and (for concluded calls) an output.
|
||||
@@ -33,10 +31,6 @@ export interface PhaseMessage {
|
||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>;
|
||||
}
|
||||
|
||||
interface PlanOutput {
|
||||
tree?: { nodes?: unknown };
|
||||
}
|
||||
|
||||
/** The phase at `upToIndex` (inclusive; default: the last message). */
|
||||
export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number): Phase3 {
|
||||
let phase: Phase3 = "probe";
|
||||
@@ -55,15 +49,15 @@ function advance(
|
||||
if (!parts) return phase;
|
||||
for (const part of parts) {
|
||||
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
|
||||
// plan → teach only via a successful plan_init. A marker out of order
|
||||
// (e.g. a legacy plan_init with no probe) advances nothing — the chain
|
||||
// never skips, and the rank table below only guards regressions.
|
||||
// plan → teach only via the learner's plan acceptance
|
||||
// (data-plan-accepted). A marker out of order advances nothing — the
|
||||
// chain never skips. plan_init PRESENTS the map; only the verdict
|
||||
// (the accept button's marker part) sanctions teaching.
|
||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||
phase = "plan";
|
||||
}
|
||||
if (phase === "plan" && part.type === "tool-plan_init" && part.state === "output-available") {
|
||||
const tree = (part.output as PlanOutput | undefined)?.tree;
|
||||
if (tree && Array.isArray(tree.nodes) && tree.nodes.length > 0) phase = "teach";
|
||||
if (phase === "plan" && part.type === "data-plan-accepted") {
|
||||
phase = "teach";
|
||||
}
|
||||
}
|
||||
return phase;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type {
|
||||
DemoToolInput,
|
||||
PlanAcceptedData,
|
||||
PlanBlockData,
|
||||
ProbeDoneInput,
|
||||
ProbeDoneResult,
|
||||
@@ -37,6 +38,9 @@ export type TeacherUIDataParts = {
|
||||
"subagent-artifact": SubagentArtifactData;
|
||||
/** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */
|
||||
"plan-block": PlanBlockData;
|
||||
/** The plan-acceptance marker: the accept button's user message carries
|
||||
* it — the plan→teach phase seam (docs/probe-teach-split.md §4). */
|
||||
"plan-accepted": PlanAcceptedData;
|
||||
};
|
||||
|
||||
export type TeacherUIMessage = UIMessage<unknown, TeacherUIDataParts, TeacherUITools>;
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user