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:
@@ -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(
|
||||
<QuestionDock
|
||||
|
||||
Reference in New Issue
Block a user