fix(copilot): stop the special-tag parser discarding text it cannot resolve - #5952
fix(copilot): stop the special-tag parser discarding text it cannot resolve#5952j15z wants to merge 27 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview Special-tag parser ( Display sanitizer ( 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 SummaryThis PR makes streamed special-tag parsing lossless for unresolved content.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure related to the previous review thread remains. No blocking failure remains.
|
| 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
|
@cursor review |
There was a problem hiding this comment.
✅ 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.
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.
c61dd9d to
3335d70
Compare
`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>
|
@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>
…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>
|
@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>
|
@cursor review |
There was a problem hiding this comment.
✅ 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.
|
Follow-up filed as #6001 — make the parser code-span aware and delete the sanitizer's tag handling, with the 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). |
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:
<workspace_resource>chip needs a real path" — blanked the remainder of the message until the stream ended.resolveTagAtnow 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 cardThe 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.
}], so the trailing</optwould otherwise look like stray content and trip the viability rule below.dropArrivingClosestrips a trailing fragment that is a prefix of this tag's own closing marker, so</optreads 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.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<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.<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>orPromise<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<question>type guard, running afterJSON.parsealready succeeded. The body is valid JSON, but a question needs apromptand at least one option.{the Q4 report}— prose someone wrapped in braces — from a real payload. A body that failsJSON.parsefalls through to outcome 2 and is shown, which also covers the two commonest slips a model makes, unquoted keys and single quotes.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[{"type":"single_selis one keystroke from a valid card. Releasing it early would print raw JSON that snaps into a card a frame later.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:
inspectWithinclassifyBodyresumeForClassThe closed set is the point:
resolveMatchedPairandresumeForClasseach switch over it exhaustively, so adding a case fails to compile until both questions are answered for it. Verified by adding a sixth case —tscreports 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
<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.Type of Change
Testing
162 tests across the touched area, plus
tscand Biome clean.Coverage is built around failure shapes rather than line count:
What reviewers should focus on:
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
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.