Skip to content

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

Open
j15z wants to merge 34 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 34 commits into
stagingfrom
fix/unclosed-special-tags

Conversation

@j15z

@j15z j15z commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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:

  • 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.

Every opening tag now resolves to one of four outcomes.

Outcome When What the reader sees
segment the body parsed and matched its shape guard the card
literal the span is provably not a tag the text, verbatim and immediately
discard the body parsed as JSON but failed its shape guard nothing — a broken payload, not lost prose
pending still streaming and a close is plausible a shimmer, until it resolves

discard is the only outcome that intentionally removes text, and it is deliberately narrow: a body that will not parse at all falls through to literal and 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:

  • Quoted tag syntax is not evidence. A <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.
  • An arriving close is not stray content. A closing marker lands one character at a time, so a valid tag spends several frames looking like a payload with junk after it. A trailing fragment that is a prefix of this tag's own close is ignored; a genuinely wrong close like </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> or Promise<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

  • 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. The alternative swallows the answer whenever the model opens the tag and never closes 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. 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

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

Coverage is built around failure shapes rather than line count:

  • 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.
  • Four property invariants over generated messages, 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. Their fixtures cover all seven tags, checked against SPECIAL_TAG_NAMES so a new tag cannot be added without one.
  • Complexity is pinned, not just correctness. Two tests assert that pathological inputs stay linear, as a ratio across a 4x input rather than a wall-clock ceiling — a fixed millisecond bound measures the machine as much as the algorithm. Every fixture was under 1 KB before, which is why two separate quadratics went unnoticed.
  • Each behavioural fix is mutation-checked: reverting it fails its test, and only its test.

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. The truncated-body resume backs off by the longest marker so an opener straddling the window edge is re-scanned rather than lost.
  • 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.

@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 29, 2026 6:19am

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 the hot-path chat parser and sanitizer on every streamed chunk with intricate resume/classification logic; risk is mitigated by broad regression and property tests but behavior changes are user-visible across all copilot messages.

Overview
Stops the copilot chat renderer from swallowing assistant text when inline special tags (<workspace_resource>, <question>, etc.) are malformed, mentioned in prose, or only half-streamed.

parseSpecialTags no longer treats every failed tag as “drop and stop.” Each opener resolves to segment, literal (show verbatim), discard (only valid JSON that failed shape guards), or pending while streaming. Rejected spans resume scanning inside the message so later valid tags still become cards. Streaming uses JSON viability (with string-literal blanking), tolerates arriving close tags, and caps body inspection at 4096 chars plus memoized indexOf so long replies stay near-linear on every chunk.

sanitizeChatDisplayContent is rewritten to one left-to-right pass: unwrap inline code only when it contains a complete <workspace_resource> tag, strip stray backticks beside real tags, and avoid unpairing backticks when the model only mentions the tag name (which used to break markdown for the rest of the message).

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-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes special-tag parsing so unresolved spans preserve user-visible text while malformed structured payloads remain discardable.

  • Classifies parsed tags as rendered segments, literal text, discarded payloads, or pending stream content.
  • Bounds and memoizes parser scans to avoid pathological main-thread costs.
  • Corrects backtick sanitization around workspace-resource tags and prose mentions.
  • Adds regression, streaming-invariant, and scaling tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failures remain in the fixes related to the previous review threads.

Important Files Changed

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

@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
j15z force-pushed the fix/unclosed-special-tags branch from c61dd9d to 3335d70 Compare July 27, 2026 20:28
@j15z

j15z commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 27, 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 0abfbc1. Configure here.

@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

j15z and others added 4 commits July 28, 2026 18:56
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>
@j15z
j15z force-pushed the fix/unclosed-special-tags branch from 7dc9148 to 86fe480 Compare July 29, 2026 02:02
j15z and others added 4 commits July 28, 2026 19:24
…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>
@j15z

j15z commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 29, 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 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>
@j15z

j15z commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 29, 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 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>
@j15z

j15z commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@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>
@j15z

j15z commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile

@j15z

j15z commented Jul 29, 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 6fbd399. Configure here.

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