fix(arch): parse, don't cast — the UI parse seam for model-authored input
The architectural answer to the validation-crash question: validation IS
first at the model-facing boundary (pi validates arguments before
execute; failures return to the model same-turn — all three live
incidents retried successfully). But the same bytes also exist UNVALIDATED
by design in two other places: the live wire streams raw tool-input
deltas DURING generation, and the transcript keeps the raw input of
REJECTED calls (honesty + the replay invariant). The consumers were
supposed to trust part STATES — but that trust was enforced by
TypeScript casts ('part.input as X'), which are compile-time lies with
no runtime check behind them. That is the hole both crashes came
through, and it should not have been possible.
The structural rule now: model-authored INPUT is consumed only through
parsers running the SAME zod schema the model faced — boundary-valid ⇔
consumer-parseable, mechanically. Server/learner-authored OUTPUT stays
trusted (our code authors it).
- src/core/tool-parts.ts (new): parseQuizInput (display parse over the
shared quizInputBase — strips the teacher-only nodeId, accepts both
prober and teacher boundary shapes), parseAskInput, parseProbeDoneInput.
- contracts/probe.ts: probeDoneInputSchema is now core-owned zod — the
probe_done tool declares it (single source of truth), the seam runs
the literal same schema.
- question-dock.tsx: the dock parses its input at entry; unparsable
renders the quiet malformed-question fallback, never a crash.
- probe-done-card.tsx: the input preview goes through the parse; the
old state reasoning and shape guards collapse into it.
Pinned in tests/tool-parts.test.ts: both exact live crash shapes
(stringified quiz options, stringified assessment), boundary-parity
cases for all three schemas, nodeId stripping, mid-stream partials;
render-cards pins the dock fallback markup.
Gates: tsc, lint, build, 328/328.
This commit is contained in:
@@ -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 (
|
||||
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
|
||||
This question arrived malformed — the transcript keeps the retry.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{!expanded && (
|
||||
<QuestionDockBar
|
||||
toolName={toolName}
|
||||
question={input.question}
|
||||
question={parsed.question}
|
||||
onExpand={onExpand}
|
||||
variant={reveal ? "reveal" : "collapsed"}
|
||||
/>
|
||||
@@ -90,7 +106,7 @@ export function QuestionDock({
|
||||
<QuestionDockForm
|
||||
toolName={toolName}
|
||||
toolCallId={toolCallId}
|
||||
input={input}
|
||||
input={parsed}
|
||||
phase={phase}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user