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:
amosblomqvist
2026-10-09 07:59:23 +03:00
parent c23eb1f15b
commit 1f41fb05a5
21 changed files with 411 additions and 127 deletions
+14 -1
View File
@@ -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>
+4 -1
View File
@@ -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
View File
@@ -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}
+35 -7
View File
@@ -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">