fix: verdict death — plan→teach seam inferred from first nodeId-tagged quiz

The Accept/Decline verdict gate is gone (owner ruling 2026-09-26):
presenting the plan ends the teacher's turn; the learner's next message
is a natural reply. Teaching begins at the first quiz call tagged with a
nodeId (probe quizzes carry none) — both folds (view phaseAt3, native
phaseAt3Lh) advance on the call itself, and the teach divider renders
before that whole assistant block. No backwards compat: verdict markers,
the verdict input event, optimistic appends, composer buttons, and
projection all deleted; prompts and docs rewritten to the natural flow.
This commit is contained in:
amosblomqvist
2026-09-27 00:48:46 +03:00
parent dcb839232c
commit 145b470752
17 changed files with 221 additions and 389 deletions
+12 -53
View File
@@ -128,7 +128,7 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
* as a sub-agent result — rendered as an artifact card, never a bubble. */ * as a sub-agent result — rendered as an artifact card, never a bubble. */
/** The quiet system line (custom-message scheme rendering): centered, muted, /** The quiet system line (custom-message scheme rendering): centered, muted,
* small — the transcript's voice for events that are not speech (wake * small — the transcript's voice for events that are not speech (wake
* scaffolding, the learner's plan verdict). */ * scaffolding). */
function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) { function SystemLine({ children, testId }: { children: React.ReactNode; testId?: string }) {
return ( return (
<div <div
@@ -402,7 +402,7 @@ export function Chat({
.catch(() => {}); .catch(() => {});
}); });
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
/** The last explicitly-sent draft (composer send, verdict note): the /** The last explicitly-sent draft (composer send): the
* refusal flow restores it into the composer and Save replays it. * refusal flow restores it into the composer and Save replays it.
* Auto-continuations (probe answers) never set it — their refusal * Auto-continuations (probe answers) never set it — their refusal
* opens the dialog with no replay. */ * opens the dialog with no replay. */
@@ -575,35 +575,6 @@ export function Chat({
// derives its own from the same transcript). // derives its own from the same transcript).
const planTree = useMemo(() => latestTree(messages), [messages]); const planTree = useMemo(() => latestTree(messages), [messages]);
// ── 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" && planTree !== null,
[messages, planTree],
);
const sendVerdict = useCallback(
(accepted: boolean) => {
const note = text.trim();
if (note) draftRef.current = note; // refusal flow: the note replays as text
setText("");
// TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided
// 2026-09-12): a typed note is the learner's own user message, and the
// verdict is its own custom message — marker part ONLY, no human-
// authored text (the model-facing wording lives in toLlmMessages,
// the native convertToLlm seam; nothing fake is persisted as speech).
// ONE input event, ONE turn: the session hook appends the optimistic
// note + verdict marker (local ids — the settle sync lands the
// server canon) and POSTs {type:"verdict"}; the server persists the
// pair as its own write-ahead — the same single-turn mechanism
// syncFromServer uses, no 409 dance, no double teacher turn.
void send({ type: "verdict", accepted, note: note || undefined });
},
[text, send],
);
// ── 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),
@@ -1076,20 +1047,16 @@ export function Chat({
// assistant group — is gone). Never a user bubble: the wake is // assistant group — is gone). Never a user bubble: the wake is
// scaffolding; the model-facing instruction lives in the adapter. // scaffolding; the model-facing instruction lives in the adapter.
<PhaseDivider key={m.id || `m-${mi}`} phase="plan" /> <PhaseDivider key={m.id || `m-${mi}`} phase="plan" />
) : m.role === "user" && m.parts.some((p) => p.type === "data-verdict") ? ( ) : m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
// The learner's plan verdict (custom-message scheme): the // THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
// accept IS the phase seam — it renders the TEACHING DIVIDER // death): teaching begins where the FIRST nodeId-tagged quiz is
// (owner ruling 2026-09-26: replaced the "✓ Plan accepted" // posed — the fold (phaseTimeline, the same derivation the phase
// line; same spot, one signal, not two). A DECLINE is not a // chip reads) flips to teach exactly AT this assistant group, so
// seam — it stays the quiet revision line. Never a user // the divider renders before the whole block (thinking -> prose ->
// bubble: the model-facing wording is the adapter's, and this // the quiz call) — where the explicit accept marker used to sit.
// is the learner's ACT, not their words (a typed note, if // The boundary check (this index flips, the previous didn't)
// any, renders above as its own real message). // guarantees exactly ONE divider per session.
((m.parts.find((p) => p.type === "data-verdict") as <PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
| { data?: { accepted?: boolean } }
| undefined)?.data?.accepted)
? <PhaseDivider key={m.id || `m-${mi}`} phase="teach" />
: <SystemLine key={m.id || `m-${mi}`} testId="verdict-declined">↻ Plan revision requested</SystemLine>
) : ( ) : (
<Fragment key={m.id || `m-${mi}`}> <Fragment key={m.id || `m-${mi}`}>
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}> <Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
@@ -1234,14 +1201,6 @@ export function Chat({
onStop={abortInFlightTurn} onStop={abortInFlightTurn}
canSend={canSend} canSend={canSend}
onEscape={dictation.active ? dictation.cancel : undefined} onEscape={dictation.active ? dictation.cancel : undefined}
verdict={
planVerdict
? {
onAccept: () => sendVerdict(true),
onDecline: () => sendVerdict(false),
}
: undefined
}
/> />
} }
onSubmitAnswer={(output) => { onSubmitAnswer={(output) => {
+12 -57
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react"; import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
@@ -12,16 +12,13 @@ import { Hint } from "@/components/ui/hint";
* Corner-radius math (concentric with the shell): shell rounded-2xl (16px) − * Corner-radius math (concentric with the shell): shell rounded-2xl (16px) −
* row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button. * row padding p-1.5 (6px) = button rounded-[10px] = 25% of the 40px button.
*/ */
/** What one Enter press does, in precedence order (the plan-verdict /** What one Enter press does, in precedence order: stop a busy stream,
* behavior is user-decided 2026-09-19, docs/probe-teach-split.md §4): * otherwise send the message. (The plan-verdict accept branch died
* stop a busy stream; ACCEPT when the plan verdict pends — typed text * 2026-09-26 with the verdict itself.) Pure so the static-render harness
* rides the acceptance as the note (the green button's exact semantics; * can pin the decision (key events can't be simulated in
* an empty box accepts too, so Enter can never strand the verdict); * renderToStaticMarkup). */
* otherwise send the message. Pure so the static-render harness can pin export function enterAction(state: { busy: boolean; canSend: boolean }): "stop" | "send" | "none" {
* the decision (key events can't be simulated in renderToStaticMarkup). */
export function enterAction(state: { busy: boolean; verdictPending: boolean; canSend: boolean }): "stop" | "accept" | "send" | "none" {
if (state.busy) return "stop"; if (state.busy) return "stop";
if (state.verdictPending) return "accept";
if (state.canSend) return "send"; if (state.canSend) return "send";
return "none"; return "none";
} }
@@ -34,7 +31,6 @@ export function Composer({
onStop, onStop,
canSend, canSend,
autoFocus, autoFocus,
verdict,
onEscape, onEscape,
placeholder = "Ask, answer, or steer the lesson…", placeholder = "Ask, answer, or steer the lesson…",
}: { }: {
@@ -45,10 +41,6 @@ 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 };
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op /** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
* when absent — Esc otherwise means nothing in the composer. */ * when absent — Esc otherwise means nothing in the composer. */
onEscape?: () => void; onEscape?: () => void;
@@ -63,9 +55,8 @@ export function Composer({
className="flex items-end gap-1.5 p-1.5" className="flex items-end gap-1.5 p-1.5"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend }); const action = enterAction({ busy, canSend });
if (action === "stop") onStop(); if (action === "stop") onStop();
else if (action === "accept") verdict?.onAccept();
else if (action === "send") onSend(); else if (action === "send") onSend();
}} }}
> >
@@ -86,13 +77,10 @@ export function Composer({
// Chat convention: Enter sends, Shift+Enter is a newline. // Chat convention: Enter sends, Shift+Enter is a newline.
// ⌘/Ctrl+Enter also sends — the universal submit shortcut // ⌘/Ctrl+Enter also sends — the universal submit shortcut
// across every input in the app (thinking field, answers). // across every input in the app (thinking field, answers).
// While the plan verdict pends, Enter ACCEPTS the plan instead
// (enterAction — user-decided 2026-09-19).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
e.preventDefault(); e.preventDefault();
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend }); const action = enterAction({ busy, canSend });
if (action === "stop") onStop(); if (action === "stop") onStop();
else if (action === "accept") verdict?.onAccept();
else if (action === "send") onSend(); else if (action === "send") onSend();
} }
}} }}
@@ -101,41 +89,9 @@ export function Composer({
rows={1} rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/> />
{verdict && !busy ? ( {/* No tooltip on the send square — the ↵ glyph is the
// The plan verdict (docs/probe-teach-split.md §4): accept affordance and Enter is obvious (owner, subtlety
// sanctions teaching (phase plan→teach); decline sends the round 2026-09-25). */}
// revision request. Enter accepts too (user-decided 2026-09-19 —
// it formerly sent a plain message, which left the verdict
// pending on the next message; a side question now rides the
// acceptance as its note instead).
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
<Hint
// Enter accepts too — one press, either button (the user
// decided 2026-09-19; surface keys honestly per state).
label="Accept the plan"
binding="enter"
side="left"
>
<button
type="button"
onClick={verdict.onAccept}
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
>
<CheckIcon className="size-3" /> Accept plan
</button>
</Hint>
<button
type="button"
onClick={verdict.onDecline}
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-xs font-medium text-destructive-foreground transition-colors hover:opacity-90"
>
<XIcon className="size-3" /> Decline
</button>
</div>
) : (
// No tooltip on the send square — the ↵ glyph is the
// affordance and Enter is obvious (owner, subtlety
// round 2026-09-25).
<button <button
type="submit" type="submit"
aria-label={busy ? "Stop" : "Send"} aria-label={busy ? "Stop" : "Send"}
@@ -149,7 +105,6 @@ 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>
); );
} }
-17
View File
@@ -19,7 +19,6 @@ import {
needsContinuation, needsContinuation,
newAnswerResult, newAnswerResult,
newUserMessage, newUserMessage,
newVerdictMessage,
projectView, projectView,
type ChatInput, type ChatInput,
type LhMessage, type LhMessage,
@@ -133,16 +132,6 @@ export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
(r as ToolResultMessage).toolCallId === toolCallId, (r as ToolResultMessage).toolCallId === toolCallId,
); );
} }
if (role === "custom") {
const marker = m as { customType?: string; details?: { accepted?: unknown } };
if (marker.customType !== "verdict") return false;
return record.some(
(r) =>
(r as { role?: string }).role === "custom" &&
(r as { customType?: string }).customType === "verdict" &&
(r as { details?: { accepted?: unknown } }).details?.accepted === marker.details?.accepted,
);
}
return false; return false;
} }
@@ -465,12 +454,6 @@ export class SessionModel {
else if (input.type === "answer") { else if (input.type === "answer") {
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz"; const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() }); optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
} else if (input.type === "verdict") {
if (input.note) optimistic.push({ ...newUserMessage(input.note), id: localId() });
optimistic.push({
...newVerdictMessage(input.accepted, new Date().toISOString()),
id: localId(),
});
} }
if (optimistic.length > 0) this.mutate(() => { if (optimistic.length > 0) this.mutate(() => {
this.tail = [...this.tail, ...optimistic]; this.tail = [...this.tail, ...optimistic];
+1 -1
View File
@@ -147,7 +147,7 @@ flowchart TB
- `contracts/plan.ts` — plan tree types + derived statuses (`derivePlanStatuses`) - `contracts/plan.ts` — plan tree types + derived statuses (`derivePlanStatuses`)
- `contracts/graph.ts` — graph domain: ledgers, findings/audit shapes, snapshot, birth-session clusters - `contracts/graph.ts` — graph domain: ledgers, findings/audit shapes, snapshot, birth-session clusters
- `contracts/chat.ts` — session records, usage/money channel, settings, verdicts - `contracts/chat.ts` — session records, usage/money channel, settings, handoff text
- `contracts/jobs.ts` — the ack-and-steer protocol: job records, steer message builders, deterministic steer ids, usage accumulation - `contracts/jobs.ts` — the ack-and-steer protocol: job records, steer message builders, deterministic steer ids, usage accumulation
- `contracts/probe.ts` — probe assessment + the shared zod schema - `contracts/probe.ts` — probe assessment + the shared zod schema
- `contracts/models.ts` — client-safe model-catalog shapes - `contracts/models.ts` — client-safe model-catalog shapes
+16 -17
View File
@@ -95,24 +95,22 @@ That is the wrong signal and is **abolished**. The new contract:
The phase chain is **event-driven**: The phase chain is **event-driven**:
**probe (prober)** — `probe_done` → **planning (teacher)** — `plan_init` **probe (prober)** — `probe_done` → **planning (teacher)** — the first
presents the plan → **accept** → **teach (teacher)** `nodeId`-tagged quiz call → **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**.
- The verdict gate is LIVE (landed 2026-09-11, same day): presenting the - The verdict gate is DEAD (owner ruling 2026-09-26 — simplified away):
plan (`plan_init`) does NOT move the phase — the learner's **accept** presenting the plan ends the teacher's turn, and the learner's next
does. The composer's send square becomes two stacked buttons (green message is a natural reply — a change request (revise, re-present, end
Accept plan / red Decline) while the verdict pends; accept sends a user the turn again) or the go-ahead. The plan→teach seam is **inferred**:
message carrying a `data-verdict` data part — THE plan→teach seam the first quiz call tagged with a `nodeId` (a question ABOUT a plan
(`phaseAt3` reads it; `convertToModelMessages` drops data parts, so the node; probe quizzes carry no nodeId) is where teaching begins. The
teacher sees only the text). Decline asks for a revision. While the phase fold (`phaseAt3`) advances there, the teaching divider renders
verdict pends, Enter ACCEPTS the plan — typed text rides the acceptance before that whole assistant block, and the live phase indicator may lag
as its note, an empty box accepts too (user-decided 2026-09-19; formerly until the first node's question is posed (owner-accepted). Mid-lesson
Enter sent a plain message, which left the verdict still pending on the `plan_init` rewrites need no gate at all.
next message — a side question now rides the acceptance note instead). - The session picker phase derives from the transcript the same way.
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) ## 5. No global-graph context in sessions (for now)
@@ -140,8 +138,9 @@ live-derived movement.
## 7. Deferred / later waves (designed, not now) ## 7. Deferred / later waves (designed, not now)
- ~~**Accept-plan UI**~~ — **landed 2026-09-11** (see §4): stacked - ~~**Accept-plan UI**~~ — landed 2026-09-11, then **removed 2026-09-26**
Accept/Decline buttons; the `data-verdict` part is the seam. (the verdict death): the seam is inferred from the first `nodeId`-tagged
quiz call (see §4).
- **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; the plain v1 as a proper card (data already durable in the transcript; the plain v1
probe-done card renders the assessment meanwhile). probe-done card renders the assessment meanwhile).
+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/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts"; import "./.next/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.
+4 -4
View File
@@ -50,9 +50,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.
**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. **Presenting ENDS your turn.** The plan is presented in prose and map, and then you stop — his next message is the natural next move. Do not ask a check or teach in the same turn as presenting.
- **Acceptance** arrives as a message like "[Plan accepted] — begin teaching." Begin the first node. - If his reply asks for changes, revise: fix the map (`plan_init` again — a wholesale rewrite), present again, and end your turn once more.
- **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. - Otherwise his reply moves the lesson forward — begin the first node.
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. Never start teaching in the same turn as presenting the plan. A plan rewrite mid-lesson needs no fresh presentation gate: just re-call `plan_init` and carry on.
+1 -1
View File
@@ -181,7 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
steeringMode: "all", steeringMode: "all",
// Wave B (owner-approved 2026-09-26): the model-facing conversion is // Wave B (owner-approved 2026-09-26): the model-facing conversion is
// THE app seam — markers flatten to their canonical user text // THE app seam — markers flatten to their canonical user text
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced), // (HANDOFF_TEXT / steer text, all single-sourced),
// UI-only markers (plan-block) drop, the app's id fields strip, and // UI-only markers (plan-block) drop, the app's id fields strip, and
// an abort's dangling question call gets its INTERRUPTED result. It // an abort's dangling question call gets its INTERRUPTED result. It
// must never throw (pi's convertToLlm contract). // must never throw (pi's convertToLlm contract).
+4 -26
View File
@@ -149,30 +149,6 @@ 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 verdict (docs/probe-teach-split.md §4): a data part
* riding its own user message — the ONLY thing on that message. It is THE
* plan→teach phase seam (phaseAt3 reads accepted from the transcript), and
* both verdicts are real transcript events: accept sanctions teaching,
* decline asks for revision. The part is the message TYPE (the "custom
* message" scheme, 2026-09-12): the message carries no human-authored text,
* the model-facing wording is owned by toLlmMessages (native-messages.ts
* flattens it to verdictText below), and the UI renders the verdict
* marker — never a user bubble. A learner who also typed a note sends the
* note as its own real user message first; the verdict message follows. */
export interface PlanVerdictData {
/** true = accept (sanction to teach); false = decline (revise the plan). */
accepted: boolean;
/** ISO timestamp of the click. */
at: string;
}
/** The model-facing text of a verdict message — single-sourced here so the
* adapter, the tests, and reality can't drift apart. */
export function verdictText(accepted: boolean): string {
return accepted
? "The learner accepted the plan — begin teaching."
: "The learner would like the plan revised before teaching begins.";
}
/** The model-facing text of the probe→teach handoff wake (the custom-message /** The model-facing text of the probe→teach handoff wake (the custom-message
* scheme): the wake message carries only the data-probe-handoff part, and * scheme): the wake message carries only the data-probe-handoff part, and
@@ -185,14 +161,16 @@ export function verdictText(accepted: boolean): string {
* fragment asks for. So the wake paces it: reason out the optimal way to * fragment asks for. So the wake paces it: reason out the optimal way to
* teach THIS person THIS thing BEFORE plan_init. Honest to the machinery * teach THIS person THIS thing BEFORE plan_init. Honest to the machinery
* — plan_init is still the first tool, the plan map still the first * — plan_init is still the first tool, the plan map still the first
* artifact, and presenting still waits for the verdict. */ * artifact, and presenting still ends the turn (the verdict death,
* 2026-09-26: the learner's next message is a natural reply — a change
* request or the go-ahead). */
export const HANDOFF_TEXT = export const HANDOFF_TEXT =
"The probe is concluded — the learner assessment is recorded. " + "The probe is concluded — the learner assessment is recorded. " +
"This is the session's highest-leverage moment, and nothing has been authored yet — take your time. " + "This is the session's highest-leverage moment, and nothing has been authored yet — take your time. " +
"Read his assessment and the probing transcript, and reason out the optimal way to teach this person this thing: " + "Read his assessment and the probing transcript, and reason out the optimal way to teach this person this thing: " +
"what he already holds and where each strand runs out, the misconceptions to route around, " + "what he already holds and where each strand runs out, the misconceptions to route around, " +
"and the minimal tree from what he holds as obviously true to his goal — so every step lands as obviously true. " + "and the minimal tree from what he holds as obviously true to his goal — so every step lands as obviously true. " +
"When the plan is genuinely reasoned out, call plan_init and present it, then wait for his verdict."; "When the plan is genuinely reasoned out, call plan_init, present it, and end your turn — his next message either asks for changes or moves the lesson forward.";
export interface PlanBlockData { export interface PlanBlockData {
+3 -19
View File
@@ -13,13 +13,13 @@
* built client-side exactly as today; a dismissal is an answer * built client-side exactly as today; a dismissal is an answer
* carrying its flag). Server-side this becomes the call's * carrying its flag). Server-side this becomes the call's
* native tool result — fork 1's normal agent history. * native tool result — fork 1's normal agent history.
* verdict — the plan verdict (accept/decline); an optional note is the
* learner's own real user message, sent first, exactly the
* two-real-messages scheme.
* continue — run the next turn on the current record (mount * continue — run the next turn on the current record (mount
* continuations, job-settle re-syncs: the record ends at an * continuations, job-settle re-syncs: the record ends at an
* answered question with no teacher reaction yet). * answered question with no teacher reaction yet).
* *
* (The plan verdict input died 2026-09-26 with the verdict itself — the
* learner's post-plan reply is a natural text message.)
*
* Server-originated delivery (the wake pipeline's steers) is NOT a client * Server-originated delivery (the wake pipeline's steers) is NOT a client
* input — the handler accepts it on the same path (TurnInput) so every * input — the handler accepts it on the same path (TurnInput) so every
* turn rides one composition. * turn rides one composition.
@@ -31,7 +31,6 @@ import {
danglingQuestionOf, danglingQuestionOf,
newAnswerResult, newAnswerResult,
newUserMessage, newUserMessage,
newVerdictMessage,
} from "./native-messages.ts"; } from "./native-messages.ts";
import { QUESTION_TOOLS } from "./pending.ts"; import { QUESTION_TOOLS } from "./pending.ts";
@@ -39,7 +38,6 @@ import { QUESTION_TOOLS } from "./pending.ts";
export type ChatInput = export type ChatInput =
| { type: "text"; text: string } | { type: "text"; text: string }
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData } | { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
| { type: "verdict"; accepted: boolean; note?: string }
| { type: "continue" }; | { type: "continue" };
/** A wake-pipeline steer delivery — same turn composition, server-origin. /** A wake-pipeline steer delivery — same turn composition, server-origin.
@@ -73,12 +71,6 @@ export function parseChatInput(input: unknown): ChatInput {
throw new ChatInputError("answer input requires the graded answer object"); throw new ChatInputError("answer input requires the graded answer object");
} }
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData }; return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
case "verdict":
if (typeof i.accepted !== "boolean") throw new ChatInputError("verdict input requires accepted");
if (i.note !== undefined && typeof i.note !== "string") {
throw new ChatInputError("verdict note must be a string");
}
return { type: "verdict", accepted: i.accepted, note: i.note as string | undefined };
case "continue": case "continue":
return { type: "continue" }; return { type: "continue" };
default: default:
@@ -106,14 +98,6 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
} }
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)]; return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
} }
case "verdict": {
const appends: LhMessage[] = [];
if (input.note && input.note.trim().length > 0) {
appends.push(newUserMessage(input.note));
}
appends.push(newVerdictMessage(input.accepted, new Date().toISOString()));
return appends;
}
case "continue": case "continue":
return []; return [];
} }
+3 -18
View File
@@ -14,7 +14,7 @@
* sees it (verified single site, below). * sees it (verified single site, below).
* *
* UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message * UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message
* taxonomy): verdict markers, the probe→teach handoff, sub-agent steers, * taxonomy): the probe→teach handoff, sub-agent steers,
* and plan blocks are LhMarkerMessage — this app's declaration-merged * and plan blocks are LhMarkerMessage — this app's declaration-merged
* member of pi-agent-core's CustomAgentMessages extension point, shaped * member of pi-agent-core's CustomAgentMessages extension point, shaped
* exactly like pi's own CustomMessage ({role:"custom", customType, * exactly like pi's own CustomMessage ({role:"custom", customType,
@@ -36,7 +36,7 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
import { QUESTION_TOOLS } from "./pending.ts"; import { QUESTION_TOOLS } from "./pending.ts";
import { HANDOFF_TEXT, verdictText } from "./contracts/chat.ts"; import { HANDOFF_TEXT } from "./contracts/chat.ts";
/** The hard-invariant wording for an aborted turn's unanswered calls — /** The hard-invariant wording for an aborted turn's unanswered calls —
* carried over verbatim from the pre-native era so the model's view * carried over verbatim from the pre-native era so the model's view
@@ -58,7 +58,7 @@ export interface LhMarkerMessage {
/** Record identity (pi messages carry no id — see the file header). */ /** Record identity (pi messages carry no id — see the file header). */
id: string; id: string;
role: "custom"; role: "custom";
customType: "verdict" | "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block"; customType: "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
/** The model-facing text ("" for plan blocks — UI-only). */ /** The model-facing text ("" for plan blocks — UI-only). */
content: string; content: string;
display: boolean; display: boolean;
@@ -125,21 +125,6 @@ export function newAnswerResult(
}; };
} }
/** The plan verdict (the plan→teach seam): marker only — the model-facing
* wording is owned by verdictText (core/contracts/chat.ts), the UI
* renders the quiet accept/decline line off details. */
export function newVerdictMessage(accepted: boolean, at: string): LhMarkerMessage {
return {
id: newMessageId(),
role: "custom",
customType: "verdict",
content: verdictText(accepted),
display: true,
details: { accepted, at },
timestamp: Date.now(),
};
}
/** The probe→teach handoff wake: scaffolding, not speech — the model reads /** The probe→teach handoff wake: scaffolding, not speech — the model reads
* HANDOFF_TEXT, the UI renders the quiet system line. */ * HANDOFF_TEXT, the UI renders the quiet system line. */
export function newHandoffMessage(chatId: string): LhMarkerMessage { export function newHandoffMessage(chatId: string): LhMarkerMessage {
+54 -34
View File
@@ -1,29 +1,36 @@
/** /**
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never * Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
* occupancy. The probe→teach agent split (2026-09-11) gave every phase * occupancy. The probe→teach agent split (2026-09-11) gave the first
* boundary a transcript event: * boundary a transcript event; the second is INFERRED (owner ruling
* 2026-09-26, the verdict death):
* *
* probe — until the prober's `probe_done` call CONCLUDES (a clean * probe — until the prober's `probe_done` call CONCLUDES (a clean
* toolResult for it; the assessment rides the call's arguments) * toolResult for it; the assessment rides the call's arguments)
* plan — probe concluded, until the learner ACCEPTS the presented * plan — probe concluded, until teaching actually BEGINS
* plan (the verdict marker with accepted:true — the explicit * teach — the first quiz tool call tagged with a `nodeId` (a question
* verdict gate; plan_init itself is only the PRESENTATION and * ABOUT a plan node — probe quizzes carry no nodeId in their
* moves no phase; a declined verdict stays in plan) * schema, so nothing false-triggers). The CALL is the seam, not
* teach — teaching is sanctioned * its answer: the block that poses the first node question is
* where teaching starts.
*
* There is no verdict gate anymore: presenting the plan ends the teacher's
* turn, and the learner's next message is a natural reply (a change
* request, or the go-ahead). The live indicator LAGS by design (owner
* accepted): "teach" shows only once the first node's question is posed.
* A teacher that never tags a quiz would sit in "plan" visually —
* off-doctrine (tagging is prompt-mandated and nodeId is call-validated).
* *
* 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, as is the verdict-marker fold: no backwards compatibility
* session could never leave "plan"), and the stored tree no longer carries * (legacy sessions are wiped data).
* progress fields at all. No backwards compatibility: legacy sessions
* without probe_done simply read as probe (they are wiped data).
* *
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold * TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
* VIEW messages (the client's dividers and dock — part shapes, pure and * VIEW messages (the client's dividers and dock — part shapes, pure and
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries, * structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach * tests) — probe→plan advances at the probe_done RESULT message, plan→teach
* at the verdict marker. Both are strictly sequential; the two derivations * at the assistant message carrying the first nodeId-tagged quiz CALL.
* agree on every settled record (a result/marker message projects into the * Both are strictly sequential; the two derivations agree on every settled
* same view boundary). * record (a call's part projects into the same view boundary).
* *
* Pure: no imports, no runtime deps — safe on server, client, and tests. * Pure: no imports, no runtime deps — safe on server, client, and tests.
*/ */
@@ -33,10 +40,22 @@ export type Phase3 = "probe" | "plan" | "teach";
/** /**
* 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, an input (a tool call's parsed arguments), and (for concluded
* calls) an output.
*/ */
export interface PhaseMessage { export interface PhaseMessage {
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>; parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>;
}
/** A quiz call's part input carries `nodeId` iff it is tagged (teach-only —
* the probe quiz schema has no nodeId field). */
function taggedNodeId(input: unknown): boolean {
return (
typeof input === "object" &&
input !== null &&
typeof (input as { nodeId?: unknown }).nodeId === "string" &&
(input as { nodeId: string }).nodeId.length > 0
);
} }
/** The phase at `upToIndex` (inclusive; default: the last message). */ /** The phase at `upToIndex` (inclusive; default: the last message). */
@@ -52,23 +71,18 @@ export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number):
/** One message's advance of the phase (pure fold step). */ /** One message's advance of the phase (pure fold step). */
function advance( function advance(
phase: Phase3, phase: Phase3,
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }> | undefined, parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }> | undefined,
): Phase3 { ): Phase3 {
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 the learner's plan acceptance (data-verdict // plan → teach only via the first nodeId-tagged quiz CALL (the call,
// with accepted:true). A marker out of order advances nothing — the // not its answer — an errored-and-retried call still proves teaching
// chain never skips. plan_init PRESENTS the map; only the verdict // began, and the retry lands in the same group anyway).
// sanctions teaching (a decline stays in plan).
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 ( if (phase === "plan" && part.type === "tool-quiz" && taggedNodeId(part.input)) {
phase === "plan" &&
part.type === "data-verdict" &&
(part as { data?: { accepted?: unknown } }).data?.accepted === true
) {
phase = "teach"; phase = "teach";
} }
} }
@@ -94,11 +108,12 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ─────────── // ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
import type { LhMessage } from "./native-messages.ts"; import type { LhMessage } from "./native-messages.ts";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
/** The phase at `upToIndex` (inclusive; default: the last message) of a /** The phase at `upToIndex` (inclusive; default: the last message) of a
* NATIVE record. probe→plan advances at the probe_done call's clean * NATIVE record. probe→plan advances at the probe_done call's clean
* toolResult; plan→teach at the verdict marker (details.accepted true). */ * toolResult; plan→teach at the assistant message carrying the first
* nodeId-tagged quiz CALL (mirrors the view fold — same seam, native
* shape). */
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 { export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe"; let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1); const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
@@ -106,7 +121,7 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
const m = messages[i]!; const m = messages[i]!;
const role = (m as { role?: string }).role; const role = (m as { role?: string }).role;
if (role === "toolResult") { if (role === "toolResult") {
const result = m as ToolResultMessage; const result = m as { toolName?: string; isError?: boolean };
if ( if (
phase === "probe" && phase === "probe" &&
result.toolName === "probe_done" && result.toolName === "probe_done" &&
@@ -114,14 +129,19 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
) { ) {
phase = "plan"; phase = "plan";
} }
} else if (role === "custom") { } else if (role === "assistant") {
const marker = m as { customType?: string; details?: { accepted?: unknown } }; const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
if (phase === "plan") {
for (const block of content) {
if ( if (
phase === "plan" && block.type === "toolCall" &&
marker.customType === "verdict" && block.name === "quiz" &&
marker.details?.accepted === true taggedNodeId(block.arguments)
) { ) {
phase = "teach"; phase = "teach";
break;
}
}
} }
} }
} }
+2 -11
View File
@@ -36,7 +36,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts"; import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
import { QUESTION_TOOLS } from "./pending.ts"; import { QUESTION_TOOLS } from "./pending.ts";
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts"; import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts"; import type { PlanBlockData } from "./contracts/chat.ts";
/** A view part — the loose part vocabulary the renderers consume (the same /** A view part — the loose part vocabulary the renderers consume (the same
* shape names the pre-Wave-D UIMessage dialect used, so the renderer * shape names the pre-Wave-D UIMessage dialect used, so the renderer
@@ -248,18 +248,9 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
// rendered nothing) is dropped from the view — the record keeps it. // rendered nothing) is dropped from the view — the record keeps it.
} }
/** A marker's projected message (verdict/handoff/steers) or splice (plan block). */ /** A marker's projected message (handoff/steers) or splice (plan block). */
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void { function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void {
switch (marker.customType) { switch (marker.customType) {
case "verdict": {
const data = marker.details as PlanVerdictData;
out.push({
id: marker.id,
role: "user",
parts: [{ type: "data-verdict", id: `vd_${marker.id}`, data }],
});
return;
}
case "probe-handoff": { case "probe-handoff": {
out.push({ out.push({
id: marker.id, id: marker.id,
-54
View File
@@ -1,54 +0,0 @@
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"));
});
});
+89 -46
View File
@@ -1,9 +1,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); teaching
* ends at the LEARNER'S plan acceptance (a data-verdict part with accepted:true * begins at the FIRST nodeId-tagged quiz CALL (the inferred seam, owner
* accept button's user message — plan_init only PRESENTS the map). No * ruling 2026-09-26 — the verdict death: presenting the plan ends the
* teacher's turn, the learner's reply is natural text, and nothing flips
* the phase until a question ABOUT a plan node is actually posed). No
* stored-tree seam: the phase is a pure fold over the transcript, so it can * 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 * never stick (the old currentNodeId-based phaseOfTree read a field nothing
* ever set). * ever set).
@@ -31,30 +33,21 @@ function assistantTool(
}; };
} }
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } }; /** A quiz call with its parsed input — `nodeId` present iff tagged. */
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } }; function assistantQuiz(input: Record<string, unknown> | undefined): LhViewMessage {
/** The verdict button's user message (the custom-message scheme): its own
* message carrying ONLY the data-verdict marker part — no human-authored
* text; the model-facing wording is toLlmMessages' (native-messages.ts). */
function verdict(accepted: boolean): LhViewMessage {
return { return {
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`, id: `a_quiz_${Math.random().toString(36).slice(2, 6)}`,
role: "user", role: "assistant",
parts: [ parts: [{ type: "tool-quiz", ...(input !== undefined ? { input } : {}) } as never],
{ type: "data-verdict", id: "vd_1", data: { accepted, at: "2026-09-12T00:00:00Z" } } as never,
],
}; };
} }
/** The accept button's message. */ const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
function planAccepted(): LhViewMessage { const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
return verdict(true);
}
test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEPTS; teach after", () => { test("phaseAt3: probe until a concluded probe_done; plan until the first nodeId-tagged quiz; teach after", () => {
const probe = phaseAt3([user("teach me calculus"), assistantTool("quiz", "output-available")]); const probe = phaseAt3([user("teach me calculus"), assistantQuiz({ question: "q", correctAnswer: "a" })]);
assert.equal(probe, "probe", "quiz answers alone never end the probe"); assert.equal(probe, "probe", "an UNTAGGED quiz alone never ends the probe (probe quizzes carry no nodeId)");
const concluded = phaseAt3([ const concluded = phaseAt3([
user("teach me"), user("teach me"),
@@ -66,49 +59,43 @@ test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEP
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),
user("looks good, go"),
]); ]);
assert.equal(presented, "plan", "a successful plan_init PRESENTS the map — planning continues until the verdict"); assert.equal(presented, "plan", "plan_init + the learner's go-ahead TEXT present and sanction nothing");
const accepted = phaseAt3([ const teaching = 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),
planAccepted(), user("looks good, go"),
assistantQuiz({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" }),
]); ]);
assert.equal(accepted, "teach", "the learner's acceptance ends planning"); assert.equal(teaching, "teach", "the first nodeId-tagged quiz CALL is where teaching begins");
const declined = phaseAt3([
user("teach me"),
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
assistantTool("plan_init", "output-available", PLAN_OK),
verdict(false),
assistantTool("plan_init", "output-available", PLAN_OK),
]);
assert.equal(declined, "plan", "a DECLINED verdict is a real event but 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(), assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }),
]); ]);
assert.equal(failedPlan, "teach", "acceptance sanctions teaching even after a failed init attempt — the verdict is his"); assert.equal(failedPlan, "teach", "a tagged quiz begins teaching even after a failed init attempt");
}); });
test("phaseAt3: the verdict marker is explicit — anonymous learner text never sanctions teaching", () => { test("phaseAt3: the tag is the seam — natural text and untagged questions never start teaching", () => {
const plainText = phaseAt3([ const plainText = 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),
user("The plan is accepted — begin teaching."), // words alone, no marker user("The plan is accepted — begin teaching."), // words alone
assistantQuiz({ question: "ad-hoc check", correctAnswer: "a" }), // untagged
]); ]);
assert.equal(plainText, "plan", "only the data-verdict part is the seam — text can never fake it"); assert.equal(plainText, "plan", "only a nodeId-tagged quiz flips the seam — text and ad-hoc checks never do");
const acceptOutOfOrder = phaseAt3([ const taggedOutOfOrder = phaseAt3([
user("teach me"), user("teach me"),
verdict(true), // before any probe_done: out of order, advances nothing assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }), // before probe_done
]); ]);
assert.equal(acceptOutOfOrder, "probe", "the chain never skips"); assert.equal(taggedOutOfOrder, "probe", "the chain never skips");
}); });
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", () => {
@@ -128,13 +115,69 @@ test("phaseTimeline: folds messages into phase spans for the dividers", () => {
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 user("what does node 1 mean?"), // side question — still planning
planAccepted(),
user("go on"), user("go on"),
assistantQuiz({ question: "q", nodeId: "n1", correctAnswer: "a" }),
assistantQuiz({ question: "q2", nodeId: "n2", correctAnswer: "a" }),
]); ]);
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "teach", "teach"]); assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "plan", "teach", "teach"]);
assert.equal(timeline.length, 7); assert.equal(timeline.length, 8);
}); });
test("phaseAt3: empty transcript is probe", () => { test("phaseAt3: empty transcript is probe", () => {
assert.equal(phaseAt3([]), "probe"); assert.equal(phaseAt3([]), "probe");
}); });
// ─── the native fold (phaseAt3Lh over LhMessage[]) ──────────────────────────
import type { LhMessage } from "../src/core/native-messages.ts";
import { phaseAt3Lh } from "../src/core/phases.ts";
/** A native toolResult (the probe_done conclusion). */
function toolResult(toolName: string, isError = false): LhMessage {
return {
id: `tr_${toolName}_${Math.random().toString(36).slice(2, 6)}`,
role: "toolResult",
toolCallId: `call_${toolName}`,
toolName,
content: [{ type: "text", text: "ok" }],
isError,
timestamp: Date.now(),
} as LhMessage;
}
/** A native assistant message carrying a quiz toolCall — arguments carry
* `nodeId` iff tagged (exactly what the wire would persist). */
function assistantQuizCall(arguments_: Record<string, unknown>): LhMessage {
return {
id: `a_${Math.random().toString(36).slice(2, 6)}`,
role: "assistant",
content: [{ type: "toolCall", id: "call_quiz", name: "quiz", arguments: arguments_ }],
stopReason: "toolUse",
usage: undefined,
timestamp: Date.now(),
} as unknown as LhMessage;
}
test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => {
const done = toolResult("probe_done");
const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" });
const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged quiz call never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged quiz CALL is the seam");
assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)");
assert.equal(
phaseAt3Lh([toolResult("probe_done", true), tagged]),
"probe",
"an ERRORED probe_done concludes nothing",
);
// The two folds agree on the same settled transcript (the view projection
// of the tagged call carries its arguments as the part input).
assert.equal(
phaseAt3Lh([done, tagged]),
phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] },
]),
);
});
+9 -16
View File
@@ -57,24 +57,17 @@ describe("Composer", () => {
}); });
}); });
// The Enter decision (user-decided 2026-09-19, docs/probe-teach-split.md §4): // The Enter decision: stop a busy stream, otherwise send. (The plan-verdict
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent // accept branch died 2026-09-26 with the verdict itself — the post-plan
// a plain message, leaving the verdict pending again on the next message. // reply is a natural text message.) Pure function so the static-render
// Pure function so the static-render harness can pin it (key events can't be // harness can pin it (key events can't be simulated in renderToStaticMarkup).
// simulated in renderToStaticMarkup).
describe("enterAction", () => { describe("enterAction", () => {
it("busy stream: Enter stops — verdict or not", () => { it("busy stream: Enter stops", () => {
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop"); assert.equal(enterAction({ busy: true, canSend: true }), "stop");
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
}); });
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => { it("idle: send when there is text, nothing when there is not", () => {
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note"); assert.equal(enterAction({ busy: false, canSend: true }), "send");
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict"); assert.equal(enterAction({ busy: false, canSend: false }), "none");
});
it("no verdict: send when there is text, nothing when there is not", () => {
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: true }), "send");
assert.equal(enterAction({ busy: false, verdictPending: false, canSend: false }), "none");
}); });
}); });
+1 -5
View File
@@ -24,7 +24,7 @@ import { afterEach, describe, it } from "node:test";
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts"; import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
import type { ChatInput } from "../src/core/input-events.ts"; import type { ChatInput } from "../src/core/input-events.ts";
import type { LhMessage } from "../src/core/native-messages.ts"; import type { LhMessage } from "../src/core/native-messages.ts";
import { newUserMessage, newAnswerResult, newVerdictMessage, needsContinuation } from "../src/core/native-messages.ts"; import { newUserMessage, newAnswerResult, needsContinuation } from "../src/core/native-messages.ts";
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
afterEach(() => { afterEach(() => {
@@ -589,10 +589,6 @@ describe("adoptRecord — append-only adoption", () => {
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]), absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
"the answer result absorbs by toolCallId", "the answer result absorbs by toolCallId",
); );
// A verdict marker absorbs by accepted (the timestamps differ by design).
const verdict = { ...newVerdictMessage(true, "2026-09-26T00:00:00.000Z"), id: "c_local_3" } as LhMessage;
const serverVerdict = { ...newVerdictMessage(true, "2026-09-26T00:00:01.000Z"), id: "srv_11" } as LhMessage;
assert.ok(absorbed(verdict, [serverVerdict]), "the verdict absorbs by accepted");
}); });
it("setMessages is the full snapshot swap (the idle settle sync)", () => { it("setMessages is the full snapshot swap (the idle settle sync)", () => {