fix: assistant text rendering, toolResult plumbing leak; add render smoke tests
- Markdown.tsx never passed text as children to ReactMarkdown (valid JSX, compiled clean, rendered nothing) — the 'thinking shows, text doesn't' bug - toolResult entries (quiz/ask dispatch instructions for the LLM) leaked into the transcript as visible text; now never rendered - render smoke tests (renderToStaticMarkup, no DOM): pin markdown content, KaTeX, button-based quiz options, ask answers not graded, toolResult and [Quiz answer] hiding — negative-tested against the actual bugs - React default import in all .tsx: tsx test runner uses classic JSX transform (ignores tsconfig jsx option), Vite uses automatic runtime; explicit import works under both - katex css moved to main.tsx (tests can't import css) - verified live in browser: all assistant messages render, ask card has no grading marks, pending quiz re-arms on resume
This commit is contained in:
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Render smoke tests — the layer every earlier bug slipped through.
|
||||||
|
*
|
||||||
|
* Components are rendered to static HTML (no browser, no DOM) and we assert
|
||||||
|
* the expected content is actually IN the output. These would have caught:
|
||||||
|
* - Markdown never receiving its text (assistant messages rendered empty)
|
||||||
|
* - ask answers rendered as graded quiz answers (red ✗)
|
||||||
|
* - quiz options built from native radio inputs (the desync bug)
|
||||||
|
*
|
||||||
|
* Mermaid: SSR skips effects, so diagrams don't render here — that's fine,
|
||||||
|
* we only assert the surrounding markup doesn't crash.
|
||||||
|
*
|
||||||
|
* Note: components carry `import React from "react"` because tsx (the test
|
||||||
|
* runner) uses the classic JSX transform, which needs React in scope. Vite
|
||||||
|
* uses the automatic runtime, where the import is simply unused.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createElement as h } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { Markdown } from "../web/src/components/Markdown.tsx";
|
||||||
|
import { QuizCard } from "../web/src/components/QuizCard.tsx";
|
||||||
|
import { AnsweredCard } from "../web/src/components/AnsweredCard.tsx";
|
||||||
|
import { EntryBlock } from "../web/src/views/SessionView.tsx";
|
||||||
|
import type { ClientQuestion, QuestionAnswerData } from "../src/shared/protocol.ts";
|
||||||
|
|
||||||
|
describe("render smoke tests", () => {
|
||||||
|
it("Markdown renders the actual text content", () => {
|
||||||
|
const html = renderToStaticMarkup(h(Markdown, { text: "Hello **bold** world" }));
|
||||||
|
assert.match(html, /Hello/);
|
||||||
|
assert.match(html, /<strong>bold<\/strong>/);
|
||||||
|
assert.match(html, /world/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Markdown renders LaTeX via KaTeX", () => {
|
||||||
|
const html = renderToStaticMarkup(h(Markdown, { text: "Energy is $E = mc^2$ here." }));
|
||||||
|
assert.match(html, /here\./);
|
||||||
|
assert.match(html, /katex/i, "KaTeX markup present");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Markdown survives a mermaid block without crashing", () => {
|
||||||
|
const html = renderToStaticMarkup(h(Markdown, { text: "Before\n\n```mermaid\ngraph TD\nA-->B\n```\n\nAfter" }));
|
||||||
|
assert.match(html, /Before/);
|
||||||
|
assert.match(html, /After/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("QuizCard renders options as buttons (no native radio/checkbox inputs)", () => {
|
||||||
|
const q: ClientQuestion = {
|
||||||
|
id: "q1",
|
||||||
|
kind: "quiz",
|
||||||
|
question: "Which planet?",
|
||||||
|
mode: "single-select",
|
||||||
|
options: [
|
||||||
|
{ label: "Mercury", value: "mercury" },
|
||||||
|
{ label: "Venus", value: "venus" },
|
||||||
|
],
|
||||||
|
multiSelect: false,
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(h(QuizCard, { question: q, send: () => {} }));
|
||||||
|
assert.match(html, /Mercury/);
|
||||||
|
assert.match(html, /Venus/);
|
||||||
|
assert.match(html, /I don't know/);
|
||||||
|
assert.ok(!/<input[^>]*type="radio"/.test(html), "no native radio inputs (v1 desync bug)");
|
||||||
|
assert.ok(!/<input[^>]*type="checkbox"/.test(html), "no native checkbox inputs");
|
||||||
|
assert.match(html, /<button[^>]*role="radio"/, "options are buttons with radio role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("QuizCard multi-select uses checkbox role on buttons", () => {
|
||||||
|
const q: ClientQuestion = {
|
||||||
|
id: "q2",
|
||||||
|
kind: "quiz",
|
||||||
|
question: "Pick all.",
|
||||||
|
mode: "multi-select",
|
||||||
|
options: [
|
||||||
|
{ label: "A", value: "a" },
|
||||||
|
{ label: "B", value: "b" },
|
||||||
|
],
|
||||||
|
multiSelect: true,
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(h(QuizCard, { question: q, send: () => {} }));
|
||||||
|
assert.match(html, /<button[^>]*role="checkbox"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AnsweredCard: quiz answers are graded", () => {
|
||||||
|
const data: QuestionAnswerData = {
|
||||||
|
questionId: "q1",
|
||||||
|
kind: "quiz",
|
||||||
|
questionText: "Which planet?",
|
||||||
|
mode: "single-select",
|
||||||
|
options: [
|
||||||
|
{ index: 1, label: "Mercury" },
|
||||||
|
{ index: 2, label: "Venus" },
|
||||||
|
],
|
||||||
|
answers: [{ index: 1, label: "Mercury", value: "mercury" }],
|
||||||
|
correctIndices: [1],
|
||||||
|
correct: true,
|
||||||
|
explanation: "Because.",
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(h(AnsweredCard, { data }));
|
||||||
|
assert.match(html, /Correct/);
|
||||||
|
assert.match(html, /✓/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AnsweredCard: ask answers are NOT graded (no ✗, no verdict)", () => {
|
||||||
|
const data: QuestionAnswerData = {
|
||||||
|
questionId: "q1",
|
||||||
|
kind: "ask",
|
||||||
|
questionText: "What do you prefer?",
|
||||||
|
mode: "single-select",
|
||||||
|
options: [
|
||||||
|
{ index: 1, label: "Option A" },
|
||||||
|
{ index: 2, label: "Option B" },
|
||||||
|
],
|
||||||
|
answers: [{ index: 2, label: "Option B", value: "b" }],
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(h(AnsweredCard, { data }));
|
||||||
|
assert.match(html, /Option B/);
|
||||||
|
assert.ok(!html.includes("✗"), "ask answers never show ✗ (v1 grading bug)");
|
||||||
|
assert.ok(!/Incorrect/.test(html), "no grading verdict for ask");
|
||||||
|
assert.ok(!/Correct<\/div>/.test(html));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AnsweredCard: dismissed questions render as skipped", () => {
|
||||||
|
const data: QuestionAnswerData = {
|
||||||
|
questionId: "q1",
|
||||||
|
kind: "quiz",
|
||||||
|
questionText: "Q?",
|
||||||
|
mode: "single-select",
|
||||||
|
options: [],
|
||||||
|
answers: [],
|
||||||
|
dismissed: true,
|
||||||
|
};
|
||||||
|
const html = renderToStaticMarkup(h(AnsweredCard, { data }));
|
||||||
|
assert.match(html, /Skipped/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EntryBlock: toolResult entries (LLM plumbing) are never rendered", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
h(EntryBlock, {
|
||||||
|
entry: {
|
||||||
|
type: "toolResult",
|
||||||
|
id: "e1",
|
||||||
|
toolName: "quiz",
|
||||||
|
text: "Quiz displayed to the learner. End your turn now and wait…",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(!html.includes("End your turn"), "dispatch instructions must not leak into the transcript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EntryBlock: quiz-answer user messages are not duplicated in the transcript", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
h(EntryBlock, { entry: { type: "user", id: "e2", text: "[Quiz answer] Selected: Mercury" } }),
|
||||||
|
);
|
||||||
|
assert.ok(!html.includes("Mercury"), "already rendered as an answer card");
|
||||||
|
const normal = renderToStaticMarkup(h(EntryBlock, { entry: { type: "user", id: "e3", text: "Teach me physics" } }));
|
||||||
|
assert.match(normal, /Teach me physics/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
* resumes via POST before connecting.
|
* resumes via POST before connecting.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { NewSessionResponse, SessionSummary } from "../../src/shared/protocol.ts";
|
import type { NewSessionResponse, SessionSummary } from "../../src/shared/protocol.ts";
|
||||||
import { Home } from "./views/Home.tsx";
|
import { Home } from "./views/Home.tsx";
|
||||||
import { SessionView } from "./views/SessionView.tsx";
|
import { SessionView } from "./views/SessionView.tsx";
|
||||||
|
|||||||
@@ -311,14 +311,6 @@ textarea:disabled {
|
|||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-result {
|
|
||||||
padding: 2px 24px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
max-width: 820px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import type { QuestionAnswerData } from "../../../src/shared/protocol.ts";
|
import type { QuestionAnswerData } from "../../../src/shared/protocol.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GraphPlaceholder — the home view's main panel. Deliberately isolated: the
|
* GraphPlaceholder — the home view's main panel. Deliberately isolated: the
|
||||||
* real knowledge graph (territory map, domain clusters, frontier) is a
|
* real knowledge graph (territory map, domain clusters, frontier) is a
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { memo, useEffect, useRef, useState } from "react";
|
import React, { memo, useEffect, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkMath from "remark-math";
|
import remarkMath from "remark-math";
|
||||||
import rehypeKatex from "rehype-katex";
|
import rehypeKatex from "rehype-katex";
|
||||||
import "katex/dist/katex.min.css";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown — renders assistant prose with LaTeX (KaTeX) and mermaid diagrams
|
* Markdown — renders assistant prose with LaTeX (KaTeX) and mermaid diagrams
|
||||||
@@ -73,7 +72,9 @@ export const Markdown = memo(function Markdown({ text }: { text: string }) {
|
|||||||
return <pre>{children}</pre>;
|
return <pre>{children}</pre>;
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* survives reloads and resumes.
|
* survives reloads and resumes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import type { ClientToolCall } from "../../../src/shared/protocol.ts";
|
import type { ClientToolCall } from "../../../src/shared/protocol.ts";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* - text mode (ask): free-form answer
|
* - text mode (ask): free-form answer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import type { ClientQuestion } from "../../../src/shared/protocol.ts";
|
import type { ClientQuestion } from "../../../src/shared/protocol.ts";
|
||||||
|
|
||||||
interface QuizCardProps {
|
interface QuizCardProps {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* auto-scrolling pauses and a "jump to latest" pill appears.
|
* auto-scrolling pauses and a "jump to latest" pill appears.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import React, { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
export function Transcript({ children, scrollToken }: { children: ReactNode; scrollToken: string | number }) {
|
export function Transcript({ children, scrollToken }: { children: ReactNode; scrollToken: string | number }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App.tsx";
|
import { App } from "./App.tsx";
|
||||||
|
import "katex/dist/katex.min.css";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import type { SessionSummary } from "../../../src/shared/protocol.ts";
|
import type { SessionSummary } from "../../../src/shared/protocol.ts";
|
||||||
import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx";
|
import { GraphPlaceholder } from "../components/GraphPlaceholder.tsx";
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* and the composer.
|
* and the composer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import type { DisplayEntry, SessionState } from "../useSession.ts";
|
import type { DisplayEntry, SessionState } from "../useSession.ts";
|
||||||
import { useSession } from "../useSession.ts";
|
import { useSession } from "../useSession.ts";
|
||||||
import { Markdown } from "../components/Markdown.tsx";
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
@@ -29,7 +29,7 @@ interface SessionViewProps {
|
|||||||
/** User messages already rendered as answer cards — don't duplicate them. */
|
/** User messages already rendered as answer cards — don't duplicate them. */
|
||||||
const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"];
|
const HIDDEN_USER_PREFIXES = ["[Quiz answer]", "[Answer to your question]"];
|
||||||
|
|
||||||
function EntryBlock({ entry }: { entry: DisplayEntry }) {
|
export function EntryBlock({ entry }: { entry: DisplayEntry }) {
|
||||||
switch (entry.type) {
|
switch (entry.type) {
|
||||||
case "user":
|
case "user":
|
||||||
if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null;
|
if (HIDDEN_USER_PREFIXES.some((p) => entry.text.startsWith(p))) return null;
|
||||||
@@ -37,7 +37,9 @@ function EntryBlock({ entry }: { entry: DisplayEntry }) {
|
|||||||
case "assistant":
|
case "assistant":
|
||||||
return <AssistantMessage text={entry.text} thinking={entry.thinking} toolCalls={entry.toolCalls} />;
|
return <AssistantMessage text={entry.text} thinking={entry.thinking} toolCalls={entry.toolCalls} />;
|
||||||
case "toolResult":
|
case "toolResult":
|
||||||
return <div className="tool-result">{entry.text}</div>;
|
// Internal plumbing for the LLM ("Quiz displayed to the learner,
|
||||||
|
// end your turn…") — never learner-visible.
|
||||||
|
return null;
|
||||||
case "question":
|
case "question":
|
||||||
// Rendered by the pending-question flow; when answered, the
|
// Rendered by the pending-question flow; when answered, the
|
||||||
// questionAnswered entry renders instead.
|
// questionAnswered entry renders instead.
|
||||||
|
|||||||
Reference in New Issue
Block a user