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:
@@ -26,6 +26,7 @@ import {
|
||||
type FauxResponseStep,
|
||||
} from "@earendil-works/pi-ai";
|
||||
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 { effectiveSettings } from "../src/server/stores.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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -405,3 +405,47 @@ describe("derivePlanStatuses (the map tracks position, not completion)", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
QUESTION_ANSWERED_ENTRY,
|
||||
QUESTION_ENTRY,
|
||||
quizInputProblems,
|
||||
explainInputBase,
|
||||
explainInputObject,
|
||||
explainInputProblems,
|
||||
quizInputSchema,
|
||||
probeQuizInputSchema,
|
||||
resolveCorrect,
|
||||
@@ -543,3 +546,71 @@ describe("dock selection (toggleOption / toggleOther)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
createProberTools,
|
||||
createTeacherTools,
|
||||
probeQuizTool,
|
||||
probeExplainTool,
|
||||
teachExplainToolFor,
|
||||
teachQuizToolFor,
|
||||
type TeacherToolContext,
|
||||
} from "../src/agent/tools.ts";
|
||||
@@ -228,10 +230,16 @@ describe("tool toggles (core/pending.ts + factories)", () => {
|
||||
const noAsk = createProberTools(ctx, { ask_user_question: false });
|
||||
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),
|
||||
// but probe_done is never droppable — it is how the phase ends.
|
||||
// Quizzing is mechanics-managed now (owner 2026-09-27): quiz/explain
|
||||
// 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 });
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -255,6 +263,16 @@ describe("tool toggles (core/pending.ts + factories)", () => {
|
||||
full,
|
||||
"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");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,3 +457,39 @@ describe("probe-done card", () => {
|
||||
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't know/);
|
||||
assert.ok(!html.includes("The learner answered:"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
composeSearchEngine,
|
||||
composeSlotModel,
|
||||
composeTool,
|
||||
composeQuizMechanics,
|
||||
researchToolUnavailable,
|
||||
sectionedSegments,
|
||||
} 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");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseAskInput,
|
||||
parseProbeDoneInput,
|
||||
parseQuizInput,
|
||||
parseExplainInput,
|
||||
} from "../src/core/tool-parts.ts";
|
||||
import { quizInputSchema, probeQuizInputSchema } from "../src/core/question-core.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?");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,10 +81,16 @@ describe("parseAgentSettingsBody: tool keys validate at write time", () => {
|
||||
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)", () => {
|
||||
const r = parse({ quiz: 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 });
|
||||
assert.equal(typo.ok, false);
|
||||
assert.match(!typo.ok && typo.error, /unknown tool "reasearch"/);
|
||||
|
||||
Reference in New Issue
Block a user