Skip to content

fix(copilot): stop the special-tag parser discarding text it cannot resolve - #5952

Open
j15z wants to merge 27 commits into
stagingfrom
fix/unclosed-special-tags
Open

fix(copilot): stop the special-tag parser discarding text it cannot resolve#5952
j15z wants to merge 27 commits into
stagingfrom
fix/unclosed-special-tags

Conversation

@j15z

@j15z j15z commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

The chat renderer parses seven inline special tags out of the assistant's streamed text and turns them into interactive cards and chips: <workspace_resource> (a clickable workflow/table/file chip), <question> and <options> (selection cards), <credential> (a secret input or connect control), <usage_upgrade> and <mothership-error> (notices), and <thinking> (reasoning, suppressed from the rendered output). Anything it could not turn into a tag, it used to throw away.

Two shapes cost the user real text:

  • An opening tag with no close suppressed everything after it for the rest of the stream. Simply mentioning a tag name in prose — "the <workspace_resource> chip needs a real path" — blanked the remainder of the message until the stream ended.
  • A matched pair whose body failed to parse was dropped silently, along with everything between the tags. When the model misspelled a closing tag, the opener reached forward and matched a later tag's close, so several paragraphs became "the body" and vanished. The message resumed mid-sentence.

resolveTagAt now resolves every opening tag to one of four named outcomes. Each section below gives a failing message, when the parser detects it, how, and what the reader ends up seeing.

1. segment — the body parsed; render the card

The interesting case is a tag that looks broken mid-stream but is not. A closing marker arrives one character at a time, so a valid tag spends several frames looking like a payload with junk after it.

mid-stream   Ready when you are. <options>[{"title":"Ship it","description":"Open the PR"}]</opt
complete     Ready when you are. <options>[{"title":"Ship it","description":"Open the PR"}]</options>
  • Where it's caught: nowhere. No rule fires on any frame — this is the outcome that must not trigger a false alarm.
  • What keeps it from being caught: the JSON value already closed at }], so the trailing </opt would otherwise look like stray content and trip the viability rule below. dropArrivingClose strips a trailing fragment that is a prefix of this tag's own closing marker, so </opt reads as a close in transit. It only strips at the very end of the buffer, so a genuinely wrong close such as </workflow_resource> is not a prefix of </workspace_resource> and still trips the rule immediately.
  • What the reader sees: Ready when you are. and a shimmer while </options> finishes arriving, then the options card. The payload JSON is never on screen.

2. literal — provably not a tag; render the span verbatim

The `<workspace_resource>` chip only renders when the path points to a real file.
  • Where it's caught: on the first frame containing the complete opener — one character into the body, without waiting for the stream to end.
  • What catches it: the JSON-viability rule. Every tag body except <thinking> must be JSON, and this one begins with a backtick, which cannot start a JSON value. The same rule fires later in a body the moment its top-level value closes and non-whitespace follows — which is how a misspelled close, a truncated close, and a complete payload trailed by prose with no close are all caught by one check rather than by pattern-matching each mistake.
  • What the reader sees: the whole sentence as ordinary markdown, with no delay. Previously everything from the backtick onward stayed blank until the stream finished, then appeared at once.

<thinking> is the exception, since its body is prose. There the test is that tags never nest: a marker for another known tag in the body proves the opener was text. Tag names specifically, not anything tag-shaped — reasoning that mentions <div> or Promise<void> is still reasoning. The rule applies whether or not the close arrives, so a late </thinking> cannot retroactively swallow content the stream had already put on screen.

Rejection resumes inside the message rather than abandoning it, so a genuine tag later in the same reply still renders as a card.

3. discard — a payload that failed its shape guard; drop it

Here are the options. <question>{"type":"single_select"}</question> Let me know.
  • Where it's caught: at the closing tag. This outcome is unreachable until one arrives — with no close, a broken body can only end up as outcome 2 or 4.
  • What catches it: the <question> type guard, running after JSON.parse already succeeded. The body is valid JSON, but a question needs a prompt and at least one option.
  • What is deliberately NOT caught here: a body that will not parse at all. Dropping text is only defensible for a payload the agent actually formed, and bracket depth cannot tell {the Q4 report} — prose someone wrapped in braces — from a real payload. A body that fails JSON.parse falls through to outcome 2 and is shown, which also covers the two commonest slips a model makes, unquoted keys and single quotes.
  • What the reader sees: Here are the options. Let me know. The payload is dropped rather than printed, because showing a reader raw JSON is worse than showing nothing. This is the only outcome that intentionally removes text, and a message consisting solely of one renders nothing rather than falling back to the raw content.

4. pending — still streaming and a close is plausible; hold the remainder

