Studio: stop later code spans changing whether an earlier one is escaped - #9028
Conversation
`findCodeBlockRegions` feeds `isInRegion`, which binary-searches and so needs
sorted, non-overlapping spans. The inline-code pass dropped a span nested inside
a fence, but an inline span can also contain one, as in `` `~~~a~~~ $5` ``. That
pushed two overlapping spans, and the search then descended into the inner one
and missed the outer, so the lookup answered according to how many spans the
rest of the reply happened to add.
The result was that a completed code span could be rewritten by text that
arrived after it:
preprocessLaTeX("`~~~a~~~ $5`") -> "`~~~a~~~ $5`"
preprocessLaTeX("`~~~a~~~ $5`\n\n`x`") -> "`~~~a~~~ \$5`\n\n`x`"
which contradicts the documented contract that currency inside code blocks and
spans is untouched. The same lookup guards the delimiter conversion, so a code
sample showing `\(x\)` could also be turned into real math.
Merge the two ascending lists into their union instead. For input that was
already non-overlapping the merge reproduces the previous list exactly, and it
replaces the sort as well.
Differential fuzz against the previous implementation over 200,000 generated
documents mixing inline math, display math, currency, fences, tilde fences,
links and escaped backslashes: 6,004 documents changed, and every one of them
had overlapping spans under the old code. Nothing else moved.
Verification roundIndependent replication of the claims in the description, plus the surface the The defect is real, and narrower than the description impliesInstrumenting
With two entries the search's first probe is index 0 and lands on the outer span; A start-sorted list only mis-steers a binary search when one span contains Over 20,000 generated realistic-sentence replies, the region list overlapped in 1,552 Differential against the merge baseEvery difference classified, on the full document and on every streaming prefix:
Three difference classes, all fixes: currency inside such a code span; The strongest check is a third build: the merge base's source with CommonMarkPer the 0.31.2 spec, block structure is resolved before inline structure (section 3.1) Streaming monotonicity
Cross engine and cross checks
Interaction with #9017They are independent. #9017 changes only |
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
Before/after, from two isolated installsTwo Studios built with The reply contains the shape from the description: two inline code spans that each Read the last two lines of the reply. The model sent Both are visible because a backslash inside a code span is literal, so the escape the Read from the DOM of the same servers that were photographed, so the picture and the Everything else on screen is unchanged: the same two KaTeX nodes, the same The picture shows that the two differ; the character-level differential in the |


Problem
findCodeBlockRegionsinstudio/frontend/src/lib/latex.tsfeedsisInRegion,which binary-searches and therefore requires sorted, non-overlapping spans.
It does not always produce them.
The inline-code pass drops a span that sits inside a fence, but an inline span
can also contain a fence pair:
The fenced scan matches
~~~a~~~at[1, 8), the inline scan matches the whole`~~~a~~~ $5`at[0, 12), and both are kept. The search then descends intothe inner span and misses the outer one, so whether a position is reported as
"inside code" depends on how many spans the rest of the reply happens to add.
The visible effect is that a finished code span gets rewritten by text that
arrives after it:
That contradicts the contract stated in the file's own doc comment, "Currency
inside code blocks/spans is untouched". The same lookup guards
convertLatexDelimiters, so a code sample showing\(x\)can also be turnedinto real math once another code span appears later in the reply.
Because replies stream, "text that arrives after it" is the normal case: a code
span can render correctly and then change as generation continues.
The change
Collect the fenced and inline spans into two ascending lists and merge them into
their union, which is sorted and non-overlapping by construction. For input that
was already non-overlapping the merge reproduces the previous list exactly, so
the only behaviour that moves is the broken case. The merge also replaces the
sort.Testing
npm test(2,940 pass),npm run typecheck,npm run buildandnpx eslintonboth changed files are clean.
npm run biome:checkreports the same 5 errors and16 warnings for
latex.tsas it does onmain, so this adds none; biome isalready non-blocking in
studio-frontend-ci.yml.Differential fuzz
200,000 generated documents built from fragments mixing inline math, display
math, currency, bold-wrapped math, inline code, fenced code, tilde fences, links
with parentheses in the destination, tables, quotes and escaped backslashes, each
compared against the previous implementation:
Every difference is a case where the old code emitted overlapping spans. Nothing
else moved.
New tests, and what each one catches
studio/frontend/tests/latex-code-regions.test.ts. There was previously no testfile for
latex.tsat all; its only coverage was indirect, through the streamingschedule tests.
Run against the unmodified tree, three of the four fail and one passes:
currency inside inline code survives unrelated later code spans- failson
mainwith`~~~a~~~ \$5`\n\n`x`against the expected`~~~a~~~ $5`\n\n`x`. This is the reported defect.a code span's own text decides its escaping, whatever follows it- failson
main. Sweeps seven code-span shapes against one to four trailing spans,so it pins the general property rather than the one input, and would catch a
fix that special-cased
~~~inside backticks.LaTeX inside inline code stays literal whatever follows it- fails onmain. Covers the delimiter-conversion path, which uses the same lookup andwould otherwise turn a code sample into a rendered formula.
ordinary code spans and fences are unchanged- passes onmainaswell. It is the regression guard for the merge itself, not a defect
detector: it pins ten already-correct shapes so that a future change to the
merge cannot move them. Stating this explicitly because a test that passes
both ways proves nothing about the bug.