Studio: render unclosed HTML/SVG fences as the tabbed preview too

End-to-end probe against Qwen3-0.6B caught a real renderer-engagement
bug: the model emits a complete SVG body inside a ``` fence but
forgets the closing ```. parseCodeFence is strict about the closing
backticks, and the StreamdownBlock fallback only tried
parseIncompleteCodeFence while isIncomplete was true. Once streaming
finished, parseCodeFence returned null, the fallback was skipped, and
HtmlSvgRenderer never mounted -- the user saw a plain code block where
the new Preview / Code tabs should have been.

Fix: always fall back to parseIncompleteCodeFence when parseCodeFence
fails. The fallback is safe for non-fence content (returns null) and
for non-HTML/SVG fences (HtmlSvgRenderer only engages on html/svg
languages; everything else falls through to renderHighlightedCode).

Adds a vitest case pinning the unclosed-final-fence path so a future
regression is loud.
This commit is contained in:
Daniel Han 2026-05-25 13:45:32 +00:00
commit 6e4d71d09f
2 changed files with 24 additions and 6 deletions

View file

@ -203,6 +203,23 @@ describe("parseIncompleteCodeFence", () => {
it("returns null when the block is not a fence at all", () => {
expect(parseIncompleteCodeFence("just text")).toBeNull();
});
it("recovers a final-but-never-closed fence (small LLMs drop the closing ```)", () => {
// Regression: live probe against Qwen3-0.6B caught the model emitting a
// complete SVG body but no closing ```. parseCodeFence rejects (strict
// ``` ... ``` shape), so the StreamdownBlock fallback must still find the
// fence via parseIncompleteCodeFence even after streaming completes -- or
// HtmlSvgRenderer never mounts and the user sees a plain code block.
const finalNoClose =
"```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10 10\">" +
"<circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"orange\"/></svg>";
expect(parseCodeFence(finalNoClose)).toBeNull();
const fence = parseIncompleteCodeFence(finalNoClose);
expect(fence).not.toBeNull();
expect(fence?.language).toBe("svg");
expect(fence?.source).toContain("<circle");
expect(fence?.source).toContain("</svg>");
});
});
describe("Fence helpers", () => {

View file

@ -222,13 +222,14 @@ function renderHighlightedCode(props: BlockProps, codeFence: CodeFenceInfo) {
function StreamdownBlock(props: BlockProps) {
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
// parseCodeFence requires a closing ```; while the fence is still
// streaming we fall through to parseIncompleteCodeFence so HtmlSvgRenderer
// can mount with isIncomplete=true and lock the Code tab on partial
// HTML/SVG fences (the advertised stream-in behaviour).
// parseCodeFence requires a closing ```; we fall back to
// parseIncompleteCodeFence both while the fence is still streaming AND
// when a finished reply forgot to emit the closing ``` (small local LLMs
// routinely drop it). Without the second fallback the HtmlSvgRenderer
// never mounts on an unclosed final message and the reply degrades to a
// plain code block.
const codeFence =
parseCodeFence(props.content) ??
(props.isIncomplete ? parseIncompleteCodeFence(props.content) : null);
parseCodeFence(props.content) ?? parseIncompleteCodeFence(props.content);
if (props.isIncomplete && hasMermaidFence) {
return (