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 34 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview
Tests add production-shaped regressions, frame-replay invariants, seeded property tests, and 4× scaling-ratio checks for parser and sanitizer. Reviewed by Cursor Bugbot for commit 6fbd399. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryFixes special-tag parsing so unresolved spans preserve user-visible text while malformed structured payloads remain discardable.
Confidence Score: 5/5The PR appears safe to merge. No blocking failures remain in the fixes related to the previous review threads.
|
| Filename | Overview |
|---|---|
| apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx | Introduces bounded tag classification and resumption logic; the quoted-marker fix from the previous thread correctly keeps markers inside JSON strings from bypassing payload discard. |
| apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts | Adds comprehensive regression, streaming-stability, property, and complexity coverage for the parser behavior. |
| apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts | Reworks workspace-resource backtick handling into a single left-to-right sanitizer pass. |
| 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, fences, stray delimiters, and scaling. |
| apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts | Adds a reusable ratio-based helper for detecting nonlinear parsing and sanitization costs. |
Reviews (12): Last reviewed commit: "docs(copilot): state the actual bound on..." | 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.
c61dd9d to
3335d70
Compare
|
@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 0abfbc1. Configure here.
|
@cursor review |
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>
…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>
7dc9148 to
86fe480
Compare
…ill parses Applied from an automated code review of this branch. Two findings, both verified by reverting the fix and confirming only the new test fails. `resumeForClass`'s `unexamined` case resumed at exactly `bodyStart + 4096`. That edge is an arbitrary cut, so an opener can begin just before it and finish just after — leaving its `<` behind the cursor. The opener scan only looks forward, so the tag was never found and its payload rendered as raw JSON text on a COMPLETED message, not just mid-stream. Reproducible for every filler length in 4077..4095; the existing borrowed-body test steps in 11-character units and never lands in that band. Backing the resume off by the longest marker guarantees a straddling opener is re-scanned from its `<`. The step is still ~4076 characters, so a long body still costs a bounded number of re-entries. The new test sweeps every offset across the band. The two complexity tests asserted absolute wall-clock ceilings (<50ms, <20ms), which measure the machine as much as the algorithm: they fail on a loaded CI box, and set generously enough not to, they let a genuine quadratic through at the single size they sample. Both now assert the scaling ratio across a 4x input instead (quadratic ~16x, linear ~4x). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…be JSON `literalTextReason` blanks a body's quoted regions before scanning it for tag markers, so that syntax quoted inside a JSON string is not mistaken for a real nested tag. That blanking assumes quotes delimit JSON strings. One unbalanced `"` breaks the assumption: everything after it is treated as string content, which can hide a genuine marker. The verdict then degrades from `foreign-markers` to `never-a-payload`, and the resume changes with it — from the marker offset to past the close — flattening a real tag inside the span. A card already on screen un-renders into raw JSON when the closing tag finally arrives, and a valid tag after it never renders. Blanking is only meaningful while the body might BE JSON. Once viability or a failed parse has proved it never was, that premise is void and the raw text is the honest evidence, so rescan it and resume at the marker. Both routes to "never JSON" now funnel through one branch. The rescan applies to the failed-parse route too, not only the viability one — patching just the latter leaves the same defect reachable through the former. Behaviour for a body that IS valid JSON is untouched: a well-formed payload that fails its shape guard is still discarded, and tag syntax quoted inside a valid payload is still invisible to the scan. Adds the repro as two tests — the settled parse, and frame-by-frame so the un-render is pinned directly — plus two unbalanced-quote fragments to the property corpus. Reverting the rescan fails exactly those two tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rf tests Both complexity tests hand-rolled the same 15-line harness — build a repeated tag mention, take the fastest of five runs at two input sizes, assert the ratio — differing only in which function they timed. Changing the run count, the sample sizes, or the threshold meant editing both in lockstep. Extracted to `scalingRatioOver4x`, following the existing `*-test-helpers.ts` convention in this tree. The rationale for asserting a ratio rather than a wall-clock ceiling now lives in one place instead of being paraphrased twice. Also drops a redundant disjunct in the opener scan: `nearestStart` and `nearestTagName` are only ever assigned together, so `nearestStart === -1` holds exactly when the name is empty. Testing the name alone is the same check and is the one that narrows the union for `resolveTagAt`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `unexamined` case claimed "everything read is emitted as text by the caller". That was true when the resume was exactly the window edge, but the straddling-opener rewind holds the last marker's worth of the window back so it can be re-scanned rather than flattened — so the sentence contradicted the paragraph directly beneath it. Nothing is lost either way; the caller emits up to wherever this resumes. Also drops two "Round-N class:" prefixes from test comments. They point at review rounds of this PR, which mean nothing to a reader after it merges; the sentences that follow already say what the bug was. 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 2972209. Configure here.
CI runs the merge with staging, and staging's desktop PR (#5998) taught `parseSpecialTagData` to recover a failed `<question>` body's prompt and render it as text instead of returning null. That recovery lands before `classifyBody` is ever consulted, so this fixture — whose quoted `</options>` sat inside its `prompt` — stopped exercising the blanking rule and started asserting the recovery. Merged, it rendered "A use </options> here? B" instead of "A B". Moving the quoted marker to a non-`prompt` field restores what the test is for: a marker inside a JSON string must be blanked before the scan, or a broken payload gets classified as literal text and its raw JSON is shown. A body with no recoverable prompt reaches `discard` on both sides of the merge, matching the prompt-less fixture the sibling test above already uses. Behaviour is unchanged on either branch alone; only the fixture moved. 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 540667a. Configure here.
…at it stays that way `VALID_TAGS` and `NEEDLES` hand-picked three or four of the seven tags the parser resolves. `credential`, `usage_upgrade`, and `mothership-error` were exercised by no invariant at all — not the card-count property, not retraction-across-frames, not settled-vs-last-frame — and nothing failed to say so. The property block's whole argument is that it covers combinations no fixed example set would, which was only true for four sevenths of the tag space. Fixtures are now keyed by tag name and checked against SPECIAL_TAG_NAMES, so adding a tag without a fixture fails a test instead of quietly falling outside every property here. `thinking` is listed separately: it renders nothing, so it cannot carry a card invariant. SPECIAL_TAG_NAMES is exported for that check, matching the five sibling `*_TYPES` unions in the same module that are already exported. NEEDLES derives from it too, so the memoization test searches every opener the parser does. All three newly covered tags pass every invariant — this closes a coverage gap, it does not fix a bug. Removing any one fixture fails the new guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cursor review |
The docstring implied generics are excluded from the marker check. They are not: the match is a substring test, so `Promise<void>` is safe only because `void` is not a tag name, while `Promise<options>` does match and would release a thinking body as text. Says so plainly now, with why it is left as-is: the boundary check that would narrow it wants a lookbehind, which is Safari 16.4+ and would be a parse-time SyntaxError on the versions this app still supports — a dead client chunk is a worse outcome than the bug. Reaching it also needs an inline `<thinking>` body, which the agent no longer emits, discussing a type named exactly after a tag. Comment only; no behaviour change. 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 6fbd399. Configure here.
Summary
The chat renderer pulls 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, hidden from the rendered output). Anything it could not turn into a tag, it threw 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.Every opening tag now resolves to one of four outcomes.
segmentliteraldiscardpendingdiscardis the only outcome that intentionally removes text, and it is deliberately narrow: a body that will not parse at all falls through toliteraland is shown, because bracket depth cannot tell{the Q4 report}— prose someone wrapped in braces — from a real payload. That also covers the two commonest model slips, unquoted keys and single quotes.Rejection resumes inside the message rather than abandoning it, so a genuine tag later in the same reply still renders.
Telling a real tag from a mention of one
Every tag body except
<thinking>must be JSON, so a body that can no longer grow into a valid JSON value proves the opener was ordinary text. That single rule settles a prose mention (never opens with{), a misspelled close, a truncated close, and a payload trailed by prose — mid-stream, without waiting for the stream to end.Two details make it hold up:
<question>may legitimately quote a tag name in its prompt, so the scan runs on a copy with JSON string contents blanked. Once the body is known never to have been JSON, that premise is void and the raw text is scanned instead — otherwise one unbalanced"blanks the wrong regions, hides a real marker, and flattens a nested tag that was already on screen.</workflow_resource>is not a prefix of</workspace_resource>and still settles immediately.<thinking>is the exception, since its body is prose. There the test is that tags never nest: a marker for another known tag inside the body proves the opener was text. Tag names specifically — reasoning that mentions<div>orPromise<void>is still reasoning.Cost
These checks are not free. The parser re-runs over the whole message on every streamed chunk, on the main thread, so an unbounded check is quadratic in the length of the reply — that is what froze the tab.
Inspection is capped at 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. Opener and close lookups are memoized per parse instead of rescanning to the end of the buffer for every opener. 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.The cap has one accepted blind spot, stated in its docstring and pinned by a test: a JSON body whose value closes beyond the window, followed by prose and no close, waits for the stream to end rather than settling mid-stream. It stays lossless and needs a payload several times larger than any tag emits.
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 an adjacent unrelated code span and a fenced block containing a tag correct structurally rather than as separate patches.
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. The alternative swallows the answer whenever the model opens the tag and never closes it.Type of Change
Testing
167 tests across the touched area, plus
tscand Biome clean.Coverage is built around failure shapes rather than line count:
SPECIAL_TAG_NAMESso a new tag cannot be added without one.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.