diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18ed233d80b3..22c8182bf99b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -596,19 +596,6 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk - extensions/workspaces: - dependencies: - typebox: - specifier: 1.3.3 - version: 1.3.3 - devDependencies: - '@openclaw/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk - openclaw: - specifier: workspace:* - version: link:../.. - extensions/crabbox: devDependencies: '@openclaw/plugin-sdk': @@ -1845,6 +1832,19 @@ importers: specifier: workspace:* version: link:../.. + extensions/workspaces: + dependencies: + typebox: + specifier: 1.3.3 + version: 1.3.3 + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + openclaw: + specifier: workspace:* + version: link:../.. + extensions/xai: dependencies: typebox: @@ -2009,12 +2009,12 @@ importers: packages/normalization-core: {} - packages/retry: {} - packages/plugin-package-contract: {} packages/plugin-sdk: {} + packages/retry: {} + packages/sdk: dependencies: '@openclaw/gateway-client': @@ -2115,6 +2115,9 @@ importers: marked: specifier: 18.0.5 version: 18.0.5 + remend: + specifier: 1.3.0 + version: 1.3.0 devDependencies: '@types/markdown-it': specifier: 14.1.2 @@ -7081,6 +7084,9 @@ packages: remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remend@1.3.0: + resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + repeat-string@1.6.1: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} @@ -7291,14 +7297,14 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} - engines: {node: '>=18'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -9454,6 +9460,15 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.25) @@ -9476,15 +9491,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)': - dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) - '@standard-schema/spec': 1.1.0 - zod: 4.4.3 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - '@mozilla/readability@0.6.0': {} '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -13588,6 +13594,8 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remend@1.3.0: {} + repeat-string@1.6.1: {} require-directory@2.1.1: {} @@ -13845,10 +13853,10 @@ snapshots: is-fullwidth-code-point: 5.1.0 optional: true - smol-toml@1.7.0: {} - smart-buffer@4.2.0: {} + smol-toml@1.7.0: {} + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 6c465504fce8..9e1616ac3680 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -4566,7 +4566,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "ui/src/components/markdown.ts: encodeBlockArtCodeBlockCopyPayload", "ui/src/components/markdown.ts: highlightCode", "ui/src/components/markdown.ts: md", - "ui/src/components/markdown.ts: toStreamingPlainTextHtml", "ui/src/components/mcp-app-view.ts: buildMcpAppHostCapabilities", "ui/src/components/mcp-app-view.ts: McpAppView", "ui/src/components/mcp-app-view.ts: resolveMcpAppSandboxUrl", diff --git a/scripts/ts-max-loc-baseline-v2.json b/scripts/ts-max-loc-baseline-v2.json index 221d4f377c0f..726a3ddcbcf3 100644 --- a/scripts/ts-max-loc-baseline-v2.json +++ b/scripts/ts-max-loc-baseline-v2.json @@ -1181,7 +1181,7 @@ "ui/src/components/github-link-hovercard.ts": 582, "ui/src/components/icons.ts": 701, "ui/src/components/lobster-pet.ts": 1790, - "ui/src/components/markdown.ts": 1493, + "ui/src/components/markdown.ts": 1489, "ui/src/components/terminal/terminal-panel.ts": 1140, "ui/src/components/tooltip.ts": 502, "ui/src/lib/agents/display.ts": 661, diff --git a/ui/package.json b/ui/package.json index 705cab004465..7c588c165fc2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,7 +31,8 @@ "lit": "3.3.3", "markdown-it": "14.3.0", "markdown-it-task-lists": "2.1.1", - "marked": "18.0.5" + "marked": "18.0.5", + "remend": "1.3.0" }, "devDependencies": { "@types/markdown-it": "14.1.2", diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index f22da48d34df..9937ddd52c1e 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -9,7 +9,6 @@ import { md, toSanitizedMarkdownHtml, toStreamingMarkdownHtml, - toStreamingPlainTextHtml, } from "./markdown.ts"; function htmlFragment(html: string): HTMLElement { @@ -943,28 +942,6 @@ PY }); }); -describe("toStreamingPlainTextHtml", () => { - it("strips unsupported citation control markers before escaping streaming text", () => { - const html = toStreamingPlainTextHtml( - "v2026.5.20 release note citeturn2view0\n\nStill readable.", - ); - - expect(html).toBe( - '
v2026.5.20 release note\n\nStill readable.
', - ); - expect(html).not.toContain("cite"); - expect(html).not.toContain("turn2view0"); - }); - - it("normalizes Unicode and CR line breaks before escaping streaming text", () => { - const html = toStreamingPlainTextHtml("first\u2028second\u2029third\rfourth\r\nfifth"); - - expect(html).toBe( - '
first\nsecond\nthird\nfourth\nfifth
', - ); - }); -}); - describe("toStreamingMarkdownHtml", () => { it("renders streaming raw block art without collapsing quiet-zone spaces", () => { const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ "; @@ -994,40 +971,43 @@ describe("toStreamingMarkdownHtml", () => { ); }); - it("renders completed block prefixes as markdown and keeps the open tail plain", () => { + it("renders completed block prefixes as markdown and closes the streaming tail", () => { const html = toStreamingMarkdownHtml("## Done\n\nworking **tail"); - expect(html).toBe( - '

