diff --git a/frontend/src/components/ai-elements/streamdown.tsx b/frontend/src/components/ai-elements/streamdown.tsx index 210053d9b..be8dfc9fd 100644 --- a/frontend/src/components/ai-elements/streamdown.tsx +++ b/frontend/src/components/ai-elements/streamdown.tsx @@ -1,9 +1,10 @@ "use client"; -import { type ComponentProps } from "react"; +import { Component, useMemo, type ComponentProps, type ReactNode } from "react"; import { Streamdown } from "streamdown"; import { installClipboardFallback } from "@/core/clipboard"; +import { capBlockquoteNesting } from "@/core/streamdown/preprocess"; export type ClipboardSafeStreamdownProps = ComponentProps; @@ -12,6 +13,61 @@ if (typeof document !== "undefined") { installClipboardFallback(); } -export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) { - return ; +// marked (used by Streamdown to split content into blocks) has mutually +// recursive tokenizers — blockquote/list nesting a couple thousand levels +// deep overflows the call stack during render and would otherwise take down +// the whole route. When rendering a message throws, fall back to showing +// that message as plain pre-formatted text instead. +class StreamdownFallbackBoundary extends Component< + { raw: ClipboardSafeStreamdownProps["children"]; children: ReactNode }, + { errored: boolean; prevRaw: ClipboardSafeStreamdownProps["children"] } +> { + state = { errored: false, prevRaw: this.props.raw }; + + static getDerivedStateFromError() { + return { errored: true }; + } + + static getDerivedStateFromProps( + props: { raw: ClipboardSafeStreamdownProps["children"] }, + state: { + errored: boolean; + prevRaw: ClipboardSafeStreamdownProps["children"]; + }, + ) { + // Retry rendering when the content changes (e.g. the next streaming chunk). + if (props.raw !== state.prevRaw) { + return { errored: false, prevRaw: props.raw }; + } + return null; + } + + render() { + if (this.state.errored) { + return ( +
+ {typeof this.props.raw === "string" ? this.props.raw : null} +
+ ); + } + return this.props.children; + } +} + +export function ClipboardSafeStreamdown({ + children, + ...props +}: ClipboardSafeStreamdownProps) { + // Fast path for the dominant pathological input (pure ">" chains) so the + // error boundary below rarely has to absorb a full stack overflow. + const safeChildren = useMemo( + () => + typeof children === "string" ? capBlockquoteNesting(children) : children, + [children], + ); + return ( + + {safeChildren} + + ); } diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 458759255..c3c33a90e 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -19,7 +19,6 @@ import { Loader } from "@/components/ai-elements/loader"; import { Message as AIElementMessage, MessageContent as AIElementMessageContent, - MessageResponse as AIElementMessageResponse, MessageToolbar, } from "@/components/ai-elements/message"; import { @@ -44,7 +43,6 @@ import { type FileInMessage, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; -import { humanMessagePlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; @@ -300,17 +298,10 @@ function MessageContent_({ } if (isHuman) { - const messageResponse = contentToDisplay ? ( - - {contentToDisplay} - - ) : null; + // Composer input is plain text, not authored Markdown. Parsing it as + // Markdown mangles pasted code/logs (indented lines become code blocks, + // "$...$" spans become math) and lets pathological input crash the page + // through marked's recursive blockquote lexer, so render it verbatim. return (
{filesList} - {messageResponse && ( + {contentToDisplay && ( - {messageResponse} +
+ {contentToDisplay} +
)}
diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts index d576252c5..c4458fd93 100644 --- a/frontend/src/core/streamdown/plugins.ts +++ b/frontend/src/core/streamdown/plugins.ts @@ -36,15 +36,3 @@ export const reasoningPlugins = { (p) => p !== rehypeRaw, ) as StreamdownProps["rehypePlugins"], }; - -// Plugins for human messages - no autolink to prevent URL bleeding into adjacent text -export const humanMessagePlugins = { - remarkPlugins: [ - // Use remark-gfm without autolink literals by not including it - // Only include math support for human messages - [remarkMath, { singleDollarTextMath: true }], - ] as StreamdownProps["remarkPlugins"], - rehypePlugins: [ - [rehypeKatex, { output: "html" }], - ] as StreamdownProps["rehypePlugins"], -}; diff --git a/frontend/src/core/streamdown/preprocess.ts b/frontend/src/core/streamdown/preprocess.ts index 6d0c9bfa7..47bd242e0 100644 --- a/frontend/src/core/streamdown/preprocess.ts +++ b/frontend/src/core/streamdown/preprocess.ts @@ -2,6 +2,59 @@ import { normalizeMermaidMarkdown } from "./mermaid"; const MERMAID_BLOCK_HINT_RE = /mermaid/i; +// marked's blockquote tokenizer (used by Streamdown to split content into +// memoizable blocks) recurses once per nesting level and overflows the call +// stack at roughly 2,000 levels, replacing the whole chat route with an error +// page. 100 levels is far beyond any legitimate content while keeping a wide +// margin below the crash threshold. +const MAX_BLOCKQUOTE_DEPTH = 100; +const DEEP_BLOCKQUOTE_HINT_RE = new RegExp( + `^(?:[ \\t]*>){${MAX_BLOCKQUOTE_DEPTH + 1}}`, + "m", +); +// Only up to 3 leading spaces can start a blockquote; 4+ (or a tab) is an +// indented code block, where ">" runs are literal content. +const BLOCKQUOTE_PREFIX_RE = /^ {0,3}(?:[ \t]*>)+/; +const CODE_FENCE_RE = /^ {0,3}(?:```|~~~)/; +const INDENTED_CODE_RE = /^(?: {4}|\t)/; + +export function capBlockquoteNesting(markdown: string): string { + if (!DEEP_BLOCKQUOTE_HINT_RE.test(markdown)) { + return markdown; + } + + let insideFence = false; + return markdown + .split("\n") + .map((line) => { + if (CODE_FENCE_RE.test(line)) { + insideFence = !insideFence; + return line; + } + // ">" runs inside fenced or indented code blocks are literal text, not + // nesting — rewriting them would silently corrupt code content. + if (insideFence || INDENTED_CODE_RE.test(line)) { + return line; + } + const match = BLOCKQUOTE_PREFIX_RE.exec(line); + if (!match) { + return line; + } + const prefix = match[0]; + let depth = 0; + for (let i = 0; i < prefix.length; i++) { + if (prefix[i] === ">") { + depth += 1; + if (depth > MAX_BLOCKQUOTE_DEPTH) { + return line.slice(0, i) + line.slice(prefix.length); + } + } + } + return line; + }) + .join("\n"); +} + export function preprocessStreamdownMarkdown(markdown: string): string { if (!MERMAID_BLOCK_HINT_RE.test(markdown) || !markdown.includes("-.->")) { return markdown; diff --git a/frontend/tests/e2e/user-message-plain-text.spec.ts b/frontend/tests/e2e/user-message-plain-text.spec.ts new file mode 100644 index 000000000..402cee57c --- /dev/null +++ b/frontend/tests/e2e/user-message-plain-text.spec.ts @@ -0,0 +1,137 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; + +const C_SOURCE = `#include +#include + +static volatile int connected = 0; + +static void daemon_handle_signal(int sig) { + if (sig == SIGTERM) { + connected = 0; + + printf("daemon stop requested\\n"); + return; + } + + printf("ignored signal %d\\n", sig); +}`; + +function threadWithMessages( + humanText: string, + aiText = "ack", +): Parameters[1] { + return { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Plain text rendering", + updated_at: "2025-06-01T12:00:00Z", + messages: [ + { + type: "human", + id: "msg-human-plain-text", + content: [{ type: "text", text: humanText }], + }, + { + type: "ai", + id: "msg-ai-plain-text", + content: aiText, + }, + ], + }, + ], + }; +} + +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(`${error.name}: ${error.message}`); + }); + return errors; +} + +test.describe("User message plain-text rendering", () => { + test("pasted source code renders verbatim as one block", async ({ page }) => { + mockLangGraphAPI(page, threadWithMessages(C_SOURCE)); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("ack")).toBeVisible({ timeout: 15_000 }); + + // The pasted file must not be split into Markdown code-block widgets. + await expect( + page.locator('[data-code-block-container="true"]'), + ).toHaveCount(0); + + // Indentation and line structure must be preserved verbatim. + const bubble = page.locator(".is-user"); + const text = await bubble.innerText(); + expect(text).toContain("#include "); + expect(text).toContain(" if (sig == SIGTERM) {"); + expect(text).toContain(' printf("daemon stop requested\\n");'); + }); + + test("dollar signs are not parsed as math", async ({ page }) => { + const message = "this costs $5 and $10 in total"; + mockLangGraphAPI(page, threadWithMessages(message)); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("ack")).toBeVisible({ timeout: 15_000 }); + + await expect(page.locator(".is-user")).toContainText(message); + await expect(page.locator(".is-user .katex")).toHaveCount(0); + }); + + test("deeply nested blockquote markers in a user message do not crash the page", async ({ + page, + }) => { + const pageErrors = collectPageErrors(page); + mockLangGraphAPI(page, threadWithMessages("> ".repeat(3000) + "hi")); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("ack")).toBeVisible({ timeout: 15_000 }); + + expect(pageErrors).toEqual([]); + await expect(page.locator(".is-user")).toContainText("> > >"); + }); + + test("deeply nested blockquote markers in an AI message do not crash the page", async ({ + page, + }) => { + const pageErrors = collectPageErrors(page); + mockLangGraphAPI( + page, + threadWithMessages("hello", "> ".repeat(3000) + "deep"), + ); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("hello")).toBeVisible({ timeout: 15_000 }); + + expect(pageErrors).toEqual([]); + // The capped blockquote chain still renders (100 levels of indentation can + // squeeze the innermost element to zero width, so assert presence, not + // visibility). + await expect(page.getByText("deep")).toBeAttached(); + }); + + test("list-prefixed deep nesting in an AI message falls back to plain text instead of crashing", async ({ + page, + }) => { + // marked's list and blockquote tokenizers are mutually recursive, so a + // list marker in front of the quote chain bypasses the nesting cap; the + // render error boundary must absorb it. + const pageErrors = collectPageErrors(page); + mockLangGraphAPI( + page, + threadWithMessages("hello", "- " + "> ".repeat(3000) + "deep-list"), + ); + + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText("hello")).toBeVisible({ timeout: 15_000 }); + + expect(pageErrors).toEqual([]); + await expect(page.getByText("deep-list")).toBeAttached(); + }); +}); diff --git a/frontend/tests/unit/core/streamdown/preprocess.test.ts b/frontend/tests/unit/core/streamdown/preprocess.test.ts new file mode 100644 index 000000000..71ca7f242 --- /dev/null +++ b/frontend/tests/unit/core/streamdown/preprocess.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "vitest"; + +import { + capBlockquoteNesting, + preprocessStreamdownMarkdown, +} from "@/core/streamdown/preprocess"; + +test("capBlockquoteNesting returns normal content unchanged", () => { + const input = "# Title\n\n> a quote\n>> nested\n\nsome `code`"; + expect(capBlockquoteNesting(input)).toBe(input); +}); + +test("capBlockquoteNesting keeps nesting at or below the cap untouched", () => { + const input = "> ".repeat(100) + "hi"; + expect(capBlockquoteNesting(input)).toBe(input); +}); + +test("capBlockquoteNesting caps pathological nesting and preserves content", () => { + const result = capBlockquoteNesting("> ".repeat(5000) + "hi"); + expect((result.match(/>/g) ?? []).length).toBe(100); + expect(result.endsWith("hi")).toBe(true); +}); + +test("capBlockquoteNesting handles markers without spaces", () => { + const result = capBlockquoteNesting(">".repeat(5000) + "hi"); + expect((result.match(/>/g) ?? []).length).toBe(100); + expect(result.endsWith("hi")).toBe(true); +}); + +test("capBlockquoteNesting leaves fenced code content untouched", () => { + const literal = ">".repeat(150); + const input = `${"> ".repeat(3000)}hi\n\`\`\`text\n${literal}\n\`\`\``; + const result = capBlockquoteNesting(input); + expect(result.split("\n")[2]).toBe(literal); +}); + +test("capBlockquoteNesting leaves indented code blocks untouched", () => { + const literal = " " + ">".repeat(150); + const input = `${"> ".repeat(3000)}hi\n\n${literal}`; + const result = capBlockquoteNesting(input); + expect(result.split("\n")[2]).toBe(literal); +}); + +test("capBlockquoteNesting only rewrites pathological lines", () => { + const normal = "> normal quote"; + const deep = "> ".repeat(3000) + "deep"; + const result = capBlockquoteNesting(`${normal}\n${deep}\nplain`); + const lines = result.split("\n"); + expect(lines[0]).toBe(normal); + expect((lines[1]?.match(/>/g) ?? []).length).toBe(100); + expect(lines[2]).toBe("plain"); +}); + +test("preprocessStreamdownMarkdown leaves non-mermaid content unchanged", () => { + const input = "just some text"; + expect(preprocessStreamdownMarkdown(input)).toBe(input); +});