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:
@@ -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<string, LhTool<any,
|
||||
"(floor, ceiling, misconceptions, what to build from). It streams to the learner live and becomes " +
|
||||
"the handoff artifact the teacher plans from. Refused while a question is still open, and after " +
|
||||
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
|
||||
schema: 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<ProbeDoneInput>,
|
||||
// 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<ProbeDoneResult> => {
|
||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
|
||||
|
||||
Reference in New Issue
Block a user