question tools: phase-visible names, hardened teach schemas, context-slim seams
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
This commit is contained in:
@@ -52,7 +52,20 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
data-testid="answered-card"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{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 ? (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+10
-7
@@ -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 ? (
|
||||
<QuestionDock
|
||||
toolName={pending.tool as "quiz" | "ask_user_question" | "explain"}
|
||||
toolName={pending.tool as "quiz" | "probe" | "ask_user_question" | "explain" | "recall"}
|
||||
toolCallId={pending.toolCallId}
|
||||
input={pending.input as never}
|
||||
expanded={expanded}
|
||||
|
||||
@@ -58,7 +58,7 @@ import { CONFIDENCE_COLORS, confidenceRailStyle } from "@/components/confidence-
|
||||
*/
|
||||
|
||||
export interface QuestionDockProps {
|
||||
toolName: "quiz" | "ask_user_question" | "explain";
|
||||
toolName: "quiz" | "probe" | "ask_user_question" | "explain" | "recall";
|
||||
toolCallId: string;
|
||||
input: QuizToolInput | AskToolInput | ExplainToolInput;
|
||||
/** Collapse state, owned by the DockOverlay (it drives the shell's height morph). */
|
||||
@@ -106,6 +106,16 @@ export function QuestionDock({
|
||||
dictationClaim = true,
|
||||
compactionRunning = false,
|
||||
}: QuestionDockProps) {
|
||||
// The probe-phase names (owner, 2026-10-09) canonicalize onto their
|
||||
// teach twins for every dock concern — parse dispatch, labels, kinds:
|
||||
// the probe quiz ("probe") is quiz-shaped, the probe free-recall
|
||||
// ("recall") is explain-shaped. Old records keep the teach names.
|
||||
const canonical =
|
||||
toolName === "probe" ? "quiz" : toolName === "recall" ? "explain" : toolName;
|
||||
// The chip's display name carries the tool's OWN name (owner
|
||||
// 2026-10-09): probe-phase questions label Probe/Recall in the
|
||||
// composer; teach tools keep the standing labels.
|
||||
const toolLabel = toolName === "probe" ? "Probe" : toolName === "recall" ? "Recall" : undefined;
|
||||
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
|
||||
// input, and only the schema the tool declared vouches for its shape.
|
||||
// The dock mounts on input-available parts (already validated at the
|
||||
@@ -113,7 +123,7 @@ export function QuestionDock({
|
||||
// anything that slips through renders the quiet fallback, never a
|
||||
// crash on `.map` over model-authored bytes.
|
||||
const parsed =
|
||||
toolName === "quiz" ? parseQuizInput(input) : toolName === "explain" ? parseExplainInput(input) : parseAskInput(input);
|
||||
canonical === "quiz" ? parseQuizInput(input) : canonical === "explain" ? parseExplainInput(input) : parseAskInput(input);
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
|
||||
@@ -125,12 +135,14 @@ export function QuestionDock({
|
||||
<>
|
||||
{!expanded && (
|
||||
<QuestionDockBar
|
||||
toolName={toolName}
|
||||
toolName={canonical}
|
||||
question={parsed.question}
|
||||
onExpand={onExpand}
|
||||
variant={reveal ? "reveal" : "collapsed"}
|
||||
toolLabel={toolLabel}
|
||||
label={
|
||||
toolName === "quiz" && normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
|
||||
(canonical === "quiz" || canonical === "explain") &&
|
||||
normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
|
||||
? "Prediction"
|
||||
: undefined
|
||||
}
|
||||
@@ -176,6 +188,7 @@ export function QuestionDockBar({
|
||||
onExpand,
|
||||
variant = "collapsed",
|
||||
label,
|
||||
toolLabel,
|
||||
}: {
|
||||
toolName: "quiz" | "ask_user_question" | "explain";
|
||||
question: string;
|
||||
@@ -185,6 +198,9 @@ export function QuestionDockBar({
|
||||
* predict-type quiz minimizes by scroll — predictions never use the
|
||||
* reveal variant, but the scroll-collapse strip can still hide them. */
|
||||
label?: string;
|
||||
/** The chip's TOOL display name (owner 2026-10-09): probe-phase calls
|
||||
* label Probe/Recall — the phase boundary visible in the composer. */
|
||||
toolLabel?: string;
|
||||
}) {
|
||||
if (variant === "reveal") {
|
||||
return (
|
||||
@@ -215,7 +231,9 @@ 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"
|
||||
>
|
||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label ?? (toolName === "quiz" ? "Quiz" : toolName === "explain" ? "Explain" : "Question")}
|
||||
{label ??
|
||||
toolLabel ??
|
||||
(toolName === "quiz" ? "Check" : toolName === "explain" ? "Explain" : "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">
|
||||
@@ -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). */}
|
||||
<div className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{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"}
|
||||
</div>
|
||||
<div className="mb-3 font-medium">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+45
-20
@@ -108,11 +108,14 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
|
||||
// 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<typeof probeQuizInputSchema> = {
|
||||
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<typeof explainInputBase> = {
|
||||
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<string, AgentTool<any>> downstream).
|
||||
const quizzing: Record<string, AgentTool<any>> =
|
||||
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<PlanToolContext, "chatId" | "chatStor
|
||||
return {
|
||||
name: "quiz",
|
||||
label: "quiz",
|
||||
description: quizDescription({ nodeId: true, types: true }),
|
||||
description: quizDescription({ name: "quiz", nodeId: true, types: true }),
|
||||
parameters: quizInputObject,
|
||||
prepareArguments: prepareQuestionArguments,
|
||||
executionMode: "sequential",
|
||||
|
||||
+34
-11
@@ -3,9 +3,17 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts";
|
||||
import { createTree, PlanTreeError } from "../../core/plan-tree.ts";
|
||||
import { parseMermaidPlan } from "./mermaid-plan.ts";
|
||||
|
||||
/** The binding-id reminder riding plan_init's slim content (owner,
|
||||
* 2026-10-09): plan_init is the exact moment the node ids become
|
||||
* load-bearing — every later quiz/explain call must reference one. The
|
||||
* field-drop incidents (tools_fails_2) showed the turn's tail is where
|
||||
* discipline slips; the reminder lands right there. */
|
||||
const PLAN_NODE_IDS_REMINDER =
|
||||
"These are the plan's node ids — the only valid nodeId values for every quiz and explain call from here on. Reference them exactly.";
|
||||
|
||||
/** What the plan tools close over (the plan-tree half of the tool context). */
|
||||
export interface PlanToolContext {
|
||||
chatId: string;
|
||||
@@ -33,10 +41,13 @@ export interface PlanToolContext {
|
||||
*
|
||||
* The former mutation tools (add/update/remove node/edge, set_current,
|
||||
* mark_done) are gone by design: restructuring is a rewrite, tracking is
|
||||
* derived. SERVER-SIDE with execute: parse → validate → persist → return
|
||||
* the FULL canonicalized tree (PlanToolResult), so the tree panel renders the
|
||||
* latest state straight from the transcript's tool outputs (the plan
|
||||
* window and the plan blocks).
|
||||
* derived. SERVER-SIDE with execute: parse → validate → persist. The
|
||||
* result is CONTEXT-SLIM (owner, context diet 2026-10-09): the model
|
||||
* WROTE the mermaid one message ago, so content never echoes the tree —
|
||||
* it carries the validated nodeIds + the binding-nodeId reminder (the
|
||||
* exact moment the ids become load-bearing); `details` keeps the full
|
||||
* tree for the record and the plan popup (latestTree reads it — the
|
||||
* agent-invisible channel, serializer-verified).
|
||||
*/
|
||||
const planInitInput = Type.Object({
|
||||
mermaid: Type.String({
|
||||
@@ -60,25 +71,37 @@ export function planTools(ctx: PlanToolContext): Record<string, AgentTool<any>>
|
||||
"`-->` 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<PlanToolResult> => {
|
||||
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;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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<string, AgentTool<a
|
||||
// The core-owned schema (contracts/probe.ts) — the SAME schema the UI
|
||||
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
|
||||
parameters: probeDoneInputSchema,
|
||||
execute: serverExecute("probe_done", probeDoneInputSchema, async (input): Promise<ProbeDoneResult> => {
|
||||
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<string, AgentTool<a
|
||||
// constructs the teacher off the transcript seam) — record the
|
||||
// assessment anyway; the wake is an optimization, not a gate.
|
||||
}
|
||||
return { ok: true, assessment: input.assessment };
|
||||
// Context diet (owner, 2026-10-09): the assessment lives ONCE — as
|
||||
// this call's arguments. content is the fixed ack (the agent never
|
||||
// sees an echo); details = {ok:true} for the record/UI — the
|
||||
// probe-done card reads the call's input, which it always preferred.
|
||||
return {
|
||||
text: PROBE_DONE_ACK_TEXT,
|
||||
details: { ok: true },
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -114,7 +114,12 @@ export function derivePlanStatuses(tree: PlanTree, messages: readonly PartsCarri
|
||||
export interface PlanToolResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
tree: PlanTree;
|
||||
/** The saved tree — SUCCESS results only (owner, context diet
|
||||
* 2026-10-09): this is the record/UI-facing `details` value. The
|
||||
* agent-facing content never carries it — content is the validated
|
||||
* nodeIds + reminder; failures carry just the error. The plan
|
||||
* popup's latestTree seam reads this field. */
|
||||
tree?: PlanTree;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,5 +81,14 @@ export interface ProbeDoneResult {
|
||||
ok: boolean;
|
||||
/** Refusal reason when ok === false (open question, already concluded). */
|
||||
error?: string;
|
||||
assessment?: ProbeAssessment;
|
||||
}
|
||||
|
||||
/** The probe_done ack's model-facing text (owner, context diet
|
||||
* 2026-10-09): the assessment is the CALL's arguments — durable in the
|
||||
* transcript, streamed to the learner — so echoing it back in the
|
||||
* result would duplicate the probe phase's largest payload in every
|
||||
* future context. The agent sees this fixed line instead; `details`
|
||||
* carries only {ok:true} for the record/UI. Same doctrine as
|
||||
* CONCLUDE_ACK_TEXT. */
|
||||
export const PROBE_DONE_ACK_TEXT =
|
||||
"Assessment recorded — it is shown to the learner now and the teacher plans from it. Say your closing sentence and end your turn; the teaching phase takes over from here.";
|
||||
|
||||
+14
-4
@@ -38,13 +38,23 @@ export interface PendingToolCall {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
/** Tools that render in the question dock and gate the composer. */
|
||||
export const QUESTION_TOOLS = ["quiz", "ask_user_question", "explain"] as const;
|
||||
/** Tools that render in the question dock and gate the composer. The
|
||||
* probe-phase names (probe/recall, owner 2026-10-09) join their teach
|
||||
* twins — old records keep the teach names; new probe-phase calls carry
|
||||
* the phase names. */
|
||||
export const QUESTION_TOOLS = [
|
||||
"quiz",
|
||||
"probe",
|
||||
"ask_user_question",
|
||||
"explain",
|
||||
"recall",
|
||||
] as const;
|
||||
|
||||
/** The tool→kind map (the dock's own fold, named once — the dismissal
|
||||
* path and the dock must never disagree on what a tool is). */
|
||||
* path and the dock must never disagree on what a tool is). The probe
|
||||
* names fold onto their teach kinds — the dock is kind-driven. */
|
||||
export function toolQuestionKind(tool: string): "quiz" | "ask" | "explain" {
|
||||
return tool === "quiz" ? "quiz" : tool === "explain" ? "explain" : "ask";
|
||||
return tool === "quiz" || tool === "probe" ? "quiz" : tool === "explain" || tool === "recall" ? "explain" : "ask";
|
||||
}
|
||||
|
||||
/** Tools fulfilled by background sub-agent jobs (W2/W6). */
|
||||
|
||||
+56
-18
@@ -220,26 +220,41 @@ export function normalizeQuizType(raw: unknown): QuizQuestionType | undefined {
|
||||
return v === "predict" ? "predict" : v === "check" ? "check" : undefined;
|
||||
}
|
||||
|
||||
/** The reveal law as a pure predicate (owner 2026-10-03): a teach-phase
|
||||
* CHECK collapses behind the "Check your understanding" reveal — a
|
||||
* prediction NEVER does (it precedes the teaching and gates it;
|
||||
* collapsing it would invert the cycle), and neither do probe/plan
|
||||
* questions, ask, or explain. chat.tsx calls this for its pending
|
||||
* question; the reading-thinking field mounts on the same truth. */
|
||||
export function quizNeedsReveal(tool: string, rawInput: unknown, phase: string): boolean {
|
||||
if (tool !== "quiz" || phase !== "teach") return false;
|
||||
/** The reveal law as a pure predicate (owner 2026-10-03; explain joined
|
||||
* 2026-10-09): a teach-phase CHECK collapses behind the "Check your
|
||||
* understanding" reveal — a prediction NEVER does (it precedes the
|
||||
* teaching and gates it; collapsing it would invert the cycle), and
|
||||
* neither do probe/plan questions or ask. Both teach quizzing tools
|
||||
* (quiz and explain) obey the same law. chat.tsx calls this for its
|
||||
* pending question; the reading-thinking field mounts on the same
|
||||
* truth. Records that predate the `type` field carry none — they render
|
||||
* as checks, the standing default. */
|
||||
export function questionNeedsReveal(tool: string, rawInput: unknown, phase: string): boolean {
|
||||
if ((tool !== "quiz" && tool !== "explain") || phase !== "teach") return false;
|
||||
return normalizeQuizType((rawInput as { type?: unknown } | null)?.type) !== "predict";
|
||||
}
|
||||
|
||||
/** The teaching-cycle role field, shared VERBATIM by both teach quizzing
|
||||
* tools (owner, 2026-10-09: explain gains the cycle's types — a
|
||||
* free-recall check collapses behind the reveal exactly like a quiz
|
||||
* check; a prediction never does). Bare words, never quoted (the
|
||||
* quote-copy wobble — see the whitehead pins, tests/tool-heal.test.ts). */
|
||||
const predictCheckType = Type.Union([Type.Literal("predict"), Type.Literal("check")], {
|
||||
description:
|
||||
"REQUIRED — the question's role in the teaching cycle, as a bare word (no quotes): predict = posed BEFORE " +
|
||||
"you teach the step (its own turn; the learner answers, then your teaching resolves it — arrives open, never " +
|
||||
"collapsed); check = posed AFTER the step's teaching is complete (arrives collapsed behind the Check your " +
|
||||
"understanding reveal). ONE question per turn either way.",
|
||||
});
|
||||
|
||||
|
||||
/** The TEACH-side quiz object. The required pair leads the property
|
||||
* order (owner, 2026-10-09): glm-5.3 dropped `type`/`nodeId` on long
|
||||
* calls BECAUSE they trailed every content field — fields generated
|
||||
* first get dropped least. The order is also the authoring order:
|
||||
* commit to the node and the question's role, then write the question. */
|
||||
export const quizInputObject = Type.Object({
|
||||
...quizInputBase.properties,
|
||||
type: Type.Union([Type.Literal("predict"), Type.Literal("check")], {
|
||||
description:
|
||||
"REQUIRED — the question's role in the teaching cycle, as a bare word (no quotes): predict = posed BEFORE " +
|
||||
"you teach the step (its own turn; the learner answers, then your teaching resolves it — arrives open, never " +
|
||||
"collapsed); check = posed AFTER the step's teaching is complete (arrives collapsed behind the Check your " +
|
||||
"understanding reveal). ONE question per turn either way.",
|
||||
}),
|
||||
type: predictCheckType,
|
||||
nodeId: Type.String({
|
||||
description:
|
||||
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
|
||||
@@ -249,6 +264,7 @@ export const quizInputObject = Type.Object({
|
||||
"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.",
|
||||
}),
|
||||
...quizInputBase.properties,
|
||||
});
|
||||
|
||||
/** The PROBE-side quiz declaration: the shared authoring contract, no
|
||||
@@ -303,8 +319,12 @@ export const explainInputBase = Type.Object({
|
||||
|
||||
/** The TEACH-side explain object: base + the plan-position field (same
|
||||
* semantics as the quiz's nodeId — the seam recognizes BOTH tools). */
|
||||
/** The TEACH-side explain object: base + the teaching-cycle role field
|
||||
* and the plan-position field (same semantics as the quiz's — the seam
|
||||
* recognizes BOTH tools). The required pair leads the property order,
|
||||
* exactly as the teach quiz's (owner, 2026-10-09). */
|
||||
export const explainInputObject = Type.Object({
|
||||
...explainInputBase.properties,
|
||||
type: predictCheckType,
|
||||
nodeId: Type.String({
|
||||
description:
|
||||
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
|
||||
@@ -314,6 +334,17 @@ export const explainInputObject = Type.Object({
|
||||
"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.",
|
||||
}),
|
||||
...explainInputBase.properties,
|
||||
});
|
||||
|
||||
/** The DISPLAY parse for persisted explain calls (tool-parts.ts): the
|
||||
* base + `type` as an OPTIONAL plain string — exactly the quiz display
|
||||
* pattern. Old records carry none; questionNeedsReveal treats them as
|
||||
* checks (the standing default). nodeId stays stripped (unknown key).
|
||||
* The PROBE recall parses against the bare base. */
|
||||
export const explainDisplayInputSchema = Type.Object({
|
||||
...explainInputBase.properties,
|
||||
type: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
/** The explain authoring contract: the commitment must exist. Empty
|
||||
@@ -404,6 +435,12 @@ export interface QuestionData {
|
||||
* toolCallId restated — the toolResult message already carries it. */
|
||||
export interface QuestionAnswerData {
|
||||
kind: QuestionKind;
|
||||
/** The persisted call's tool NAME (owner, 2026-10-09): probe-phase
|
||||
* calls are `probe`/`recall`, teach calls `quiz`/`explain` — the
|
||||
* cards label by it ("Probe"/"Recall"), so the phase boundary is
|
||||
* visible in the transcript. Absent on pre-rename records: they
|
||||
* keep the kind-based labels. */
|
||||
tool?: string;
|
||||
/** quiz only (teach phase): the role snapshot for rendering (a
|
||||
* prediction tucks its explanation) and later calibration splits.
|
||||
* Absent on old records and probe answers. */
|
||||
@@ -644,9 +681,10 @@ function formatOptionRef(options: QuizOption[], index: number): string {
|
||||
* Turn an AnswerRequest into the full answer record + the user message the
|
||||
* agent will see. Quiz answers are graded here; ask answers are not.
|
||||
*/
|
||||
export function buildAnswer(qd: QuestionData, req: AnswerRequest): QuestionAnswerData {
|
||||
export function buildAnswer(qd: QuestionData, req: AnswerRequest, tool?: string): QuestionAnswerData {
|
||||
const base: QuestionAnswerData = {
|
||||
kind: qd.kind,
|
||||
...(tool ? { tool } : {}),
|
||||
questionType: qd.questionType,
|
||||
questionText: qd.question,
|
||||
options: qd.options.map((o, i) => ({ index: i + 1, label: o.label })),
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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<ExplainToolInput>(explainInputBase, restoreMathEscapesDeep(input));
|
||||
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, restoreMathEscapesDeep(input));
|
||||
if (!cleaned) return undefined;
|
||||
if (explainInputProblems(cleaned).length > 0) return undefined;
|
||||
return cleaned;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -98,8 +98,8 @@ describe("prober graph tools — registration", () => {
|
||||
"list_sessions",
|
||||
"ls",
|
||||
"pdf",
|
||||
"probe",
|
||||
"probe_done",
|
||||
"quiz",
|
||||
"read",
|
||||
"research",
|
||||
]);
|
||||
|
||||
+10
-8
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+30
-21
@@ -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<string, unknown> }).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 {
|
||||
|
||||
@@ -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<any>;
|
||||
assert.equal(Value.Check(quiz.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail");
|
||||
const probe = tools.probe as AgentTool<any>;
|
||||
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 () => {
|
||||
|
||||
@@ -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(
|
||||
<ProbeDoneCard
|
||||
part={{ state: "output-available", output: { ok: true, assessment: validAssessment } } as never}
|
||||
part={{ state: "output-available", input: { assessment: validAssessment }, output: { ok: true } } as never}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ProbeDoneCard part={{ state: "output-available", output: { ok: true } } as never} />,
|
||||
);
|
||||
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(<AnsweredCard answer={buildAnswer(qd, { values: ["mercury"] }, "probe")} />);
|
||||
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(<AnsweredCard answer={buildAnswer(recallQD, { text: "A hand-wave." }, "recall")} />);
|
||||
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(<AnsweredCard answer={buildAnswer(oldQD, { text: "A hand-wave." })} />);
|
||||
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(
|
||||
<QuestionDock
|
||||
{...{
|
||||
@@ -568,7 +644,8 @@ describe("predict vs check rendering", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
Reference in New Issue
Block a user