free-recall explain tool: the quizzing-mechanic switch (owner 2026-09-27)

Sycophancy mitigation as a structural commitment: the model writes its
full correctAnswer (plus explanation, in that order) BEFORE the learner
sees the question; the commitment rides the persisted call args, is
revealed on submit, and grades his free text in the model's next reply —
the client never grades free recall.

- tools.explain setting (segmented 'Quizzing — Multiple choice | Free
  recall' in Agent settings' Tools group): free arms explain and drops
  quiz for BOTH agents (prober variant carries no nodeId, same pattern
  as the probe quiz); mechanics keys never pass filterEnabledTools —
  quiz stays unwritable (the 2026-09-19 wedge ruling stands)
- doctrine lives pi-natively in the tool descriptions (owner ruling —
  no conditional system-prompt chunks): explain carries the commitment
  rule; the MC option-building craft moved out of the prompt into the
  quiz description; shared/20-question-tools.md slims to the
  quiz-agnostic core
- display seams: parseExplainInput (LaTeX-debris restoration included),
  dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard
  reveal (The answer / Why, no verdict chips), [Explain answer] format
- phases/plan seams recognize both quizzing tools' nodeId tags; explain
  answers deliberately feed no ledger evidence (no client grading to
  key on — a future ruling can parse the model's grade)

1049/1049 tests, typecheck/lint/build green.
This commit is contained in:
amosblomqvist
2026-09-27 08:55:13 +03:00
parent 07d2c84461
commit 2f39766528
25 changed files with 784 additions and 68 deletions
+28 -2
View File
@@ -11,6 +11,9 @@ import type { QuestionAnswerData } from "@/src/core";
* Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the * Quiz answers are GRADED: ✓/✗ marks, the correct answers, and the
* explanation. Ask answers are NOT graded — a plain record of what was said. * explanation. Ask answers are NOT graded — a plain record of what was said.
* (The v1 bug this pins: ask answers must never get quiz grading UI.) * (The v1 bug this pins: ask answers must never get quiz grading UI.)
* Explain answers are graded by the MODEL in its next reply, not by the
* client — the card reveals his answer, the committed correct answer, and
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/ */
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
if (answer.dismissed) { if (answer.dismissed) {
@@ -22,13 +25,14 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
} }
const isQuiz = answer.kind === "quiz"; const isQuiz = answer.kind === "quiz";
const isExplain = answer.kind === "explain";
const selectedIdx = new Set(answer.answers.map((a) => a.index)); const selectedIdx = new Set(answer.answers.map((a) => a.index));
const correctIdx = new Set(answer.correctIndices ?? []); const correctIdx = new Set(answer.correctIndices ?? []);
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card"> <div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="answered-card">
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{isQuiz ? "Quiz" : "Question"} {isQuiz ? "Quiz" : isExplain ? "Explain" : "Question"}
{isQuiz && {isQuiz &&
(answer.dontKnow ? ( (answer.dontKnow ? (
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span> <span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
@@ -97,7 +101,9 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} )}
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>} {answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
{answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>} {answer.dontKnow && (isQuiz || isExplain) && (
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
)}
{answer.note && ( {answer.note && (
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3"> <div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
@@ -119,6 +125,26 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse> <MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</div> </div>
)} )}
{/* The explain reveal (owner ruling 2026-09-27): the committed answer
and its explanation, shown on submit — no verdict, the model's next
reply IS the grading. */}
{isExplain && (answer.committed || answer.explanation) && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
{answer.committed && (
<>
<span className="font-medium text-foreground">The answer: </span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
</>
)}
{answer.committed && answer.explanation && <div className="my-1" />}
{answer.explanation && (
<>
<span className="font-medium text-foreground">Why: </span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
</>
)}
</div>
)}
</div> </div>
); );
} }
+3 -2
View File
@@ -1115,7 +1115,8 @@ export function Chat({
/> />
); );
case "tool-quiz": case "tool-quiz":
case "tool-ask_user_question": { case "tool-ask_user_question":
case "tool-explain": {
// Pending/streaming questions live in the dock, not the // Pending/streaming questions live in the dock, not the
// transcript; answered ones render as graded records. // transcript; answered ones render as graded records.
if (part.state === "output-available") { if (part.state === "output-available") {
@@ -1650,7 +1651,7 @@ function DockOverlay({
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}> <div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
{pending ? ( {pending ? (
<QuestionDock <QuestionDock
toolName={pending.tool as "quiz" | "ask_user_question"} toolName={pending.tool as "quiz" | "ask_user_question" | "explain"}
toolCallId={pending.toolCallId} toolCallId={pending.toolCallId}
input={pending.input as never} input={pending.input as never}
expanded={expanded} expanded={expanded}
+28 -14
View File
@@ -16,11 +16,13 @@ import {
shuffleOptions, shuffleOptions,
parseAskInput, parseAskInput,
parseQuizInput, parseQuizInput,
parseExplainInput,
toggleOption, toggleOption,
toggleOther, toggleOther,
type AnswerRequest, type AnswerRequest,
type AskToolInput, type AskToolInput,
type DockSelection, type DockSelection,
type ExplainToolInput,
type QuestionAnswerData, type QuestionAnswerData,
type QuestionData, type QuestionData,
type QuestionMode, type QuestionMode,
@@ -50,9 +52,9 @@ import {
*/ */
export interface QuestionDockProps { export interface QuestionDockProps {
toolName: "quiz" | "ask_user_question"; toolName: "quiz" | "ask_user_question" | "explain";
toolCallId: string; toolCallId: string;
input: QuizToolInput | AskToolInput; input: QuizToolInput | AskToolInput | ExplainToolInput;
/** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */ /** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */
expanded: boolean; expanded: boolean;
/** /**
@@ -92,7 +94,8 @@ export function QuestionDock({
// bridge), so this is pure defense in depth — but it is STRUCTURAL now: // bridge), so this is pure defense in depth — but it is STRUCTURAL now:
// anything that slips through renders the quiet fallback, never a // anything that slips through renders the quiet fallback, never a
// crash on `.map` over model-authored bytes. // crash on `.map` over model-authored bytes.
const parsed = toolName === "quiz" ? parseQuizInput(input) : parseAskInput(input); const parsed =
toolName === "quiz" ? parseQuizInput(input) : toolName === "explain" ? parseExplainInput(input) : parseAskInput(input);
if (!parsed) { if (!parsed) {
return ( return (
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground"> <div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
@@ -148,7 +151,7 @@ export function QuestionDockBar({
onExpand, onExpand,
variant = "collapsed", variant = "collapsed",
}: { }: {
toolName: "quiz" | "ask_user_question"; toolName: "quiz" | "ask_user_question" | "explain";
question: string; question: string;
onExpand: () => void; onExpand: () => void;
variant?: "collapsed" | "reveal"; variant?: "collapsed" | "reveal";
@@ -182,7 +185,7 @@ export function QuestionDockBar({
className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40" className="dock-variant flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent/40"
> >
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "quiz" ? "Quiz" : "Question"} {toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question"}
</span> </span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span> <span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
<span className="flex shrink-0 items-center gap-1 text-xs font-medium"> <span className="flex shrink-0 items-center gap-1 text-xs font-medium">
@@ -202,7 +205,7 @@ function QuestionDockForm({
onSubmit, onSubmit,
onEscape, onEscape,
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) { }: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); const kind = toolName === "quiz" ? ("quiz" as const) : toolName === "explain" ? ("explain" as const) : ("ask" as const);
// Canonical QuestionData, built once per question. Quiz options are // Canonical QuestionData, built once per question. Quiz options are
// deterministically shuffled (seeded by toolCallId) so position never leaks // deterministically shuffled (seeded by toolCallId) so position never leaks
@@ -211,7 +214,8 @@ function QuestionDockForm({
const qd = useMemo<QuestionData>(() => { const qd = useMemo<QuestionData>(() => {
const quizInput = kind === "quiz" ? (input as QuizToolInput) : null; const quizInput = kind === "quiz" ? (input as QuizToolInput) : null;
const askInput = kind === "ask" ? (input as AskToolInput) : null; const askInput = kind === "ask" ? (input as AskToolInput) : null;
const multi = input.multiSelect === true; const explainInput = kind === "explain" ? (input as ExplainToolInput) : null;
const multi = input instanceof Object && "multiSelect" in input ? input.multiSelect === true : false;
const mode: QuestionMode = const mode: QuestionMode =
kind === "quiz" kind === "quiz"
? multi ? multi
@@ -241,6 +245,12 @@ function QuestionDockForm({
q.correctIndices = resolved.indices; q.correctIndices = resolved.indices;
q.explanation = quizInput.explanation; q.explanation = quizInput.explanation;
} }
if (explainInput) {
// The commitment rides the question data (never sent to the
// client before answering) and snapshots into the answer at submit.
q.committedAnswer = explainInput.correctAnswer;
q.explanation = explainInput.explanation;
}
return q; return q;
}, [toolCallId, kind, input]); }, [toolCallId, kind, input]);
@@ -275,7 +285,7 @@ function QuestionDockForm({
const canSubmit = const canSubmit =
qd.mode === "text" qd.mode === "text"
? text.trim().length > 0 ? sel.dontKnow || text.trim().length > 0 // explain: dontKnow is a first-class answer
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0; : sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
const submit = () => { const submit = () => {
@@ -314,13 +324,14 @@ function QuestionDockForm({
const otherInputRef = useRef<HTMLInputElement>(null); const otherInputRef = useRef<HTMLInputElement>(null);
const thinkingRef = useRef<HTMLTextAreaElement>(null); const thinkingRef = useRef<HTMLTextAreaElement>(null);
// One row past the options is the trailing row: quiz → don't-know, // One row past the options is the trailing row: quiz → don't-know,
// ask → Other (a question never has both). // ask → Other (a question never has both). Explain has exactly ONE
// row — its don't-know (there are no options to walk).
const optionCount = qd.mode === "text" ? 0 : qd.options.length; const optionCount = qd.mode === "text" ? 0 : qd.options.length;
const rowCount = qd.mode === "text" ? 0 : optionCount + 1; const rowCount = qd.mode === "text" ? (kind === "explain" ? 1 : 0) : optionCount + 1;
const kbdAct = (idx: number) => { const kbdAct = (idx: number) => {
if (idx < optionCount) toggle(idx); if (idx < optionCount) toggle(idx);
else if (kind === "quiz") else if (kind === "quiz" || kind === "explain")
setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow })); setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow }));
else { else {
// Space on the ask's Other row (owner refinement round, // Space on the ask's Other row (owner refinement round,
@@ -412,7 +423,7 @@ function QuestionDockForm({
{/* Question area — scrolls internally if very tall; the bottom row stays put. */} {/* Question area — scrolls internally if very tall; the bottom row stays put. */}
<div className="max-h-[55dvh] overflow-y-auto px-4 pt-3"> <div className="max-h-[55dvh] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{kind === "quiz" ? "Quiz" : "Question"} {kind === "quiz" ? "Quiz" : kind === "explain" ? "Explain" : "Question"}
{qd.mode === "multi-select" && " · select all that apply"} {qd.mode === "multi-select" && " · select all that apply"}
</div> </div>
<div className="mb-3 font-medium"> <div className="mb-3 font-medium">
@@ -424,7 +435,10 @@ function QuestionDockForm({
</div> </div>
)} )}
{qd.mode !== "text" && ( {/* Text-mode questions render no options list — EXCEPT explain,
whose single "I don't know" row is the one walkable answer
(kbd row 0). The answer itself goes in the composer below. */}
{(qd.mode !== "text" || kind === "explain") && (
<div className="flex flex-col gap-2 pb-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}> <div className="flex flex-col gap-2 pb-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
{qd.options.map((opt, i) => { {qd.options.map((opt, i) => {
const isSel = sel.selected.has(i); const isSel = sel.selected.has(i);
@@ -470,7 +484,7 @@ function QuestionDockForm({
); );
})} })}
{kind === "quiz" && ( {(kind === "quiz" || kind === "explain") && (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
+50
View File
@@ -66,6 +66,7 @@ import {
} from "@/src/core"; } from "@/src/core";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format"; import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx"; import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu"; import { ModelMenuList, ThinkingMenuList } from "./model-menu";
@@ -110,6 +111,15 @@ export function composeTool(agent: AgentSettings | undefined, id: string, on: bo
return { ...agent, tools: { ...agent?.tools, [id]: on } }; return { ...agent, tools: { ...agent?.tools, [id]: on } };
} }
/** The quizzing mechanics (owner 2026-09-27): a segmented control, not a
* toggle — exactly one mechanic is ever active. `explain: true` arms
* free-recall (explain in, quiz out); anything else is multiple choice.
* `quiz` itself is never written (mechanics-managed — see quizMechanics
* in agent/tools.ts). Pure — exported for tests. */
export function composeQuizMechanics(agent: AgentSettings | undefined, mechanics: "choice" | "free"): AgentSettings {
return { ...agent, tools: { ...agent?.tools, explain: mechanics === "free" } };
}
/** Compose the prober graph-tools toggle (wave 7): its own key in the /** Compose the prober graph-tools toggle (wave 7): its own key in the
* agent section — never in the tool-toggles map (owner ruling). Absent = * agent section — never in the tool-toggles map (owner ruling). Absent =
* ON (the toggle discipline); the write is explicit either way, so an * ON (the toggle discipline); the write is explicit either way, so an
@@ -725,6 +735,46 @@ export function AgentSection({
</SettingsRow> </SettingsRow>
); );
})()} })()}
{/* The quizzing mechanic (owner 2026-09-27): segmented — exactly one
of Multiple choice / Free recall is active; the choice swaps which
quizzing tool BOTH agents carry (prober checks + teach checks).
Persisted as agent.tools.explain; absent = Multiple choice. */}
{(() => {
const free = agent?.tools?.explain === true;
return (
<SettingsRow
label="Quizzing"
hint="How both tutors check your understanding"
testid="quiz-mechanics-row"
>
<div
role="radiogroup"
aria-label="Quizzing mechanics"
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
>
{(["choice", "free"] as const).map((m) => {
const active = m === "free" ? free : !free;
return (
<button
key={m}
type="button"
role="radio"
aria-checked={active}
data-testid={`quiz-mechanics-${m}`}
onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
className={cn(
"h-8 rounded-md px-3 text-sm font-medium transition-colors",
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{m === "choice" ? "Multiple choice" : "Free recall"}
</button>
);
})}
</div>
</SettingsRow>
);
})()}
{TOGGLE_TOOLS.map((id) => { {TOGGLE_TOOLS.map((id) => {
// Research needs a live search engine; the row + toggle gray // Research needs a live search engine; the row + toggle gray
// on the same signal the registry drops the tool on (see // on the same signal the registry drops the tool on (see
@@ -1,6 +1,6 @@
# The probe # The probe
Your whole job is two measurements: **what he needs to learn** (his goal — `ask_user_question`) and **what he already knows** (a detailed map of his exact understanding — `quiz`). The whole lesson will be planned from your map — it must be precise. Your whole job is two measurements: **what he needs to learn** (his goal — `ask_user_question`) and **what he already knows** (a detailed map of his exact understanding — the quizzing tool: `quiz` or `explain`, whichever the learner’s mechanic armed). The whole lesson will be planned from your map — it must be precise.
Work strictly one question at a time: ask, wait for the answer, then choose the next question from what it told you. Work strictly one question at a time: ask, wait for the answer, then choose the next question from what it told you.
@@ -16,7 +16,7 @@ Work strictly one question at a time: ask, wait for the answer, then choose the
From the goal, what will the lesson depend on? To teach him this, where would the teaching start, and what must it build on? Each dependency is a strand — bound by relevance, nothing more. From the goal, what will the lesson depend on? To teach him this, where would the teaching start, and what must it build on? Each dependency is a strand — bound by relevance, nothing more.
## 4. Binary-search every strand — `quiz` ## 4. Binary-search every strand — the quizzing tool
The edge of a strand is found by bracketing: something he reliably gets **right** (a floor) and something he gets **wrong** or doesn't know (a ceiling). Narrow the bracket one question at a time: The edge of a strand is found by bracketing: something he reliably gets **right** (a floor) and something he gets **wrong** or doesn't know (a ceiling). Narrow the bracket one question at a time:
+6 -20
View File
@@ -1,28 +1,14 @@
## The question tools ## The question tools
Two instruments. `quiz` only for questions with a known correct answer, `ask_user_question` for everything else. 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.
### `quiz` ### Quiz checks — shared doctrine
Asks a question, grades instantly, shows ✓/✗ + the correct answer + explanation to both of you. Single- or multi-select. - `explanation` (required) says why the correct answer is correct. It comes BEFORE `correctAnswer` in the call by design: reason it out first, then commit to the answer the reasoning supports.
- An "I don't know" choice is always available to the learner — never author your own opt-out. A dontKnow result is a genuine gap, not a wrong answer.
- `correctAnswer` (required) is the option's `value` string — self-checking, never a position number. Multi-select: array of values, graded as an exact-set match. - Answers may carry a free-text `note` — read it; it says what he was thinking.
- `explanation` (required) says why the correct answer is correct. It comes BEFORE `correctAnswer` in the call by design: reason it out first, then commit the answer the reasoning supports. - ONE question per turn: ask, then end your turn — his answer determines the next.
- An "I don't know" option is added automatically — provide only the real, gradable options (≥2), never your own opt-out. A dontKnow result is a genuine gap, not a wrong answer.
- Answers may carry a free-text `note` from the always-present note field — read it; it says what he was thinking.
- Options display shuffled by default; `shuffle: false` only when order is meaningful (e.g. an 'All/None of the above' that must stay last). `multiSelect: true` only when several options are correct.
### `ask_user_question` ### `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. 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.
### Building quiz 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, don't audit it in afterwards:
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 — or confusing it with a specific neighbour — claim? Same skeleton, same grain, same register: parallelism by construction.
3. Each distractor is a real error he might actually make (so his pick is diagnostic) yet unambiguously wrong.
4. No asymmetric bolding — bold the parallel term in every option or in none.
If you can tell the right one cold without knowing the material, regenerate — don't patch.
+1 -1
View File
@@ -26,7 +26,7 @@ You write the map with `plan_init` (a mermaid `graph TD` block). It is a **map,
The map is yours to rewrite at your own initiative: re-call `plan_init` whenever it's wrong — a detour node for a question worth exploring, a repair node for a caught misconception, prune what proves unnecessary. A map that can't change is a wrong map. The map is yours to rewrite at your own initiative: re-call `plan_init` whenever it's wrong — a detour node for a question worth exploring, a repair node for a caught misconception, prune what proves unnecessary. A map that can't change is a wrong map.
**Every quiz carries `nodeId`** (required — a quiz without it is rejected): that's what moves the map. Its live position is simply the latest tagged node, so asking about the next node IS how the lesson gets there. A tag is a position, not a verdict — nodes are never marked finished. A node takes as many questions as it deserves: stay on one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward — and move on by asking about the next one. Off-map checks (no plan node fits) use ask_user_question. **Every quizzing call carries `nodeId`** (required — the quizzing tool is one of `quiz` or `explain` depending on the mechanic the learner chose; a call without it is rejected): that's what moves the map. Its live position is simply the latest tagged node, so asking about the next node IS how the lesson gets there. A tag is a position, not a verdict — nodes are never marked finished. A node takes as many questions as it deserves: stay on one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward — and move on by asking about the next one. Off-map checks (no plan node fits) use ask_user_question.
## Presenting the plan — always, before any teaching ## Presenting the plan — always, before any teaching
@@ -5,5 +5,5 @@ Every check exists to answer one question: did the idea actually **take** — no
- **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. - **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.
- **Never just reciting your words back.** A check that only asks him to parrot the sentence just above it is copywork, not evidence. - **Never just reciting your words back.** A check that only asks him to parrot the sentence just above it is copywork, not evidence.
- **Prefer problems that apply the knowledge.** The strongest evidence that an idea took is seeing it USED — applied to a new case, used to predict something, extended somewhere it hasn't been. Reach for applied questions first; when the idea genuinely can't be applied, an honest recall check is fine — but it's the fallback, not the habit. - **Prefer problems that apply the knowledge.** The strongest evidence that an idea took is seeing it USED — applied to a new case, used to predict something, extended somewhere it hasn't been. Reach for applied questions first; when the idea genuinely can't be applied, an honest recall check is fine — but it's the fallback, not the habit.
- **Catch confusion early, and make it nameable.** Design the wrong options as real misconceptions — each the answer he'd give under a specific wrong model. Then a miss TELLS you which wrong model he holds; diagnose and repair before building anything on top of it. - **Catch confusion early, and make it nameable.** Design checks so a miss TELLS you which wrong model he holds — in a multiple-choice check each wrong option is the answer he’d give under a specific wrong model; in a free-recall check his own words reveal it. Diagnose and repair before building anything on top of it.
- **His answer is signal, not verdict.** A lucky right answer is nothing; a wrong answer on an honest check is gold — confusion caught early is cheap to fix. - **His answer is signal, not verdict.** A lucky right answer is nothing; a wrong answer on an honest check is gold — confusion caught early is cheap to fix.
+2 -1
View File
@@ -203,7 +203,8 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
} }
/** /**
* The PROBER (agent A): quiz (no nodeId) + ask + probe_done, lean on * The PROBER (agent A): the quizzing tool (quiz, or explain under free
* recall — no nodeId either way) + ask + probe_done, lean on
* probing doctrine. Its whole job is bracketing the learner's edge and * probing doctrine. Its whole job is bracketing the learner's edge and
* concluding with the assessment (probe_done) — which also queues the * concluding with the assessment (probe_done) — which also queues the
* handoff wake (see tools/probe.ts) and is the transcript seam the * handoff wake (see tools/probe.ts) and is the transcript seam the
+114 -6
View File
@@ -4,6 +4,9 @@ import { questionResult, serverExecute, ToolInputError } from "./tool-execute.ts
import { import {
askInputSchema, askInputSchema,
askOptionProblems, askOptionProblems,
explainInputBase,
explainInputObject,
explainInputProblems,
parseOptionsIfString, parseOptionsIfString,
probeQuizInputSchema, probeQuizInputSchema,
quizInputObject, quizInputObject,
@@ -92,6 +95,32 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
// ─── the prober's question tools (no nodeId — no map exists yet) ───────────── // ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
/** 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 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 " +
"explanation (your reasoning) first, then correctAnswer — the FULL correct answer, 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 and the " +
"explanation on submit. An 'I don't know' choice is always available to him — a genuine gap, not a wrong answer. " +
"ONE question per turn: call a single explain, then end your turn — his answer determines the next.";
export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = { export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
name: "quiz", name: "quiz",
label: "quiz", label: "quiz",
@@ -105,7 +134,7 @@ export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
"signal a genuine gap instead of guessing. An always-present optional note field lets the learner attach a " + "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 " + "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 — " + "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.", "his answer determines the next question to ask. " + QUIZ_OPTION_CRAFT,
parameters: probeQuizInputSchema, parameters: probeQuizInputSchema,
prepareArguments: prepareQuestionArguments, prepareArguments: prepareQuestionArguments,
executionMode: "sequential", executionMode: "sequential",
@@ -116,6 +145,48 @@ export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
}, },
}; };
/** The teacher's free-recall variant (nodeId-validated, same seam as the
* teach quiz — phases/plan recognize BOTH tool names). */
export function teachExplainToolFor(
ctx: Pick<PlanToolContext, "chatId" | "chatStore">,
): AgentTool<typeof explainInputObject> {
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.",
parameters: explainInputObject,
prepareArguments: prepareQuestionArguments,
executionMode: "sequential",
execute: async (_toolCallId, input) => {
const problems = [...explainInputProblems(input)];
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
if (nodeIdProblemText) problems.push(nodeIdProblemText);
if (problems.length > 0) throw new ToolInputError("explain", problems);
return questionResult();
},
};
}
/** The prober's free-recall variant (no nodeId — same pattern as the
* probe quiz). */
export const probeExplainTool: AgentTool<typeof explainInputBase> = {
name: "explain",
label: "explain",
description: EXPLAIN_DOCTRINE,
parameters: explainInputBase,
prepareArguments: prepareQuestionArguments,
executionMode: "sequential",
execute: async (_toolCallId, input) => {
const problems = explainInputProblems(input);
if (problems.length > 0) throw new ToolInputError("explain", problems);
return questionResult();
},
};
/** The prober's registry context: the probe's own needs (probe_done — /** The prober's registry context: the probe's own needs (probe_done —
* ProbeDoneContext) PLUS the job-dispatch wiring, since the owner ruling * ProbeDoneContext) PLUS the job-dispatch wiring, since the owner ruling
* (2026-09-20) puts research + pdf in the probe phase's registry too. */ * (2026-09-20) puts research + pdf in the probe phase's registry too. */
@@ -156,10 +227,20 @@ export function createProberTools(
spawn: ctx.spawn, spawn: ctx.spawn,
searchAvailable: ctx.searchAvailable, searchAvailable: ctx.searchAvailable,
}); });
// Quizzing mechanics (owner 2026-09-27): free-recall swaps the probe
// quiz for the probe explain — one quizzing tool at a time, whichever
// the settings arm. ask stays always-on.
const mechanics = quizMechanics(enabled);
// The armed quizzing tool, widened to the registry's heterogeneous
// AgentTool record BEFORE the filter (a conditional spread of two
// differently-typed literals makes the whole object's type a union —
// unusable as Record<string, AgentTool<any>> downstream).
const quizzing: Record<string, AgentTool<any>> =
mechanics === "free" ? { explain: probeExplainTool } : { quiz: probeQuizTool };
return { return {
...filterEnabledTools( ...filterEnabledTools(
{ {
quiz: probeQuizTool, ...quizzing,
ask_user_question: askUserQuestionTool, ask_user_question: askUserQuestionTool,
// The learner-filesystem set is always-on (user-decided 2026-09, // The learner-filesystem set is always-on (user-decided 2026-09,
// sources v1): the prober inspects and LOCATES learner-referenced // sources v1): the prober inspects and LOCATES learner-referenced
@@ -184,7 +265,7 @@ export function createProberTools(
: {}), : {}),
...done, ...done,
}, },
enabled, withoutMechanics(enabled),
), ),
...done, ...done,
}; };
@@ -230,7 +311,8 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
"plus an automatic 'I don't know' choice so the learner can signal a genuine gap instead of guessing. " + "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 " + "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. " + "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.", "ONE question per turn: call a single quiz, then end your turn — his answer determines the next. " +
QUIZ_OPTION_CRAFT,
parameters: quizInputObject, parameters: quizInputObject,
prepareArguments: prepareQuestionArguments, prepareArguments: prepareQuestionArguments,
executionMode: "sequential", executionMode: "sequential",
@@ -269,13 +351,39 @@ export interface TeacherToolContext extends PlanToolContext {
* future toggleables (question-tool variants, …) need no change here. * future toggleables (question-tool variants, …) need no change here.
* Absent/undefined = enabled — tests and old records see today's set. * Absent/undefined = enabled — tests and old records see today's set.
*/ */
/** The quizzing mechanics (owner 2026-09-27): `tools.explain === true` in
* the resolved settings ⇒ free-recall checks (explain armed, quiz dropped)
* — anything else ⇒ multiple choice, exactly today's set. Mechanics-
* managed, NOT individually toggleable: quiz/explain never pass through
* filterEnabledTools (the 2026-09-19 wedge ruling stands — a quiz-less
* prober collapses its assessment flow). */
export function quizMechanics(enabled?: EnabledTools): "choice" | "free" {
return enabled?.explain === true ? "free" : "choice";
}
/** Strip the mechanics keys so filterEnabledTools can never drop the
* quizzing tools (the derivation above is their only switch). */
function withoutMechanics(enabled?: EnabledTools): EnabledTools | undefined {
if (!enabled) return undefined;
const { explain: _e, quiz: _q, ...rest } = enabled;
return rest;
}
export function createTeacherTools( export function createTeacherTools(
ctx: TeacherToolContext, ctx: TeacherToolContext,
enabled?: EnabledTools, enabled?: EnabledTools,
): Record<string, AgentTool<any>> { ): Record<string, AgentTool<any>> {
// Quizzing mechanics (owner 2026-09-27): free-recall swaps the teach
// quiz for the teach explain — one quizzing tool at a time, whichever
// the settings arm. ask stays always-on.
const mechanics = quizMechanics(enabled);
// Same widening as the prober's (see above) — the conditional spread of
// two differently-typed literals collapses into an unusable union type.
const quizzing: Record<string, AgentTool<any>> =
mechanics === "free" ? { explain: teachExplainToolFor(ctx) } : { quiz: teachQuizToolFor(ctx) };
return filterEnabledTools( return filterEnabledTools(
{ {
quiz: teachQuizToolFor(ctx), ...quizzing,
ask_user_question: askUserQuestionTool, ask_user_question: askUserQuestionTool,
// The learner-filesystem set is always-on (user-decided 2026-09, // The learner-filesystem set is always-on (user-decided 2026-09,
// sources v1): the teacher locates and reads learner-referenced // sources v1): the teacher locates and reads learner-referenced
@@ -292,6 +400,6 @@ export function createTeacherTools(
// reaches jobTools' research gate. // reaches jobTools' research gate.
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }), ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
}, },
enabled, withoutMechanics(enabled),
); );
} }
+4 -2
View File
@@ -85,7 +85,9 @@ export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarri
// duplicates collapsed (several checks on one node = one stay on it) // duplicates collapsed (several checks on one node = one stay on it)
for (const m of messages) { for (const m of messages) {
for (const part of m.parts) { for (const part of m.parts) {
if (part.type !== "tool-quiz") continue; // Either quizzing tool (quiz/explain) tags position — the mechanic
// is a settings switch; the fold recognizes both (owner 2026-09-27).
if (part.type !== "tool-quiz" && part.type !== "tool-explain") continue;
const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId; const nodeId = (part.input as { nodeId?: unknown } | undefined)?.nodeId;
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; // untagged/unknown — moves nothing
if (runs.at(-1) !== nodeId) runs.push(nodeId); if (runs.at(-1) !== nodeId) runs.push(nodeId);
@@ -130,7 +132,7 @@ export function derivePlanStatusesLh(tree: PlanTree, messages: readonly LhMessag
const content = (m as { content?: unknown }).content; const content = (m as { content?: unknown }).content;
if (!Array.isArray(content)) continue; if (!Array.isArray(content)) continue;
for (const block of content as Array<{ type?: string; name?: string; arguments?: unknown }>) { for (const block of content as Array<{ type?: string; name?: string; arguments?: unknown }>) {
if (block.type !== "toolCall" || block.name !== "quiz") continue; if (block.type !== "toolCall" || (block.name !== "quiz" && block.name !== "explain")) continue;
const nodeId = (block.arguments as { nodeId?: unknown } | undefined)?.nodeId; const nodeId = (block.arguments as { nodeId?: unknown } | undefined)?.nodeId;
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue; if (typeof nodeId !== "string" || !ids.has(nodeId)) continue;
if (runs.at(-1) !== nodeId) runs.push(nodeId); if (runs.at(-1) !== nodeId) runs.push(nodeId);
+8 -1
View File
@@ -39,7 +39,7 @@ export interface PendingToolCall {
} }
/** Tools that render in the question dock and gate the composer. */ /** Tools that render in the question dock and gate the composer. */
export const QUESTION_TOOLS = ["quiz", "ask_user_question"] as const; export const QUESTION_TOOLS = ["quiz", "ask_user_question", "explain"] as const;
/** Tools fulfilled by background sub-agent jobs (W2/W6). */ /** Tools fulfilled by background sub-agent jobs (W2/W6). */
export const JOB_TOOLS = ["research", "demo", "visual"] as const; export const JOB_TOOLS = ["research", "demo", "visual"] as const;
@@ -62,6 +62,13 @@ export const JOB_TOOLS = ["research", "demo", "visual"] as const;
* 2026-09). */ * 2026-09). */
export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const; export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
/** The keys the settings WRITE side accepts in agent.tools: the plain
* toggleables PLUS the quizzing-mechanics switch (`explain: true` arms
* free-recall quizzing and drops the MC quiz — see quizMechanics in
* agent/tools.ts; owner 2026-09-27). `quiz` itself stays unwritable:
* mechanics-managed, never individually toggleable. */
export const WRITABLE_TOOL_KEYS = [...TOGGLEABLE_TOOLS, "explain"] as const;
/** The resolved settings → factory seam shape (Platform wires chat-handler /** The resolved settings → factory seam shape (Platform wires chat-handler
* to pass this through from the settings record; storage and defaults are * to pass this through from the settings record; storage and defaults are
* Platform's lane). JSON-native: a partial record keyed by tool name. * Platform's lane). JSON-native: a partial record keyed by tool name.
+8 -2
View File
@@ -83,7 +83,13 @@ function advance(
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") { if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
phase = "plan"; phase = "plan";
} }
if (phase === "plan" && part.type === "tool-quiz" && taggedNodeId(part.input)) { // plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
// explain, whichever mechanic the settings armed (both carry nodeId).
if (
phase === "plan" &&
(part.type === "tool-quiz" || part.type === "tool-explain") &&
taggedNodeId(part.input)
) {
phase = "teach"; phase = "teach";
} }
} }
@@ -136,7 +142,7 @@ export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number):
for (const block of content) { for (const block of content) {
if ( if (
block.type === "toolCall" && block.type === "toolCall" &&
block.name === "quiz" && (block.name === "quiz" || block.name === "explain") &&
taggedNodeId(block.arguments) taggedNodeId(block.arguments)
) { ) {
phase = "teach"; phase = "teach";
+89 -4
View File
@@ -217,6 +217,68 @@ export const quizInputObject = Type.Object({
* it ran at the call boundary, where quizInputProblems runs now). */ * it ran at the call boundary, where quizInputProblems runs now). */
export const probeQuizInputSchema = quizInputBase; export const probeQuizInputSchema = quizInputBase;
// ─── the explain tool (free-recall quizzing, owner 2026-09-27) ─────────────
//
// Quiz mechanics WITHOUT options: the learner answers in his own words.
// The SYCOPHANCY MITIGATION is structural — the model commits to the full
// correct answer BEFORE posing (the commitment rides the persisted call
// args, is revealed to the learner on submit, and is restated in the answer
// result the model grades against; the client never grades free text).
// Mechanics doctrine lives in the TOOL DESCRIPTIONS (owner ruling
// 2026-09-27: pi-native — doctrine travels with the armed tool), not in
// the system prompt.
export const explainInputBase = Type.Object({
question: Type.String({
description:
"The single question to ask. ONE question per turn: ask, then end your turn — the answer determines the next.",
}),
explanation: Type.String({
description:
"REQUIRED. Explanation revealed AFTER the learner answers (shown however he did). It is written BEFORE " +
"correctAnswer by design: reason it out first, then commit to the answer the reasoning supports.",
}),
correctAnswer: Type.String({
description:
"REQUIRED. The full correct answer in your own words — COMMITTED before the learner ever sees the question. " +
"The commitment is immutable: when his answer returns, grade it strictly against this text — never revise the " +
"commitment after seeing his words, and never mark a wrong answer right to be kind. Both this and the explanation " +
"are shown to him on submit; your next reply grades the nuance of what he wrote.",
}),
details: Type.Optional(
Type.String({ description: "Optional extra context or instructions shown under the question." }),
),
});
/** The TEACH-side explain object: base + the plan-position field (same
* semantics as the quiz's nodeId — the seam recognizes BOTH tools). */
export const explainInputObject = Type.Object({
...explainInputBase.properties,
nodeId: Type.String({
description:
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
"The lesson's position on the map derives from these tags (the node the latest tagged question is about is " +
"where the map stands; earlier ones count as visited). A node takes as many questions as it deserves: stay on " +
"one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward. " +
"For off-map checks use ask_user_question.",
}),
});
/** The explain authoring contract: the commitment must exist. Empty
* result = an explain the dock can arm and the model can grade against. */
export function explainInputProblems(input: { correctAnswer: string }): string[] {
if (input.correctAnswer.trim().length === 0) {
return ["correctAnswer is required — commit to the full correct answer BEFORE posing the question."];
}
return [];
}
/** The tool names that carry the quizzing mechanic — the teach seam
* (phases), the plan-position fold, and the pending gate all recognize
* whichever is armed. */
export const QUIZ_TOOL_NAMES = ["quiz", "explain"] as const;
/** The TEACHER's quiz declaration (base + the position field). */ /** The TEACHER's quiz declaration (base + the position field). */
export const quizInputSchema = quizInputObject; export const quizInputSchema = quizInputObject;
@@ -249,6 +311,8 @@ export const askInputSchema = Type.Object({
export type QuizToolInput = Static<typeof quizInputObject>; export type QuizToolInput = Static<typeof quizInputObject>;
export type AskToolInput = Static<typeof askInputSchema>; export type AskToolInput = Static<typeof askInputSchema>;
export type ExplainToolInput = Static<typeof explainInputObject>;
export type ProbeExplainToolInput = Static<typeof explainInputBase>;
/** A real selected answer, in the order the options were displayed. */ /** A real selected answer, in the order the options were displayed. */
@@ -258,7 +322,7 @@ export interface OptionAnswer {
index: number; // 1-based display index index: number; // 1-based display index
} }
export type QuestionKind = "quiz" | "ask"; export type QuestionKind = "quiz" | "ask" | "explain";
export type SelectMode = "single-select" | "multi-select"; export type SelectMode = "single-select" | "multi-select";
export type QuestionMode = SelectMode | "text"; export type QuestionMode = SelectMode | "text";
@@ -272,6 +336,8 @@ export interface QuestionData {
options: QuizOption[]; // display order (post-shuffle) options: QuizOption[]; // display order (post-shuffle)
// quiz only. NEVER send to the client before answering: // quiz only. NEVER send to the client before answering:
correctIndices?: number[]; correctIndices?: number[];
// explain only. NEVER send to the client before answering:
committedAnswer?: string;
explanation?: string; explanation?: string;
createdAt: string; createdAt: string;
} }
@@ -285,11 +351,12 @@ export interface QuestionAnswerData {
dontKnow: boolean; dontKnow: boolean;
answers: OptionAnswer[]; answers: OptionAnswer[];
other?: string; // ask: "Other" free text other?: string; // ask: "Other" free text
text?: string; // ask: text-mode answer text?: string; // ask/explain: text-mode answer
note?: string; note?: string;
correct?: boolean; // quiz only correct?: boolean; // quiz only
correctIndices?: number[]; // quiz only (rendering) correctIndices?: number[]; // quiz only (rendering)
explanation?: string; // quiz only (rendering) committed?: string; // explain only (rendering): the model's committed answer
explanation?: string; // quiz/explain only (rendering)
dismissed?: boolean; dismissed?: boolean;
answeredAt: string; answeredAt: string;
} }
@@ -526,12 +593,18 @@ export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswe
base.explanation = qd.explanation; base.explanation = qd.explanation;
base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []); base.correct = base.dontKnow ? false : isCorrect(base.answers.map((a) => a.index), qd.correctIndices ?? []);
} }
if (qd.kind === "explain") {
// No client grading — the model grades in its reply, against the
// commitment. Snapshots ride along for the reveal + rendering.
base.committed = qd.committedAnswer;
base.explanation = qd.explanation;
}
return base; return base;
} }
/** The user message that carries the answer into the agent's context. */ /** The user message that carries the answer into the agent's context. */
export function formatAnswerMessage(ad: QuestionAnswerData): string { export function formatAnswerMessage(ad: QuestionAnswerData): string {
const label = ad.kind === "quiz" ? "Quiz answer" : "Answer to your question"; const label = ad.kind === "quiz" ? "Quiz answer" : ad.kind === "explain" ? "Explain answer" : "Answer to your question";
const head = `[${label}]`; const head = `[${label}]`;
const lines: string[] = [head]; const lines: string[] = [head];
@@ -553,6 +626,18 @@ export function formatAnswerMessage(ad: QuestionAnswerData): string {
lines.push(`Result: ${verdict}`); lines.push(`Result: ${verdict}`);
} }
lines.push(`Correct: ${correctStr}`); lines.push(`Correct: ${correctStr}`);
} else if (ad.kind === "explain") {
if (ad.dontKnow) {
lines.push(
`The learner selected "I don't know" — they did NOT attempt an answer (a genuine knowledge gap, not a wrong guess).`,
);
} else {
lines.push(`The learner answered: ${ad.text ?? "(no answer)"}`);
}
if (ad.committed) {
lines.push(`Your committed correct answer (immutable — grade against it, never revise it): ${ad.committed}`);
}
lines.push("Grade his answer strictly against your committed answer in your reply: name what he got and what he missed.");
} else { } else {
if (ad.text) { if (ad.text) {
lines.push(`The learner answered: ${ad.text}`); lines.push(`The learner answered: ${ad.text}`);
+12 -1
View File
@@ -29,7 +29,7 @@
import { Value } from "typebox/value"; import { Value } from "typebox/value";
import type { TSchema } from "typebox"; import type { TSchema } from "typebox";
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, type AskToolInput, type QuizToolInput } from "./question-core.ts"; import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, explainInputBase, explainInputProblems, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
import { restoreMathEscapes } from "./math-delims.ts"; import { restoreMathEscapes } from "./math-delims.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -103,6 +103,17 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
return cleaned; return cleaned;
} }
/** Explain parse for DISPLAY: the shared authoring base (the teacher's
* nodeId is an unknown key Value.Clean strips, same as the quiz seam) —
* both explain variants share the same fields, and the commitment
* refinement (explainInputProblems) mirrors the boundary chain. */
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
const cleaned = boundaryParse<ExplainToolInput>(explainInputBase, restoreMathEscapesDeep(input));
if (!cleaned) return undefined;
if (explainInputProblems(cleaned).length > 0) return undefined;
return cleaned;
}
/** probe_done parse — the core-owned schema the tool itself declares. */ /** probe_done parse — the core-owned schema the tool itself declares. */
export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined { export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined {
return boundaryParse<ProbeDoneInput>(probeDoneInputSchema, input); return boundaryParse<ProbeDoneInput>(probeDoneInputSchema, input);
+6 -5
View File
@@ -23,7 +23,7 @@ import type {
RoleModelOverride, RoleModelOverride,
} from "../core/settings.ts"; } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
import { TOGGLEABLE_TOOLS } from "../core/pending.ts"; import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */ /** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult<T> = export type SettingsBodyResult<T> =
@@ -157,10 +157,11 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult<AgentS
if (tools.map) { if (tools.map) {
// Unknown tool keys are rejected at WRITE time (user decision // Unknown tool keys are rejected at WRITE time (user decision
// 2026-09-19): the delta is inert while unread, but once the bridge // 2026-09-19): the delta is inert while unread, but once the bridge
// honors it a persisted `quiz: false` would genuinely drop the quiz — // honors it a persisted bogus key would drift. The writable set is
// and the definitive list is the adjudicated job-tools-only set. // the job tools + the quizzing-mechanics switch (explain, owner
// The read-side sanitizer stays lenient for forward-compat. // 2026-09-27). The read-side sanitizer stays lenient for
const supported = TOGGLEABLE_TOOLS as readonly string[]; // forward-compat.
const supported = WRITABLE_TOOL_KEYS as readonly string[];
for (const key of Object.keys(tools.map)) { for (const key of Object.keys(tools.map)) {
if (!supported.includes(key)) { if (!supported.includes(key)) {
return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` }; return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` };
+82
View File
@@ -26,6 +26,7 @@ import {
type FauxResponseStep, type FauxResponseStep,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createTeacherTools } from "../src/agent/tools.ts";
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts"; import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
import { effectiveSettings } from "../src/server/stores.ts"; import { effectiveSettings } from "../src/server/stores.ts";
import { isTurnRunning } from "../src/server/turn-liveness.ts"; import { isTurnRunning } from "../src/server/turn-liveness.ts";
@@ -1050,3 +1051,84 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
} }
}); });
}); });
// ─── the explain mechanic end-to-end (free-recall, owner 2026-09-27) ───────
it("explain tool call persists as a pending part; the free-text answer reveals the commitment and reaches the model", async () => {
const EXPLAIN_INPUT = {
question: "Explain why Mercury is hard to observe.",
explanation: "It never strays far from the Sun in our sky.",
correctAnswer: "Its small angular separation from the Sun.",
nodeId: "planet-order",
};
const { engine, calls } = scriptedTeacher((cs) => [
(context) => {
cs.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
return fauxAssistantMessage(
[fauxThinking("Free recall this time."), fauxToolCall("explain", EXPLAIN_INPUT, { id: "call-1" })],
{ stopReason: "toolUse" },
);
},
(context) => {
cs.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
return fauxAssistantMessage([fauxText("Right — it hugs the Sun.")]);
},
]);
const chat = createConfiguredChat("Test: explain mechanic");
createdChats.push(chat.id);
// Free-recall armed: the mechanic swap is the ONLY change to the registry.
const tools = createTeacherTools(
{ chatId: chat.id, chatStore, jobStore, spawn: () => {} },
{ explain: true },
);
assert.ok(tools.explain && !tools.quiz, "explain armed, quiz dropped — one quizzing tool");
const factory: Parameters<typeof handleChatRequest>[1] = (opts) =>
createTeacherAgent(undefined, { engine, messages: opts.messages, tools });
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), factory));
const saved1 = chatStore.getChat(chat.id)!;
const savedContent1 = (saved1.messages[1] as { content: unknown }).content as Array<{ type: string; name?: string }>;
const call = savedContent1.find((b) => b.type === "toolCall" && b.name === "explain");
assert.ok(call, "explain tool call persisted");
const pending = danglingQuestionOf(saved1.messages);
assert.equal(pending?.tool, "explain");
assert.deepEqual(pending?.input, EXPLAIN_INPUT);
const view1 = projectView(saved1.messages);
assert.ok(view1[1]!.parts.some((p) => p.type === "tool-explain"), "tool-explain part projected — the dock arms");
// The client answers in his own words (the dock's buildAnswer seam):
const qd: QuestionData = {
id: "call-1",
kind: "explain",
question: EXPLAIN_INPUT.question,
mode: "text",
options: [],
committedAnswer: EXPLAIN_INPUT.correctAnswer,
explanation: EXPLAIN_INPUT.explanation,
createdAt: new Date().toISOString(),
};
const answer = buildAnswer(qd, { text: "It stays near the Sun." });
assert.equal(answer.correct, undefined, "no client grading — the model grades");
await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer }), factory));
const saved2 = chatStore.getChat(chat.id)!;
const view2 = projectView(saved2.messages);
const part = view2[1]!.parts.find((p) => p.type === "tool-explain");
assert.equal((part as { state?: string }).state, "output-available");
const out = (part as { output?: { committed?: string; explanation?: string } }).output!;
assert.equal(out.committed, EXPLAIN_INPUT.correctAnswer, "the commitment snapshots into the answer record");
assert.equal(out.explanation, EXPLAIN_INPUT.explanation);
// The model's continuation saw the answer JSON (fork 1: the answer IS
// the call's tool result) — his text AND the commitment snapshot ride it.
const seen = calls[1]!.join("\n");
assert.match(seen, /It stays near the Sun\./);
assert.match(seen, /committed/);
assert.match(seen, /small angular separation/);
assert.equal(danglingQuestionOf(saved2.messages), null, "no pending question remains");
});
+29
View File
@@ -181,3 +181,32 @@ test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; pla
]), ]),
); );
}); });
// ─── the explain mechanic at the seam (owner 2026-09-27) ───────────────────
// Both quizzing tools (quiz OR explain, whichever the settings armed) open
// teaching — the seam is structural, not name-bound to the MC tool.
test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => {
const done = toolResult("probe_done");
const explainCall = (args: Record<string, unknown>): LhMessage =>
({
id: "a_explain",
role: "assistant",
content: [{ type: "toolCall", id: "call_explain", name: "explain", arguments: args }],
timestamp: Date.now(),
}) as unknown as LhMessage;
const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" });
const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged explain never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged explain CALL is the seam");
assert.equal(
phaseAt3Lh([done, tagged]),
phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] },
]),
"the two folds agree on the explain mechanic",
);
});
+44
View File
@@ -405,3 +405,47 @@ describe("derivePlanStatuses (the map tracks position, not completion)", () => {
assert.equal(derivedNodeStatus(later, "c"), "upcoming"); assert.equal(derivedNodeStatus(later, "c"), "upcoming");
}); });
}); });
// ─── the explain mechanic derives position identically (owner 2026-09-27) ──
describe("derivePlanStatuses: tagged explain calls move the map like quiz calls", () => {
const tree: PlanTree = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
],
edges: [{ from: "a", to: "b" }],
version: 1,
};
const explainPart = (nodeId: string | undefined) => ({
type: "tool-explain",
state: "output-available",
input: nodeId === undefined ? {} : { nodeId },
});
type Part = { type: string; state: string; input: unknown; output?: unknown };
const msg = (...parts: Part[]) => ({ parts });
it("a tagged explain call is the current position; earlier ones are visited", () => {
const st = derivePlanStatuses(tree, [
msg(explainPart("a")),
msg(explainPart("b")),
]);
assert.equal(st.current, "b");
assert.deepEqual([...st.visited], ["a"]);
});
it("untagged explain calls move nothing", () => {
const st = derivePlanStatuses(tree, [msg(explainPart(undefined))]);
assert.equal(st.current, null);
assert.equal(st.visited.size, 0);
});
it("mixed mechanics (an old quiz record, then an explain) still derive one run", () => {
const st = derivePlanStatuses(tree, [
msg({ type: "tool-quiz", state: "output-available", input: { nodeId: "a" } }),
msg(explainPart("b")),
]);
assert.equal(st.current, "b");
assert.deepEqual([...st.visited], ["a"]);
});
});
+71
View File
@@ -20,6 +20,9 @@ import {
QUESTION_ANSWERED_ENTRY, QUESTION_ANSWERED_ENTRY,
QUESTION_ENTRY, QUESTION_ENTRY,
quizInputProblems, quizInputProblems,
explainInputBase,
explainInputObject,
explainInputProblems,
quizInputSchema, quizInputSchema,
probeQuizInputSchema, probeQuizInputSchema,
resolveCorrect, resolveCorrect,
@@ -543,3 +546,71 @@ describe("dock selection (toggleOption / toggleOther)", () => {
assert.deepEqual([...sel.selected], [1]); assert.deepEqual([...sel.selected], [1]);
}); });
}); });
// ─── the explain tool: free-recall quizzing (owner 2026-09-27) ─────────────
describe("explainInputBase / explainInputProblems (the commitment contract)", () => {
it("commits the argument order: explanation (reasoning) BEFORE correctAnswer (the pick)", () => {
// Same ruling as the quiz (2026-09-25): models emit tool args in
// schema order — reasoning first, then the answer it supports.
const keys = Object.keys(explainInputBase.properties);
assert.ok(keys.indexOf("explanation") < keys.indexOf("correctAnswer"));
});
it("the teach object adds the required nodeId AFTER the base fields", () => {
const keys = Object.keys(explainInputObject.properties);
assert.deepEqual(keys, [...Object.keys(explainInputBase.properties), "nodeId"]);
});
it("an empty commitment is the one authoring problem", () => {
assert.deepEqual(explainInputProblems({ correctAnswer: "Full speed is c." }), []);
assert.deepEqual(explainInputProblems({ correctAnswer: " " }), [
"correctAnswer is required — commit to the full correct answer BEFORE posing the question.",
]);
});
});
describe("buildAnswer / formatAnswerMessage: explain", () => {
const explainQD = (overrides?: Partial<QuestionData>): QuestionData => ({
id: "e1",
kind: "explain",
question: "Why does the Michelson-Morley null result matter?",
mode: "text",
options: [],
committedAnswer: "It ruled out the luminiferous aether and set up special relativity.",
explanation: "A stationary aether would produce a fringe shift; none was observed.",
createdAt: new Date().toISOString(),
...overrides,
});
it("snapshots the commitment + explanation; the client NEVER grades free text", () => {
const ad = buildAnswer(explainQD(), { text: "Something about light speed being constant?" });
assert.equal(ad.correct, undefined, "no client verdict — the model grades in its reply");
assert.equal(ad.correctIndices, undefined);
assert.equal(ad.committed, "It ruled out the luminiferous aether and set up special relativity.");
assert.equal(ad.text, "Something about light speed being constant?");
});
it("dontKnow records the gap with no attempted text", () => {
// The DOCK never sends text alongside dontKnow (its submit gates it);
// buildAnswer stays a pure mapping of the request it receives.
const ad = buildAnswer(explainQD(), { dontKnow: true });
assert.equal(ad.dontKnow, true);
assert.equal(ad.text, undefined);
});
it("formats the model's view: his answer, the immutable commitment, and the grading instruction", () => {
const text = formatAnswerMessage(buildAnswer(explainQD(), { text: "No idea about aether." }));
assert.ok(text.startsWith("[Explain answer]"));
assert.match(text, /The learner answered: No idea about aether\./);
assert.match(text, /Your committed correct answer \(immutable/);
assert.match(text, /luminiferous aether/);
assert.match(text, /Grade his answer strictly/);
});
it("formats dontKnow as a gap, not a guess", () => {
const text = formatAnswerMessage(buildAnswer(explainQD(), { dontKnow: true }));
assert.match(text, /did NOT attempt/);
assert.ok(!text.includes("The learner answered:"), "no phantom answer beside the gap");
});
});
+96 -3
View File
@@ -20,6 +20,8 @@ import {
createProberTools, createProberTools,
createTeacherTools, createTeacherTools,
probeQuizTool, probeQuizTool,
probeExplainTool,
teachExplainToolFor,
teachQuizToolFor, teachQuizToolFor,
type TeacherToolContext, type TeacherToolContext,
} from "../src/agent/tools.ts"; } from "../src/agent/tools.ts";
@@ -228,10 +230,16 @@ describe("tool toggles (core/pending.ts + factories)", () => {
const noAsk = createProberTools(ctx, { ask_user_question: false }); const noAsk = createProberTools(ctx, { ask_user_question: false });
assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]); assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]);
// quiz off is honored by the generic machinery too (future toggleable), // Quizzing is mechanics-managed now (owner 2026-09-27): quiz/explain
// but probe_done is never droppable — it is how the phase ends. // NEVER pass through the generic filter — hand-written quiz:false is a
// no-op (the wedge danger stands: a quiz-less prober collapses its
// assessment flow). probe_done is never droppable — it ends the phase.
const noQuiz = createProberTools(ctx, { quiz: false }); const noQuiz = createProberTools(ctx, { quiz: false });
assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "read", "research"]); assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]);
// The mechanic switch (owner 2026-09-27): tools.explain === true arms
// free-recall — explain in, quiz out, exactly one quizzing tool.
const free = createProberTools(ctx, { explain: true });
assert.deepEqual(Object.keys(free).sort(), ["ask_user_question", "explain", "find", "grep", "ls", "pdf", "probe_done", "read", "research"]);
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on"); assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
}); });
@@ -255,6 +263,16 @@ describe("tool toggles (core/pending.ts + factories)", () => {
full, full,
"absent keys mean enabled — old records behave exactly as today", "absent keys mean enabled — old records behave exactly as today",
); );
// The mechanic switch (owner 2026-09-27): free-recall swaps the teach
// quiz for the teach explain — one quizzing tool, whichever is armed.
const freeFull = ["ask_user_question", "demo", "explain", "find", "grep", "ls", "pdf", "plan_init", "read", "research", "visual"];
assert.deepEqual(Object.keys(createTeacherTools(ctx, { explain: true })).sort(), freeFull);
assert.deepEqual(
Object.keys(createTeacherTools(ctx, { explain: true, research: false })).sort(),
freeFull.filter((t) => t !== "research"),
"mechanics and plain toggles compose",
);
}); });
}); });
@@ -293,3 +311,78 @@ describe("prepareQuestionArguments (the string-encoded options wobble)", () => {
assert.equal(prepare(unparseable), unparseable, "unparseable strings pass through — validation reports honestly"); assert.equal(prepare(unparseable), unparseable, "unparseable strings pass through — validation reports honestly");
}); });
}); });
// ─── the explain tool boundary (free-recall, owner 2026-09-27) ─────────────
describe("teachExplainToolFor: the explain variant validates nodeId like the quiz", () => {
const validExplain = {
question: "Explain why the null result matters.",
explanation: "A stationary aether predicts a fringe shift.",
correctAnswer: "It ruled out the aether.",
nodeId: "arith",
};
it("accepts a nodeId matching a tree node", async () => {
const chatId = newChat("Explain nodeId: valid");
chatStore.saveTree(chatId, {
nodes: [
{ id: "arith", title: "Arithmetic" },
{ id: "expressions", title: "Expressions" },
],
edges: [{ from: "arith", to: "expressions" }],
version: 1,
});
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), {
...validExplain,
nodeId: "expressions",
});
assert.equal(res.success, true);
});
it("rejects an off-map nodeId with the slugs listed", async () => {
const chatId = newChat("Explain nodeId: typo");
chatStore.saveTree(chatId, { nodes: [{ id: "arith", title: "A" }], edges: [], version: 1 });
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), {
...validExplain,
nodeId: "arithmetic",
});
assert.equal(res.success, false);
assert.match(problemLines(res.error).join("\n"), /matches no node/);
});
it("no tree yet: rejected — checks wait for plan_init", async () => {
const chatId = newChat("Explain nodeId: no tree");
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), validExplain);
assert.equal(res.success, false);
assert.match(problemLines(res.error).at(-1)!, /No plan map exists yet — call plan_init/);
});
it("an empty commitment is rejected at the boundary", async () => {
const chatId = newChat("Explain: empty commitment");
chatStore.saveTree(chatId, { nodes: [{ id: "arith", title: "A" }], edges: [], version: 1 });
const res = await boundaryParse(teachExplainToolFor({ chatId, chatStore }), {
...validExplain,
correctAnswer: " ",
});
assert.equal(res.success, false);
assert.match(problemLines(res.error).join("\n"), /correctAnswer is required/);
});
});
describe("the prober's explain: no nodeId field at all", () => {
it("the probe schema carries no nodeId; the commitment contract still binds", async () => {
assert.ok(!("nodeId" in probeExplainTool.parameters.properties));
const res = await boundaryParse(probeExplainTool, {
question: "Explain what a derivative measures.",
explanation: "It is instantaneous rate of change.",
correctAnswer: "The instantaneous rate of change.",
});
assert.equal(res.success, true);
const empty = await boundaryParse(probeExplainTool, {
question: "q",
explanation: "e",
correctAnswer: " ",
});
assert.equal(empty.success, false);
});
});
+36
View File
@@ -457,3 +457,39 @@ describe("probe-done card", () => {
assert.match(html, /Writing the assessment/); assert.match(html, /Writing the assessment/);
}); });
}); });
// ─── the explain reveal (free-recall, owner 2026-09-27) ────────────────────
describe("AnsweredCard: the explain variant", () => {
const explainQD = (): QuestionData => ({
id: "e1",
kind: "explain",
question: "Explain why the null result matters.",
mode: "text",
options: [],
committedAnswer: "It ruled out the luminiferous aether.",
explanation: "A stationary aether predicts a fringe shift; none was seen.",
createdAt: new Date().toISOString(),
});
it("reveals the committed answer + explanation with NO verdict chips", () => {
const html = renderToStaticMarkup(<AnsweredCard answer={buildAnswer(explainQD(), { text: "Something about relativity?" })} />);
assert.match(html, /Explain/, "labeled as the explain kind");
assert.match(html, /The answer:/);
assert.match(html, /luminiferous aether/);
assert.match(html, /Why:/);
assert.ok(!html.includes("incorrect") && !html.includes("correct</") , "no client grading");
assert.ok(!html.includes('data-mark="x"') && !html.includes('data-mark="check"'));
});
it("renders his free text as the answer", () => {
const html = renderToStaticMarkup(<AnsweredCard answer={buildAnswer(explainQD(), { text: "Something about relativity?" })} />);
assert.match(html, /Something about relativity\?/);
});
it("dontKnow renders as the gap, not a wrong answer", () => {
const html = renderToStaticMarkup(<AnsweredCard answer={buildAnswer(explainQD(), { dontKnow: true })} />);
assert.match(html, /I don&#x27;t know/);
assert.ok(!html.includes("The learner answered:"));
});
});
+17
View File
@@ -14,6 +14,7 @@ import {
composeSearchEngine, composeSearchEngine,
composeSlotModel, composeSlotModel,
composeTool, composeTool,
composeQuizMechanics,
researchToolUnavailable, researchToolUnavailable,
sectionedSegments, sectionedSegments,
} from "../components/settings/agent-section.tsx"; } from "../components/settings/agent-section.tsx";
@@ -388,3 +389,19 @@ describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
assert.equal(PROMPT_IN_USE_HINT, "Your version is in use"); assert.equal(PROMPT_IN_USE_HINT, "Your version is in use");
}); });
}); });
// ─── the quizzing-mechanics segmented control (owner 2026-09-27) ───────────
describe("composeQuizMechanics (the segmented control's write)", () => {
it("free recall writes explain:true; multiple choice writes explain:false — nothing else moves", () => {
const before: AgentSettings = { tools: { research: false }, prompts: { teacher: true } } as AgentSettings;
const free = composeQuizMechanics(before, "free");
assert.equal(free.tools?.explain, true);
const choice = composeQuizMechanics(before, "choice");
assert.equal(choice.tools?.explain, false);
for (const after of [free, choice]) {
assert.equal(after.tools?.research, false, "the research toggle carries verbatim");
assert.deepEqual(after.prompts, before.prompts, "the prompts map carries verbatim");
}
});
});
+40
View File
@@ -16,6 +16,7 @@ import {
parseAskInput, parseAskInput,
parseProbeDoneInput, parseProbeDoneInput,
parseQuizInput, parseQuizInput,
parseExplainInput,
} from "../src/core/tool-parts.ts"; } from "../src/core/tool-parts.ts";
import { quizInputSchema, probeQuizInputSchema } from "../src/core/question-core.ts"; import { quizInputSchema, probeQuizInputSchema } from "../src/core/question-core.ts";
import { probeDoneInputSchema } from "../src/core/contracts/probe.ts"; import { probeDoneInputSchema } from "../src/core/contracts/probe.ts";
@@ -266,3 +267,42 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?"); assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?");
}); });
}); });
// ─── parseExplainInput (free-recall display parse, owner 2026-09-27) ───────
describe("parseExplainInput: the dock's display seam for explain calls", () => {
const good = {
question: "Explain why Mercury is hard to observe.",
explanation: "It never strays far from the Sun in our sky.",
correctAnswer: "Its small angular separation from the Sun.",
nodeId: "planet-order", // the teacher's field — STRIPPED by Clean, like quiz
details: "Think about where it appears at dusk.",
};
it("parses a valid call and strips the teacher-only nodeId", () => {
const parsed = parseExplainInput(good)!;
assert.equal(parsed.question, "Explain why Mercury is hard to observe.");
assert.equal(parsed.correctAnswer, "Its small angular separation from the Sun.");
assert.equal(parsed.details, "Think about where it appears at dusk.");
assert.ok(!("nodeId" in parsed), "the teacher-only key is cleaned away");
});
it("an empty commitment never arms the dock", () => {
assert.equal(parseExplainInput({ ...good, correctAnswer: " " }), undefined);
});
it("restores LaTeX escape debris inside math spans (the pi-ai streaming wobble)", () => {
// \f of \forall was eaten by the lenient args parse: U+000C + "orall".
const dirty = "Because \( orall x\) holds.";
const parsed = parseExplainInput({ ...good, correctAnswer: dirty })!;
assert.equal(parsed.correctAnswer, "Because \(\forall x\) holds.");
});
it("wrong-shape input never arms the dock (numeric strings DO coerce — the seam's leniency)", () => {
assert.equal(parseExplainInput("just a string"), undefined);
assert.equal(parseExplainInput({ explanation: "e", correctAnswer: "a" }), undefined, "missing question");
assert.equal(parseExplainInput({ question: "q", explanation: "e", correctAnswer: { nested: true } }), undefined);
// 42 → "42": the same numeric coercion the quiz seam pins.
assert.equal(parseExplainInput({ question: "q", explanation: "e", correctAnswer: 42 })?.correctAnswer, "42");
});
});
+7 -1
View File
@@ -81,10 +81,16 @@ describe("parseAgentSettingsBody: tool keys validate at write time", () => {
assert.deepEqual(r.ok && r.settings.tools, { research: false, pdf: false }); assert.deepEqual(r.ok && r.settings.tools, { research: false, pdf: false });
}); });
it("the quizzing-mechanics key (explain) is writable (owner 2026-09-27)", () => {
const r = parse({ explain: true });
assert.equal(r.ok, true);
assert.deepEqual(r.ok && r.settings.tools, { explain: true });
});
it("unknown keys are rejected with the supported list (the quiz wedge never persists)", () => { it("unknown keys are rejected with the supported list (the quiz wedge never persists)", () => {
const r = parse({ quiz: false }); const r = parse({ quiz: false });
assert.equal(r.ok, false); assert.equal(r.ok, false);
assert.match(!r.ok && r.error, /unknown tool "quiz" \(supported: research, demo, visual, pdf\)/); assert.match(!r.ok && r.error, /unknown tool "quiz" \(supported: research, demo, visual, pdf, explain\)/);
const typo = parse({ reasearch: false }); const typo = parse({ reasearch: false });
assert.equal(typo.ok, false); assert.equal(typo.ok, false);
assert.match(!typo.ok && typo.error, /unknown tool "reasearch"/); assert.match(!typo.ok && typo.error, /unknown tool "reasearch"/);