Skip to content

Commit a5ca810

Browse files
authored
fix(chat): drop unparsable special-tag payloads instead of dumping raw JSON (#6142)
* fix(chat): drop unparsable special-tag payloads instead of dumping raw JSON * fix(chat): suppress broken payloads mid-stream; reserve marker rescan for mispaired quotes * improvement(chat): require key-value colon evidence before dropping an unparsable tag body
1 parent 88f054e commit a5ca810

2 files changed

Lines changed: 296 additions & 58 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,20 @@ describe('parseSpecialTags with <question>', () => {
246246
})
247247

248248
it('does not rescan the interior of a body that carried no markers', () => {
249-
// Pins WHY the two literal reasons resume at different offsets. A
250-
// never-a-payload body resumes past the CLOSE; resuming past the opener
251-
// instead would rescan the interior, and since the marker scan runs on the
252-
// blanked body, a tag quoted inside a JSON string is invisible to it and
253-
// would be re-parsed as a real tag on the second pass — then dropped,
254-
// deleting the very text this parser exists to preserve.
249+
// Pins WHY a settled span resumes past the CLOSE, never past the opener.
250+
// Resuming past the opener would rescan the interior, and since the marker
251+
// scan runs on the blanked body, a tag quoted inside a JSON string is
252+
// invisible to it and would be re-parsed as a REAL tag on the second pass —
253+
// painting the quoted JSON verbatim as raw text (its escaped quotes cannot
254+
// re-parse as a card), the exact failure `discard` exists to prevent. The
255+
// span itself opens `{"` and will not parse (trailing junk), so it is an
256+
// attempted payload and is discarded whole; what must never happen is a
257+
// partial re-parse of its quoted interior.
255258
const raw =
256259
'A <question>{"a":"<options>{\\"k\\":{\\"title\\":\\"x\\",\\"description\\":\\"y\\"}}</options>"} junk</question> B'
257260
const { segments } = parseSpecialTags(raw, false)
258261
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
259-
expect(renderedText(segments)).toBe(raw)
262+
expect(renderedText(segments)).toBe('A B')
260263
})
261264

262265
it('keeps prose a tag wrapped instead of a payload', () => {
@@ -320,6 +323,88 @@ describe('parseSpecialTags with <question>', () => {
320323
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
321324
})
322325

326+
it('drops a payload one typo away from valid instead of showing raw JSON', () => {
327+
// The first three are verbatim from production screenshots (2026-07-31): an
328+
// extra `}` before the array close, a missing opening quote on a key, and a
329+
// stray `]}` after the map closes; the fourth adds a trailing comma. Each
330+
// fails JSON.parse, so the old was-it-ever-JSON test called them prose and
331+
// rendered the whole payload verbatim — the markdown layer then swallowed
332+
// the tag markers and the reader saw a wall of raw JSON. They open `{"` or
333+
// `[{` and carry key-value colons, which marks them as attempted payloads:
334+
// droppable, like any other broken emission.
335+
const cases = [
336+
'Prose before. <question>{"type": "single_select", "prompt": "How should I proceed?", "options": [{"id": "a", "label": "Confirm the id"}}]}</question>',
337+
'Prose before. <question>{"type":"multi_select","prompt":"What should I build now?",options": [{"id":"lib","label":"Pattern library"}]}</question>',
338+
'Prose before. <options>{"1": {"title": "Define the criteria", "description": "Populate"}}]}</options>',
339+
'Prose before. <options>[{"title":"Ship it","description":"Open the PR"},]</options>',
340+
]
341+
for (const raw of cases) {
342+
const { segments, hasPendingTag } = parseSpecialTags(raw, false)
343+
expect(hasPendingTag, raw).toBe(false)
344+
expect(renderedText(segments), raw).toBe('Prose before. ')
345+
expect(
346+
segments.every((segment) => segment.type === 'text'),
347+
raw
348+
).toBe(true)
349+
}
350+
})
351+
352+
it('drops a broken inline payload rather than dumping it mid-sentence', () => {
353+
// Same treatment for the inline tag: a `{"`-opening body with a syntax
354+
// error reads as an attempted chip, and the sentence survives around the
355+
// hole exactly as it does for a wrong-shape payload today.
356+
const raw =
357+
'I saved <workspace_resource>{"type":"file",path:"a.md"}</workspace_resource> for you.'
358+
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('I saved for you.')
359+
})
360+
361+
it('renders nothing for a message that is only an unparsable payload', () => {
362+
// The discardedTag guard must cover the new class too: with every segment
363+
// discarded, the raw-content fallback would otherwise resurrect the exact
364+
// JSON the discard removed.
365+
const { segments } = parseSpecialTags(
366+
'<options>{"1": {"title": "a", "description": "b"}}]}</options>',
367+
false
368+
)
369+
expect(segments).toHaveLength(0)
370+
})
371+
372+
it('still shows an unparsable body that never opened like a payload', () => {
373+
// The other side of the attempted-payload line: a bare scalar opens with
374+
// its own first character, not `{"`/`[{`, so it reads as prose in quotes
375+
// and must render — same as the brace-wrapped prose cases above.
376+
const raw = 'see <options>"just a phrase"</options> end'
377+
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe(raw)
378+
})
379+
380+
it('renders brace-wrapped quoted prose — an opener alone is not an attempt', () => {
381+
// The attempted-payload call takes BOTH kinds of evidence: the `{"` opener
382+
// and a key-value colon outside string literals. `{"the Q4 report"}` has
383+
// the opener but no colon — prose in costume, so it renders; a colon
384+
// inside the quotes changes nothing. The array twin parses as JSON, so it
385+
// was dropped as `wrong-shape` before this heuristic existed and still is —
386+
// that verdict comes from a real parse, not from the opener.
387+
const braceWrapped = 'see <options>{"the Q4 report"}</options> end'
388+
expect(renderedText(parseSpecialTags(braceWrapped, false).segments)).toBe(braceWrapped)
389+
const quotedColon = 'see <options>{"ratio: 4:5"}</options> end'
390+
expect(renderedText(parseSpecialTags(quotedColon, false).segments)).toBe(quotedColon)
391+
const arrayWrapped = 'see <options>["some list item"]</options> end'
392+
expect(renderedText(parseSpecialTags(arrayWrapped, false).segments)).toBe('see end')
393+
})
394+
395+
it('discards a broken payload whose strings legitimately mention tag syntax', () => {
396+
// The prompt quotes a tag name, so a raw scan sees a marker — but the
397+
// body's quotes are balanced, so the blanked scan already proved the marker
398+
// sits inside a string. Treating it as a nested tag would render the broken
399+
// payload as raw JSON, the exact failure `discard` exists to prevent. The
400+
// raw rescan is reserved for mispaired quotes, where blanked offsets lie.
401+
const raw =
402+
'Prose before. <question>{"type": "single_select", "prompt": "Use the <options> tag", "options": [{"id": "a", "label": "x"}}]}</question>'
403+
const { segments } = parseSpecialTags(raw, false)
404+
expect(renderedText(segments)).toBe('Prose before. ')
405+
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
406+
})
407+
323408
it('does not flash the payload while the closing tag is still arriving', () => {
324409
// Each frame below is a real mid-stream state: the JSON value has closed, so
325410
// without tolerating an arriving close the trailing `</opt` reads as stray
@@ -334,6 +419,23 @@ describe('parseSpecialTags with <question>', () => {
334419
}
335420
})
336421

422+
it('never flashes a broken payload at any streamed frame', () => {
423+
// A body that goes non-viable mid-stream (the stray `]}` lands before the
424+
// close does) used to release as literal text at that frame, then vanish
425+
// when the close arrived and classified it not-parsable — raw JSON painted
426+
// on screen only for the close to retract it. Suppression must hold at
427+
// EVERY frame from the completed opener on, and the settled parse must
428+
// agree with what the frames showed.
429+
const raw =
430+
'Prose before. <options>{"1": {"title": "Define the criteria", "description": "Populate"}}]}</options> after.'
431+
const bodyStart = raw.indexOf('<options>') + '<options>'.length
432+
for (let end = bodyStart; end <= raw.length; end++) {
433+
const { segments } = parseSpecialTags(raw.slice(0, end), true)
434+
expect(renderedText(segments), `frame ${end}`).not.toContain('{')
435+
}
436+
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('Prose before. after.')
437+
})
438+
337439
it('still rejects a close whose name is wrong rather than merely unfinished', () => {
338440
// The counterpart to the case above: `</workflow_resource>` can never grow
339441
// into `</workspace_resource>`, so it settles immediately instead of hiding
@@ -363,7 +465,7 @@ describe('parseSpecialTags with <question>', () => {
363465
it('finds a nested tag an unbalanced quote hid from the blanked scan', () => {
364466
// One stray `"` is enough to make blankJsonStringLiterals treat the REST of
365467
// the body as a string literal, hiding the real `<options>` marker from the
366-
// scan. The verdict then degrades from `foreign-markers` to `never-a-payload`
468+
// scan. The verdict then degrades from `foreign-markers` to `not-viable-json`
367469
// and resumes past the close, flattening both nested tags into one literal
368470
// span — so a card already on screen un-renders when the close arrives.
369471
//
@@ -901,8 +1003,13 @@ describe('parser properties', () => {
9011003
/**
9021004
* Fragments that must survive verbatim. Every one is a shape the parser has to
9031005
* reject: prose mentions, malformed closes, bodies that never were payloads.
904-
* None is a valid tag and none is a well-formed payload, so nothing here is
905-
* eligible for `discard` — which makes "output equals input" a legal assertion.
1006+
* Nothing here is eligible for `discard` — which makes "output equals input" a
1007+
* legal assertion. That takes two properties, not one: no fragment is a
1008+
* well-formed payload (`wrong-shape`), and the `{"`-opening bodies never land
1009+
* in a marker-free matched pair (`not-parsable`) — their own close is
1010+
* misspelled, truncated, or absent, so any close they borrow from a later
1011+
* fragment drags that fragment's own opener into the body, and the
1012+
* nested-marker rule settles the span before the attempted-payload test runs.
9061013
*/
9071014
const LOSSLESS_FRAGMENTS = [
9081015
'Plain prose with no markup at all. ',

0 commit comments

Comments
 (0)