diff --git a/components/chat.tsx b/components/chat.tsx index 3ad44ea..ad6e405 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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 (
{}); }); 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. - ) : 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) - ? - : ↻ Plan revision requested - ) : ( + ) : 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. + + ) : ( @@ -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) => { diff --git a/components/composer.tsx b/components/composer.tsx index ce766e4..315351c 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -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). -
- - - - -
- ) : ( - // 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). */} - )} ); } diff --git a/components/session-core.ts b/components/session-core.ts index bb6c946..5a456dd 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -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]; diff --git a/docs/architecture.md b/docs/architecture.md index 9a12cc3..25a3a68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/probe-teach-split.md b/docs/probe-teach-split.md index 3641911..35fd287 100644 --- a/docs/probe-teach-split.md +++ b/docs/probe-teach-split.md @@ -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). diff --git a/next-env.d.ts b/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -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. diff --git a/src/agent/prompts/teacher/10-planning.md b/src/agent/prompts/teacher/10-planning.md index c49173c..8cfc495 100644 --- a/src/agent/prompts/teacher/10-planning.md +++ b/src/agent/prompts/teacher/10-planning.md @@ -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. \ No newline at end of file +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. \ No newline at end of file diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 2f18506..6dad665 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -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). diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 7f49f73..f8497c4 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -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 { diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 19f34b6..8ec4e64 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -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 []; } diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index eeb5609..5b8542a 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -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 { diff --git a/src/core/phases.ts b/src/core/phases.ts index 1d47a5d..4c563cd 100644 --- a/src/core/phases.ts +++ b/src/core/phases.ts @@ -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; + } + } } } } diff --git a/src/core/view.ts b/src/core/view.ts index 2372672..5eb313d 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -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, diff --git a/tests/composer-verdict.test.tsx b/tests/composer-verdict.test.tsx deleted file mode 100644 index dd13f97..0000000 --- a/tests/composer-verdict.test.tsx +++ /dev/null @@ -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( - , - ); - 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( - , - ); - 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( - , - ); - assert.ok(html.includes('aria-label="Stop"')); - assert.ok(!html.includes("plan-verdict-buttons")); - }); -}); \ No newline at end of file diff --git a/tests/phases.test.ts b/tests/phases.test.ts index 4cc16f7..1154319 100644 --- a/tests/phases.test.ts +++ b/tests/phases.test.ts @@ -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 | 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"); -}); \ No newline at end of file +}); + +// ─── 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): 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] }, + ]), + ); +}); diff --git a/tests/render-composer.test.tsx b/tests/render-composer.test.tsx index 0c153a1..4df65d2 100644 --- a/tests/render-composer.test.tsx +++ b/tests/render-composer.test.tsx @@ -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"); }); }); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index acb8579..890862e 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -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)", () => {