Files
learnharness/tests/tool-parts.test.ts
T
amosblomqvist b87a6925af Wave A: pi-native tool layer — delete LhTool/toPiTool, TypeBox everywhere, terminate on questions
- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
  (z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
  is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
  (runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
  'Operation aborted' / timeout wording preserved), ToolInputError (the
  retryable '- problem at path' format), result mapping (string verbatim /
  object JSON / image parts, details rides the record's tool part output),
  and questionResult() — placeholder + terminate:true (owner Option A:
  belt of terminate + slim makeQuestionStopper with the clean-question
  rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
  (typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
  (quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
  into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
  with the UI); tool-parts parse seam runs the boundary's full chain
  (Convert → Check → Clean → refinement), fixing the live-smoke lockout:
  pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
  keeps raw args, so a parse without Convert rejected a posed quiz
  (invisible pending question, turn ended). Pinned with the real smoke
  emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
  visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
  fns, extraction submit schemas re-authored in TypeBox (owner option 2:
  zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
  primitive coercion fast path... (boundary still Value.Converts), and
  prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
  read-tool rewritten against the native seams; question-core/question-
  tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
  tool-toggles converted to Value.Check/execute pins.

Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
2026-09-25 23:32:13 +03:00

155 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests — the UI parse seam (src/core/tool-parts.ts).
*
* The rule born of the 2026-09-12 live crashes: model-authored tool input
* is consumed only through these parsers, each running the SAME zod schema
* the model faced at the boundary. These pins hold the two exact live
* crash shapes (the model's double-encoding habit) and the boundary-parity
* property: anything a boundary schema accepts, the display parse accepts.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { quizInputProblems } from "../src/core/question-core.ts";
import { Value } from "typebox/value";
import {
parseAskInput,
parseProbeDoneInput,
parseQuizInput,
} from "../src/core/tool-parts.ts";
import { quizInputSchema, probeQuizInputSchema } from "../src/core/question-core.ts";
import { probeDoneInputSchema } from "../src/core/contracts/probe.ts";
const TEACHER_QUIZ = {
question: "What does Tarski's convention-T define?",
options: [
{ label: "Truth for a language", value: "truth" },
{ label: "Provability", value: "provability" },
],
correctAnswer: "truth",
explanation: "Convention-T schematizes truth for the object language.",
nodeId: "convention-t", // teacher-only field — the display parse must accept and strip it
};
const PROBER_QUIZ = {
question: "Which planet is closest to the sun?",
options: [{ label: "Venus" }, { label: "Mercury" }],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
};
const VALID_ASSESSMENT = {
assessment: {
strands: [
{ strand: "modal logic basics", assessment: "Floor: solid intuition; ceiling: notation." },
{ strand: "indexical 'actual'", assessment: "Genuine floor — prior exposure likely." },
],
summary: "Bracketed: teach the symbols on the intuition he has.",
},
};
describe("parseQuizInput", () => {
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<string, unknown>).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", () => {
assert.ok(Value.Check(quizInputSchema, TEACHER_QUIZ), "fixture must be boundary-valid for this pin to mean anything");
assert.ok(parseQuizInput(TEACHER_QUIZ));
});
it("boundary parity: anything the PROBE schema accepts, the display parse accepts", () => {
assert.ok(Value.Check(probeQuizInputSchema, PROBER_QUIZ));
assert.ok(parseQuizInput(PROBER_QUIZ));
});
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("boundary parity under Value.Convert: a NUMERIC correctAnswer parses (live-smoke lockout fix, 2026-09-26)", () => {
// The live smoke's model emitted correctAnswer: 17 (number). pi's
// boundary runs Value.Convert before Value.Check, so the quiz posed
// — but the record keeps the RAW args. A parse seam without Convert
// would reject exactly what the boundary accepted: the pending quiz
// renders nowhere while the turn has ended = learner lockout.
// Dock-visible parity case: the smoke's real emission — options whose
// values ARE the numbers, correctAnswer numeric. Convert → "17",
// refinement passes (it matches an option), the quiz parses.
const numeric = {
question: "What is 5 + 3 × 4?",
options: [
{ label: "17", value: "17" },
{ label: "27", value: "27" },
{ label: "9", value: "9" },
],
explanation: "Multiplication first.",
correctAnswer: 17,
};
const parsed = parseQuizInput(numeric);
assert.ok(parsed);
assert.equal(parsed.correctAnswer, "17", "converted to the string the boundary saw");
const numericArray = { ...numeric, multiSelect: true, correctAnswer: [17, "27"] };
const parsedArray = parseQuizInput(numericArray);
assert.ok(parsedArray);
assert.deepEqual(parsedArray.correctAnswer, ["17", "27"]);
// Non-convertible garbage still never parses (structure owns it)…
assert.equal(parseQuizInput({ ...PROBER_QUIZ, correctAnswer: { bad: true } }), undefined);
// …and Convert's widening (undefined → "") is caught by the refinement
// chain the boundary itself uses — garbage renders as a quiet fallback.
const widened = { ...PROBER_QUIZ, correctAnswer: undefined };
assert.equal(parseQuizInput(widened), undefined, "the refinement owns the reject");
});
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", () => {
assert.ok(Value.Check(probeDoneInputSchema, VALID_ASSESSMENT));
assert.ok(parseProbeDoneInput(VALID_ASSESSMENT));
});
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);
});
});