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:
+13
-54
@@ -128,7 +128,7 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
|
||||
* as a sub-agent result — rendered as an artifact card, never a bubble. */
|
||||
/** The quiet system line (custom-message scheme rendering): centered, muted,
|
||||
* 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 }) {
|
||||
return (
|
||||
<div
|
||||
@@ -402,7 +402,7 @@ export function Chat({
|
||||
.catch(() => {});
|
||||
});
|
||||
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.
|
||||
* Auto-continuations (probe answers) never set it — their refusal
|
||||
* opens the dialog with no replay. */
|
||||
@@ -575,35 +575,6 @@ export function Chat({
|
||||
// derives its own from the same transcript).
|
||||
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) ──
|
||||
// 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),
|
||||
@@ -1076,21 +1047,17 @@ export function Chat({
|
||||
// assistant group — is gone). Never a user bubble: the wake is
|
||||
// scaffolding; the model-facing instruction lives in the adapter.
|
||||
<PhaseDivider key={m.id || `m-${mi}`} phase="plan" />
|
||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-verdict") ? (
|
||||
// The learner's plan verdict (custom-message scheme): the
|
||||
// accept IS the phase seam — it renders the TEACHING DIVIDER
|
||||
// (owner ruling 2026-09-26: replaced the "✓ Plan accepted"
|
||||
// line; same spot, one signal, not two). A DECLINE is not a
|
||||
// seam — it stays the quiet revision line. Never a user
|
||||
// bubble: the model-facing wording is the adapter's, and this
|
||||
// is the learner's ACT, not their words (a typed note, if
|
||||
// any, renders above as its own real message).
|
||||
((m.parts.find((p) => p.type === "data-verdict") as
|
||||
| { 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>
|
||||
) : (
|
||||
) : m.role === "assistant" && mi > 0 && phases[mi] === "teach" && phases[mi - 1] !== "teach" ? (
|
||||
// THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
|
||||
// death): teaching begins where the FIRST nodeId-tagged quiz is
|
||||
// posed — the fold (phaseTimeline, the same derivation the phase
|
||||
// chip reads) flips to teach exactly AT this assistant group, so
|
||||
// the divider renders before the whole block (thinking -> prose ->
|
||||
// the quiz call) — where the explicit accept marker used to sit.
|
||||
// The boundary check (this index flips, the previous didn't)
|
||||
// guarantees exactly ONE divider per session.
|
||||
<PhaseDivider key={`teach-${m.id || `m-${mi}`}`} phase="teach" />
|
||||
) : (
|
||||
<Fragment key={m.id || `m-${mi}`}>
|
||||
<Message from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
|
||||
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
|
||||
@@ -1234,14 +1201,6 @@ export function Chat({
|
||||
onStop={abortInFlightTurn}
|
||||
canSend={canSend}
|
||||
onEscape={dictation.active ? dictation.cancel : undefined}
|
||||
verdict={
|
||||
planVerdict
|
||||
? {
|
||||
onAccept: () => sendVerdict(true),
|
||||
onDecline: () => sendVerdict(false),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
onSubmitAnswer={(output) => {
|
||||
|
||||
+15
-60
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import { CornerDownLeftIcon, SquareIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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) −
|
||||
* 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
|
||||
* behavior is user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
||||
* stop a busy stream; ACCEPT when the plan verdict pends — typed text
|
||||
* rides the acceptance as the note (the green button's exact semantics;
|
||||
* an empty box accepts too, so Enter can never strand the verdict);
|
||||
* otherwise send the message. Pure so the static-render harness can pin
|
||||
* the decision (key events can't be simulated in renderToStaticMarkup). */
|
||||
export function enterAction(state: { busy: boolean; verdictPending: boolean; canSend: boolean }): "stop" | "accept" | "send" | "none" {
|
||||
/** What one Enter press does, in precedence order: stop a busy stream,
|
||||
* otherwise send the message. (The plan-verdict accept branch died
|
||||
* 2026-09-26 with the verdict itself.) Pure so the static-render harness
|
||||
* can pin the decision (key events can't be simulated in
|
||||
* renderToStaticMarkup). */
|
||||
export function enterAction(state: { busy: boolean; canSend: boolean }): "stop" | "send" | "none" {
|
||||
if (state.busy) return "stop";
|
||||
if (state.verdictPending) return "accept";
|
||||
if (state.canSend) return "send";
|
||||
return "none";
|
||||
}
|
||||
@@ -34,7 +31,6 @@ export function Composer({
|
||||
onStop,
|
||||
canSend,
|
||||
autoFocus,
|
||||
verdict,
|
||||
onEscape,
|
||||
placeholder = "Ask, answer, or steer the lesson…",
|
||||
}: {
|
||||
@@ -45,10 +41,6 @@ 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 };
|
||||
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
|
||||
* when absent — Esc otherwise means nothing in the composer. */
|
||||
onEscape?: () => void;
|
||||
@@ -63,9 +55,8 @@ export function Composer({
|
||||
className="flex items-end gap-1.5 p-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
|
||||
const action = enterAction({ busy, canSend });
|
||||
if (action === "stop") onStop();
|
||||
else if (action === "accept") verdict?.onAccept();
|
||||
else if (action === "send") onSend();
|
||||
}}
|
||||
>
|
||||
@@ -86,56 +77,21 @@ export function Composer({
|
||||
// Chat convention: Enter sends, Shift+Enter is a newline.
|
||||
// ⌘/Ctrl+Enter also sends — the universal submit shortcut
|
||||
// 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)) {
|
||||
e.preventDefault();
|
||||
const action = enterAction({ busy, verdictPending: Boolean(verdict), canSend });
|
||||
if (action === "stop") onStop();
|
||||
else if (action === "accept") verdict?.onAccept();
|
||||
else if (action === "send") onSend();
|
||||
}
|
||||
const action = enterAction({ busy, canSend });
|
||||
if (action === "stop") onStop();
|
||||
else if (action === "send") onSend();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
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"
|
||||
/>
|
||||
{verdict && !busy ? (
|
||||
// The plan verdict (docs/probe-teach-split.md §4): accept
|
||||
// sanctions teaching (phase plan→teach); decline sends the
|
||||
// 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).
|
||||
{/* No tooltip on the send square — the ↵ glyph is the
|
||||
affordance and Enter is obvious (owner, subtlety
|
||||
round 2026-09-25). */}
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={busy ? "Stop" : "Send"}
|
||||
@@ -149,7 +105,6 @@ export function Composer({
|
||||
>
|
||||
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
needsContinuation,
|
||||
newAnswerResult,
|
||||
newUserMessage,
|
||||
newVerdictMessage,
|
||||
projectView,
|
||||
type ChatInput,
|
||||
type LhMessage,
|
||||
@@ -133,16 +132,6 @@ export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
|
||||
(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;
|
||||
}
|
||||
|
||||
@@ -465,12 +454,6 @@ export class SessionModel {
|
||||
else if (input.type === "answer") {
|
||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||
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(() => {
|
||||
this.tail = [...this.tail, ...optimistic];
|
||||
|
||||
@@ -147,7 +147,7 @@ flowchart TB
|
||||
|
||||
- `contracts/plan.ts` — plan tree types + derived statuses (`derivePlanStatuses`)
|
||||
- `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/probe.ts` — probe assessment + the shared zod schema
|
||||
- `contracts/models.ts` — client-safe model-catalog shapes
|
||||
|
||||
+16
-17
@@ -95,24 +95,22 @@ That is the wrong signal and is **abolished**. The new contract:
|
||||
|
||||
The phase chain is **event-driven**:
|
||||
|
||||
**probe (prober)** — `probe_done` → **planning (teacher)** — `plan_init`
|
||||
presents the plan → **accept** → **teach (teacher)**
|
||||
**probe (prober)** — `probe_done` → **planning (teacher)** — the first
|
||||
`nodeId`-tagged quiz call → **teach (teacher)**
|
||||
|
||||
- The broken derived-occupancy seam (phase-of-tree via `currentNodeId`
|
||||
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
|
||||
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-verdict` 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. While the
|
||||
verdict pends, Enter ACCEPTS the plan — typed text rides the acceptance
|
||||
as its note, an empty box accepts too (user-decided 2026-09-19; formerly
|
||||
Enter sent a plain message, which left the verdict still pending on the
|
||||
next message — a side question now rides the acceptance note instead).
|
||||
Mid-lesson rewrites need no fresh verdict: acceptance happens once.
|
||||
- The session picker phase derives from transcript markers the same way.
|
||||
- The verdict gate is DEAD (owner ruling 2026-09-26 — simplified away):
|
||||
presenting the plan ends the teacher's turn, and the learner's next
|
||||
message is a natural reply — a change request (revise, re-present, end
|
||||
the turn again) or the go-ahead. The plan→teach seam is **inferred**:
|
||||
the first quiz call tagged with a `nodeId` (a question ABOUT a plan
|
||||
node; probe quizzes carry no nodeId) is where teaching begins. The
|
||||
phase fold (`phaseAt3`) advances there, the teaching divider renders
|
||||
before that whole assistant block, and the live phase indicator may lag
|
||||
until the first node's question is posed (owner-accepted). Mid-lesson
|
||||
`plan_init` rewrites need no gate at all.
|
||||
- The session picker phase derives from the transcript the same way.
|
||||
|
||||
## 5. No global-graph context in sessions (for now)
|
||||
|
||||
@@ -140,8 +138,9 @@ live-derived movement.
|
||||
|
||||
## 7. Deferred / later waves (designed, not now)
|
||||
|
||||
- ~~**Accept-plan UI**~~ — **landed 2026-09-11** (see §4): stacked
|
||||
Accept/Decline buttons; the `data-verdict` part is the seam.
|
||||
- ~~**Accept-plan UI**~~ — landed 2026-09-11, then **removed 2026-09-26**
|
||||
(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
|
||||
as a proper card (data already durable in the transcript; the plain v1
|
||||
probe-done card renders the assessment meanwhile).
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
- If his reply asks for changes, revise: fix the map (`plan_init` again — a wholesale rewrite), present again, and end your turn 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.
|
||||
@@ -181,7 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
steeringMode: "all",
|
||||
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
|
||||
// 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
|
||||
// an abort's dangling question call gets its INTERRUPTED result. It
|
||||
// must never throw (pi's convertToLlm contract).
|
||||
|
||||
@@ -149,30 +149,6 @@ 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 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
|
||||
* 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
|
||||
* 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
|
||||
* 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 =
|
||||
"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. " +
|
||||
"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, " +
|
||||
"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 {
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
* built client-side exactly as today; a dismissal is an answer
|
||||
* carrying its flag). Server-side this becomes the call's
|
||||
* 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
|
||||
* continuations, job-settle re-syncs: the record ends at an
|
||||
* 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
|
||||
* input — the handler accepts it on the same path (TurnInput) so every
|
||||
* turn rides one composition.
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
danglingQuestionOf,
|
||||
newAnswerResult,
|
||||
newUserMessage,
|
||||
newVerdictMessage,
|
||||
} from "./native-messages.ts";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
|
||||
@@ -39,7 +38,6 @@ import { QUESTION_TOOLS } from "./pending.ts";
|
||||
export type ChatInput =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
||||
| { type: "verdict"; accepted: boolean; note?: string }
|
||||
| { type: "continue" };
|
||||
|
||||
/** 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");
|
||||
}
|
||||
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":
|
||||
return { type: "continue" };
|
||||
default:
|
||||
@@ -106,14 +98,6 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput
|
||||
}
|
||||
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":
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* sees it (verified single site, below).
|
||||
*
|
||||
* 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
|
||||
* member of pi-agent-core's CustomAgentMessages extension point, shaped
|
||||
* exactly like pi's own CustomMessage ({role:"custom", customType,
|
||||
@@ -36,7 +36,7 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
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 —
|
||||
* 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). */
|
||||
id: string;
|
||||
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). */
|
||||
content: string;
|
||||
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
|
||||
* HANDOFF_TEXT, the UI renders the quiet system line. */
|
||||
export function newHandoffMessage(chatId: string): LhMarkerMessage {
|
||||
|
||||
+57
-37
@@ -1,29 +1,36 @@
|
||||
/**
|
||||
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
|
||||
* occupancy. The probe→teach agent split (2026-09-11) gave every phase
|
||||
* boundary a transcript event:
|
||||
* occupancy. The probe→teach agent split (2026-09-11) gave the first
|
||||
* 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
|
||||
* toolResult for it; the assessment rides the call's arguments)
|
||||
* plan — probe concluded, until the learner ACCEPTS the presented
|
||||
* plan (the verdict marker with accepted:true — the explicit
|
||||
* verdict gate; plan_init itself is only the PRESENTATION and
|
||||
* moves no phase; a declined verdict stays in plan)
|
||||
* teach — teaching is sanctioned
|
||||
* plan — probe concluded, until teaching actually BEGINS
|
||||
* teach — the first quiz tool call tagged with a `nodeId` (a question
|
||||
* ABOUT a plan node — probe quizzes carry no nodeId in their
|
||||
* schema, so nothing false-triggers). The CALL is the seam, not
|
||||
* 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`)
|
||||
* is GONE: nothing ever writes currentNodeId (the stuck-on-plan bug — a
|
||||
* session could never leave "plan"), and the stored tree no longer carries
|
||||
* progress fields at all. No backwards compatibility: legacy sessions
|
||||
* without probe_done simply read as probe (they are wiped data).
|
||||
* is GONE, as is the verdict-marker fold: no backwards compatibility
|
||||
* (legacy sessions are wiped data).
|
||||
*
|
||||
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
|
||||
* VIEW messages (the client's dividers and dock — part shapes, pure and
|
||||
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
|
||||
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach
|
||||
* at the verdict marker. Both are strictly sequential; the two derivations
|
||||
* agree on every settled record (a result/marker message projects into the
|
||||
* same view boundary).
|
||||
* at the assistant message carrying the first nodeId-tagged quiz CALL.
|
||||
* Both are strictly sequential; the two derivations agree on every settled
|
||||
* record (a call's part projects into the same view boundary).
|
||||
*
|
||||
* 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
|
||||
* 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 {
|
||||
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). */
|
||||
@@ -52,23 +71,18 @@ export function phaseAt3(messages: readonly PhaseMessage[], upToIndex?: number):
|
||||
/** One message's advance of the phase (pure fold step). */
|
||||
function advance(
|
||||
phase: Phase3,
|
||||
parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }> | undefined,
|
||||
parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }> | undefined,
|
||||
): Phase3 {
|
||||
if (!parts) return phase;
|
||||
for (const part of parts) {
|
||||
// STRICTLY SEQUENTIAL: probe → plan only via a concluded probe_done,
|
||||
// plan → teach only via the learner's plan acceptance (data-verdict
|
||||
// with accepted:true). A marker out of order advances nothing — the
|
||||
// chain never skips. plan_init PRESENTS the map; only the verdict
|
||||
// sanctions teaching (a decline stays in plan).
|
||||
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
|
||||
// not its answer — an errored-and-retried call still proves teaching
|
||||
// began, and the retry lands in the same group anyway).
|
||||
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
|
||||
phase = "plan";
|
||||
}
|
||||
if (
|
||||
phase === "plan" &&
|
||||
part.type === "data-verdict" &&
|
||||
(part as { data?: { accepted?: unknown } }).data?.accepted === true
|
||||
) {
|
||||
if (phase === "plan" && part.type === "tool-quiz" && taggedNodeId(part.input)) {
|
||||
phase = "teach";
|
||||
}
|
||||
}
|
||||
@@ -94,11 +108,12 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
|
||||
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
|
||||
|
||||
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
|
||||
* 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 {
|
||||
let phase: Phase3 = "probe";
|
||||
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 role = (m as { role?: string }).role;
|
||||
if (role === "toolResult") {
|
||||
const result = m as ToolResultMessage;
|
||||
const result = m as { toolName?: string; isError?: boolean };
|
||||
if (
|
||||
phase === "probe" &&
|
||||
result.toolName === "probe_done" &&
|
||||
@@ -114,14 +129,19 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
|
||||
) {
|
||||
phase = "plan";
|
||||
}
|
||||
} else if (role === "custom") {
|
||||
const marker = m as { customType?: string; details?: { accepted?: unknown } };
|
||||
if (
|
||||
phase === "plan" &&
|
||||
marker.customType === "verdict" &&
|
||||
marker.details?.accepted === true
|
||||
) {
|
||||
phase = "teach";
|
||||
} else if (role === "assistant") {
|
||||
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
|
||||
if (phase === "plan") {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block.type === "toolCall" &&
|
||||
block.name === "quiz" &&
|
||||
taggedNodeId(block.arguments)
|
||||
) {
|
||||
phase = "teach";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -36,7 +36,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"
|
||||
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||
import { QUESTION_TOOLS } from "./pending.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
|
||||
* 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.
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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": {
|
||||
out.push({
|
||||
id: marker.id,
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
+90
-47
@@ -1,9 +1,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 LEARNER'S plan acceptance (a data-verdict part with accepted:true
|
||||
* accept button's user message — plan_init only PRESENTS the map). No
|
||||
* probe ends at the first CONCLUDED probe_done (output-available); teaching
|
||||
* begins at the FIRST nodeId-tagged quiz CALL (the inferred seam, owner
|
||||
* 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
|
||||
* never stick (the old currentNodeId-based phaseOfTree read a field nothing
|
||||
* ever set).
|
||||
@@ -31,30 +33,21 @@ function assistantTool(
|
||||
};
|
||||
}
|
||||
|
||||
const DONE_ASSESSMENT = { assessment: { strands: [{ strand: "s", assessment: "a" }] } };
|
||||
const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: [] } };
|
||||
|
||||
/** 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 {
|
||||
/** A quiz call with its parsed input — `nodeId` present iff tagged. */
|
||||
function assistantQuiz(input: Record<string, unknown> | undefined): LhViewMessage {
|
||||
return {
|
||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "user",
|
||||
parts: [
|
||||
{ type: "data-verdict", id: "vd_1", data: { accepted, at: "2026-09-12T00:00:00Z" } } as never,
|
||||
],
|
||||
id: `a_quiz_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-quiz", ...(input !== undefined ? { input } : {}) } as never],
|
||||
};
|
||||
}
|
||||
|
||||
/** The accept button's message. */
|
||||
function planAccepted(): LhViewMessage {
|
||||
return verdict(true);
|
||||
}
|
||||
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 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");
|
||||
test("phaseAt3: probe until a concluded probe_done; plan until the first nodeId-tagged quiz; teach after", () => {
|
||||
const probe = phaseAt3([user("teach me calculus"), assistantQuiz({ question: "q", correctAnswer: "a" })]);
|
||||
assert.equal(probe, "probe", "an UNTAGGED quiz alone never ends the probe (probe quizzes carry no nodeId)");
|
||||
|
||||
const concluded = phaseAt3([
|
||||
user("teach me"),
|
||||
@@ -66,49 +59,43 @@ test("phaseAt3: probe until a concluded probe_done; plan until the learner ACCEP
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
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"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
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");
|
||||
|
||||
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");
|
||||
assert.equal(teaching, "teach", "the first nodeId-tagged quiz CALL is where teaching begins");
|
||||
|
||||
const failedPlan = phaseAt3([
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
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([
|
||||
user("teach me"),
|
||||
assistantTool("probe_done", "output-available", DONE_ASSESSMENT),
|
||||
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"),
|
||||
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", () => {
|
||||
@@ -128,13 +115,69 @@ test("phaseTimeline: folds messages into phase spans for the dividers", () => {
|
||||
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"),
|
||||
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.equal(timeline.length, 7);
|
||||
assert.deepEqual(timeline satisfies Phase3[], ["probe", "probe", "plan", "plan", "plan", "plan", "teach", "teach"]);
|
||||
assert.equal(timeline.length, 8);
|
||||
});
|
||||
|
||||
test("phaseAt3: empty transcript is 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] },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,24 +57,17 @@ describe("Composer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The Enter decision (user-decided 2026-09-19, docs/probe-teach-split.md §4):
|
||||
// while the plan verdict pends, Enter must ACCEPT the plan — formerly it sent
|
||||
// a plain message, leaving the verdict pending again on the next message.
|
||||
// Pure function so the static-render harness can pin it (key events can't be
|
||||
// simulated in renderToStaticMarkup).
|
||||
// The Enter decision: stop a busy stream, otherwise send. (The plan-verdict
|
||||
// accept branch died 2026-09-26 with the verdict itself — the post-plan
|
||||
// reply is a natural text message.) Pure function so the static-render
|
||||
// harness can pin it (key events can't be simulated in renderToStaticMarkup).
|
||||
describe("enterAction", () => {
|
||||
it("busy stream: Enter stops — verdict or not", () => {
|
||||
assert.equal(enterAction({ busy: true, verdictPending: false, canSend: true }), "stop");
|
||||
assert.equal(enterAction({ busy: true, verdictPending: true, canSend: true }), "stop");
|
||||
it("busy stream: Enter stops", () => {
|
||||
assert.equal(enterAction({ busy: true, canSend: true }), "stop");
|
||||
});
|
||||
|
||||
it("verdict pends: Enter ACCEPTS — with text (the note rides) and with an empty box alike", () => {
|
||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: true }), "accept", "typed text rides the acceptance note");
|
||||
assert.equal(enterAction({ busy: false, verdictPending: true, canSend: false }), "accept", "empty box still accepts — Enter can never strand the verdict");
|
||||
});
|
||||
|
||||
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");
|
||||
it("idle: send when there is text, nothing when there is not", () => {
|
||||
assert.equal(enterAction({ busy: false, canSend: true }), "send");
|
||||
assert.equal(enterAction({ busy: false, canSend: false }), "none");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import { afterEach, describe, it } from "node:test";
|
||||
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
||||
import type { ChatInput } from "../src/core/input-events.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;
|
||||
afterEach(() => {
|
||||
@@ -589,10 +589,6 @@ describe("adoptRecord — append-only adoption", () => {
|
||||
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
|
||||
"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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user