Done

\n
working **tail
', - ); + expect(html).toBe("

Done

\n

working tail

\n"); }); it("uses Unicode separators as stable markdown boundaries", () => { const html = toStreamingMarkdownHtml("## Done\u2028\u2028working **tail"); - expect(html).toBe( - '

Done

\n
working **tail
', - ); + expect(html).toBe("

Done

\n

working tail

\n"); }); - it("keeps a single open paragraph as escaped text", () => { + it("renders a single open paragraph as markdown with closed formatting", () => { const html = toStreamingMarkdownHtml("**still streaming"); - expect(html).toBe('
**still streaming
'); + expect(html).toBe("

still streaming

\n"); }); - it("does not invoke the markdown parser before a stable block boundary exists", () => { - const renderSpy = vi.spyOn(md, "render"); - try { - const html = toStreamingMarkdownHtml("**still streaming parser sentinel"); + it("renders half-written links as text only while streaming", () => { + const html = toStreamingMarkdownHtml("see [Streamdown](https://strea"); - expect(html).toBe( - '
**still streaming parser sentinel
', - ); - expect(renderSpy).not.toHaveBeenCalled(); - } finally { - renderSpy.mockRestore(); - } + expect(html).toBe("

see Streamdown

\n"); + }); + + it("streams tables as markdown before the closing row arrives", () => { + const html = toStreamingMarkdownHtml("| left | right |\n| --- | --- |\n| 1 | 2"); + const fragment = htmlFragment(html); + + expect(fragment.querySelector("table")).not.toBeNull(); + expect(fragment.querySelector("th")?.textContent).toBe("left"); + expect(html).not.toContain("markdown-plain-text-fallback"); + }); + + it("leaves dollar amounts alone while streaming", () => { + const html = toStreamingMarkdownHtml("prices are $$50 and"); + + expect(html).toBe("

prices are $$50 and

\n"); }); it("reuses the rendered stable prefix while only the streaming tail changes", () => { @@ -1037,35 +1017,58 @@ describe("toStreamingMarkdownHtml", () => { const second = toStreamingMarkdownHtml("## Streaming cache sentinel\n\nsecond **tail"); expect(first).toContain("

Streaming cache sentinel

"); + expect(first).toContain("

first tail

"); expect(second).toContain("

Streaming cache sentinel

"); - expect(renderSpy).toHaveBeenCalledTimes(1); + expect(second).toContain("

second tail

"); + // Stable prefix parses once (second call hits the cache); each tail parses fresh. + const stableParses = renderSpy.mock.calls.filter( + ([input]) => input === "## Streaming cache sentinel", + ); + expect(stableParses).toHaveLength(1); + expect(renderSpy).toHaveBeenCalledTimes(3); } finally { renderSpy.mockRestore(); } }); - it("does not parse an open code fence while streaming", () => { + it("streams an open code fence as a live-highlighted code block", () => { const html = toStreamingMarkdownHtml("Intro\n\n```ts\nconst x = 1 < 2"); + const fragment = htmlFragment(html); - expect(html).toBe( - '

Intro

\n
```ts\nconst x = 1 < 2
', - ); + expect(fragment.querySelector("p")?.textContent).toBe("Intro"); + expect(fragment.querySelector("code.language-ts")?.textContent).toContain("const x = 1 < 2"); + expect(html).not.toContain("markdown-plain-text-fallback"); }); - it("keeps an open list code fence streaming through blank lines", () => { + it("streams an open list code fence through blank lines", () => { const html = toStreamingMarkdownHtml("- ```ts\n const x = 1;\n\n const y = 2;"); + const fragment = htmlFragment(html); + const code = fragment.querySelector("li code"); - expect(html).toBe( - '
- ```ts\n const x = 1;\n\n const y = 2;
', - ); + expect(code?.textContent).toContain("const x = 1;"); + expect(code?.textContent).toContain("const y = 2;"); + expect(html).not.toContain("markdown-plain-text-fallback"); }); - it("keeps an open blockquote code fence streaming through blank lines", () => { - const html = toStreamingMarkdownHtml("> ```ts\n> const x = 1;\n>\n> const y = 2;"); + it("keeps completed tilde-fence code out of the remend tail", () => { + // remend only understands ``` fences; a closed ~~~ block must land in the + // stable prefix so its raw markers are never "completed" as inline markdown. + const html = toStreamingMarkdownHtml('~~~ts\nconst s = "**open";\n~~~\ncontinuing **bold'); + const fragment = htmlFragment(html); - expect(html).toBe( - '
> ```ts\n> const x = 1;\n>\n> const y = 2;
', - ); + expect(fragment.querySelector("code")?.textContent).toContain('const s = "**open";'); + expect(fragment.querySelector("code strong")).toBeNull(); + expect(fragment.querySelector("p strong")?.textContent).toBe("bold"); + }); + + it("streams an open blockquote code fence through blank lines", () => { + const html = toStreamingMarkdownHtml("> ```ts\n> const x = 1;\n>\n> const y = 2;"); + const fragment = htmlFragment(html); + const code = fragment.querySelector("blockquote code"); + + expect(code?.textContent).toContain("const x = 1;"); + expect(code?.textContent).toContain("const y = 2;"); + expect(html).not.toContain("markdown-plain-text-fallback"); }); it("renders a completed code fence once the closing fence arrives", () => { diff --git a/ui/src/components/markdown.ts b/ui/src/components/markdown.ts index cffa80a46f93..328ca4258058 100644 --- a/ui/src/components/markdown.ts +++ b/ui/src/components/markdown.ts @@ -17,6 +17,7 @@ import xml from "highlight.js/lib/languages/xml"; import yaml from "highlight.js/lib/languages/yaml"; import MarkdownIt from "markdown-it"; import markdownItTaskLists from "markdown-it-task-lists"; +import remend, { type RemendOptions } from "remend"; import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js"; import { routeIdFromPath } from "../app-route-paths.ts"; import { resolveControlUiBasePath } from "../app/browser.ts"; @@ -689,16 +690,6 @@ function normalizeMarkdownLineBreaks(value: string): string { return value.replace(/\r\n?|[\u2028\u2029]/g, "\n"); } -function normalizeMarkdownInput(markdownLocal: string): string { - const input = normalizeMarkdownLineBreaks( - stripUnsupportedCitationControlMarkers(markdownLocal), - ).trim(); - if (!input) { - return ""; - } - return formatTruncatedMarkdownInput(input); -} - function formatTruncatedMarkdownInput(input: string): string { const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); return appendMarkdownTruncationNotice(truncated); @@ -776,7 +767,14 @@ function isFenceClose(line: string, fence: { marker: "`" | "~"; length: number } return trimmed.slice(match[0].length).trim() === ""; } -function findStableStreamingMarkdownBoundary(markdownLocal: string): number { +type StreamingMarkdownSplit = { + /** Offset just past the last blank line outside a code fence; the prefix is block-stable. */ + boundary: number; + /** True when the text after the boundary contains a code fence that has not closed yet. */ + tailHasOpenFence: boolean; +}; + +function splitStableStreamingMarkdown(markdownLocal: string): StreamingMarkdownSplit { let boundary = 0; let index = 0; let openFence: { marker: "`" | "~"; length: number } | null = null; @@ -808,7 +806,7 @@ function findStableStreamingMarkdownBoundary(markdownLocal: string): number { index = lineEnd; } - return boundary; + return { boundary, tailHasOpenFence: openFence !== null }; } for (const [language, definition, aliases] of [ @@ -1380,6 +1378,36 @@ md.renderer.rules.code_block = (tokens, idx, _options, env) => { }); }; +// Uncached render core shared by the static and streaming paths. The streaming +// tail changes on every delta, so routing it through here (instead of the cached +// wrapper) keeps per-message churn out of the LRU cache. +function renderSanitizedMarkdown(renderInput: string, renderOptions: MarkdownRenderEnv): string { + installHooks(); + const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT); + const input = appendMarkdownTruncationNotice(truncated); + if (isMarkdownBlockArtText(truncated.text)) { + return DOMPurify.sanitize( + renderCodeBlock(input, "", renderOptions, { blockArt: true }), + sanitizeOptions, + ); + } + if (truncated.text.length > MARKDOWN_PARSE_LIMIT) { + // Large plain-text replies should stay readable without inheriting the + // capped code-block chrome, while still preserving whitespace for logs + // and other structured text that commonly trips the parse guard. + return DOMPurify.sanitize(toEscapedPlainTextHtml(input), sanitizeOptions); + } + let rendered: string; + try { + rendered = md.render(input, renderOptions); + } catch (err) { + // Fall back to escaped plain text when md.render() throws (#36213). + console.warn("[markdown] md.render failed, falling back to plain text:", err); + rendered = `
${escapeHtml(input)}
`; + } + return DOMPurify.sanitize(rendered, sanitizeOptions); +} + export function toSanitizedMarkdownHtml( markdownLocal: string, options: MarkdownRenderOptions = {}, @@ -1392,48 +1420,17 @@ export function toSanitizedMarkdownHtml( if (!input) { return ""; } - installHooks(); const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input; + const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS; const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${renderOptions.fileLinks}\0${renderInput}`; - if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { + if (cacheable) { const cached = getCachedMarkdown(cacheKey); if (cached !== null) { return cached; } } - const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT); - if (isMarkdownBlockArtText(truncated.text)) { - const rendered = renderCodeBlock(appendMarkdownTruncationNotice(truncated), "", renderOptions, { - blockArt: true, - }); - const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions); - if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { - setCachedMarkdown(cacheKey, sanitized); - } - return sanitized; - } - if (truncated.text.length > MARKDOWN_PARSE_LIMIT) { - // Large plain-text replies should stay readable without inheriting the - // capped code-block chrome, while still preserving whitespace for logs - // and other structured text that commonly trips the parse guard. - const html = toEscapedPlainTextHtml(appendMarkdownTruncationNotice(truncated)); - const sanitized = DOMPurify.sanitize(html, sanitizeOptions); - if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { - setCachedMarkdown(cacheKey, sanitized); - } - return sanitized; - } - let rendered: string; - try { - rendered = md.render(appendMarkdownTruncationNotice(truncated), renderOptions); - } catch (err) { - // Fall back to escaped plain text when md.render() throws (#36213). - console.warn("[markdown] md.render failed, falling back to plain text:", err); - const escaped = escapeHtml(appendMarkdownTruncationNotice(truncated)); - rendered = `
${escaped}
`; - } - const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions); - if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { + const sanitized = renderSanitizedMarkdown(renderInput, renderOptions); + if (cacheable) { setCachedMarkdown(cacheKey, sanitized); } return sanitized; @@ -1443,33 +1440,33 @@ function toEscapedPlainTextHtml(value: string): string { return `
${escapeHtml(normalizeMarkdownLineBreaks(value))}
`; } -export function toStreamingPlainTextHtml(markdownLocal: string): string { - const input = normalizeMarkdownInput(markdownLocal); - if (!input) { - return ""; - } - return toEscapedPlainTextHtml(input); +// Streaming-tail repair config: math is not rendered by this pipeline, so +// completing `$$` would inject visible characters into ordinary prose. +const streamingRemendOptions = { katex: false, linkMode: "text-only" } satisfies RemendOptions; + +// Renders the in-flight block live. remend closes/strips unterminated inline +// constructs (`**bold`, half links, …) so partially streamed markup styles +// immediately instead of flashing raw markers. Inside an open code fence the +// tail is code, not prose: skip remend (it only understands top-level ``` +// fences) and let markdown-it auto-close the fence at end of input (CommonMark +// allows unterminated fences), so code streams with live highlighting. +// Invariant: the tail never contains a *closed* fence — the split boundary +// advances past every fence close — so remend (which cannot see ~~~ fences) +// never runs across completed fenced code. +function toStreamingTailHtml(tail: string, renderOptions: MarkdownRenderEnv): string { + return renderSanitizedMarkdown(remend(tail, streamingRemendOptions), renderOptions); } export function toStreamingMarkdownHtml( markdownLocal: string, options: MarkdownRenderOptions = {}, ): string { + const renderOptions = normalizeMarkdownRenderOptions(options); const rawInput = normalizeMarkdownLineBreaks( stripUnsupportedCitationControlMarkers(markdownLocal), ); if (isMarkdownBlockArtText(rawInput)) { - const truncated = truncateText(rawInput, MARKDOWN_CHAR_LIMIT); - installHooks(); - return DOMPurify.sanitize( - renderCodeBlock( - appendMarkdownTruncationNotice(truncated), - "", - normalizeMarkdownRenderOptions(options), - { blockArt: true }, - ), - sanitizeOptions, - ); + return renderSanitizedMarkdown(rawInput, renderOptions); } const trimmedInput = rawInput.trim(); @@ -1478,16 +1475,15 @@ export function toStreamingMarkdownHtml( } const input = formatTruncatedMarkdownInput(trimmedInput); - const boundary = findStableStreamingMarkdownBoundary(input); - if (boundary <= 0) { - return toEscapedPlainTextHtml(input); - } - + const { boundary, tailHasOpenFence } = splitStableStreamingMarkdown(input); const stableMarkdown = input.slice(0, boundary); const streamingTail = input.slice(boundary); - const stableHtml = toSanitizedMarkdownHtml(stableMarkdown, options); + const stableHtml = boundary > 0 ? toSanitizedMarkdownHtml(stableMarkdown, options) : ""; if (!streamingTail.trim()) { return stableHtml; } - return `${stableHtml}${toEscapedPlainTextHtml(streamingTail)}`; + const tailHtml = tailHasOpenFence + ? renderSanitizedMarkdown(streamingTail, renderOptions) + : toStreamingTailHtml(streamingTail, renderOptions); + return `${stableHtml}${tailHtml}`; } diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 1c9a9888a8c5..459c41c8bd0b 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -19,9 +19,6 @@ const markdownRenderMock = vi.hoisted(() => (value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) => value, ), ); -const streamingTextRenderMock = vi.hoisted(() => - vi.fn((value: string) => `
${value}
`), -); const streamingMarkdownRenderMock = vi.hoisted(() => vi.fn( (value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) => @@ -43,7 +40,6 @@ vi.mock("../../../components/markdown.ts", async (importOriginal) => { ...actual, toSanitizedMarkdownHtml: markdownRenderMock, toStreamingMarkdownHtml: streamingMarkdownRenderMock, - toStreamingPlainTextHtml: streamingTextRenderMock, }; }); @@ -1005,7 +1001,6 @@ describe("grouped chat rendering", () => { const container = document.createElement("div"); markdownRenderMock.mockClear(); streamingMarkdownRenderMock.mockClear(); - streamingTextRenderMock.mockClear(); render( renderStreamGroup([ @@ -1021,7 +1016,6 @@ describe("grouped chat rendering", () => { ); expect(markdownRenderMock).not.toHaveBeenCalled(); - expect(streamingTextRenderMock).not.toHaveBeenCalled(); expect(streamingMarkdownRenderMock).toHaveBeenCalledWith("**live**\nreply", { codeBlockChrome: "copy", fileLinks: true,