- {kind === "quiz" ? "Quiz" : kind === "explain" ? "Explain" : "Question"}
+ {kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"}
{qd.mode === "multi-select" && " · select all that apply"}
diff --git a/components/reading-thinking.tsx b/components/reading-thinking.tsx
new file mode 100644
index 0000000..061be15
--- /dev/null
+++ b/components/reading-thinking.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useCallback } from "react";
+import { joinDictation, useDictationTarget } from "@/components/dictation";
+
+/**
+ * The reading-thinking field (owner design 2026-10-03, "two questions per
+ * teaching unit"): a quiet textarea for thinking WHILE READING the
+ * teaching prose — the same transfer channel the question dock's "Your
+ * thinking" gives the answer, opened one phase earlier. It mounts while a
+ * teach-phase check waits behind its reveal (the same cue that collapses
+ * the check), rides the check answer as the SEPARATE `readingThinking`
+ * channel (never conflated with the quiz thinking), and disappears with
+ * the question.
+ *
+ * Dictation: while the check sits UNREVEALED this field is the primary
+ * target (the dock releases its claim — dictated reading-thoughts land
+ * here, not in the hidden quiz field); once the learner reveals the
+ * check, the dock reclaims and this field is plain typing.
+ */
+export function ReadingThinkingField({
+ value,
+ onChange,
+ onEscape,
+ claim,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ /** Esc in the field stops dictation (threaded from the page's hook). */
+ onEscape?: () => void;
+ /** Primary dictation claim — true while the check is behind its reveal. */
+ claim: boolean;
+}) {
+ useDictationTarget(
+ useCallback(
+ (t: string) => {
+ onChange(joinDictation(value, t));
+ },
+ [value, onChange],
+ ),
+ claim,
+ );
+ return (
+
+
+ Thinking while reading
+
+
+ );
+}
diff --git a/src/agent/prompts/shared/20-question-tools.md b/src/agent/prompts/shared/20-question-tools.md
index d6609ea..9471937 100644
--- a/src/agent/prompts/shared/20-question-tools.md
+++ b/src/agent/prompts/shared/20-question-tools.md
@@ -1,13 +1,27 @@
## The question tools
-Two instruments. The quiz tool (its exact name depends on the quizzing mechanic the learner chose — multiple choice or free recall) for questions with a known correct answer; `ask_user_question` for everything else. Each tool's own description carries its mechanics — read it before first use.
+Two instruments. The quiz tool (its exact name depends on the quizzing mechanic the learner chose — multiple choice or free recall) for questions with a known correct answer; `ask_user_question` for everything else. Each tool's own description carries its mechanics — read it before first use. What the questions should BE is here.
-### Quiz checks — shared doctrine
+### Building graded questions — the invariant craft
+
+Applies to every graded question you ask, whatever its role:
- Multiple choice carries a separate `explanation` (why the correct option is correct, written BEFORE `correctAnswer`: reason it out first, then commit). Free recall has ONE field — `correctAnswer`, the committed answer with its reasoning inside.
-- Option-style answers may carry a free-text `note` — read it; it says what he was thinking.
-- ONE question per turn: ask, then end your turn — his answer determines the next.
+- **The option craft.** The giveaway is never the distractors' wrongness, it's asymmetry: the correct option carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: (1) every option is a bare claim — zero "why" in any of them; all reasoning goes in the explanation, shown only after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real, BELIEVABLE error — the answer he would actually give under a specific wrong model, wrong on knowledge, never obviously wrong on sight. An option a cold reader eliminates instantly is a wasted option: his pick is diagnostic only when the wrong model is what selects it; (4) no asymmetric bolding. If you can tell the right one cold without knowing the material, regenerate — don't patch.
+- **Free-recall grading.** Grade his words strictly against the committed answer; never mark a wrong answer right to be kind — a near-miss is partial understanding: name exactly what he got and what he missed. If he genuinely doesn't know, he says so in his own words — that is a genuine gap, not a wrong answer.
+
+### His thinking channels
+
+His thinking is the most precise transfer of his understanding you get — read it all:
+
+- Answers may carry `thinking` — his reasoning on the question itself.
+- Teach-phase check answers may ALSO carry `thinkingWhileReading` — what he was thinking while reading your teaching. Two distinct channels; never conflate them.
+- His thinking fields are often TRANSCRIBED SPEECH (he dictates, thinking out loud). Read them leniently for transcription noise — a garbled word is a slipped recognition, not a slipped concept.
### `ask_user_question`
Asks a single question and waits. He can always pick "Other" and type. If you recommend an option, list it first with "(Recommended)" appended. Multiple questions → multiple calls.
+
+### ONE question per turn
+
+Ask, then end your turn — his answer determines the next.
diff --git a/src/agent/prompts/teacher/20-teaching.md b/src/agent/prompts/teacher/20-teaching.md
index dce1de4..004f811 100644
--- a/src/agent/prompts/teacher/20-teaching.md
+++ b/src/agent/prompts/teacher/20-teaching.md
@@ -21,13 +21,13 @@ Choose per topic and per his apparent energy:
A Socratic step is about *who* speaks first, not about grading: a question with a definite right answer is still gradable — use `quiz`, not `ask_user_question` — even when it's an open-ended prompt he answers freely, which you then frame as options. Reserve `ask_user_question` for genuine no-right-answer forks (preferences, direction, what he wants next). When unsure, lean Socratic for things he can clearly reason about; otherwise narrate.
-## How checks arrive — the learner's pacing
+## How questions arrive — the learner's pacing
-Once the plan has been presented, everything you write is teaching, and every question you ask arrives **collapsed behind a "Check your understanding" button** — the learner opens it when he's ready. Nothing interrupts him mid-reading — which puts the burden on you:
+Once the plan has been presented, everything you write is teaching. The two question roles arrive differently (the predict/check doctrine, above): **predictions arrive open** — they precede the teaching and gate it — while **checks arrive collapsed behind a "Check your understanding" button** — the learner opens it when he's ready, after reading. Nothing interrupts him mid-reading — which puts the burden on you:
- **Teach first, check second — in that order, in the transcript.** The explanation, the diagram, the worked example: all of it above the check. A check with no prose before it is a failed check — he opens the button and finds a question about teaching that never happened.
- **Finish the thought before you fire.** Never call a check mid-explanation. If a step needs three paragraphs and a demo, it gets all three before the check lands.
- **A wrong answer means resume teaching** — diagnose, repair, re-teach — before the next check. The button is a gate, not a quiz-show buzzer.
-- **One step, one check.** The check fires when the step's prose is done; teaching advances in a rhythm of step → check → repair-or-continue.
+- **One step, one check.** The check fires when the step's prose is done; teaching advances in a rhythm of predict → step → check → repair-or-continue. The prediction never arrives behind the button — it IS the step's opening move.
-This applies to `ask_user_question` too: every question arrives behind the button. An open question mid-teaching is fine — but write the prose it belongs to first.
+This collapse law applies to `ask_user_question` too: it arrives behind the button like a check. (Predictions are the one open arrival.) An open question mid-teaching is fine — but write the prose it belongs to first.
diff --git a/src/agent/prompts/teacher/28-predictions.md b/src/agent/prompts/teacher/28-predictions.md
new file mode 100644
index 0000000..8b70a51
--- /dev/null
+++ b/src/agent/prompts/teacher/28-predictions.md
@@ -0,0 +1,22 @@
+# The two questions — predict, then check (the teaching cycle)
+
+Every teaching unit is a **predict → teach → check** cycle, and the two questions have entirely different jobs. Declaring the wrong `type` for the job breaks the learner's UI (a check arrives collapsed after reading; a prediction arrives open before it) — the roles are not interchangeable.
+
+## The prediction (`type: "predict"`) — motivation
+
+- Posed BEFORE you teach the step, as its own turn. He answers first; your teaching then resolves it.
+- Its subject is the NEXT step — not yet taught. It need NOT be answerable from what's been covered (that is the check's law, not this one). Ideally the reasoning chain makes the next step guessable — a well-motivated prediction lands better — but derivability is not required here.
+- Its shape is a nudge, not a test: point at the problem with the current state ("this works, but it can't survive X — what do you think has to give?"), and let him try to discover the next step himself. Direct him toward the right territory; reveal nothing more. A prediction he can't fully attempt is fine — the attempt is the point.
+- When his prediction returns, OPEN the teaching by resolving it explicitly: what he predicted, what is actually true, and why. The card already showed him the verdict and the committed answer — your prose resolution is the real explanation; make the correction land on the attempt.
+- Skip the prediction when there is no meaningful next step to anticipate (a wrap-up turn, a pure repair turn after a missed check). Teacher's judgment — never force one, never let skipping become the habit.
+
+## The check (`type: "check"`) — data for you
+
+- Posed AFTER the step's teaching is complete. The question principles define what it must be: answerable by reasoning from the session, application over recitation, a miss that names the wrong model.
+- Its verdict is your continuation data: continue, repair, or reclarify. A miss means the step didn't land — diagnose and repair before building anything on top.
+
+## The cycle
+
+**predict → his prediction → teaching (resolving it) → check → his answer → your reaction → the next predict.**
+
+One question per turn, every turn, in that order. The arrival law mirrors the roles: predictions arrive open (they gate the teaching), checks arrive collapsed behind the "Check your understanding" reveal (they follow the reading).
diff --git a/src/agent/prompts/teacher/30-question-principles.md b/src/agent/prompts/teacher/30-question-principles.md
index 361686c..54d9441 100644
--- a/src/agent/prompts/teacher/30-question-principles.md
+++ b/src/agent/prompts/teacher/30-question-principles.md
@@ -1,5 +1,7 @@
# Question principles — what a teach-phase check must be
+These govern `type: "check"` questions (and explain). Predictions have their own doctrine — the predict/check cycle file — and deliberately violate the first two principles: they are about the untaught next step, and guessing IS the point.
+
Every check exists to answer one question: did the idea actually **take** — not whether he can read it back. The principles:
- **Answerable by reasoning from the session.** Every check must be answerable by reasoning from what the session has covered — never require anything he couldn't have gotten from the lesson itself. A check he has no route to is not a check, it's a coin flip.
diff --git a/src/agent/tools.ts b/src/agent/tools.ts
index 6cf55fd..36a6910 100644
--- a/src/agent/tools.ts
+++ b/src/agent/tools.ts
@@ -93,47 +93,61 @@ export const askUserQuestionTool: AgentTool
= {
},
};
-// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
+// ─── the question tools ───────────────────────────────────────────────────────
+//
+// MECHANICAL DESCRIPTIONS ONLY (owner 2026-10-03): a tool's description
+// carries what the tool DOES and how its fields work — nothing about what
+// the questions should BE. The craft (option-building, free-recall
+// grading, question roles) lives in the system prompt — a single-sourced
+// chunk both agents compose (prompts/shared/20-question-tools.md and the
+// teacher's question fragments), reversing the 2026-09-27 placement now
+// that the question family has grown (predict/check roles, future
+// practice variants): duplicates stay honest by sharing SCHEMA ATOMS
+// (question-core) + PROMPT CHUNKS, and each tool declares only its
+// mechanical delta.
-/** The option-building craft (owner 2026-09-27: moved OUT of the system
- * prompt into the tool description — pi-native doctrine travels with
- * the armed tool). Shared verbatim by both quiz variants. */
-const QUIZ_OPTION_CRAFT =
- "Building options — the giveaway is never the distractors' wrongness, it's asymmetry: the correct option " +
- "carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: " +
- "(1) every option is a bare claim — zero 'why' in any of them; all reasoning goes in explanation, shown only " +
- "after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone " +
- "holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real " +
- "error he might actually make (his pick is diagnostic) yet unambiguously wrong; (4) no asymmetric bolding. If " +
- "you can tell the right one cold without knowing the material, regenerate — don't patch.";
+/** The quiz mechanics every variant shares; `delta` appends the variant's
+ * own fields. All mechanics, zero doctrine. */
+function quizDescription(delta: { nodeId: boolean; types: boolean }): string {
+ return (
+ "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
+ "Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
+ "correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
+ "can show an explanation. Options-only: single-select or multi-select, plus an automatic 'I don't know' " +
+ "choice so the learner can signal a genuine gap instead of guessing. An always-present optional thinking " +
+ "field lets the learner attach his reasoning to ANY answer; it reaches you only when non-empty. " +
+ "No free-text answers — for non-graded questions use ask_user_question instead. " +
+ (delta.types
+ ? "Every call declares `type` — the question's role in the teaching cycle; the field's own description says when each kind fires. "
+ : "") +
+ (delta.nodeId
+ ? "Set `nodeId` (required) to the plan node this question is about — validated at call time against the session's plan map. For off-map checks use ask_user_question. "
+ : "") +
+ "ONE question per turn: call a single quiz, then end your turn — his answer determines the next."
+ );
+}
-/** The explain doctrine (owner 2026-09-27): free-recall quizzing — the
- * sycophancy mitigation is the commitment. Carried by the DESCRIPTION
- * (pi-native ruling), not the system prompt. Shared by both variants;
- * the teach variant appends the nodeId tagging paragraph. */
-const EXPLAIN_DOCTRINE =
- "Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " +
- "answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: write correctAnswer — the FULL " +
- "correct answer in your own words, reasoning included — committed before he ever sees the question. When his answer returns, grade it strictly against your committed answer in your reply: never " +
- "revise the commitment after seeing his words, and never mark a wrong answer right to be kind — a near-miss is " +
- "partial understanding; name exactly what he got and what he missed. He sees the committed answer on submit. If he genuinely doesn't know, he says so in his own words — that is a genuine " +
- "gap, not a wrong answer. " +
- "ONE question per turn: call a single explain, then end your turn — his answer determines the next.";
+/** The explain mechanics (free-recall quizzing): the commitment rule is
+ * the GRADING CONTRACT — mechanics, not doctrine. The softer doctrine
+ * (near-miss reading, genuine gaps) lives in the shared prompt chunk. */
+function explainDescription(nodeId: boolean): string {
+ return (
+ "Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " +
+ "answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: " +
+ "correctAnswer is the FULL correct answer in your own words, reasoning included, COMMITTED before he ever " +
+ "sees the question. When his answer returns, grade it strictly against the commitment — never revise it " +
+ "after seeing his words. He sees the committed answer on submit. " +
+ (nodeId
+ ? "Set `nodeId` (required) to the plan node this question is about — validated at call time against the session's plan map. "
+ : "") +
+ "ONE question per turn: call a single explain, then end your turn — his answer determines the next."
+ );
+}
export const probeQuizTool: AgentTool = {
name: "quiz",
label: "quiz",
- description:
- "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
- "Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
- "correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " +
- "can show an explanation. Use it to locate the edge of his understanding: bracket each strand the lesson will " +
- "depend on — something he gets right (a floor), something he gets wrong or doesn't know (a ceiling). " +
- "Options-only: single-select or multi-select, plus an automatic 'I don't know' choice so the learner can " +
- "signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " +
- "free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
- "questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " +
- "his answer determines the next question to ask. " + QUIZ_OPTION_CRAFT,
+ description: quizDescription({ nodeId: false, types: false }),
parameters: probeQuizInputSchema,
prepareArguments: prepareQuestionArguments,
executionMode: "sequential",
@@ -152,11 +166,7 @@ export function teachExplainToolFor(
return {
name: "explain",
label: "explain",
- description:
- EXPLAIN_DOCTRINE +
- " Set `nodeId` (required) to the plan node this question is about — the lesson's position on the map derives " +
- "from those tags; a node takes as many questions as it deserves, so don't rush forward. Off-map checks use " +
- "ask_user_question.",
+ description: explainDescription(true),
parameters: explainInputObject,
prepareArguments: prepareQuestionArguments,
executionMode: "sequential",
@@ -175,7 +185,7 @@ export function teachExplainToolFor(
export const probeExplainTool: AgentTool = {
name: "explain",
label: "explain",
- description: EXPLAIN_DOCTRINE,
+ description: explainDescription(false),
parameters: explainInputBase,
prepareArguments: prepareQuestionArguments,
executionMode: "sequential",
@@ -299,19 +309,7 @@ export function teachQuizToolFor(ctx: Pick ({ index: i + 1, label: o.label })),
dontKnow: req.dontKnow === true,
answers: [],
- note: req.note?.trim() || undefined,
+ thinking: req.thinking?.trim() || undefined,
+ readingThinking: req.readingThinking?.trim() || undefined,
answeredAt: new Date().toISOString(),
};
// resolve selected option values → OptionAnswer (in display order)
@@ -647,6 +717,12 @@ export function compactAnswerForModel(ad: QuestionAnswerData): Record(quizInputBase, restoreMathEscapesDeep(input));
+ // quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
+ // OPTIONAL `type` — new teach-phase records carry predict|check and the
+ // dock renders/gates by it (normalizeQuizType at the read seam); old and
+ // probe records carry none. nodeId stays an unknown key Value.Clean
+ // strips, exactly as before.
+ const cleaned = boundaryParse(quizDisplayInputSchema, restoreMathEscapesDeep(input));
if (!cleaned) return undefined;
if (quizInputProblems(cleaned).length > 0) return undefined;
return cleaned;
diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts
index d39d5fc..85bd375 100644
--- a/tests/chat-flow.test.ts
+++ b/tests/chat-flow.test.ts
@@ -62,6 +62,7 @@ const QUIZ_INPUT = {
],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
+ type: "check",
nodeId: "planet-order",
};
@@ -619,6 +620,7 @@ describe("teacher chat flow (scripted model)", () => {
// same turn. This pins that whole mechanism (the live Nietzsche bug).
const BAD_QUIZ_INPUT = {
question: "What is Nietzsche's genealogical claim about religion?",
+ type: "check",
nodeId: "genealogy",
options: [{ label: "A cultural diagnosis" }, { label: "Atheism as a conclusion" }],
correctAnswer: "cultural-diagnosis", // the exact live failure shape: a slug, no match
@@ -695,6 +697,7 @@ describe("teacher chat flow (scripted model)", () => {
// question, and the model's retry must become the pending quiz.
const STRINGY_QUIZ_INPUT = {
question: "Lewis's indexical analysis of 'actual' — which is it?",
+ type: "check",
nodeId: "indexicals",
options: JSON.stringify([
{ label: "Like 'I'", value: "indexical" },
diff --git a/tests/graph.test.ts b/tests/graph.test.ts
index 0e3cc6f..877a154 100644
--- a/tests/graph.test.ts
+++ b/tests/graph.test.ts
@@ -185,7 +185,7 @@ function fixtureMessages(): LhMessage[] {
quizPart(
"call-3",
{ question: "Q3", nodeId: "alpha", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
- answer({ questionText: "Q3 text", correct: true, note: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }),
+ answer({ questionText: "Q3 text", correct: true, thinking: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }),
),
]),
assistant("a4", [
diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts
index e84863d..7a0c0e5 100644
--- a/tests/jobs.test.ts
+++ b/tests/jobs.test.ts
@@ -1280,6 +1280,27 @@ describe("prompt composition", () => {
assert.ok(!prober.includes("Question principles"), "the teach-phase fragment never composes into the prober");
});
+ it("the predict-check cycle composes into the teacher; the craft chunk reaches BOTH agents (owner 2026-10-03)", () => {
+ const teacher = composeInstructions("teacher");
+ // the cycle doctrine (28-predictions.md) — teacher-only
+ assert.ok(teacher.includes("predict → teach → check"), "the cycle line is in");
+ assert.ok(teacher.includes('The prediction (`type: "predict"`)'), "the prediction role section is in");
+ assert.ok(teacher.includes("Skip the prediction"), "the skip rule is in");
+ assert.ok(teacher.includes('The check (`type: "check"`)'), "the check role section is in");
+ // the craft + thinking channels (shared/20-question-tools.md) — BOTH agents
+ const prober = composeInstructions("prober");
+ for (const [name, prompt] of [
+ ["teacher", teacher],
+ ["prober", prober],
+ ] as const) {
+ assert.ok(prompt.includes("asymmetry"), `${name} carries the option craft (moved out of tool descriptions)`);
+ assert.ok(prompt.includes("TRANSCRIBED SPEECH"), `${name} reads dictated thinking leniently`);
+ assert.ok(prompt.includes("thinkingWhileReading"), `${name} knows the reading channel`);
+ }
+ // the cycle is teach-phase doctrine — it never composes into the prober
+ assert.ok(!prober.includes("predict → teach → check"), "the teaching cycle stays out of the prober");
+ });
+
it("the sources-v0 prompt lines compose: read/pdf doctrine in the teacher, the read line in the prober", () => {
const teacher = composeInstructions("teacher");
assert.ok(teacher.includes("Learner source files"), "the teacher carries the source-files section");
diff --git a/tests/question-core.test.ts b/tests/question-core.test.ts
index e4e0e9d..6665625 100644
--- a/tests/question-core.test.ts
+++ b/tests/question-core.test.ts
@@ -24,6 +24,9 @@ import {
explainInputProblems,
quizInputSchema,
probeQuizInputSchema,
+ quizDisplayInputSchema,
+ normalizeQuizType,
+ quizNeedsReveal,
resolveCorrect,
toggleOption,
toggleOther,
@@ -64,6 +67,7 @@ describe("the schema's argument order (reasoning before the pick)", () => {
"explanation",
"correctAnswer",
"details",
+ ...(keys.includes("type") ? ["type"] : []),
...(keys.includes("nodeId") ? ["nodeId"] : []),
]);
}
@@ -76,6 +80,7 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
options: [{ label: "Venus" }, { label: "Mercury" }, { label: "Mars" }],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
+ type: "check",
nodeId: "planet-order",
};
@@ -366,8 +371,8 @@ describe("buildAnswer", () => {
});
it("keeps the note only when non-empty", () => {
- assert.equal(buildAnswer(quizData(), { values: ["mercury"], note: " " }).note, undefined);
- assert.equal(buildAnswer(quizData(), { values: ["mercury"], note: " x " }).note, "x");
+ assert.equal(buildAnswer(quizData(), { values: ["mercury"], thinking: " " }).thinking, undefined);
+ assert.equal(buildAnswer(quizData(), { values: ["mercury"], thinking: " x " }).thinking, "x");
});
it("ignores unknown option values", () => {
@@ -418,14 +423,14 @@ describe("buildAnswer", () => {
describe("compactAnswerForModel", () => {
it("quiz: the learner's selection, verdict, confidence, note — NOTHING the model authored", () => {
- const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70, note: "guessed" });
+ const ad = buildAnswer(quizData(), { values: ["mercury"], confidence: 70, thinking: "guessed" });
const compact = compactAnswerForModel(ad);
assert.deepEqual(compact, {
kind: "quiz",
answers: [{ index: 1, label: "Mercury" }],
correct: true,
confidence: 70,
- note: "guessed",
+ thinking: "guessed",
});
const raw = JSON.stringify(compact);
// every one of these lives in the tool CALL's arguments already
@@ -615,3 +620,99 @@ describe("buildAnswer / formatAnswerMessage: explain", () => {
});
});
+
+// ─── the question's role: predict vs check (owner 2026-10-03) ─────────────
+// Teaching units are a predict → teach → check cycle. The teach quiz schema
+// carries a REQUIRED `type` declaring the role; the prober's quiz never does
+// (probing questions are role-less). The reveal law keys off it: only CHECKS
+// collapse behind "Check your understanding" — predictions arrive open.
+describe("the quiz role field (predict | check)", () => {
+ const valid = {
+ question: "Which planet is closest to the Sun?",
+ options: [{ label: "Venus" }, { label: "Mercury" }, { label: "Mars" }],
+ correctAnswer: "Mercury",
+ explanation: "Mercury is the innermost planet.",
+ type: "check",
+ nodeId: "planet-order",
+ };
+ it("the teach schema REQUIRES type; the probe schema has no such field", () => {
+ assert.equal(Value.Check(quizInputSchema, valid), true, "fixture (type: check) is boundary-valid");
+ assert.equal(
+ Value.Check(quizInputSchema, { ...valid, type: undefined }),
+ false,
+ "a teach quiz without a role is rejected at the boundary",
+ );
+ assert.equal(
+ "nodeId" in (probeQuizInputSchema as { properties: Record }).properties === false,
+ true,
+ );
+ assert.equal(
+ "type" in (probeQuizInputSchema as { properties: Record }).properties,
+ false,
+ "the probe quiz stays role-less",
+ );
+ });
+
+ it("normalizeQuizType: exact literals only — old records and wobble degrade to check-style", () => {
+ assert.equal(normalizeQuizType("predict"), "predict");
+ assert.equal(normalizeQuizType("check"), "check");
+ assert.equal(normalizeQuizType(undefined), undefined);
+ assert.equal(normalizeQuizType("prediction"), undefined);
+ assert.equal(normalizeQuizType(42), undefined);
+ });
+
+ it("quizNeedsReveal: checks collapse, predictions never do", () => {
+ assert.equal(quizNeedsReveal("quiz", { type: "check" }, "teach"), true);
+ assert.equal(quizNeedsReveal("quiz", { type: "predict" }, "teach"), false, "predictions arrive OPEN");
+ assert.equal(
+ quizNeedsReveal("quiz", undefined, "teach"),
+ true,
+ "old records (no type) render check-style",
+ );
+ assert.equal(quizNeedsReveal("quiz", { type: "predict" }, "probe"), false);
+ assert.equal(quizNeedsReveal("quiz", { type: "check" }, "probe"), false);
+ assert.equal(quizNeedsReveal("ask_user_question", {}, "teach"), false);
+ assert.equal(quizNeedsReveal("explain", {}, "teach"), false);
+ });
+
+ function quizQd(): QuestionData {
+ const options = normalizeOptions(valid.options);
+ return {
+ id: "q1",
+ kind: "quiz",
+ question: valid.question,
+ mode: "single-select",
+ options,
+ correctIndices: resolveCorrect(coerceCorrectAnswer(valid.correctAnswer), options).indices,
+ explanation: valid.explanation,
+ createdAt: new Date().toISOString(),
+ };
+ }
+
+ it("buildAnswer snapshots the question's role and carries both thinking channels", () => {
+ const predictQd: QuestionData = { ...quizQd(), questionType: "predict" };
+ const a = buildAnswer(predictQd, { values: ["Mercury"], thinking: " guessed ", readingThinking: " reading note " });
+ assert.equal(a.questionType, "predict");
+ assert.equal(a.thinking, "guessed", "thinking is trimmed");
+ assert.equal(a.readingThinking, "reading note");
+ const b = buildAnswer(quizQd(), { values: ["Mercury"] });
+ assert.equal(b.thinking, undefined, "empty channels stay absent, never empty strings");
+ assert.equal(b.readingThinking, undefined);
+ assert.equal(b.questionType, undefined, "a role-less QuestionData (probe / old record) answers role-less");
+ });
+
+ it("compactAnswerForModel: distinct keys for the two channels; legacy `note` folds into thinking", () => {
+ const a = buildAnswer(quizQd(), { values: ["Mercury"], thinking: "h", readingThinking: "r" });
+ const compact = compactAnswerForModel(a) as Record;
+ assert.equal(compact.thinking, "h");
+ assert.equal(compact.thinkingWhileReading, "r");
+ const legacy = compactAnswerForModel({ ...a, thinking: undefined, note: "old note" }) as Record;
+ assert.equal(legacy.thinking, "old note", "records written before the rename still reach the model");
+ assert.equal("note" in legacy, false, "the legacy key never re-crosses the wire");
+ });
+
+ it("the display parse KEEPS the role: quizDisplayInputSchema retains type where the base strips it", () => {
+ const cleaned = Value.Clean(quizDisplayInputSchema, { ...valid }) as Record;
+ assert.equal(cleaned.type, "check", "role survives the display parse (the dock gates collapse + labels on it)");
+ });
+});
diff --git a/tests/question-tools.test.ts b/tests/question-tools.test.ts
index 1208a0b..3e6bc53 100644
--- a/tests/question-tools.test.ts
+++ b/tests/question-tools.test.ts
@@ -59,6 +59,7 @@ const validQuiz = {
],
correctAnswer: "4",
explanation: "Arithmetic.",
+ type: "check",
nodeId: "arith",
};
@@ -383,3 +384,49 @@ describe("the prober's explain: no nodeId field at all", () => {
assert.equal(empty.success, false);
});
});
+
+// ─── descriptions are MECHANICS (owner 2026-10-03) ────────────────────────
+// Question-design doctrine lives in the system prompt (shared craft chunk +
+// teacher fragments), NOT in tool descriptions. A description that teaches
+// the model how to write questions drifts the moment a new variant (prober,
+// future adaptive practice) reuses the tool with a different doctrine.
+describe("tool descriptions carry mechanics only, no question-craft", () => {
+ const teachQuiz = teachQuizToolFor({ chatId: "chat_x", chatStore: undefined as never });
+ for (const [name, tool] of [
+ ["teach quiz", teachQuiz],
+ ["probe quiz", probeQuizTool],
+ ] as const) {
+ it(`${name}: no craft doctrine, mechanics intact`, () => {
+ const d = tool.description;
+ for (const banned of ["asymmetry", "distractor", "believable", "misconception"]) {
+ assert.ok(!d.toLowerCase().includes(banned), `${name} description leaks craft: "${banned}"`);
+ }
+ for (const mechanical of ["GRADED", "ONE question per turn", "ask_user_question"]) {
+ assert.ok(d.includes(mechanical), `${name} description lost mechanics: "${mechanical}"`);
+ }
+ });
+ }
+ it("the teach quiz declares the `type` field and `nodeId`; the probe quiz declares neither", () => {
+ assert.ok(teachQuiz.description.includes("`type`"), "teach quiz points at the role field");
+ assert.ok(teachQuiz.description.includes("`nodeId`"), "teach quiz points at the plan-map validation");
+ assert.ok(!probeQuizTool.description.includes("`type`"), "the probe quiz has no roles");
+ assert.ok(!probeQuizTool.description.includes("`nodeId`"), "the probe quiz has no plan map");
+ });
+ it("the explain tools keep the COMMITMENT rule (the grading contract) and drop the softer doctrine", () => {
+ for (const [name, tool] of [
+ ["teach explain", teachExplainToolFor({ chatId: "chat_x", chatStore: undefined as never })],
+ ["probe explain", probeExplainTool],
+ ] as const) {
+ assert.ok(tool.description.includes("COMMITTED"), `${name} keeps the commitment rule`);
+ assert.ok(!tool.description.includes("near-miss"), `${name} drops the doctrine (now in the shared prompt)`);
+ }
+ });
+ it("the craft now lives in the shared prompt chunk (both agents compose it)", async () => {
+ const shared = await fs.promises.readFile(
+ path.join(PATHS.root, "src/agent/prompts/shared/20-question-tools.md"),
+ "utf-8",
+ );
+ assert.ok(shared.includes("asymmetry"), "the option craft moved here, not deleted");
+ assert.ok(shared.includes("TRANSCRIBED SPEECH"), "the dictation-leniency note lives here");
+ });
+});
diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx
index f3dc414..58d59f1 100644
--- a/tests/render-cards.test.tsx
+++ b/tests/render-cards.test.tsx
@@ -23,7 +23,14 @@ import { JobsContext } from "../components/jobs-context.ts";
import { ChatActionsContext } from "../components/chat.tsx";
import type { JobRecord } from "../src/core/contracts.ts";
import { AnsweredCard } from "../components/answered-card.tsx";
-import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
+import {
+ buildAnswer,
+ coerceCorrectAnswer,
+ normalizeOptions,
+ resolveCorrect,
+ quizNeedsReveal,
+ type QuestionData,
+} from "../src/core/question-core.ts";
const quizInput = {
question: "Which planet is closest to the sun?",
@@ -276,7 +283,7 @@ describe("render smoke tests", () => {
it("AnsweredCard promotes the learner's thinking to a first-class block", () => {
const answer = buildAnswer(quizQD(), {
values: ["Venus"],
- note: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
+ thinking: "I picked Venus because it looks brightest — but now I realize brightness isn't distance.",
});
const html = renderToStaticMarkup();
assert.ok(html.includes('data-testid="your-thinking"'), "the thinking block exists");
@@ -509,3 +516,74 @@ describe("AnsweredCard: the explain variant", () => {
});
});
+
+// ─── the two roles in the UI (predict vs check, owner 2026-10-03) ─────────
+// Predictions arrive OPEN before the teaching; checks arrive collapsed after
+// it. The answered card tells them apart (eyebrow) and tucks a prediction's
+// explanation (the teaching that follows IS the explanation) while a check's
+// stays inline.
+describe("predict vs check rendering", () => {
+ it("a predict quiz renders the 'Prediction' eyebrow in the dock", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onSubmit={() => {}}
+ />,
+ );
+ assert.ok(html.includes("Prediction"), `eyebrow missing: ${html.slice(0, 200)}`);
+ });
+
+ it("a check quiz keeps the 'Quiz' eyebrow (old records unchanged)", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onSubmit={() => {}}
+ />,
+ );
+ assert.ok(html.includes("Quiz"));
+ });
+
+ it("a prediction answer SHOWS the verdict but TUCKS the explanation behind a toggle", () => {
+ const qd: QuestionData = { ...quizQD(), questionType: "predict" };
+ const answer = buildAnswer(qd, { values: ["Venus"] }); // wrong on purpose
+ const html = renderToStaticMarkup();
+ assert.ok(html.includes("Prediction"), "the eyebrow distinguishes the card");
+ assert.ok(/incorrect|wrong|×|✗/i.test(html), "the verdict still shows (owner: predictions are graded)");
+ assert.ok(!html.includes("Why:"), "the explanation is not center stage");
+ assert.ok(html.includes("tucked-explanation"), "it sits behind the toggle");
+ });
+
+ it("a check answer keeps the inline explanation", () => {
+ const answer = buildAnswer(quizQD(), { values: ["Venus"] });
+ const html = renderToStaticMarkup();
+ assert.ok(html.includes("Why:"), "checks explain inline (current behavior)");
+ assert.ok(!html.includes("tucked-explanation"));
+ });
+
+ it("the reading-thinking channel renders as its own block; legacy `note` still renders", () => {
+ const withReading = buildAnswer(quizQD(), {
+ values: ["Mercury"],
+ thinking: "answering thought",
+ readingThinking: "was unsure mid-paragraph",
+ });
+ const html = renderToStaticMarkup();
+ assert.ok(html.includes("Your thinking while reading"), "the reading channel is labeled distinctly");
+ assert.ok(html.includes("answering thought"));
+ assert.ok(html.includes("was unsure mid-paragraph"));
+ const legacy = { ...buildAnswer(quizQD(), { values: ["Mercury"] }), note: "a 2026-09 answer" };
+ const legacyHtml = renderToStaticMarkup();
+ assert.ok(legacyHtml.includes("a 2026-09 answer"), "records from before the rename still render");
+ });
+});
diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts
index d3da13c..f4f46a9 100644
--- a/tests/session-core.test.ts
+++ b/tests/session-core.test.ts
@@ -923,7 +923,7 @@ describe("newAnswerResult two audiences", () => {
explanation: "Mercury orbits closest.",
createdAt: "2026-01-01T00:00:00Z",
};
- const full = buildAnswer(qd, { values: ["mercury"], confidence: 70, note: "guessed" });
+ const full = buildAnswer(qd, { values: ["mercury"], confidence: 70, thinking: "guessed" });
const msg = newAnswerResult("call-1", "quiz", full);
const modelSees = JSON.parse((msg.content as { type: "text"; text: string }[])[0]!.text);
assert.deepEqual(modelSees, {
@@ -931,7 +931,7 @@ describe("newAnswerResult two audiences", () => {
answers: [{ index: 1, label: "Mercury" }],
correct: true,
confidence: 70,
- note: "guessed",
+ thinking: "guessed",
});
assert.equal(msg.details, full, "details is the full QuestionAnswerData verbatim");
assert.equal((msg.details as { questionText?: string }).questionText, "Which planet is closest to the Sun?");
diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts
index cf216d2..f7d7030 100644
--- a/tests/tool-parts.test.ts
+++ b/tests/tool-parts.test.ts
@@ -29,6 +29,7 @@ const TEACHER_QUIZ = {
],
correctAnswer: "truth",
explanation: "Convention-T schematizes truth for the object language.",
+ type: "check", // teach-only role field — the display parse KEEPS it (the dock gates by it)
nodeId: "convention-t", // teacher-only field — the display parse must accept and strip it
};
@@ -55,6 +56,7 @@ describe("parseQuizInput", () => {
assert.ok(parsed);
assert.equal(parsed.question, TEACHER_QUIZ.question);
assert.equal((parsed as Record).nodeId, undefined, "unknown keys are stripped, not trusted");
+ assert.equal((parsed as { type?: unknown }).type, "check", "the role field survives the display parse (owner 2026-10-03)");
});
it("parses a boundary-valid PROBER quiz (no nodeId at all)", () => {