hermes-agent/apps/desktop/src/lib/markdown-blocks.ts
Brooklyn Nicholson e934ee440e fix(desktop): correct incremental markdown split boundary (setext merge)
The streaming block splitter added in #67154 dropped only the previous
parse's trailing whitespace blocks plus its LAST content block before
re-lexing the appended suffix. That boundary is unsound: a trailing
Setext underline (`-`/`=`) underlines the paragraph ABOVE it, so
appending to it can retroactively merge the previous parse's last TWO
blocks into one.

Minimal repro: cached "…#e\n5\n-" lexes to [ …, "#e\n", "5\n-" ], but
grown to "…#e\n5\n-p2=kj:c" collapses "#e"/"5\n-" into a single block.
The reused settled prefix still contained a stale "#e\n" block. The
`blocks.join('') === text` guard can't detect this because the wrong
split reconstructs the same source string, so the divergence rendered
as mis-split blocks with no fallback.

Fix: drop the last TWO content blocks (skipping whitespace-only blocks
around them) before re-lexing the suffix. The block before the last is
the deepest an append can reach — a Setext underline consumes exactly
one preceding block — and earlier blocks stay fenced off by settled
blank lines, so re-lexing two is sufficient and safe.

Tests: a deterministic regression for the exact prev→grown pair, and a
character-level streaming property fuzz (12 seeds × 500 growing prefixes
over the markdown control alphabet). Both fail on the pre-fix boundary
and pass after. tsc/eslint/prettier clean; markdown-text suite green.
2026-07-18 18:21:58 -04:00

136 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
/**
* Block splitting for the streaming markdown pipeline, without re-lexing the
* whole message on every token flush.
*
* `parseMarkdownIntoBlocks` is a full `marked` lex of the entire text —
* measured 3.49.6ms per call at 64192KB. During streaming every flush is a
* new string, so the stock splitter pays that O(full-text) cost ~30×/s on
* long replies. Two caches remove it:
*
* 1. Exact-string LRU — a message that REMOUNTS with unchanged text
* (virtualizer scroll, session switch) reuses its parse outright.
* 2. Streaming-append cache — when the new text starts with a recently parsed
* text (the token-append case), the previous parse's blocks are reused up
* to a settled boundary and only the suffix is lexed. The boundary drops
* the previous parse's trailing whitespace-only blocks AND its last content
* block, because appended text can retroactively change how that last
* block parses (open fence, list/table continuation, setext underline, a
* lazy blockquote line). Blocks before it are separated by settled blank
* lines and cannot be affected. Cross-block reference links can't regress:
* Streamdown renders each block as an independent markdown document
* already. Verified property: `blocks.join('') === text`, and incremental
* output is asserted byte-identical to a full lex in tests across fences,
* lists, tables, setext headings, blockquotes, and HTML blocks.
*
* Any doubt — no prefix match, reconstruction mismatch — falls back to the
* full lex, i.e. exactly the previous behavior.
*/
const EXACT_CACHE_MAX = 64
const EXACT_CACHE_MIN_LENGTH = 1024
const exactCache = new Map<string, string[]>()
// Streaming messages grow monotonically, and only a handful stream at once
// (main reply + reasoning part, maybe a tile). A tiny ring is enough; each
// entry holds the last parse for one growing text lineage.
const APPEND_CACHE_MAX = 4
const APPEND_CACHE_MIN_LENGTH = 2048
const appendCache: { blocks: string[]; text: string }[] = []
function rememberAppend(text: string, blocks: string[]): void {
if (text.length < APPEND_CACHE_MIN_LENGTH) {
return
}
// Replace the lineage this text grew from (its cached prefix), else push.
const index = appendCache.findIndex(entry => text.startsWith(entry.text))
if (index !== -1) {
appendCache.splice(index, 1)
}
appendCache.push({ blocks, text })
if (appendCache.length > APPEND_CACHE_MAX) {
appendCache.shift()
}
}
function lexIncrementally(text: string): null | string[] {
const entry = appendCache.find(cached => text.length > cached.text.length && text.startsWith(cached.text))
if (!entry) {
return null
}
// Settled boundary: drop the last TWO content blocks (skipping any
// whitespace-only blocks around them). Dropping only the single last content
// block is unsound: appended text can retroactively merge the previous
// parse's last two blocks into one. The trigger is a trailing Setext
// underline — `marked` only treats `-`/`=` as an underline for the paragraph
// ABOVE it, so a settled `"#e\n5\n-"` lexes as ["#e\n", "5\n-"], but growing
// the tail to `"#e\n5\n-p2=kj:c"` collapses both into one paragraph. The
// block before the last is the deepest an append can reach (the underline
// consumes exactly one preceding block), so re-lexing the last two is safe;
// earlier blocks are fenced off by settled blank lines. join('') === text
// still holds either way, so the reconstruction check below can't catch this.
let keep = entry.blocks.length
for (let dropped = 0; dropped < 2 && keep > 0; dropped += 1) {
while (keep > 0 && !entry.blocks[keep - 1].trim()) {
keep -= 1
}
if (keep > 0) {
keep -= 1
}
}
if (keep === 0) {
return null
}
const settled = entry.blocks.slice(0, keep)
let settledLength = 0
for (const block of settled) {
settledLength += block.length
}
// Defensive reconstruction check — the splitter's join(blocks) === text
// property is what makes offsets exact. If it ever doesn't hold, full lex.
if (settledLength > entry.text.length || !text.startsWith(entry.text.slice(0, settledLength), 0)) {
return null
}
return [...settled, ...parseMarkdownIntoBlocks(text.slice(settledLength))]
}
export function parseMarkdownIntoBlocksCached(markdown: string): string[] {
if (markdown.length < EXACT_CACHE_MIN_LENGTH) {
return parseMarkdownIntoBlocks(markdown)
}
const hit = exactCache.get(markdown)
if (hit) {
// Refresh recency (Map iteration order is insertion order).
exactCache.delete(markdown)
exactCache.set(markdown, hit)
return hit
}
const blocks = lexIncrementally(markdown) ?? parseMarkdownIntoBlocks(markdown)
rememberAppend(markdown, blocks)
exactCache.set(markdown, blocks)
if (exactCache.size > EXACT_CACHE_MAX) {
exactCache.delete(exactCache.keys().next().value as string)
}
return blocks
}