Before I start, one question: <question>[{"type":"single_sel
  • Where it's caught: nowhere yet, deliberately. This is the only outcome that is not terminal — it always resolves into one of the other three.
  • What catches it: nothing does, and that is the correct answer. The body is an open JSON array, so viability still holds, and the parser genuinely cannot tell a broken payload from a half-arrived one: [{"type":"single_sel is one keystroke from a valid card. Releasing it early would print raw JSON that snaps into a card a frame later.
  • What the reader sees: Before I start, one question: and a shimmer. If the payload completes it becomes a card; if the stream ends first it falls to outcome 2 and the text is shown in full. Nothing is lost either way — the cost of being held is a delay, never deleted text.

How the decision is structured

Deciding this well turned out to need three separate questions, and answering them in one function is what made the first several rounds of this PR regress each other. They are now split:

Answers Knows nothing about
inspectWithin how much of a body may be read what it is, where to resume
classifyBody what the body is — one of five closed cases positions, outcomes, resume
resumeForClass where scanning continues what the outcome is

The closed set is the point: resolveMatchedPair and resumeForClass each switch over it exhaustively, so adding a case fails to compile until both questions are answered for it. Verified by adding a sixth case — tsc reports exactly two errors, one per switch.

That refactor is behaviour-identical, not merely believed to be: diffed against the previous implementation over 7,500 comparisons (1,500 generated messages, each parsed streaming, settled, and at three mid-stream cut points), asserting deep equality of the whole result.

What these checks cost

These checks are not free. Deciding whether a body is still viable JSON means reading it — twice: once to blank out the contents of JSON strings, once to walk it counting bracket depth.

It gets worse for two reasons. The parser re-runs on every streamed chunk, over the whole message received so far. And it runs on the main thread, so the time lands directly in a frozen chat window.

With no limit, one check is O(body length) — and a misspelled closing tag makes a single body stretch across most of the message. So a reply of length n that mentions tags k times costs O(k·n) per parse, paid again on every chunk. That is what froze the tab.

Capping how much of a body gets inspected makes each check constant work instead. The limit is 4096 characters, comfortably above the largest payload these tags emit — a <question> card runs about 1,500 characters, a <workspace_resource> about 100 — so it never changes the verdict for real content. With it, the worst shapes we could construct (a 58 KB reply with a borrowed close, and a 68 KB reply mentioning tag names 360 times) parse in under 35 ms.

Two cheaper guards do the rest: opener and closing-tag lookups are memoized per parse rather than rescanning to the end of the buffer for every opener, and both the viability check and the string-blanker return early when a single character already settles the answer.

The limit has one blind spot, stated in its docstring and pinned by a test: a JSON body whose value closes beyond 4096 characters, followed by prose and no closing tag, still looks like a viable prefix inside the window, so it waits for the stream to end instead of settling mid-stream. It stays lossless, and it needs a payload several times larger than any tag emits. A tag name mentioned in prose — the case that actually happens — settles at its first character no matter how long the message is.

One more fix, outside the parser

Preserving this text exposed a second bug in the display sanitizer that runs before the parser. It strips a backtick next to a <workspace_resource> tag so a code span cannot stop a chip rendering — but it could not tell a real tag from prose mentioning the tag name, and stripping a mention's opening backtick left the closing one unpaired. That opens a markdown code span running to the next backtick, inverting every code span in the rest of the message.

It now pairs backticks the way markdown does — a backtick to the next one on the same line — and unwraps a span only when it genuinely contains a complete tag. Pairing is what makes two further cases correct structurally rather than as separate patches: an adjacent unrelated code span, and a fenced block containing a tag. A negative lookahead bounds the scan, which also removed a quadratic in the previous version.

This was only reachable because of the parser fix: until now a prose mention blanked the rest of the message, so the mangled formatting was never on screen.

Two deliberate trades

  • An unclosed <thinking> is hidden while streaming and shown once the stream ends. Hiding is the right mid-stream default — a close is still plausible, and the body renders as nothing anyway. Showing it at the end does surface reasoning when the close never arrives; that is accepted, because the alternative swallows the answer whenever the model opens the tag and then writes its reply without closing it.
  • A resource whose title or path contains a backtick renders as text rather than a chip. The sanitizer tells a real tag from a mention by requiring no backtick inside the payload, so a payload that has one is not matched. That costs one chip and is rare; the failure it replaces corrupts a whole message and is common.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other: ___________

Testing

162 tests across the touched area, plus tsc and Biome clean.

Coverage is built around failure shapes rather than line count:

  • Four malformed messages taken verbatim from production replies are pinned as regression tests: a matched pair whose prose was swallowed by a later backticked example, a truncated closing tag, a matched pair wrapping prose instead of a payload, and no closing tag at all. The last is asserted lossless — every character survives mid-stream, so nothing waits for the stream to end.
  • Four property invariants over generated messages, composed from fragments and seeded so a failure reproduces: no character is lost from a message containing nothing droppable; every valid tag renders as a card whatever surrounds it; across streamed frames a card never un-renders and text never retracts; the settled parse never shows less than the last streaming frame. Each was validated by reverting a real fix and confirming the property catches it.
  • Complexity is pinned, not just correctness. Two tests assert that pathological inputs stay linear. Every fixture was under 1 KB before, which is exactly why two separate quadratics went unnoticed.
  • Each behavioural fix is mutation-checked: reverting it fails its test, and only its test. Where a fix could not be pinned that way — one only shifts a text-segment boundary, and adjacent text segments are concatenated before rendering — the test was deleted rather than kept as decoration, and the invariant documented in the code.

What reviewers should focus on:

  • Where a rejected tag resumes scanning. The rejection reasons deliberately resume at different offsets, and collapsing them passes every test while silently deleting text, so both are pinned.
  • Rejected spans are emitted as several text segments rather than one. Display-neutral, since the renderer concatenates them; tests assert the joined text, which is what a reader sees.
  • The sanitizer runs before the parser and is the one piece here that duplicates the parser's knowledge of what a tag looks like.

Known follow-ups, deliberately not in this PR: extracting the parser out of this 'use client' module (it cannot be imported by server code today, which is why the inbox executor carries its own weaker <thinking> regex), and moving backtick handling into the parser so the sanitizer's separate notion of "what is a tag" goes away.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

No visual change to any component — the difference is which text survives parsing and renders correctly. The before/after for each failure shape is given as rendered output in the Summary above.

@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 28, 2026 5:35am

Request Review

@j15z j15z changed the title fix(copilot): render unclosed special tags as text on completed messages improvement(copilot): show text as soon as an unclosed special tag cannot resolve Jul 25, 2026
@j15z j15z changed the title improvement(copilot): show text as soon as an unclosed special tag cannot resolve fix(copilot): stop the special-tag parser discarding text it cannot resolve Jul 25, 2026
@j15z
j15z marked this pull request as ready for review July 25, 2026 06:04
@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches hot-path streaming parsing and pre-render sanitization for all assistant messages; behavior is heavily tested but edge cases (scan window, <thinking> tradeoffs) remain documented.

Overview
Fixes silent text loss in copilot chat when assistant messages mention special tags (<workspace_resource>, <question>, etc.) or contain malformed tag markup.

Special-tag parser (parseSpecialTags) is refactored so each opener resolves to one of four outcomes: render a card (segment), show the span as plain text (literal), drop only valid JSON that fails shape guards (discard), or hold while streaming (pending). Unclosed openers no longer blank the rest of the message; prose mentions and non-JSON bodies are shown instead of discarded; borrowed/wrong closes resume scanning at safe offsets; JSON viability, arriving-close tolerance, and a 4096-character inspection window plus memoizedIndexOf keep streaming parses bounded on the main thread.

Display sanitizer (sanitizeChatDisplayContent) replaces multi-pass backtick stripping with a single left-to-right pass that unwraps only real <workspace_resource> tags (or stray flank backticks) without unpairing backticks when the model only mentions the tag name in prose or when code spans/fences sit nearby.

Tests add production-shaped regressions, frame-replay streaming invariants, property tests over generated messages, and performance ceilings for pathological inputs.

Reviewed by Cursor Bugbot for commit 7dc9148. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes streamed special-tag parsing lossless for unresolved content.

  • Adds explicit segment, literal, discard, and pending resolution paths for malformed and incomplete tags.
  • Prevents quoted tag markers inside JSON strings from bypassing payload discard behavior.
  • Reworks workspace-resource backtick sanitization to preserve ordinary code spans and prose mentions.
  • Adds regression, streaming-invariant, property, and complexity coverage for parser and sanitizer behavior.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure related to the previous review thread remains.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts Adds sanitizer regressions for prose mentions, adjacent code spans, fenced blocks, stray backticks, and pathological repeated openers.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts Replaces independent backtick-stripping expressions with a bounded left-to-right matcher that distinguishes code spans from complete workspace-resource tags.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts Adds extensive malformed-tag, streaming-stability, payload-classification, scan-bound, memoization, and property-based regression coverage.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx Refactors special-tag resolution into explicit outcomes, preserves unresolved literal text, blanks JSON strings before marker detection, and bounds body inspection work.

Reviews (8): Last reviewed commit: "fix(copilot): sanitize backticks in one ..." | Re-trigger Greptile

@j15z

j15z commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c61dd9d. Configure here.

j15z added 8 commits July 27, 2026 13:19
The special-tag parser suppressed everything after an opening tag with no
close, even after streaming ended — so an assistant message that merely
MENTIONED `<workspace_resource>` in prose lost its entire remainder in the
UI. The stream itself completed fine; only the render truncated.

Suppression now applies only while streaming. A completed message can never
finish an unclosed tag, so the marker was literal text and the remainder is
rendered as-is.

Originally written alongside the docs/ VFS work and reverted at review time
to keep that PR to one concern; this restores it on its own.
…olve

Restoring the text only at end of stream left a real gap: once the model
mentions a tag in prose, everything after it stays invisible for the rest of
the stream and reappears in one jump when streaming stops. On a long reply
that is most of the message.

Two properties let us decide much earlier, both conservative — they only fire
on content that could not have parsed:

- Tags never nest, so any special-tag marker inside the body (opening OR
  closing, not just a mismatched close) proves the opener was literal text.
- JSON-bodied tags must start with `{` or `[`, so the first non-space
  character settles it — prose after the marker is caught on the very next
  chunk rather than at the end.

The second is per-tag, not global: `thinking` bodies are prose by design
(parseTextTagBody), so the JSON rule cannot apply there and only the nesting
rule can rescue it. A test documents that remaining gap rather than leaving
it implicit.

A false positive is cheap by construction: text shows early and the
end-of-stream parse still produces the correct final render.
The nesting rule treated any tag marker in the body as proof the opener was
literal text. But a JSON body can legitimately quote tag syntax — a
`<question>` asking which tag to use, a `<workspace_resource>` whose title
mentions one — and that is exactly the "model explains its own tags"
situation this whole fix exists for.

Bailing there is the one expensive false positive in the design: the raw JSON
renders and STAYS for the rest of the stream, then snaps into a card when the
real close arrives. More jarring than the bug being fixed.

For JSON-bodied tags the nesting scan now runs over a copy with string
literals blanked (escape-aware, and tolerant of the unterminated trailing
string that is normal mid-stream). Markers in real body position still count.
`thinking` is unaffected — its body is prose, so there are no strings to
confuse it.

Tests cover both halves: the streaming case does not bail, and the same body
resolves to a question card once it closes.
…ead JSON

Two more real cases from traces, both of which the earlier heuristics missed.

1. A MATCHED pair whose body fails to parse was silently dropped, cursor and
   all. When the model explains tag syntax and ends with a backticked example
   containing a real closing tag, that example closes an EARLIER opener and
   three paragraphs become the "body" — which is not valid JSON, so the whole
   span vanished and the render resumed mid-sentence (trace b095e080).

   Now emitted verbatim, but only when the body contains a tag-shaped marker,
   which is what shows the pairing was wrong. A marker-free body that merely
   fails validation is a genuinely malformed agent payload and keeps being
   dropped — an existing test asserts that deliberately, and showing the user
   raw JSON there would be a regression.

2. The JSON heuristic only checked the FIRST character, so
   `{"type":"file"}</workspac and then prose...` looked viable forever: it
   opens with `{`, and the truncated close is not a marker any rule can see
   (trace afbeefd0). Track depth instead — once the top-level value closes,
   any non-whitespace after it is fatal, so the stray character decides it
   immediately. String contents are blanked first so braces inside strings do
   not skew the count.
Third real case from a trace (1206fd8a): `<workspace_resource>the gmail-agent
workflow</workspace_resource>` — a matched pair whose body is plain prose. The
sentence rendered as "...once I wired up to handle the welcome sequence" with
its subject silently removed.

The marker test could not fire (prose contains no tag markers), so it fell to
the deliberate drop-malformed-payload path. But that path exists for an agent
emitting BROKEN JSON, not for a tag wrapping prose.

The distinction is whether the body was ever an attempted payload, which
isViableJsonPrefix already answers: `{"type":"single_select"}` is a well-formed
JSON value failing its shape guard and keeps being dropped; `the gmail-agent
workflow` was never a payload and is emitted.
parseSpecialTags had grown five inline branches, each added for a specific
malformation found in a trace. The shape made "drop it" the implicit
fallback, which is how spans that were never malformed payloads ended up
silently swallowed.

Extracts resolveTagAt, returning one of four named outcomes — segment,
literal, discard, pending — so each decision is explicit and the main loop
just dispatches on it.

Fixes a latent bug the old shape hid: rejecting an unclosed tag ran `break`,
abandoning the rest of the message, so a genuinely valid tag after a literal
mention was never parsed. Resolution now resumes just past the rejected
opener and scanning continues. Test added.

Two behavior notes:
- Rejected spans are emitted in smaller pieces. The renderer concatenates
  adjacent text segments into one markdown string, so this is display-neutral;
  the two tests that asserted exact segment arrays now assert joined text.
- Each opener is judged on its own evidence. Previously one verdict ended the
  whole parse, so a nested opener released everything; now the outer is
  released immediately and the inner is a fresh candidate that can still hold
  mid-stream. It resolves at end of stream either way.
Fourth malformation from a trace (220cc02d): the model wrote an opening tag
with valid JSON and no closing tag of any kind. No marker rule can fire —
there is no marker — but the JSON value completes and prose follows, which
the depth rule settles at the first space after the `}`.

Already handled by that rule; this pins it. Asserted as lossless rather than
merely visible: mid-stream and complete, every character of the message
survives, so nothing waits for the stream to end.
…the buffer

Review round on the four-outcome rewrite. Three defects in how an opener is
judged, plus the cost of judging it.

A valid tag showed its raw payload as text while its closing marker streamed
in. The JSON value closes at the `}`, so a half-arrived `</opt` read as stray
trailing content and settled the tag as unresolvable. Every JSON-bodied tag hit
this, and most replies carry a trailing <options> block. dropArrivingClose now
ignores a trailing fragment that could still grow into this tag's own close.
Evidence that a close is genuinely wrong still lands immediately: a misspelled
`</workflow_resource>` is not a prefix of `</workspace_resource>`, and a
truncated `</workspac` stops being one the moment prose follows it.

bodyIsLiteralText scanned the raw body for tag markers while its streaming
counterpart blanked JSON string literals first. A payload that failed its shape
guard and legitimately quoted tag syntax was therefore called literal text and
rendered as raw JSON, which is what discard exists to prevent. Both paths now
judge the same blanked body.

An opener whose own close was misspelled reached forward and matched the NEXT
tag's close, swallowing a valid resource into one literal span and destroying
its chip. When markers in the body prove the matched close belongs to a
different opener, resolution resumes past the opener so the interior is
rescanned and the inner tag still renders.

Cost: resuming past a rejected opener instead of abandoning the message made
each parse O(openers x length), and the parse re-runs for every streamed chunk.
A 40KB reply repeatedly mentioning a tag name cost 7.3s of blocked main thread.
The unclosed-body inspection is now bounded, since both rules decide on their
first piece of evidence, and the opener and close lookups are memoized per
parse rather than rescanning to the end of the buffer for every opener. Same
message: 1.2s. A realistic 40KB reply with 8 mentions: 0.35ms per parse, 28ms
across the entire stream.

Also derives JSON_BODY_TAG_NAMES from SPECIAL_TAG_NAMES so a new tag cannot
silently fall back to the weaker prose heuristics; deletes a docstring the
rewrite orphaned above TAG_SHAPED_MARKER; corrects one still describing the
first-character check that depth tracking replaced; drops a test duplicating
one already in the file; and fixes a test that named the no-nesting rule but
passed with that rule deleted, because its JSON closed before the marker.
@j15z
j15z force-pushed the fix/unclosed-special-tags branch from c61dd9d to 3335d70 Compare July 27, 2026 20:28
`discard` fired on any body parseSpecialTagData rejected, which conflated "is not
valid JSON" with "is valid JSON of the wrong shape" — even though its own comment
has always said "a well-formed value that failed its shape guard." The viability
rule cannot separate them, because bracket depth reads `{the Q4 report}` as a
viable JSON value.

So prose someone wrapped in braces was deleted:

  I saved <workspace_resource>{the Q4 report}</workspace_resource> for you.
    ->    I saved  for you.

along with the two commonest JSON slips a model makes, unquoted keys
(`{type: "file"}`) and single quotes (`{'type':'file'}`). All three are text loss
of exactly the kind this branch exists to remove.

Dropping text is only defensible for a payload the agent actually formed, so
discard now requires the body to parse. Anything that will not parse was never
demonstrably a payload and is shown instead. A valid payload with the wrong shape
still discards, which is the case the outcome was written for, and a valid tag
still renders.

Costs a second JSON.parse of a body that already failed one. That is the rare
path: a valid payload returns before it, and prose is rejected by the cheaper
viability rule before it.

Verified by mutation: removing the gate fails the new test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor review

…air path too

A prose body has no shape to fail — parseTextTagBody accepts any non-empty text —
so <thinking> was the one tag whose close was accepted unconditionally. The
unclosed path already refuses an opener whose body carries a tag marker, on the
grounds that tags never nest. The matched-pair path did not, and the two
disagreeing is the bug.

Mid-stream a nested marker disproves the outer opener, so its text is released
and the inner tag renders. When </thinking> finally arrived it was accepted as a
segment, and everything already on screen was swallowed into it and suppressed:

  a <thinking>b <options>[...]</options> c        -> "a <thinking>b [CARD] c"
  a <thinking>b <options>[...]</options> c</thinking> d -> "a  d"

A rendered card and its surrounding text disappearing from a message the reader
was already looking at. Both paths now apply the same rule, and resolution
resumes at the opener so the inner tag survives the rescan — safe here because
prose bodies are never blanked, so no marker is hidden from the scan the way one
can be inside a JSON string.

The frame-replay helper counted a thinking body as visible text, which is why its
card-monotonicity assertion could not see this. It now models the renderer:
thinking contributes nothing. Verified by mutation — removing the rule fails the
new test and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every regression review found on this branch was a combination nobody had written
an example for. The example tests cannot cover the space: a body is judged on
body kind, close state, JSON state, marker placement, size against the scan
window, and streaming mode — a product of roughly six hundred cells, each needing
both an outcome and a resume point.

Four invariants over messages composed from fragments, so a new combination is
covered without a new test:

- no character is lost from a message containing nothing droppable
- every valid tag renders as a card whatever surrounds it
- across streamed frames a card never un-renders and text never retracts
- the settled parse never shows less than the last streaming frame

Fragments are the shapes the parser must reject — prose mentions, misspelled and
truncated closes, brace-wrapped prose, unquoted keys, a nested marker in a prose
body, filler long enough to cross the scan window. None is a valid tag or a
well-formed payload, so nothing is eligible for discard, which is what makes
"output equals input" a legal assertion. Seeded, so a failure reproduces.

Validated against the three defects review found, with the fix for each reverted
and only these tests running: the flattened tag past the window fails the card
invariant, the deleted brace-wrapped prose fails the loss invariant, and the
retracted thinking close fails both loss and retraction. They pass on current
code, so nothing further is reachable through the shapes they generate.

The generator composes several fragments between tags rather than one. With a
single fragment it cannot place an unclosed opener and then enough prose to push
a valid tag past the scan window, which is exactly the shape of one of the three —
the first draft missed it for that reason alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A space in the visibleView card placeholder was written as U+0000, so the file
contained one null byte. Git classifies a file with a null byte as binary and
shows "Bin 34936 -> 41628 bytes" instead of a diff, which made the test file
unreviewable in the two commits since 2f9b60a.

Nothing caught it: vitest, tsc and biome all read the file fine, and the byte sat
inside a string literal used only as a placeholder in test-only output, so no
assertion depended on it. Only `git diff` noticed.

The PR diff renders as text again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
j15z and others added 7 commits July 27, 2026 19:55
…d resume

resolveTagAt answered three orthogonal questions in one body of branches — is
this a payload, how much of it may I read, and where do I resume — and every
regression review found on this parser was one of those answers changing without
another. Two of the five were regressions from the fix for the previous one, and
the last two were not missed inputs at all: one was the code contradicting its
own docstring, the other two branches applying different rules to the same
question. That is what coupling looks like, not bad luck.

Now three pieces:

- inspectWithin — the read budget, and nothing else. Both paths spend it through
  the same helper; they previously applied the same constant in two shapes, and
  the difference between those shapes was a bug.
- classifyBody — what the body IS. Pure: no positions, no outcome, no resume.
  Returns one of a closed set of five classes.
- resumeForClass — where scanning continues, given the class. Nothing else.

The closed set is the point. resolveMatchedPair and resumeForClass each switch
over it exhaustively, so adding a class fails to compile until BOTH questions are
answered for it. Verified by adding a sixth case: tsc reports exactly two errors,
one per switch. The failure mode that produced five review rounds is no longer
representable.

Behaviour is identical, not merely believed to be. Diffed against the previous
implementation over 7,500 comparisons — 1,500 generated messages, each parsed
streaming, settled, and at three mid-stream cut points — asserting deep equality
of the full result, not just rendered text. The one divergence the differential
found was mine: the prose nested-marker path resumed at the marker rather than
the opener, emitting one text segment where the old code emitted two. Display-
identical, since adjacent text segments concatenate, and arguably better — but a
behaviour change does not belong in a refactor, so prose nesting is now its own
class that resumes exactly where it used to. The improvement is noted in the type
for whoever wants it.

The 70 tests, including the four property invariants added on the parent branch,
pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The display sanitizer removes a backtick sitting next to a workspace-resource
tag, so a stray one cannot stop a chip rendering. It could not tell a real tag
from prose MENTIONING the tag name, and a mention is the common case:

  The `<workspace_resource>` tag needs a real `path` to render.
    ->  The <workspace_resource>` tag needs a real `path` to render.

The opening backtick goes, the closing one is left unpaired, and it opens a code
span that runs to the next backtick — inverting every code span for the rest of
the message. A three-paragraph explanation renders with most of its prose in
monospace and stray backticks visible in the text.

Both unpaired-strip patterns now require the complete opener-payload-closer to
be present with no backtick inside it. That is what separates the two cases: a
payload is JSON and contains no backticks, while a mention has no closer at all.
The one-sided stray backtick around a real tag — the case these patterns exist
for — still strips, and the balanced-code-span unwrap above them is untouched.

Pre-existing, and not in this branch's two files, but only reachable because of
them: until this branch, a prose mention of the tag blanked the rest of the
message, so the mangled formatting was never on screen to see. Verified end to
end through sanitizer -> parser -> Streamdown: the reported message keeps all 14
backticks and renders 7 balanced code spans with no spurious chip, and a real
backticked tag still renders its chip with the wrapping removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit fixed the two unpaired-backtick strips but left the balanced
unwrap above them, which has the identical flaw. It allows anything between
opener and closer, so a message writing the two markers as separately backticked
spans reads as ONE wrapped tag and loses its outer pair:

  Use `<workspace_resource>` then close with `</workspace_resource>` here.
    ->  Use <workspace_resource>` then close with `</workspace_resource> here.

Two backticks gone, the remaining two mispaired, same message-wide code-span
inversion. This is the shape a message explaining the tag syntax naturally
takes, which is how the original report was produced.

All three patterns now forbid a backtick between opener and closer. Verified
across the matrix: a real tag still unwraps whether the stray backtick is on
both sides or one, and every mention shape keeps its backticks balanced.

Known and accepted: a resource whose title or path itself contains a backtick
will not have wrapping backticks stripped, so it renders as text instead of a
chip. That failure costs one chip and is rare; the one it replaces corrupts the
formatting of an entire message and is common.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found my previous two commits traded one bug for a worse one. Three
independent reviewers measured it.

The trailing-backtick pattern anchored on the bare `<workspace_resource>`
literal, so every opener started a lazy scan hunting a closer that never
arrives. A message repeating the tag name in prose went quadratic: 168KB cost
154ms per call, and this runs on the main thread for every streamed chunk, so a
long reply is seconds of frozen tab. The pattern it replaced was linear. Now
0.36ms on the same input.

Two correctness bugs came with it, both from matching `\s*` around the tag: the
pattern could start at one code span's delimiter and finish at another's, so
`Open `config.json` <tag> then run `bun test`` lost a backtick; and the
whitespace crossed newlines, eating one of the three backticks closing a fenced
block that contained a tag, leaving the rest of the message inside the fence.

The root problem was three global regexes each guessing at which backticks
belong together. They now model what markdown actually does: find code spans by
pairing a backtick with the next one on the same line, and unwrap a span only
when it genuinely contains a complete tag. Pairing is what makes the neighbour
and fence cases correct rather than separately patched. A leftover backtick
pressed directly against a tag, with no partner, is still stripped.

A negative lookahead stops any scan crossing another opener — the cost bound.

Adds tests for the neighbour and fence shapes, and one that pins the complexity
by asserting 168KB of repeated-opener prose finishes well inside 50ms; every
fixture until now was under 1KB, which is why both quadratics were invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five findings from the review, each reproduced before being fixed and
mutation-checked after.

The nesting rule on the matched-pair path tested for anything tag-shaped while
the streaming path tested for the tag NAMES. Reasoning that mentioned `<div>` or
a generic was therefore released as visible prose — the model's thinking on
screen because of an incidental angle bracket. Both paths now share one
predicate, hasSpecialTagMarker, so they cannot disagree about whether a body was
ever a tag. The broad regex keeps its other job: on a JSON body an invented name
like `</workflow_resource>` really is stray content.

Not changed, and worth saying why: a `<thinking>` body containing a REAL nested
tag still releases and renders it. Suppressing it instead would mean the
streaming path shows a card and the close then retracts it, which is the defect
the previous commit fixed. Security rated the containment loss P2 while noting it
grants no capability the top-level path lacks — a stream that can nest a tag can
emit one at top level, which already rendered.

unclosedTagCannotResolve blanked a full window before viability rejected the body
on its first character, which is the common case of a tag name in prose. Testing
that first: 43ms per streaming parse at 84KB, now 2ms.

The unclosed path sliced the whole remaining buffer and then bounded it, copying
the rest of the message per opener per chunk. inspectFrom slices once, bounded.

A message that is ONLY a discarded payload rendered the raw JSON: discard emits
no segment by design, so it reached the empty-segments fallback, whose job is to
never blank a plain-text message. It now knows a discard happened. Pre-existing,
but discard only became a first-class outcome on this branch.

Three docstrings still pointed at resolveTagAt for decisions the refactor moved
into classifyBody and resumeForClass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four changes from a reuse/simplification/efficiency/altitude review. No behaviour
change; 162 tests unchanged.

blankJsonStringLiterals returns the body untouched when it contains no quote. No
quote means no string literal, so the loop was copying the body to itself
character by character. unclosedTagCannotResolve had learned to short-circuit
before calling it; literalTextReason had not, and blanked unconditionally on
every already-rejected tag on every later chunk. Putting the guard inside the
helper fixes both callers at once.

inspectWithin and inspectFrom were near-duplicates added at different times. One
function with an optional start expresses both, and keeps the property the second
one existed for: slice once, already bounded, never `content.slice(bodyStart)`
first and bound after.

The frame-replay property was 1727ms — 95% of the file's test time — because it
parses every prefix, so message length multiplies into parse count, and the
fragment pool included a 6KB filler. Retraction happens AT a frame boundary, not
as a function of message size, so that property now draws from the short
fragments; the window-crossing filler still gets coverage from the properties
that parse each message once. 1727ms to 205ms, same seeds, same assertions. It
also now calls replayFrames instead of hand-rolling the stepping loop it already
had a helper for.

Deferred deliberately, each with a reason:

- Collapsing `prose-nested-marker` into `nested-marker`. Its own docstring names
  the simplification and defers it; it is a behaviour change and wants its own
  commit and test, which is the pattern the rest of this branch follows.
- Bounding hasSpecialTagMarker on the prose path. Costs tens of microseconds on
  a long reasoning body, but a marker past the bound would flip that body from
  released to suppressed — a retraction, which is the bug two commits back.
- Splitting the parser out of this `'use client'` file. The strongest structural
  finding: the file cannot be imported by server code, which is why the inbox
  executor carries its own thinking-strip regex. It is a mechanical move with no
  logic change and deserves its own PR, not commit 25 of this one.
- Generalising the backtick sanitizer past `workspace_resource`, and replacing it
  with parser-owned backtick consumption. Same reasoning: real, and not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One real defect. Adding hasSpecialTagMarker put it BETWEEN
unclosedTagCannotResolve and the docstring written for it, so two doc blocks sat
stacked and the function they described ended up with none. A reader scanning the
file would have read the first block as preamble for the wrong function. Moved
back, and while there: "14 substring scans" is SPECIAL_TAG_NAMES.length * 2, so
adding an eighth tag would have quietly made it wrong — it now says a pass per
tag name.

The rest is noise removal:

- Three comments narrating what the parser used to do. A comment is read by
  someone looking at the current code, not the diff, and this branch already
  writes that history at length in its commit messages.
- Three millisecond measurements. Each one already stated the durable claim —
  quadratic, or a copy thrown away per opener per chunk — and then appended a
  number that will rot. The one measurement kept is the blind-spot paragraph on
  MAX_UNCLOSED_BODY_SCAN, where the number is what justifies the constant.
- Two of the four restatements of "the renderer concatenates adjacent text
  segments". Kept where it is load-bearing, on pushText and on the test helper.
- One claim gone stale in the last commit: the read-budget doc still described
  two helpers agreeing with each other, after they became one function.

Left alone deliberately: the rules that look arbitrary and are not — marker
blanking, the differing resume offsets, the accepted trades. Those are why this
file reads as heavily commented, and they are the ones worth having.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor review

…vives

Review caught a case the previous fix missed. Two passes each decided
independently which backticks belonged together, and with no space between a
code span and a tag they disagreed:

  Open `config.json`<tag> ok     ->  lost the span's closing backtick
  Open <tag>`config.json` ok     ->  lost the span's opening backtick

My test for this used a space between them, which is exactly why it passed.

Both passes are now one left-to-right scan alternating between a code span and a
tag with a stray backtick against it. A span consumes its own delimiters as the
scan reaches them, so a flush neighbour keeps its pair with no special case —
where a second pass had no way to know the backtick was already spoken for. This
is the third arrangement of this file; the first two each handled the cases they
were written for and broke a different one, which is what two passes guessing at
the same question produces.

A trailing backtick is only taken as a stray when no further backtick follows on
the line. Otherwise it is the opener of the next span. Mutation-checked: removing
that lookahead fails the new test and nothing else.

Swapping the alternation order changes nothing any fixture covers, so the comment
no longer claims the order is load-bearing — it distinguishes only a span that
opens flush against a tag and closes elsewhere, which nothing pins.

Thirteen shapes verified balanced, including all three flush variants, the
fenced block, the neighbour with a space, both one-sided strays, and the mention
shapes. 168KB of repeated openers: 0.18ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 7dc9148. Configure here.

@j15z

j15z commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up filed as #6001 — make the parser code-span aware and delete the sanitizer's tag handling, with the 'use client' parser extraction as its prerequisite.

Both are named in the description; the issue carries the reasoning, the four corruption shapes that motivated it, and the mid-stream complication (whether a tag sits inside a code span is undecidable while the span is still open, the same way a tag's close is).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant