From 69150366fd3ee4e500e0c01193d5a039c057bbb0 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Mon, 17 Aug 2026 16:45:53 +0300 Subject: [PATCH] fix(ui): correct markdown cache identity, streaming churn, and redundant tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the #2769 highlight caches. Fingerprint strength. The block/highlight caches are now global and content-addressed, so a hash collision no longer mis-colors a block — it returns a *different* block's rendered HTML and shows the user source they never wrote. Length + one 32-bit FNV-1a is not enough key space for that failure mode at session scale. `contentFingerprint` now combines two independent 32-bit multiplicative hashes with a final avalanche (~64 bits); two multiplies per character are free next to Shiki tokenization. Streaming churn. Content addressing made every streaming step of the trailing `live` block insert a new cache entry, so one long message evicted the settled `full` blocks the fix exists to keep warm. `full` and `live` blocks now use separate caches; the live cache is small (32 entries / 2MB) because it only has to absorb repeat renders of the same step. Redundant worker-side caches. `markdown-worker.ts` is the only sender to the Shiki worker, and its client cache is larger than the worker-side ones, so the worker caches could not serve a hit the client had not already served — they only duplicated up to 48MB of payloads in a second heap. Removed; the reason memoization belongs on the client is now documented there, along with why only `highlightTokens` carries a theme in its key. Dead `cacheKey` plumbing. `renderMarkdownBlocks` kept a `cacheKey` parameter it only `void`-ed. Removed it and the now-unused `useMorphdomMarkdown` prop; the remaining call-site local is renamed `fadeKey` for what it actually keys. Tests: image-mode cache identity, streaming-does-not-evict-settled-blocks, live-cache reuse, and a 20k same-length-source fingerprint collision check. Each new guard was verified to fail without its fix. --- CHANGELOG.md | 1 + .../components/chat/MarkdownRendererImpl.tsx | 15 +-- .../chat/markdown/highlightResultCache.ts | 44 +++++-- .../chat/markdown/markdown-shiki.worker.ts | 55 +-------- .../chat/markdown/markdown-worker.ts | 9 ++ .../components/chat/markdown/markdownCore.ts | 67 +++++++---- .../markdownHighlightingRepro.test.ts | 112 ++++++++++++++---- packages/ui/src/types/bun-test.d.ts | 1 + 8 files changed, 181 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46731e19..35576c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. +- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech). - Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 19312197..6d4665e2 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -835,7 +835,6 @@ const useMorphdomMarkdown = ({ containerRef, text, streaming, - cacheKey, imageMode = 'inline', syntaxVars, ctx, @@ -843,7 +842,6 @@ const useMorphdomMarkdown = ({ containerRef: React.RefObject; text: string; streaming: boolean; - cacheKey: string; imageMode?: MarkdownImageMode; syntaxVars: Record; ctx: DecorateContext; @@ -908,7 +906,7 @@ const useMorphdomMarkdown = ({ const target = container.querySelector('[data-markdown-content]') ?? container; let active = true; - void renderMarkdownBlocks(text, streaming, cacheKey, imageMode).then((blocks) => { + void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => { if (!active) return; const existing = Array.from(target.children) as HTMLElement[]; @@ -959,7 +957,7 @@ const useMorphdomMarkdown = ({ return () => { active = false; }; - }, [containerRef, text, streaming, cacheKey, imageMode, ctx, refreshMermaidViewers]); + }, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]); React.useEffect(() => { const container = containerRef.current; @@ -1040,13 +1038,13 @@ const MarkdownRendererImpl: React.FC = ({ const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); - const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; + // Identity for the fade-in wrapper: a new part/message restarts the animation. + const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; useMorphdomMarkdown({ containerRef, text: content, streaming: live, - cacheKey, imageMode: variant === 'assistant' ? 'label' : 'inline', syntaxVars, ctx, @@ -1060,7 +1058,7 @@ const MarkdownRendererImpl: React.FC = ({ if (isAnimated) { return ( - + {markdownContent} ); @@ -1137,9 +1135,6 @@ const SimpleMarkdownRendererImpl: React.FC<{ containerRef, text: renderedContent, streaming: false, - // Identity is unused for cache lookup (content-addressed in markdownCore); - // keep a stable per-variant key for effect deps alongside `text`. - cacheKey: `simple:${variant}`, syntaxVars, ctx, }); diff --git a/packages/ui/src/components/chat/markdown/highlightResultCache.ts b/packages/ui/src/components/chat/markdown/highlightResultCache.ts index 1f1b8440..a73fd0bc 100644 --- a/packages/ui/src/components/chat/markdown/highlightResultCache.ts +++ b/packages/ui/src/components/chat/markdown/highlightResultCache.ts @@ -1,10 +1,10 @@ -// Bounded LRU for Shiki highlight results. +// Bounded LRU for rendered markdown / Shiki highlight results. // -// Used on the main-thread worker client and inside the markdown-shiki worker so -// unchanged code is never re-tokenized. Keys are short content fingerprints -// (not the full source) so cache maps do not duplicate large strings. Entry -// byte sizes are recorded once at insert time — get/evict never re-walk the -// payload. +// Used by `markdownCore` (per-block HTML) and by the main-thread markdown +// worker client (highlight results) so unchanged content is never re-rendered +// or re-tokenized. Keys are short content fingerprints (not the full source) so +// cache maps do not duplicate large strings. Entry byte sizes are recorded once +// at insert time — get/evict never re-walk the payload. export type HighlightResultCacheOptions = { maxEntries: number; @@ -19,18 +19,36 @@ type CacheEntry = { /** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */ export const utf16Bytes = (value: string): number => value.length * 2; +/** Final avalanche so near-identical sources do not land in adjacent buckets. */ +const mix32 = (hash: number): number => { + let h = hash; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + return h >>> 0; +}; + /** - * Short stable fingerprint for cache keys: length + FNV-1a 32-bit. - * Collision of same length + same hash is vanishingly rare at session scale; - * highlight results are pure functions of (kind, lang, theme, source). + * Short stable fingerprint for cache keys: length + two independent 32-bit + * multiplicative hashes (~64 bits of key space). + * + * These caches are content-addressed and global, so a collision does not merely + * mis-color a block — the cache returns a *different* block's rendered HTML and + * the user is shown source they never wrote. One 32-bit hash is not enough for + * that failure mode: a few thousand same-length entries reach a birthday + * collision probability worth caring about, and the result would be + * undiagnosable in the field. Two multiplies per character are free next to + * Shiki tokenization. */ export const contentFingerprint = (value: string): string => { - let h = 0x811c9dc5; + let h1 = 0x811c9dc5; + let h2 = 0xc2b2ae35; for (let i = 0; i < value.length; i += 1) { - h ^= value.charCodeAt(i); - h = Math.imul(h, 0x01000193); + const code = value.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 0x01000193); + h2 = Math.imul(h2 ^ code, 0x27220a95); } - return `${value.length.toString(36)}_${(h >>> 0).toString(36)}`; + return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`; }; /** Approximate byte cost of token-run lines without JSON.stringify. */ diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index 3877d26a..a06b81fc 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,14 +1,8 @@ /// import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; -import { - contentFingerprint, - estimateTokenRunsBytes, - HighlightResultCache, - utf16Bytes, -} from './highlightResultCache'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; -import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; // Shiki FontStyle bitmask (from @shikijs/types). Inlined to avoid an extra import. const FONT_STYLE_ITALIC = 1; @@ -38,25 +32,6 @@ let highlighter: ReturnType | undefined; // Serialize work so language loading / tokenization never overlaps. let queue = Promise.resolve(); -// Memoize tokenization by content fingerprint. Defense in depth for the -// main-thread client cache (openchamber/openchamber#2769): keys stay short so -// large sources are not duplicated in the Map. -const WORKER_CACHE_MAX_ENTRIES = 1500; -const WORKER_CACHE_MAX_BYTES = 16 * 1024 * 1024; - -const htmlCache = new HighlightResultCache({ - maxEntries: WORKER_CACHE_MAX_ENTRIES, - maxBytes: WORKER_CACHE_MAX_BYTES, -}); -const linesCache = new HighlightResultCache({ - maxEntries: WORKER_CACHE_MAX_ENTRIES, - maxBytes: WORKER_CACHE_MAX_BYTES, -}); -const tokensCache = new HighlightResultCache({ - maxEntries: WORKER_CACHE_MAX_ENTRIES, - maxBytes: WORKER_CACHE_MAX_BYTES, -}); - const ensureHighlighter = (): ReturnType => { highlighter ??= createHighlighter({ // Cast: the theme is a CSS-variable TextMate theme; Shiki accepts the shape. @@ -97,19 +72,8 @@ const resolveLanguage = async (instance: Instance, requested: string): Promise `${lang}:${contentFingerprint(code)}`; -const linesKey = (lang: string, code: string): string => `${lang}:${contentFingerprint(code)}`; -const tokensKey = (themeName: string, lang: string, code: string): string => - `${themeName}:${lang}:${contentFingerprint(code)}`; - async function highlight(request: Extract): Promise { try { - const cacheKey = htmlKey(request.lang, request.code); - const cached = htmlCache.get(cacheKey); - if (cached !== undefined) { - post({ type: 'highlight', id: request.id, html: cached }); - return; - } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const html = instance.codeToHtml(request.code, { @@ -117,7 +81,6 @@ async function highlight(request: Extract): Promise { try { - const cacheKey = tokensKey(request.themeName, request.lang, request.code); - const cached = tokensCache.get(cacheKey); - if (cached !== undefined) { - post({ type: 'highlightTokens', id: request.id, lines: cached }); - return; - } const instance = await ensureHighlighter(); if (request.theme && !instance.getLoadedThemes().includes(request.themeName)) { // Cast: a resolved TextMate theme object from the app theme registry. @@ -145,7 +102,6 @@ async function highlightTokens(request: Extract line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]), ); - tokensCache.set(cacheKey, lines, utf16Bytes(cacheKey) + estimateTokenRunsBytes(lines)); post({ type: 'highlightTokens', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); @@ -154,12 +110,6 @@ async function highlightTokens(request: Extract): Promise { try { - const cacheKey = linesKey(request.lang, request.code); - const cached = linesCache.get(cacheKey); - if (cached !== undefined) { - post({ type: 'highlightLines', id: request.id, lines: cached }); - return; - } const instance = await ensureHighlighter(); const lang = await resolveLanguage(instance, request.lang); const { tokens } = instance.codeToTokens(request.code, { @@ -167,9 +117,6 @@ async function highlightLines(request: Extract line.map(tokenSpan).join('')); - let bytes = utf16Bytes(cacheKey); - for (const line of lines) bytes += utf16Bytes(line); - linesCache.set(cacheKey, lines, bytes); post({ type: 'highlightLines', id: request.id, lines }); } catch (error) { post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) }); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index a2234c61..ab5b6eb6 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -18,6 +18,15 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } // re-highlight load in openchamber/openchamber#2769. In-flight requests with // the same key coalesce so remount storms share one round-trip. Cache keys are // fingerprints (not full source) so large files are not duplicated in the Map. +// +// This module is the only sender to the worker, so memoizing here is sufficient +// and the worker itself stays stateless apart from the Shiki instance. A second +// cache inside the worker would only duplicate these payloads in another heap. +// +// `highlight` / `highlightLines` results are theme-independent: the worker +// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch +// repaints via CSS and must not invalidate these entries. Only +// `highlightTokens` resolves concrete colors, so only its key carries a theme. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 0c60c204..2d3cb7a9 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -492,34 +492,58 @@ const sanitize = (html: string): string => { // Per-block HTML cache (content-addressed LRU) // --------------------------------------------------------------------------- // -// Keyed by content hash + mode + highlight flag — NOT by renderer instance id. -// `SimpleMarkdownRenderer` historically used a shared `simple:${variant}` key, -// so every same-variant instance fought over one cache slot and re-highlighted -// unchanged content on every pass (openchamber/openchamber#2769). Content -// addressing makes identical blocks share one entry and stops that thrash. -// Bounds are high enough for long sessions; byte cap keeps memory bounded. +// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer +// instance id. `SimpleMarkdownRenderer` historically used a shared +// `simple:${variant}` key, so every same-variant instance fought over one cache +// slot and re-highlighted unchanged content on every pass +// (openchamber/openchamber#2769). Content addressing makes identical blocks +// share one entry and stops that thrash. Bounds are high enough for long +// sessions; byte cap keeps memory bounded. +// +// `full` (settled) and `live` (trailing, still streaming) blocks get separate +// caches. A live block's content changes on every stream step, so under one +// shared content-addressed cache each step would insert a new entry and a long +// streaming message would evict the settled blocks this fix exists to keep +// warm. The live cache is small on purpose: it only has to absorb repeat +// renders of the *same* step. -const HTML_CACHE_MAX_ENTRIES = 2000; -const HTML_CACHE_MAX_BYTES = 24 * 1024 * 1024; +const FULL_CACHE_MAX_ENTRIES = 2000; +const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024; +const LIVE_CACHE_MAX_ENTRIES = 32; +const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024; -const htmlCache = new HighlightResultCache({ - maxEntries: HTML_CACHE_MAX_ENTRIES, - maxBytes: HTML_CACHE_MAX_BYTES, +const fullBlockCache = new HighlightResultCache({ + maxEntries: FULL_CACHE_MAX_ENTRIES, + maxBytes: FULL_CACHE_MAX_BYTES, +}); +const liveBlockCache = new HighlightResultCache({ + maxEntries: LIVE_CACHE_MAX_ENTRIES, + maxBytes: LIVE_CACHE_MAX_BYTES, }); -/** Content-addressed cache key for a markdown block (exported for tests). */ -export const markdownBlockCacheKey = ( +const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache => + (mode === 'live' ? liveBlockCache : fullBlockCache); + +/** Content-addressed cache key for a markdown block. */ +const markdownBlockCacheKey = ( contentHash: string, mode: MarkdownBlock['mode'], highlight: boolean, imageMode: MarkdownImageMode, ): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`; -/** Test-only: clear the render HTML cache between cases. */ +/** Test-only: clear the render HTML caches between cases. */ export const resetMarkdownHtmlCacheForTests = (): void => { - htmlCache.clear(); + fullBlockCache.clear(); + liveBlockCache.clear(); }; +/** Test-only: entry counts per block cache, for churn/eviction assertions. */ +export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({ + full: fullBlockCache.size, + live: liveBlockCache.size, +}); + const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => { const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = await Promise.resolve(parser.parse(block.src)); @@ -559,18 +583,14 @@ export type RenderedBlock = { * blocks (instead of one joined string) lets the renderer re-morph only the * block that changed, keeping per-step streaming cost ~O(last block). * - * `cacheKey` is retained for call-site compatibility / debugging; the HTML - * cache itself is content-addressed so distinct renderers with identical - * blocks share results and cannot evict each other by identity collision. + * Lookup is content-addressed: distinct renderers holding identical blocks + * share one entry and cannot evict each other by identity collision. */ export const renderMarkdownBlocks = async ( text: string, streaming: boolean, - cacheKey: string, imageMode: MarkdownImageMode = 'inline', ): Promise => { - // Retained for call-site compatibility / debugging; lookup is content-addressed. - void cacheKey; if (!text) return []; const blocks = streamBlocks(text, streaming); @@ -578,12 +598,13 @@ export const renderMarkdownBlocks = async ( blocks.map(async (block) => { const contentHash = contentFingerprint(block.raw); const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode); - const cached = htmlCache.get(id); + const cache = cacheForMode(block.mode); + const cached = cache.get(id); if (cached !== undefined) { return { id, html: cached }; } const html = await parseBlock(block, imageMode); - htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html)); + cache.set(id, html, utf16Bytes(id) + utf16Bytes(html)); return { id, html }; }), ); diff --git a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts index 96a24a7c..db51aad5 100644 --- a/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownHighlightingRepro.test.ts @@ -42,7 +42,7 @@ mock.module('./markdown-worker', () => ({ const { renderMarkdownBlocks, resetMarkdownHtmlCacheForTests, - markdownBlockCacheKey, + __markdownBlockCacheSizesForTests, } = await import('./markdownCore'); const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker'); @@ -83,6 +83,22 @@ describe('HighlightResultCache', () => { expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc')); }); + test('contentFingerprint stays collision-free across a realistic session', () => { + // A collision here does not mis-color a block — it returns a *different* + // block's HTML, showing the user source they never wrote. Keep enough key + // space that a session-sized working set never collides. + const seen = new Map(); + for (let i = 0; i < 20_000; i += 1) { + // Same-length, near-identical sources are the realistic worst case: + // repeated tool output differing by a few characters. + const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`; + const fingerprint = contentFingerprint(source); + expect(seen.get(fingerprint) ?? source).toBe(source); + seen.set(fingerprint, source); + } + expect(seen.size).toBe(20_000); + }); + test('estimateTokenRunsBytes avoids JSON and stays positive', () => { const lines: Array> = [ [[3, '#fff', 0], [1, '', 1]], @@ -93,21 +109,22 @@ describe('HighlightResultCache', () => { }); describe('markdownCore content-addressed htmlCache (#2769)', () => { - test('two same-variant SimpleMarkdown-style keys do not re-highlight unchanged content', async () => { + test('repeat renders of unchanged content never re-enter the worker', async () => { const toolOutputA = '```ts\nconst a = 1;\n```'; const toolOutputB = '```ts\nconst b = 2;\n```'; // First pass: cold miss for each distinct block. - await renderMarkdownBlocks(toolOutputA, false, 'simple:tool'); - await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); const coldCalls = highlightCalls; expect(coldCalls).toBeGreaterThan(0); - // 100 more passes with the legacy shared `simple:tool` identity keys — - // must not produce additional worker calls. + // 100 more passes. Renderers used to pass a shared `simple:${variant}` + // identity key here and evict each other every pass; lookup is now + // content-addressed, so no additional worker calls may happen. for (let pass = 0; pass < 100; pass += 1) { - await renderMarkdownBlocks(toolOutputA, false, 'simple:tool'); - await renderMarkdownBlocks(toolOutputB, false, 'simple:tool'); + await renderMarkdownBlocks(toolOutputA, false); + await renderMarkdownBlocks(toolOutputB, false); } expect(highlightCalls).toBe(coldCalls); @@ -115,19 +132,18 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => { const parts = Array.from({ length: 600 }, (_, i) => ({ - key: `markdown-part-part_${i}`, content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``, })); for (const part of parts) { - await renderMarkdownBlocks(part.content, false, part.key); + await renderMarkdownBlocks(part.content, false); } const afterCold = highlightCalls; expect(afterCold).toBe(parts.length); for (let pass = 0; pass < 5; pass += 1) { for (const part of parts) { - await renderMarkdownBlocks(part.content, false, part.key); + await renderMarkdownBlocks(part.content, false); } } @@ -139,24 +155,74 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { const stable = '```ts\nconst stable = true;\n```'; const changing = '```ts\nconst n = 1;\n```'; - await renderMarkdownBlocks(stable, false, 'a'); - await renderMarkdownBlocks(changing, false, 'b'); + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks(changing, false); const afterFirst = highlightCalls; - await renderMarkdownBlocks(stable, false, 'a'); - await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false, 'b'); + await renderMarkdownBlocks(stable, false); + await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false); expect(highlightCalls).toBe(afterFirst + 1); - await renderMarkdownBlocks(stable, false, 'a'); + await renderMarkdownBlocks(stable, false); expect(highlightCalls).toBe(afterFirst + 1); }); - test('block cache keys are content-addressed (mode + highlight + imageMode + hash)', () => { - expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).toBe('abc:full:1:inline'); - expect(markdownBlockCacheKey('abc', 'live', false, 'inline')).toBe('abc:live:0:inline'); - expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', false, 'inline')); - // Image mode changes the rendered HTML, so it must not share a cache entry. - expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', true, 'label')); + test('image mode is part of the cache identity, not shared across modes', async () => { + const source = '![diagram](https://example.com/a.png)'; + + const [inline] = await renderMarkdownBlocks(source, false, 'inline'); + expect(__markdownBlockCacheSizesForTests().full).toBe(1); + + // Same source, different rendering: content addressing must not let the + // first-rendered mode answer for both. + const [label] = await renderMarkdownBlocks(source, false, 'label'); + expect(inline?.id).not.toBe(label?.id); + expect(__markdownBlockCacheSizesForTests().full).toBe(2); + + // Re-rendering a mode already seen stays a cache hit. + const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline'); + expect(inlineAgain?.id).toBe(inline?.id); + expect(__markdownBlockCacheSizesForTests().full).toBe(2); + }); + + test('streaming a message does not evict settled blocks (live cache is separate)', async () => { + const settled = Array.from( + { length: 40 }, + (_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``, + ); + for (const block of settled) { + await renderMarkdownBlocks(block, false); + } + const settledEntries = __markdownBlockCacheSizesForTests().full; + expect(settledEntries).toBe(settled.length); + const afterSettled = highlightCalls; + + // Stream a message: every step is new content for the trailing live block, + // so a single shared content-addressed cache would insert one entry per + // step and evict the settled working set this fix exists to keep warm. + let streamed = ''; + for (let step = 0; step < 150; step += 1) { + streamed += `word_${step} `; + await renderMarkdownBlocks(streamed, true); + } + + const sizes = __markdownBlockCacheSizesForTests(); + expect(sizes.live).toBeLessThanOrEqual(32); + expect(sizes.full).toBe(settledEntries); + + for (const block of settled) { + await renderMarkdownBlocks(block, false); + } + expect(highlightCalls).toBe(afterSettled); + }); + + test('a repeated streaming step is served from the live cache', async () => { + const step = 'partial answer text'; + const [first] = await renderMarkdownBlocks(step, true); + const [second] = await renderMarkdownBlocks(step, true); + + expect(second?.id).toBe(first?.id); + expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 }); }); test('multiple code fences in one document highlight concurrently', async () => { @@ -168,7 +234,7 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => { '```ts\nconst c = 3;\n```', ].join('\n'); - await renderMarkdownBlocks(multi, false, 'multi'); + await renderMarkdownBlocks(multi, false); expect(highlightCalls).toBe(3); // Sequential awaits would keep max inflight at 1. expect(highlightMaxInflight).toBeGreaterThan(1); diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index 921149c1..3a918acb 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -19,6 +19,7 @@ declare module "bun:test" { toBeGreaterThan(expected: number): void; toBeGreaterThanOrEqual(expected: number): void; toBeLessThan(expected: number): void; + toBeLessThanOrEqual(expected: number): void; toHaveLength(expected: number): void; toBeInstanceOf(expected: unknown): void; not: {