From 1f41fb05a5f977f9a2454396d748c6e0a8e73851 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 9 Oct 2026 07:59:23 +0300 Subject: [PATCH] question tools: phase-visible names, hardened teach schemas, context-slim seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools_fails_2 diagnosis (glm-5.3 dropping trailing required fields): - probe-phase quiz/explain renamed to probe/recall — a shared name carried the probe call shape into teaching (the first teach quiz dropped nodeId); display seams accept the old names forever - teach quiz+explain schemas lead with the required pair (type, nodeId); explain gains the predict/check type and joins the reveal law (checks collapse, predictions arrive open; no-type records render check-style) - plan_init/probe_done results go context-slim (owner, context diet): content = validated nodeIds + binding-id reminder / fixed ack; details keeps the tree for the popup and {ok:true} — serializer-verified that details never reaches the model; failure echoes dropped - the cards carry the tool's own name (Probe/Recall) and the quiz type reads on the label (Prediction/Check) in composer and transcript --- components/answered-card.tsx | 15 +++- components/artifacts/probe-done-card.tsx | 5 +- components/chat.tsx | 17 +++-- components/question-dock.tsx | 42 +++++++++-- src/agent/prompts/teacher/10-planning.md | 2 +- src/agent/tools.ts | 65 +++++++++++------ src/agent/tools/plan.ts | 45 +++++++++--- src/agent/tools/probe.ts | 18 +++-- src/core/contracts/plan.ts | 7 +- src/core/contracts/probe.ts | 11 ++- src/core/pending.ts | 18 +++-- src/core/question-core.ts | 74 +++++++++++++++----- src/core/session-markdown.ts | 16 ++++- src/core/tool-parts.ts | 14 ++-- tests/chat-flow.test.ts | 1 + tests/graph-tools.test.ts | 2 +- tests/plan-tree.test.ts | 18 ++--- tests/question-core.test.ts | 51 ++++++++------ tests/question-tools.test.ts | 15 ++-- tests/render-cards.test.tsx | 89 ++++++++++++++++++++++-- tests/session-markdown.test.ts | 13 ++++ 21 files changed, 411 insertions(+), 127 deletions(-) diff --git a/components/answered-card.tsx b/components/answered-card.tsx index 9afc4c2..b3f2319 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -52,7 +52,20 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { data-testid="answered-card" >
- {isQuiz ? (answer.questionType === "predict" ? "Prediction" : "Quiz") : isExplain ? "Explain" : "Question"} + {/* The tool's OWN name first (owner 2026-10-09): probe-phase + cards label Probe/Recall — the phase boundary visible in the + transcript. No `tool` on old records: kind labels as before. */} + {answer.tool === "probe" + ? "Probe" + : answer.tool === "recall" + ? "Recall" + : isQuiz + ? answer.questionType === "predict" + ? "Prediction" + : "Check" + : isExplain + ? "Explain" + : "Question"} {isQuiz && (answer.dontKnow ? ( didn't know diff --git a/components/artifacts/probe-done-card.tsx b/components/artifacts/probe-done-card.tsx index 14fb59b..1a3363b 100644 --- a/components/artifacts/probe-done-card.tsx +++ b/components/artifacts/probe-done-card.tsx @@ -33,7 +33,10 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn // fallback branch. Output stays a cast: OUR execute authored it. const input = parseProbeDoneInput(part.input); - const assessment = output?.ok ? output.assessment : input?.assessment; + // The assessment is the CALL's input (owner, context diet 2026-10-09): + // the result's echo is gone — details is {ok:true} only, and the refusal + // branch below still reads output.error. + const assessment = input?.assessment; const strands = assessment && Array.isArray(assessment.strands) ? assessment.strands : null; return ( diff --git a/components/chat.tsx b/components/chat.tsx index 50b52cd..f4153f1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -68,7 +68,7 @@ import { phaseTimeline, teachStepIndex, toolQuestionKind, - quizNeedsReveal, + questionNeedsReveal, } from "@/src/core"; import { ReadingThinkingField } from "@/components/reading-thinking"; import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block"; @@ -489,12 +489,13 @@ export function Chat({ // Teach-phase check (W5, vision §2): a pending QUIZ that lands once a // plan node is OCCUPIED (phaseAt3 "teach") starts collapsed behind a // "Check your understanding" reveal button; probe- and plan-phase - // questions auto-open as today. Graded quizzes only (owner, 2026-10-02) - // — ask/explain questions always arrive open. A pending question can - // never precede its phase's seam in the transcript (the teacher's turn + // questions auto-open as today. Both teach quizzing tools obey the + // law (owner, 2026-10-09: explain checks join quiz checks behind the + // reveal); predictions of either mechanic and ask always arrive open. + // A pending question can never precede its phase's seam in the transcript (the teacher's turn // ends at the question call), so the session's current phase IS the // question's phase. - const requiresReveal = pending !== null && quizNeedsReveal(pending.tool, pending.input, phaseAt3(messages)); + const requiresReveal = pending !== null && questionNeedsReveal(pending.tool, pending.input, phaseAt3(messages)); /* The READING-THINKING channel (owner 2026-10-03): a draft field for * thinking while reading the teaching prose, mounted while a check waits @@ -1475,8 +1476,10 @@ export function Chat({ /> ); case "tool-quiz": + case "tool-probe": case "tool-ask_user_question": - case "tool-explain": { + case "tool-explain": + case "tool-recall": { // Pending/streaming questions live in the dock, not the // transcript; answered ones render as graded records. if (part.state === "output-available") { @@ -2265,7 +2268,7 @@ function DockOverlay({ > {pending ? ( @@ -125,12 +135,14 @@ export function QuestionDock({ <> {!expanded && ( - {label ?? (toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question")} + {label ?? + toolLabel ?? + (toolName === "quiz" ? "Check" : toolName === "explain" ? "Explain" : "Question")} {teaserText(question)} @@ -373,7 +391,7 @@ function QuestionDockForm({ // "untouched"); buildAnswer drops it on "I don't know". confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined, }; - onSubmit(buildAnswer(qd, req)); + onSubmit(buildAnswer(qd, req, toolName)); }; /* ENTER SUBMITS FROM ANYWHERE (owner round 6, 2026-10-05): the same @@ -529,7 +547,17 @@ function QuestionDockForm({ long questions short — owner round, 2026-10-05). */}
- {kind === "quiz" ? (qd.questionType === "predict" ? "Prediction" : "Quiz") : kind === "explain" ? "Explain" : "Question"} + {toolName === "probe" + ? "Probe" + : toolName === "recall" + ? "Recall" + : kind === "quiz" + ? qd.questionType === "predict" + ? "Prediction" + : "Check" + : kind === "explain" + ? "Explain" + : "Question"} {qd.mode === "multi-select" && " · select all that apply"}
diff --git a/src/agent/prompts/teacher/10-planning.md b/src/agent/prompts/teacher/10-planning.md index 2c73016..e73c980 100644 --- a/src/agent/prompts/teacher/10-planning.md +++ b/src/agent/prompts/teacher/10-planning.md @@ -30,7 +30,7 @@ The map is a hypothesis about him, and the probe is only its first sketch — th And when the ground shifts mid-lesson — a missing foundation, a confusion deeper than the map assumed — never rush the repair: no wall of text that "covers" the missing ground in one turn to get back on plan. The plan serves his understanding, never the reverse. Update the map if the structure changed (sometimes nothing changed and no rewrite is needed), then build the missing ground the only way anything is built here — one reasoning step at a time, each step landing and verified, at the session's one and only pace. Detours are not interruptions to get through; they are the teaching. A map that can't change is a wrong map. -**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. +**Every quizzing call carries `nodeId` AND `type`** (both required — the quizzing tool is one of `quiz` or `explain` depending on the mechanic the learner chose; a call missing either is rejected): that's what moves the map. `type` is the question's role in the teaching cycle — `predict` before you teach the step, `check` after. 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 diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 576f047..d1c8357 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -108,11 +108,14 @@ export const askUserQuestionTool: AgentTool = { // mechanical delta. /** The quiz mechanics every variant shares; `delta` appends the variant's - * own fields. All mechanics, zero doctrine. */ -function quizDescription(delta: { nodeId: boolean; types: boolean }): string { + * own fields (its NAME among them — the probe-phase tools self-reference + * as probe/recall, owner 2026-10-09). All mechanics, zero doctrine. */ +function quizDescription(delta: { name: "quiz" | "probe"; nodeId: boolean; types: boolean }): string { return ( "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " + - "Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " + + "Unlike ask_user_question (which collects preferences/decisions with no right answer), " + + delta.name + + " always has a " + "correct answer supplied by you, marks the learner's selection right/wrong, reveals the correct answer, and " + "can show an explanation. Options-only: single-select or multi-select, plus an automatic 'I don't know' " + "choice so the learner can signal a genuine gap instead of guessing. An always-present optional thinking " + @@ -124,37 +127,51 @@ function quizDescription(delta: { nodeId: boolean; types: boolean }): string { (delta.nodeId ? "Set `nodeId` (required) to the plan node this question is about — validated at call time against the session's plan map. For off-map checks use ask_user_question. " : "") + - "ONE question per turn: call a single quiz, then end your turn — his answer determines the next." + "ONE question per turn: call a single " + + delta.name + + ", then end your turn — his answer determines the next." ); } /** The explain mechanics (free-recall quizzing): the commitment rule is * the GRADING CONTRACT — mechanics, not doctrine. The softer doctrine - * (near-miss reading, genuine gaps) lives in the shared prompt chunk. */ -function explainDescription(nodeId: boolean): string { + * (near-miss reading, genuine gaps) lives in the shared prompt chunk. + * `name`: the teach tool self-references as explain, the probe variant + * as recall (owner 2026-10-09). */ +function explainDescription(name: "explain" | "recall", nodeId: boolean): string { return ( "Ask the learner to EXPLAIN something in his own words — a graded check with NO options: he types a free-text " + - "answer. Unlike ask_user_question (no right answer), explain always has one. THE COMMITMENT RULE: " + + "answer. Unlike ask_user_question (no right answer), " + + name + + " always has one. THE COMMITMENT RULE: " + "correctAnswer is the FULL correct answer in your own words, reasoning included, COMMITTED before he ever " + "sees the question. When his answer returns, grade it strictly against the commitment — never revise it " + "after seeing his words. He sees the committed answer on submit. " + (nodeId ? "Set `nodeId` (required) to the plan node this question is about — validated at call time against the session's plan map. " : "") + - "ONE question per turn: call a single explain, then end your turn — his answer determines the next." + "ONE question per turn: call a single " + name + ", then end your turn — his answer determines the next." ); } +/** The prober's multiple-choice quizzing tool — named `probe`, not + * `quiz` (owner, 2026-10-09): the probe and teach schemas differ + * (neither `type` nor `nodeId` exists while probing), and a shared name + * invited the model to carry its probe-phase call shape into teaching + * — the first teach quiz after plan_init dropped `nodeId` on exactly + * that habit (the tools_fails_2 incident). Distinct names make the + * phase boundary visible in the transcript itself: history written by + * `probe` never lies about what `quiz` requires. */ export const probeQuizTool: AgentTool = { - name: "quiz", - label: "quiz", - description: quizDescription({ nodeId: false, types: false }), + name: "probe", + label: "probe", + description: quizDescription({ name: "probe", nodeId: false, types: false }), parameters: probeQuizInputSchema, prepareArguments: prepareQuestionArguments, executionMode: "sequential", execute: async (_toolCallId, input) => { const problems = quizInputProblems(input); - if (problems.length > 0) return invalidInputResult("quiz", problems); + if (problems.length > 0) return invalidInputResult("probe", problems); return questionResult(); }, }; @@ -167,7 +184,7 @@ export function teachExplainToolFor( return { name: "explain", label: "explain", - description: explainDescription(true), + description: explainDescription("explain", true), parameters: explainInputObject, prepareArguments: prepareQuestionArguments, executionMode: "sequential", @@ -183,16 +200,22 @@ export function teachExplainToolFor( /** The prober's free-recall variant (no nodeId — same pattern as the * probe quiz). */ +/** The prober's free-recall variant — named `recall`, not `explain` + * (owner, 2026-10-09), for the same phase-boundary reason as `probe`: + * the teach explain adds `type` and `nodeId`; a shared name carried the + * probe call shape into teaching. Old probe-phase records keep the + * `explain` name and parse identically (the display seam accepts + * both). */ export const probeExplainTool: AgentTool = { - name: "explain", - label: "explain", - description: explainDescription(false), + name: "recall", + label: "recall", + description: explainDescription("recall", false), parameters: explainInputBase, prepareArguments: prepareQuestionArguments, executionMode: "sequential", execute: async (_toolCallId, input) => { const problems = explainInputProblems(input); - if (problems.length > 0) return invalidInputResult("explain", problems); + if (problems.length > 0) return invalidInputResult("recall", problems); return questionResult(); }, }; @@ -239,14 +262,16 @@ export function createProberTools( }); // 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. + // the settings arm. ask stays always-on. The registry keys are the + // PHASE names (probe/recall, owner 2026-10-09) — distinct from the + // teach tools so the schema boundary is visible to the model. 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> downstream). const quizzing: Record> = - mechanics === "free" ? { explain: probeExplainTool } : { quiz: probeQuizTool }; + mechanics === "free" ? { recall: probeExplainTool } : { probe: probeQuizTool }; // healToolRecord (owner, 2026-10-09 — the whitehead generalization): // the standard argument heal over the WHOLE registry — every tool, // not just the one the latest wobble incident landed on. @@ -313,7 +338,7 @@ export function teachQuizToolFor(ctx: Pick> "`-->` edges meaning 'from must be understood before to' (chains fine). 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " + "Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.", parameters: planInitInput, - execute: serverExecute("plan_init", planInitInput, async ({ mermaid }): Promise => { + execute: serverExecute("plan_init", planInitInput, async ({ mermaid }) => { const { chatId, chatStore } = ctx; const record = chatStore.getChat(chatId); - if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() }; - const current = record.tree ?? emptyTree(); + if (!record) return { ok: false, error: `Chat not found: ${chatId}` }; const parsed = parseMermaidPlan(mermaid); if (!parsed.ok || !parsed.plan) { // The parser collects every problem; the teacher fixes and retries. - return { ok: false, error: parsed.problems.join("\n"), tree: current }; + // No tree echo — the model needs the problems, not a restatement of + // the map it is rewriting (owner, context diet 2026-10-09). + return { ok: false, error: parsed.problems.join("\n") }; } try { // createTree validates the structure (duplicate ids, missing // edge endpoints, self-loops, cycles → PlanTreeError). const next = createTree(parsed.plan.nodes, parsed.plan.edges); chatStore.saveTree(chatId, next); - return { ok: true, tree: next }; + // Context diet: content = the validated nodeIds + the reminder (the + // ids just became binding for every quiz/explain call); details = + // the full tree for the record/UI — the plan popup's latestTree + // seam, agent-invisible (the serializer sends content only). + return { + text: JSON.stringify({ + ok: true, + nodeIds: next.nodes.map((n) => n.id), + reminder: PLAN_NODE_IDS_REMINDER, + }), + details: { ok: true, tree: next }, + }; } catch (err) { - if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current }; + if (err instanceof PlanTreeError) return { ok: false, error: err.message }; throw err; } }), diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts index 9638b1d..11e3cd4 100644 --- a/src/agent/tools/probe.ts +++ b/src/agent/tools/probe.ts @@ -1,6 +1,6 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { serverExecute } from "../tool-execute.ts"; -import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts"; +import { probeDoneInputSchema, PROBE_DONE_ACK_TEXT } from "../../core/contracts.ts"; import { danglingQuestionOf, hasProbeDone, type LhMessage } from "../../core/native-messages.ts"; import type { ChatStore } from "../../core/ports.ts"; import { buildProbeHandoffSteer, type SteerMessage } from "../../core/contracts.ts"; @@ -37,7 +37,10 @@ function probeConcluded(messages: readonly LhMessage[]): boolean { * steer through the wake pipeline that fires the teacher's first turn * (plan authoring) the moment the prober's turn ends. Auto-wake is the * user-decided trigger — the learner experiences assessment → plan as - * one continuous flow. + * one continuous flow. CONTEXT-SLIM (owner, context diet 2026-10-09): + * the call's arguments already carry the assessment (durable, streamed + * live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an + * echo. details = {ok:true} only. * * The first probe_done in the transcript is also the construction seam: * from that message on, every request builds the TEACHER agent. @@ -58,7 +61,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record => { + execute: serverExecute("probe_done", probeDoneInputSchema, async (input) => { const record = ctx.chatStore.getChat(ctx.chatId); if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` }; @@ -81,7 +84,14 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record ({ index: i + 1, label: o.label })), diff --git a/src/core/session-markdown.ts b/src/core/session-markdown.ts index d7d3be2..64a37b6 100644 --- a/src/core/session-markdown.ts +++ b/src/core/session-markdown.ts @@ -90,8 +90,20 @@ function toolResultBody(result: ToolResultMessage, ctx?: MarkdownContext): { hea }> | undefined; - if (result.toolName === "quiz" || result.toolName === "explain") { - const heading = result.toolName === "quiz" ? "Quiz" : "Recall check"; + if ( + result.toolName === "quiz" || + result.toolName === "probe" || + result.toolName === "explain" || + result.toolName === "recall" + ) { + const heading = + result.toolName === "quiz" + ? "Quiz" + : result.toolName === "probe" + ? "Probe" + : result.toolName === "recall" + ? "Recall" + : "Recall check"; // explain — the teach free-recall name const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : "(no question text)"; const body = [`**Q:** ${question}`]; if (out?.dismissed === true) body.push("The learner skipped this check."); diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index 4e81c67..189a8ff 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -29,7 +29,7 @@ import { Value } from "typebox/value"; import type { TSchema } from "typebox"; -import { quizInputBase, quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputBase, explainInputProblems, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts"; +import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts"; import { restoreMathEscapes } from "./math-delims.ts"; import { healEncodedArguments } from "./argument-heal.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; @@ -115,12 +115,14 @@ export function parseAskInput(input: unknown): AskToolInput | undefined { 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. */ +/** Explain parse for DISPLAY: the display schema (base + OPTIONAL + * lenient `type`, exactly the quiz pattern — teach explain calls carry + * the role since 2026-10-09; the commitment refinement + * (explainInputProblems) mirrors the boundary chain). Both explain + * variants share the same fields; the teacher's nodeId is an unknown + * key Value.Clean strips, same as the quiz seam. */ export function parseExplainInput(input: unknown): ExplainToolInput | undefined { - const cleaned = boundaryParse(explainInputBase, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(explainDisplayInputSchema, restoreMathEscapesDeep(input)); if (!cleaned) return undefined; if (explainInputProblems(cleaned).length > 0) return undefined; return cleaned; diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 1f89fe4..5af2009 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -1107,6 +1107,7 @@ it("explain tool call persists as a pending part; the free-text answer reveals t question: "Explain why Mercury is hard to observe.", correctAnswer: "Its small angular separation from the Sun — it never strays far from the Sun in our sky.", nodeId: "planet-order", + type: "check", // the teach explain's role field (owner, 2026-10-09) }; const { engine, calls } = scriptedTeacher((cs) => [ (context) => { diff --git a/tests/graph-tools.test.ts b/tests/graph-tools.test.ts index 4eff7f4..d8c0ef9 100644 --- a/tests/graph-tools.test.ts +++ b/tests/graph-tools.test.ts @@ -98,8 +98,8 @@ describe("prober graph tools — registration", () => { "list_sessions", "ls", "pdf", + "probe", "probe_done", - "quiz", "read", "research", ]); diff --git a/tests/plan-tree.test.ts b/tests/plan-tree.test.ts index 1bf4be2..f3423e6 100644 --- a/tests/plan-tree.test.ts +++ b/tests/plan-tree.test.ts @@ -120,19 +120,20 @@ describe("plan_init — the author-once mermaid tool (execute against the real s }); assert.equal(res.ok, true); assert.equal(res.error, undefined); - assert.equal(res.tree.version, 1); + const tree = res.tree!; + assert.equal(tree.version, 1); assert.deepEqual( - res.tree.nodes.map((n) => n.id), + tree.nodes.map((n) => n.id), ["primitives", "operators", "expressions", "detour"], ); - assert.deepEqual(res.tree.edges, [ + assert.deepEqual(tree.edges, [ { from: "primitives", to: "operators" }, { from: "operators", to: "expressions" }, ]); const saved = chatStore.getChat(chatId)!.tree; assert.ok(saved, "tree persisted to the chat record"); - assert.deepEqual(saved, res.tree); + assert.deepEqual(saved, tree); }); it("RE-CALLABLE: a rewrite replaces the tree wholesale (the mutation tools are gone)", async () => { @@ -147,7 +148,7 @@ describe("plan_init — the author-once mermaid tool (execute against the real s }); assert.equal(res.ok, true, "re-init is the sanctioned rewrite path now"); assert.deepEqual( - res.tree.nodes.map((n) => n.id), + res.tree!.nodes.map((n) => n.id), ["truth", "goal"], ); assert.deepEqual( @@ -169,7 +170,8 @@ describe("plan_init — the author-once mermaid tool (execute against the real s assert.match(err, /line 2/); assert.match(err, /line 3/); assert.match(err, /line 4/); - assert.deepEqual(res.tree, emptyTree()); + // Context diet (2026-10-09): failures carry just the error — no tree echo. + assert.equal(res.tree, undefined); assert.equal(chatStore.getChat(chatId)!.tree, undefined, "nothing persisted on failure"); }); @@ -181,7 +183,7 @@ describe("plan_init — the author-once mermaid tool (execute against the real s }); assert.equal(res.ok, false); assert.match(res.error!, /cycle/); - assert.deepEqual(res.tree, emptyTree()); + assert.equal(res.tree, undefined, "no tree echo on failure (context diet, 2026-10-09)"); }); it("only plan_init exists — the seven mutation/tracking tools are gone by design", () => { @@ -194,7 +196,7 @@ describe("plan_init — the author-once mermaid tool (execute against the real s const res = await exec(tools, "plan_init", { mermaid: "graph TD\n a[A]\n" }); assert.equal(res.ok, false); assert.match(res.error!, /Chat not found/); - assert.deepEqual(res.tree, emptyTree()); + assert.equal(res.tree, undefined); }); }); diff --git a/tests/question-core.test.ts b/tests/question-core.test.ts index 6665625..221f1ea 100644 --- a/tests/question-core.test.ts +++ b/tests/question-core.test.ts @@ -26,7 +26,7 @@ import { probeQuizInputSchema, quizDisplayInputSchema, normalizeQuizType, - quizNeedsReveal, + questionNeedsReveal, resolveCorrect, toggleOption, toggleOther, @@ -54,22 +54,22 @@ describe("the schema's argument order (reasoning before the pick)", () => { // Schema property order, so this pin guards the emission end to end. // All consumers read by key — the order is authoring guidance. it("emits explanation immediately before correctAnswer (both agents' schemas)", () => { + // The 2026-09-25 ruling stands for the CONTENT fields (reasoning + // before the pick); the 2026-10-09 reorder adds its mirror for the + // teach schema — the required pair (`type`, `nodeId`) LEADS the + // object, because glm-5.3 dropped exactly those fields when they + // trailed every long content field (the tools_fails_2 drops). + // Models emit tool args in schema order: commit to the role and the + // target first, reason, then pick. for (const schema of [quizInputSchema, probeQuizInputSchema]) { const keys = Object.keys((schema as { properties: Record }).properties); assert.ok( keys.indexOf("explanation") < keys.indexOf("correctAnswer"), `explanation must precede correctAnswer in the emitted schema (got: ${keys.join(", ")})`, ); - assert.deepEqual(keys, [ - "question", - "options", - "multiSelect", - "explanation", - "correctAnswer", - "details", - ...(keys.includes("type") ? ["type"] : []), - ...(keys.includes("nodeId") ? ["nodeId"] : []), - ]); + assert.deepEqual(keys, schema === quizInputSchema + ? ["type", "nodeId", "question", "options", "multiSelect", "explanation", "correctAnswer", "details"] + : ["question", "options", "multiSelect", "explanation", "correctAnswer", "details"]); } }); }); @@ -567,9 +567,13 @@ describe("explainInputBase / explainInputProblems (the commitment contract)", () assert.deepEqual(keys, ["question", "correctAnswer", "details"]); }); - it("the teach object adds the required nodeId AFTER the base fields", () => { + it("the teach object LEADS with the required pair (type, nodeId) — base fields follow", () => { + // Owner, 2026-10-09: glm-5.3 dropped `type`/`nodeId` when they trailed + // the content fields — required fields generated FIRST get dropped + // least, and the order doubles as the authoring order (role, target, + // then the question). Mirrors the quiz reorder (see above). const keys = Object.keys(explainInputObject.properties); - assert.deepEqual(keys, [...Object.keys(explainInputBase.properties), "nodeId"]); + assert.deepEqual(keys, ["type", "nodeId", ...Object.keys(explainInputBase.properties)]); }); it("an empty commitment is the one authoring problem", () => { @@ -661,18 +665,23 @@ describe("the quiz role field (predict | check)", () => { assert.equal(normalizeQuizType(42), undefined); }); - it("quizNeedsReveal: checks collapse, predictions never do", () => { - assert.equal(quizNeedsReveal("quiz", { type: "check" }, "teach"), true); - assert.equal(quizNeedsReveal("quiz", { type: "predict" }, "teach"), false, "predictions arrive OPEN"); + it("questionNeedsReveal: checks collapse, predictions never do — both teach tools", () => { + assert.equal(questionNeedsReveal("quiz", { type: "check" }, "teach"), true); + assert.equal(questionNeedsReveal("quiz", { type: "predict" }, "teach"), false, "predictions arrive OPEN"); assert.equal( - quizNeedsReveal("quiz", undefined, "teach"), + questionNeedsReveal("quiz", undefined, "teach"), true, "old records (no type) render check-style", ); - assert.equal(quizNeedsReveal("quiz", { type: "predict" }, "probe"), false); - assert.equal(quizNeedsReveal("quiz", { type: "check" }, "probe"), false); - assert.equal(quizNeedsReveal("ask_user_question", {}, "teach"), false); - assert.equal(quizNeedsReveal("explain", {}, "teach"), false); + assert.equal(questionNeedsReveal("quiz", { type: "predict" }, "probe"), false); + assert.equal(questionNeedsReveal("quiz", { type: "check" }, "probe"), false); + assert.equal(questionNeedsReveal("ask_user_question", {}, "teach"), false); + // Explain joined the law 2026-10-09: teach checks collapse, predictions + // don't, old no-type records render check-style — mirroring quiz exactly. + assert.equal(questionNeedsReveal("explain", { type: "check" }, "teach"), true); + assert.equal(questionNeedsReveal("explain", { type: "predict" }, "teach"), false); + assert.equal(questionNeedsReveal("explain", undefined, "teach"), true, "pre-type explain records render check-style"); + assert.equal(questionNeedsReveal("explain", { type: "check" }, "probe"), false); }); function quizQd(): QuestionData { diff --git a/tests/question-tools.test.ts b/tests/question-tools.test.ts index bd8c5b1..38cdadc 100644 --- a/tests/question-tools.test.ts +++ b/tests/question-tools.test.ts @@ -202,10 +202,10 @@ describe("the prober's quiz: no nodeId field at all", () => { // Fix wave (2026-09-20): the owner ruling adds research + pdf dispatch // to the probe phase's registry (demo/visual stay teach-only). assert.deepEqual(Object.keys(tools).sort(), [ - "ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research", + "ask_user_question", "find", "grep", "ls", "pdf", "probe", "probe_done", "read", "research", ]); - const quiz = tools.quiz as AgentTool; - assert.equal(Value.Check(quiz.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail"); + const probe = tools.probe as AgentTool; + assert.equal(Value.Check(probe.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail"); }); }); // ─── tool availability (2026-09 settings seam) ─────────────────────────────── @@ -225,22 +225,22 @@ describe("tool toggles (core/pending.ts + factories)", () => { const chatId = newChat("Prober: toggles"); const ctx = { chatId, chatStore, deliver: () => {}, jobStore, spawn: () => {} }; assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), [ - "ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research", + "ask_user_question", "find", "grep", "ls", "pdf", "probe", "probe_done", "read", "research", ]); 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", "probe_done", "read", "research"]); // 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", "quiz", "read", "research"]); + assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe", "probe_done", "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.deepEqual(Object.keys(free).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "read", "recall", "research"]); assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on"); }); @@ -320,6 +320,7 @@ describe("teachExplainToolFor: the explain variant validates nodeId like the qui question: "Explain why the null result matters.", correctAnswer: "It ruled out the aether — a stationary aether predicts a fringe shift, and none was seen.", nodeId: "arith", + type: "check", }; it("accepts a nodeId matching a tree node", async () => { diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index 801ba16..543adee 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -28,7 +28,7 @@ import { coerceCorrectAnswer, normalizeOptions, resolveCorrect, - quizNeedsReveal, + questionNeedsReveal, type QuestionData, } from "../src/core/question-core.ts"; @@ -65,7 +65,12 @@ function askQD(): QuestionData { }; } -function renderDock(props: { toolName: "quiz" | "ask_user_question"; toolCallId: string; input: never; onSubmit?: () => void }) { +function renderDock(props: { + toolName: "quiz" | "probe" | "ask_user_question" | "explain" | "recall"; + toolCallId: string; + input: never; + onSubmit?: () => void; +}) { // expanded=true renders the full form (the collapsed bar is covered by the // QuestionDockBar test below). No scroll context needed — the DockOverlay // in the chat view owns that. @@ -458,16 +463,23 @@ describe("probe-done card", () => { summary: "Bracketed: teach the symbols on the intuition he has.", }; - it("renders the strand-by-strand assessment from the seam ack", () => { + it("renders the strand-by-strand assessment — the CALL is its home (context diet 2026-10-09)", () => { const html = renderToStaticMarkup( , ); assert.ok(html.includes('data-testid="probe-done-card"')); assert.match(html, /modal logic basics/); assert.match(html, /indexical 'actual'|indexical 'actual'/); assert.match(html, /Bracketed/); + // An ok output alone (the pre-diet echo shape) renders the quiet + // fallback, never strands: the assessment no longer rides the result. + const echoless = renderToStaticMarkup( + , + ); + assert.match(echoless, /Writing the assessment…/); + assert.ok(!echoless.includes("modal logic basics"), "no assessment echo in the result"); }); it("previews the assessment from a PENDING (validated) input", () => { @@ -533,6 +545,70 @@ describe("AnsweredCard: the explain variant", () => { }); +// ─── probe-phase naming (owner, 2026-10-09): the probe/recall rename — +// both surfaces carry the tool's OWN name ──────────────────────────── + +describe("probe-phase naming: Probe/Recall labels", () => { + it("the dock labels a probe quiz as Probe (composer surface)", () => { + const html = renderDock({ toolName: "probe", toolCallId: "p1", input: quizInput as never }); + assert.match(html, /Probe/, "the chip carries the tool's own name"); + assert.ok(!html.includes(">Quiz<"), "not the teach label"); + assert.match(html, /Which planet is closest to the sun\?/, "still a quiz-shaped card"); + }); + + it("the dock labels a recall question as Recall", () => { + const html = renderDock({ toolName: "recall", toolCallId: "r1", input: { question: "Explain the chain rule.", correctAnswer: "Outer function times the derivative of the inner." } as never }); + assert.match(html, /Recall/); + assert.ok(!html.includes(">Explain<"), "not the teach label"); + }); + + it("an answered probe labels Probe — the phase boundary visible in the transcript", () => { + const qd: QuestionData = { + id: "p2", + kind: "quiz", + question: "Which planet is closest to the sun?", + mode: "single-select", + options: [{ label: "Mercury", value: "mercury" }, { label: "Venus", value: "venus" }], + correctIndices: [1], + committedAnswer: "Mercury — closest orbit.", + explanation: "Mercury, always Mercury.", + createdAt: new Date().toISOString(), + }; + const html = renderToStaticMarkup(); + assert.match(html, /Probe/); + assert.ok(!html.includes(">Quiz<"), "not the teach label"); + }); + + it("an answered recall labels Recall", () => { + const recallQD: QuestionData = { + id: "r2", + kind: "explain", + question: "Explain why the null result matters.", + mode: "text", + options: [], + committedAnswer: "It ruled out the luminiferous aether.", + createdAt: new Date().toISOString(), + }; + const html = renderToStaticMarkup(); + assert.match(html, /Recall/); + assert.ok(!html.includes(">Explain<"), "not the teach label"); + }); + + it("old records (no tool) keep the kind labels", () => { + const oldQD: QuestionData = { + id: "o2", + kind: "explain", + question: "Explain why the null result matters.", + mode: "text", + options: [], + committedAnswer: "It ruled out the luminiferous aether.", + createdAt: new Date().toISOString(), + }; + const html = renderToStaticMarkup(); + assert.match(html, /Explain/, "no tool field → the standing kind label"); + }); +}); + // ─── the two roles in the UI (predict vs check, owner 2026-10-03) ───────── // Predictions arrive OPEN before the teaching; checks arrive collapsed after // it. The answered card tells them apart (eyebrow) and tucks a prediction's @@ -555,7 +631,7 @@ describe("predict vs check rendering", () => { assert.ok(html.includes("Prediction"), `eyebrow missing: ${html.slice(0, 200)}`); }); - it("a check quiz keeps the 'Quiz' eyebrow (old records unchanged)", () => { + it("a check quiz reads the 'Check' eyebrow — the type name IS the label (owner, 2026-10-09)", () => { const html = renderToStaticMarkup( { onSubmit={() => {}} />, ); - assert.ok(html.includes("Quiz")); + assert.ok(html.includes("Check"), "the chip carries the question's type"); + assert.ok(!html.includes(">Quiz<"), "the generic label is retired"); }); it("a prediction answer SHOWS the verdict but TUCKS the explanation behind a toggle", () => { diff --git a/tests/session-markdown.test.ts b/tests/session-markdown.test.ts index 0a52b98..a26eda7 100644 --- a/tests/session-markdown.test.ts +++ b/tests/session-markdown.test.ts @@ -100,6 +100,19 @@ describe("messageMarkdown", () => { assert.match(md, /The learner answered in his own words: it never strays far/); }); + it("probe-phase questions export under their own names (owner, 2026-10-09)", () => { + const probe = messageMarkdown( + result("r1", "p1", "probe", { questionText: "What is a derivative?", correct: true, dontKnow: false }), + "r1", + )!; + assert.match(probe, /^### Probe · r1$/m); + const recall = messageMarkdown( + result("r2", "rr1", "recall", { questionText: "Why does Mercury stay near the Sun?", text: "it never strays far" }), + "r2", + )!; + assert.match(recall, /^### Recall · r2$/m); + }); + it("ask blocks: question + chosen options + free text", () => { const chosen = messageMarkdown( result("r1", "a1", "ask_user_question", { questionText: "Which way?", answers: ["second"], other: "also the third" }),