Compare commits

...
10 changed files with 319 additions and 19 deletions
+4 -4
View File
@@ -2,7 +2,7 @@
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import type { ArtifactPartProps } from "@/src/core";
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
/**
* The practice transcript cards (practice sessions, owner design
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{statement}</MessageResponse>
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
</div>
</div>
);
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
(owner round 3): the same quiet 50% register as quiz explanations. */}
{verdict.length > 0 && (
<div className="text-sm text-foreground/50">
<MessageResponse>{verdict}</MessageResponse>
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
</div>
)}
{/* Then the divider, then the labeled model solution (Slice G: the
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{reveal}</MessageResponse>
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
</div>
</div>
</>
+2 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation";
import { useEnterSubmits } from "@/components/enter-submits";
import { cn } from "@/lib/utils";
import { repairMathForDisplay } from "@/src/core";
/**
* The practice dock (practice sessions, owner design 2026-10-04): an
@@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{input.statement}</MessageResponse>
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
</div>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "learnharness",
"version": "0.3.0",
"version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "learnharness",
"version": "0.3.0",
"version": "0.3.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@earendil-works/pi-agent-core": "1.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "learnharness",
"version": "0.3.0",
"version": "0.3.2",
"license": "AGPL-3.0-only",
"author": {
"name": "Amos Blomqvist",
+6 -6
View File
@@ -19,7 +19,7 @@ The defining condition of practice: the proposition you are exercising is **inac
Three things this session is not:
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing.
- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
@@ -73,7 +73,7 @@ Interleaving is the default. Never issue blocks of same-target problems.
1. **Target** — per the selection rules.
2. **Mode** — the cheapest undemonstrated layer for that target.
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets.
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
@@ -101,7 +101,7 @@ Three structural rules:
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later.
- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
@@ -109,17 +109,17 @@ The rung a problem needed is a difficulty readout — only R0–R1 means the pro
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on.
**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so.
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.
## VIII. Progression
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.
## IX. The arc
+4 -2
View File
@@ -202,8 +202,10 @@ export function concludeTool(gate: PracticeGate): AgentTool<typeof concludeInput
label: "conclude",
description:
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
"up in text). Nothing may conclude unless a problem is active.",
"the learner's composer. After the result, say your closing words about the learner's work if useful, then " +
"END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's " +
"explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, " +
"answer their questions about the concluded one. Nothing may conclude unless a problem is active.",
parameters: concludeInputObject,
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
executionMode: "sequential",
+5
View File
@@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
export function repairMathForDisplay(text: string): string {
return healOverescapedCommands(restoreMathEscapes(text));
}
export function restoreMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
+2 -2
View File
@@ -30,7 +30,7 @@
import { Value } from "typebox/value";
import type { TSchema } from "typebox";
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
import { repairMathForDisplay } from "./math-delims.ts";
import { healEncodedArguments } from "./argument-heal.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -79,7 +79,7 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
* plus "nabla" as letters), so even runs before a command start inside
* balanced math spans collapse to one backslash. */
function restoreMathEscapesDeep(value: unknown): unknown {
if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value));
if (typeof value === "string") return repairMathForDisplay(value);
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
if (typeof value === "object" && value !== null) {
const out: Record<string, unknown> = {};
+89 -1
View File
@@ -8,6 +8,7 @@
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
@@ -17,7 +18,8 @@ import {
PRACTICE_PROBLEM_TOOL,
type LhMessage,
} from "../src/core/native-messages.ts";
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
import { concludeInputObject, concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
@@ -235,6 +237,92 @@ describe("the practice agent", () => {
});
});
// ── persistent nudges (shipped defaults, no model calls) ──────────────────────
describe("persistent practice nudges", () => {
const doctrine = readFileSync(new URL("../src/agent/prompts/practice/10-doctrine.md", import.meta.url), "utf8");
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
const approvedParagraphs = [
"- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.",
"**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.",
"Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.",
];
it("preserves the approved paragraphs in source and the effective default prompt", () => {
// Pin approved wording and Markdown, but allow incidental line wrapping.
for (const text of [doctrine, composeInstructions("practice")]) {
const paragraphs = text.split(/\n\s*\n|\n(?=- )/).map(normalizeWhitespace);
for (const approved of approvedParagraphs) {
assert.ok(paragraphs.includes(approved), `missing approved paragraph: ${approved}`);
}
}
});
it("does not compose the retired attempt cutoff, failed-repair reveal, or automatic-abort instructions", () => {
for (const text of [doctrine, composeInstructions("practice")]) {
const normalized = normalizeWhitespace(text);
assert.doesNotMatch(normalized, /\b(?:one|single|1) bounded attempt\b/i);
assert.doesNotMatch(normalized, /If the repair attempt fails, reveal and move on/i);
assert.doesNotMatch(normalized, /A correction found by the learner after a confident error consolidates more/i);
assert.doesNotMatch(normalized, /when the trace shows genuine inability, drop straight to R5 and reveal/i);
assert.doesNotMatch(normalized, /when the learner surrenders or cannot proceed/i);
assert.doesNotMatch(normalized, /when the marginal yield of staying is exhausted/i);
assert.doesNotMatch(normalized, /do not withhold them past the point of exhaustion/i);
}
});
it("preserves the approved concluded-problem switch and unbounded structural repair in source and the effective default prompt", () => {
const approvedDifficulty = "3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.";
const approvedClassification = "Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.";
for (const text of [doctrine, composeInstructions("practice")]) {
const normalized = normalizeWhitespace(text);
assert.ok(normalized.includes(approvedDifficulty), "preserve the difficulty readout and exact approved switching rule");
assert.ok(normalized.includes(approvedClassification), "preserve the miss taxonomy and exact approved structural-repair rule");
assert.doesNotMatch(normalized, /two misses in a row, switch targets/i);
assert.doesNotMatch(normalized, /Structural gets one surgical line/i);
assert.doesNotMatch(normalized, /the correction, stated once/i);
assert.doesNotMatch(normalized, /if a repair needs more than a line/i);
}
});
it("preserves the approved misconception introduction without an affirmative one-line repair cap", () => {
const approvedIntroduction = "- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.";
for (const text of [doctrine, composeInstructions("practice")]) {
const normalized = normalizeWhitespace(text);
assert.ok(normalized.includes(approvedIntroduction), "preserve the exact approved introduction and the rest of the not-teaching bullet");
assert.doesNotMatch(normalized, /Repair them in a line, surgically, and return to practice\./i);
}
});
it("keeps the many-claims default, hint ladder, dismissal, and explicit-go-ahead pause", () => {
const prompt = normalizeWhitespace(composeInstructions("practice"));
assert.match(prompt, /A problem may take many claims; staying is the default/);
for (const rung of ["R0", "R1", "R2", "R3", "R4", "R5"]) {
assert.ok(prompt.includes(`**${rung} —`));
}
assert.match(prompt, /Concluding ends your turn; the next problem is posed only when the learner explicitly moves on/);
assert.match(prompt, /After the conclude's result.*END YOUR TURN: no further tool calls in that reply, never another problem/);
assert.match(prompt, /A clarification question is never the go-ahead; when in doubt, answer and wait/);
assert.match(prompt, /The learner may discard a problem without concluding it/);
});
it("aligns the conclude description with the canonical ack without changing outcomes or the runtime pause mechanism", async () => {
const tool = concludeTool(createPracticeGate(ctxOf(activeProblemRecord())));
const description = normalizeWhitespace(tool.description);
assert.match(description, /records the outcome \+ verdict, reveals the committed solution, and frees the learner's composer/);
assert.match(description, /After the result, say your closing words about the learner's work if useful, then END YOUR TURN — no further tool calls/);
assert.ok(description.includes(CONCLUDE_ACK_TEXT.slice(CONCLUDE_ACK_TEXT.indexOf("The next problem"))));
assert.match(description, /Nothing may conclude unless a problem is active/);
assert.doesNotMatch(description, /turn CONTINUES|reveal, then pose the next problem/);
assert.deepEqual(concludeInputObject.properties.outcome.anyOf.map((outcome) => outcome.const), [
"solved", "solvedWithHints", "movedOn", "surrendered", "dismissed",
]);
const result = await tool.execute("call_persistent_nudges", CONCLUDE_INPUT as never);
assert.equal((result.content[0] as { text: string }).text, CONCLUDE_ACK_TEXT);
assert.equal(result.terminate, undefined, "closing prose stays possible; the pause remains an instruction, not a new runtime gate");
});
});
// ── the prompt composition ──────────────────────────────────────────────────
describe("practice prompt composition (own directory + the formatting appendix)", () => {
+204
View File
@@ -0,0 +1,204 @@
/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { validateToolArguments } from "@earendil-works/pi-ai";
import { problemInputObject } from "../src/agent/tools/practice.ts";
import { PracticeDock } from "../components/practice-dock.tsx";
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
import { MessageResponse } from "../components/ai-elements/message.tsx";
import {
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
toLlmMessages, type LhMessage, type ArtifactPartProps,
} from "../src/core/index.ts";
const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`;
const clean = `$${tex}$`;
const doubled = clean.replace(/\\/g, "\\\\");
const quadrupled = clean.replace(/\\/g, "\\\\\\\\");
const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f.
const repairedDebris = String.raw`\forall x \in \mathbb{R}`;
const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$";
const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`;
const part = (input: unknown, over: Partial<ArtifactPartProps["part"]> = {}): ArtifactPartProps["part"] => ({
type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
});
const dock = (input: { statement: string }, expanded = true) => (
<PracticeDock problemCallId="call_math" input={input} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
);
function math(html: string) {
const { document } = parseHTML(html);
return {
annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent),
glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""),
errors: document.querySelectorAll(".katex-error").length,
};
}
function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) {
const actual = math(html);
assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors");
assert.equal(actual.errors, 0);
for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`);
}
describe("repairMathForDisplay — the existing composition, unchanged", () => {
const unchanged = [
clean,
String.raw`outside \\nabla`,
String.raw`$a \\\nabla b$`,
String.raw`$a \\\\\nabla b$`,
String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`,
"$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too.
"`" + doubled + "`",
"```tex\n" + doubled + "\n```",
"`" + debris + "`",
"```tex\n" + debris + "\n```",
String.raw`$\\nabla`,
"$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close.
];
for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix,
String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) {
it(`matches the old composition byte-for-byte (${index})`, () => {
assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value)));
});
}
it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => {
for (const value of unchanged) assert.equal(repairMathForDisplay(value), value);
assert.equal(repairMathForDisplay(doubled), clean);
assert.equal(repairMathForDisplay(quadrupled), clean);
assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`);
assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`);
});
});
describe("practice display surfaces", () => {
for (const [name, raw, expected, glyphs] of [
["two command slashes", doubled, tex, ["∇", "∈", "×"]],
["four command slashes", quadrupled, tex, ["∇", "∈", "×"]],
["JSON debris", debris, repairedDebris, ["∀", "∈"]],
["clean math", clean, tex, ["∇", "∈", "×"]],
["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]],
] as const) {
it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => {
const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` });
const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw });
const before = JSON.stringify({ input, conclusion });
for (const element of [dock(input), <ProblemCard part={part(input)} />]) {
const html = renderToStaticMarkup(element);
assertMath(html, [expected], [...glyphs]);
assert.ok(!html.includes("HIDDEN COMMITMENT"));
}
assertMath(renderToStaticMarkup(<ConcludeCard part={part(conclusion, { type: "tool-conclude", reveal: raw })} />),
[expected, expected], [...glyphs]);
assertMath(renderToStaticMarkup(<ConcludeCard part={part({ outcome: "solved", solutionReveal: raw })} />),
[expected], [...glyphs]);
assert.equal(JSON.stringify({ input, conclusion }), before);
});
}
it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => {
const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." };
const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw };
const validated = validateToolArguments(
{ name: "problem", parameters: problemInputObject } as never,
call as never,
) as { statement: unknown };
assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments");
assert.equal(raw.statement, 17, "saved arguments retain the number");
const messages = [
{ id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 },
{ id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
] as unknown as LhMessage[];
const before = structuredClone(messages);
const active = activeProblemOf(messages);
assert.ok(active);
assert.equal((active.input as { statement: unknown }).statement, 17);
// Mirror the existing runtime cast at the Chat -> PracticeDock seam.
const input = active.input as { statement: string };
let expanded = "";
assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); });
const { document } = parseHTML(expanded);
assert.equal(document.querySelector(".lh-prose")!.innerHTML,
parseHTML(renderToStaticMarkup(<MessageResponse>{input.statement}</MessageResponse>)).document.firstElementChild!.outerHTML,
"non-string values pass through exactly as before, without string coercion or a new fallback");
const collapsed = renderToStaticMarkup(dock(input, false));
assert.match(collapsed, /data-testid="practice-dock-bar"/);
assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17");
assert.deepEqual(math(collapsed).annotations, []);
assert.deepEqual(messages, before);
assert.equal(raw.statement, 17);
});
it("preserves code spans/fences across every repaired display surface", () => {
const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```";
for (const element of [dock({ statement: raw }), <ProblemCard part={part({ statement: raw })} />,
<ConcludeCard part={part({ outcome: "solved", verdict: raw }, { reveal: raw })} />]) {
const html = renderToStaticMarkup(element);
assert.deepEqual(math(html).annotations, []);
const { document } = parseHTML(html);
assert.ok(document.querySelector("code")?.textContent?.includes(doubled));
}
});
it("does not change the collapsed preview, dismissal or global prose renderer", () => {
const preview = renderToStaticMarkup(dock({ statement: doubled }, false));
assert.ok(preview.includes(doubled));
assert.deepEqual(math(preview).annotations, []);
const dismissed = renderToStaticMarkup(<ConcludeCard part={part({ outcome: "dismissed", verdict: doubled }, { reveal: doubled })} />);
assert.ok(dismissed.includes("Problem dismissed."));
assert.deepEqual(math(dismissed).annotations, []);
const plain = math(renderToStaticMarkup(<MessageResponse>{doubled}</MessageResponse>));
assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]);
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
});
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
const ask = { question: doubled };
const explain = { question: doubled, correctAnswer: debris };
const before = structuredClone({ quiz, ask, explain });
assert.equal(parseQuizInput(quiz)?.question, clean);
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
assert.equal(parseAskInput(ask)?.question, clean);
assert.equal(parseExplainInput(explain)?.question, clean);
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
assert.deepEqual({ quiz, ask, explain }, before);
});
it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => {
const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" };
const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` };
const messages = [
{ ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] },
{ id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
] as unknown as LhMessage[];
const concluded = [...messages,
{ ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] },
{ id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 },
] as unknown as LhMessage[];
const before = structuredClone(concluded);
const history = structuredClone(toLlmMessages(concluded));
assert.deepEqual(activeProblemOf(messages)?.input, input);
const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!;
assert.ok(problem.state && problem.toolCallId);
assert.deepEqual(problem.input, input);
const statementHtml = renderToStaticMarkup(<ProblemCard part={{ ...problem, state: problem.state, toolCallId: problem.toolCallId }} />);
assertMath(statementHtml);
assert.ok(!statementHtml.includes("COMMITTED"));
const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!;
assert.ok(conclusion.state && conclusion.toolCallId);
assert.equal(conclusion.reveal, input.modelSolution);
const revealHtml = renderToStaticMarkup(<ConcludeCard part={{ ...conclusion, state: conclusion.state, toolCallId: conclusion.toolCallId }} />);
assert.ok(revealHtml.includes("COMMITTED"));
assertMath(revealHtml, [tex, tex]);
assert.deepEqual(concluded, before);
assert.deepEqual(toLlmMessages(concluded), history);
});
});