diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx
index 392f8a7..08cd053 100644
--- a/components/practice-cards.tsx
+++ b/components/practice-cards.tsx
@@ -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) {
Problem
- {statement}
+ {repairMathForDisplay(statement)}
);
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
(owner round 3): the same quiet 50% register as quiz explanations. */}
{verdict.length > 0 && (
- {verdict}
+ {repairMathForDisplay(verdict)}
)}
{/* Then the divider, then the labeled model solution (Slice G: the
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
Model solution
- {reveal}
+ {repairMathForDisplay(reveal)}
>
diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx
index e4ed501..e24f4f4 100644
--- a/components/practice-dock.tsx
+++ b/components/practice-dock.tsx
@@ -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
Problem
- {input.statement}
+ {typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}
diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts
index 9299dd2..b939054 100644
--- a/src/core/math-delims.ts
+++ b/src/core/math-delims.ts
@@ -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));
diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts
index 980166e..cb5585d 100644
--- a/src/core/tool-parts.ts
+++ b/src/core/tool-parts.ts
@@ -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(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 = {};
diff --git a/tests/repair-math-display.test.tsx b/tests/repair-math-display.test.tsx
new file mode 100644
index 0000000..6ab3d22
--- /dev/null
+++ b/tests/repair-math-display.test.tsx
@@ -0,0 +1,204 @@
+/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
+import React from "react";
+(globalThis as Record).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"] => ({
+ type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
+});
+const dock = (input: { statement: string }, expanded = true) => (
+ {}} 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), ]) {
+ const html = renderToStaticMarkup(element);
+ assertMath(html, [expected], [...glyphs]);
+ assert.ok(!html.includes("HIDDEN COMMITMENT"));
+ }
+ assertMath(renderToStaticMarkup(),
+ [expected, expected], [...glyphs]);
+ assertMath(renderToStaticMarkup(),
+ [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({input.statement})).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 }), ,
+ ]) {
+ 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();
+ assert.ok(dismissed.includes("Problem dismissed."));
+ assert.deepEqual(math(dismissed).annotations, []);
+ const plain = math(renderToStaticMarkup({doubled}));
+ 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({parseAskInput(ask)!.question}));
+ 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();
+ 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();
+ assert.ok(revealHtml.includes("COMMITTED"));
+ assertMath(revealHtml, [tex, tex]);
+ assert.deepEqual(concluded, before);
+ assert.deepEqual(toLlmMessages(concluded), history);
+ });
+});