diff --git a/components/artifacts/probe-done-card.tsx b/components/artifacts/probe-done-card.tsx index 33b7e10..14fb59b 100644 --- a/components/artifacts/probe-done-card.tsx +++ b/components/artifacts/probe-done-card.tsx @@ -1,7 +1,7 @@ "use client"; import { ClipboardCheckIcon } from "lucide-react"; -import type { ProbeDoneResult, ProbeDoneInput } from "@/src/core"; +import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core"; /** * The probe conclusion card (docs/probe-teach-split.md §2): renders the @@ -22,16 +22,18 @@ import type { ProbeDoneResult, ProbeDoneInput } from "@/src/core"; * the UI wave; this card just guarantees the data is never invisible. */ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) { - const input = part.input as ProbeDoneInput | undefined; const output = part.output as ProbeDoneResult | undefined; const state = typeof part.state === "string" ? part.state : ""; - const assessment = - output?.ok - ? output.assessment - : state === "output-error" - ? null // the rejected call — its input is unvalidated garbage - : (input?.assessment ?? null); + // Parse, don't cast (the seam rule, src/core/tool-parts.ts): the model + // authored this input — it is consumer-trustworthy only if the SAME + // schema the tool declared says so. The parse subsumes the old state + // reasoning and shape guards: an errored call's stringified assessment, + // a mid-stream partial — anything that doesn't parse falls to the + // fallback branch. Output stays a cast: OUR execute authored it. + const input = parseProbeDoneInput(part.input); + + const assessment = output?.ok ? output.assessment : input?.assessment; const strands = assessment && Array.isArray(assessment.strands) ? assessment.strands : null; return ( diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 789ae31..db19e2b 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -13,6 +13,8 @@ import { resolveCorrect, seededRandom, shuffleOptions, + parseAskInput, + parseQuizInput, toggleOption, toggleOther, type AnswerRequest, @@ -76,12 +78,26 @@ export function QuestionDock({ onSubmit, phase = "probe", }: QuestionDockProps) { + // 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 + // bridge), so this is pure defense in depth — but it is STRUCTURAL now: + // anything that slips through renders the quiet fallback, never a + // crash on `.map` over model-authored bytes. + const parsed = toolName === "quiz" ? parseQuizInput(input) : parseAskInput(input); + if (!parsed) { + return ( +
+ This question arrived malformed — the transcript keeps the retry. +
+ ); + } return ( <> {!expanded && ( @@ -90,7 +106,7 @@ export function QuestionDock({ diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts index 72b99c1..db27a01 100644 --- a/src/agent/tools/probe.ts +++ b/src/agent/tools/probe.ts @@ -1,6 +1,5 @@ -import { z } from "zod"; import { lhTool, type LhTool } from "../lh-tool.ts"; -import type { ProbeDoneInput, ProbeDoneResult } from "../../core/contracts.ts"; +import { probeDoneInputSchema, type ProbeDoneInput, type ProbeDoneResult } from "../../core/contracts.ts"; import type { UIMessage } from "ai"; import { findPendingToolCall } from "../../core/pending.ts"; import type { ChatStore } from "../../core/ports.ts"; @@ -56,30 +55,9 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record, + // The core-owned schema (contracts/probe.ts) — the SAME schema the UI + // parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable. + schema: probeDoneInputSchema, execute: async (input): Promise => { const record = ctx.chatStore.getChat(ctx.chatId); if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` }; diff --git a/src/core/contracts/probe.ts b/src/core/contracts/probe.ts index f862184..36e2c74 100644 --- a/src/core/contracts/probe.ts +++ b/src/core/contracts/probe.ts @@ -15,6 +15,8 @@ * the learner's edge sits. */ +import { z } from "zod"; + /** One dependency strand the planned lesson will rest on. */ export interface ProbeStrand { /** Name/description of the strand (e.g. "the derivative as a limit"). */ @@ -36,6 +38,42 @@ export interface ProbeDoneInput { assessment: ProbeAssessment; } +// The zod schema of that input — SINGLE SOURCE OF TRUTH, lived by the tool +// (src/agent/tools/probe.ts) and by the UI parse seam (src/core/tool-parts.ts): +// the boundary validation and the consumer parse run literally the same +// schema, so "schema-valid at the boundary" ⇔ "parseable by a consumer" is +// structural, not a convention. (Live crash 2026-09-12: the model sent +// `assessment` as a STRING; the card cast it and died on `.strands.map`.) +export const probeDoneInputSchema = z + .object({ + assessment: z + .object({ + strands: z + .array( + z.object({ + strand: z + .string() + .describe( + "The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on.", + ), + assessment: z + .string() + .describe( + "Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " + + "misconceptions found, and what the lesson should build from.", + ), + }), + ) + .min(1) + .describe("Every strand the planned lesson will depend on, each with your read of his edge on it."), + summary: z + .string() + .optional() + .describe("Optional overall synthesis: his apparent goal and where the teaching should enter."), + }) + .describe("The learner assessment — the probe's distilled output."), + }) satisfies z.ZodType; + /** probe_done tool output. */ export interface ProbeDoneResult { ok: boolean; diff --git a/src/core/index.ts b/src/core/index.ts index f46814e..266b821 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -24,4 +24,5 @@ export * from "./phases.ts"; export * from "./pending.ts"; export * from "./math-delims.ts"; export * from "./ui-message.ts"; +export * from "./tool-parts.ts"; export * from "./prices.ts"; diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts new file mode 100644 index 0000000..f199955 --- /dev/null +++ b/src/core/tool-parts.ts @@ -0,0 +1,68 @@ +/** + * The UI parse seam — parse, don't cast (the rule born of the 2026-09-12 + * live crashes). + * + * WHY THIS EXISTS. Validation IS first at the model-facing boundary — pi + * validates arguments before execute, and our zod re-run adds the + * refinements; a failure returns to the model as an error tool result, + * same turn. But the same bytes have two other consumers, and there they + * exist UNVALIDATED BY DESIGN: + * + * - the live wire streams raw tool-input deltas while the model is still + * typing the call (validation cannot run before the call is complete); + * - the transcript keeps the raw input of REJECTED calls too (transcript + * honesty + the replay invariant: every toolCall followed by a result). + * + * So invalid input is always observable, and part STATES carry the verdict + * (input-streaming = partial, input-available = validated, output-error = + * garbage). The flaw that made the crashes possible: consumers trusted + * those shapes via TypeScript CASTS — `part.input as X` is a compile-time + * lie the checker believes, with no runtime check behind it. + * + * THE RULE (mechanical, not vigilance): model-authored INPUT is consumed + * only through these parsers, each running the SAME zod schema the model + * faced — boundary-valid ⇔ consumer-parseable, structurally. Server- and + * learner-authored OUTPUT stays trusted (our own code authors it: the + * execute acks, the learner's buildAnswer). A parse failure renders the + * quiet fallback, never a crash. + */ + +import { z } from "zod"; +import { quizInputBase, askInputSchema, type AskToolInput, type QuizToolInput } from "./question-core.ts"; +import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; + +/** + * Quiz parse for DISPLAY: the shared authoring base, pre-refinement. + * Both boundary schemas (the prober's probeQuizInputSchema over the bare + * base, the teacher's quizInputSchema over base + optional nodeId) refine + * THE SAME quizInputBase, so anything that passed either boundary parses + * here: refinements don't weaken on re-run, and the teacher-only `nodeId` + * is an unknown key the base strips. This is deliberately the LENIENT + * common core — the strict per-agent refinements are the boundary's job. + */ +export function parseQuizInput(input: unknown): QuizToolInput | undefined { + const parsed = quizInputBase.safeParse(input); + return parsed.success ? (parsed.data as QuizToolInput) : undefined; +} + +/** + * Ask parse for DISPLAY: the full ask schema (options optional — free-text + * mode is the options-less shape). The only refinement is option sanity + * (distinct values), which the boundary already enforced. + */ +export function parseAskInput(input: unknown): AskToolInput | undefined { + const parsed = askInputSchema.safeParse(input); + return parsed.success ? parsed.data : undefined; +} + +/** probe_done parse — the core-owned schema the tool itself declares. */ +export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined { + const parsed = probeDoneInputSchema.safeParse(input); + return parsed.success ? parsed.data : undefined; +} + +/** Parse helper for schemas this seam doesn't own (kept for future cards). */ +export function parseWith(schema: z.ZodType, input: unknown): T | undefined { + const parsed = schema.safeParse(input); + return parsed.success ? parsed.data : undefined; +} \ No newline at end of file diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index ec881e9..70c229d 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -120,6 +120,19 @@ describe("render smoke tests", () => { assert.ok(html.includes('data-testid="other-option"'), "Other is still an option row in multi-select"); }); + it("a malformed question input renders the quiet fallback, never a crash", () => { + // The seam rule (tool-parts.ts): the dock PARSES the model-authored + // input instead of casting it — the live crash shape (options as a + // JSON string) must render the fallback, not die on .map. + const html = renderDock({ + toolName: "quiz", + toolCallId: "bad", + input: { question: "q?", options: JSON.stringify([{ label: "a" }, { label: "b" }]) } as never, + }); + assert.ok(html.includes('data-testid="malformed-question"')); + assert.match(html, /malformed/); + }); + it("phase scales the thinking field: teaching invites depth, probing stays quick", () => { const teach = renderToStaticMarkup( { + it("parses a boundary-valid TEACHER quiz and strips the teacher-only nodeId", () => { + const parsed = parseQuizInput(TEACHER_QUIZ); + assert.ok(parsed); + assert.equal(parsed.question, TEACHER_QUIZ.question); + assert.equal((parsed as Record).nodeId, undefined, "unknown keys are stripped, not trusted"); + }); + + it("parses a boundary-valid PROBER quiz (no nodeId at all)", () => { + assert.ok(parseQuizInput(PROBER_QUIZ)); + }); + + it("boundary parity: anything the TEACH schema accepts, the display parse accepts", () => { + const boundary = quizInputSchema.safeParse(TEACHER_QUIZ); + assert.ok(boundary.success, "fixture must be boundary-valid for this pin to mean anything"); + assert.ok(parseQuizInput(boundary.data)); + }); + + it("boundary parity: anything the PROBE schema accepts, the display parse accepts", () => { + const boundary = probeQuizInputSchema.safeParse(PROBER_QUIZ); + assert.ok(boundary.success); + assert.ok(parseQuizInput(boundary.data)); + }); + + it("the live crash shape — options as a JSON string — does not parse", () => { + const stringified = { ...PROBER_QUIZ, options: JSON.stringify(PROBER_QUIZ.options) }; + assert.equal(parseQuizInput(stringified), undefined); + }); + + it("garbage never parses: non-object, missing question, missing correctAnswer", () => { + assert.equal(parseQuizInput("a string"), undefined); + assert.equal(parseQuizInput({ options: [] }), undefined); + assert.equal(parseQuizInput({ ...PROBER_QUIZ, correctAnswer: undefined }), undefined); + }); +}); + +describe("parseAskInput", () => { + it("parses free-text mode (no options) and option mode", () => { + assert.ok(parseAskInput({ question: "What do you want to learn?" })); + assert.ok(parseAskInput({ question: "Which topics?", options: [{ label: "Physics" }, { label: "Logic" }], multiSelect: true })); + }); + + it("the live crash shape — stringified options — does not parse", () => { + assert.equal(parseAskInput({ question: "q", options: "[]" }), undefined); + }); +}); + +describe("parseProbeDoneInput", () => { + it("parses the valid assessment", () => { + const parsed = parseProbeDoneInput(VALID_ASSESSMENT); + assert.ok(parsed); + assert.equal(parsed.assessment.strands.length, 2); + assert.equal(parsed.assessment.summary, "Bracketed: teach the symbols on the intuition he has."); + }); + + it("boundary parity: anything the tool schema accepts, the display parse accepts", () => { + const boundary = probeDoneInputSchema.safeParse(VALID_ASSESSMENT); + assert.ok(boundary.success); + assert.ok(parseProbeDoneInput(boundary.data)); + }); + + it("the live crash shape — assessment STRINGIFIED — does not parse", () => { + // The exact double-encoding from the modal-realism run: strands died + // on `.map` because the card cast the shape. The parse kills the class. + const stringified = { assessment: JSON.stringify(VALID_ASSESSMENT.assessment) }; + assert.equal(parseProbeDoneInput(stringified), undefined); + const garbage = JSON.stringify(VALID_ASSESSMENT.assessment).slice(0, 30); + assert.equal(parseProbeDoneInput(garbage), undefined); + }); + + it("a mid-stream partial (no strands yet) does not parse", () => { + assert.equal(parseProbeDoneInput({ assessment: { summary: "…" } }), undefined); + }); +}); \ No newline at end of file