Skip to content

Commit 5e21e34

Browse files
committed
fix(chat): drop unparsable special-tag payloads instead of dumping raw JSON
1 parent 48aeac2 commit 5e21e34

2 files changed

Lines changed: 144 additions & 46 deletions

File tree

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

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe('parseSpecialTags with <question>', () => {
247247

248248
it('does not rescan the interior of a body that carried no markers', () => {
249249
// Pins WHY the two literal reasons resume at different offsets. A
250-
// never-a-payload body resumes past the CLOSE; resuming past the opener
250+
// not-viable-json body resumes past the CLOSE; resuming past the opener
251251
// instead would rescan the interior, and since the marker scan runs on the
252252
// blanked body, a tag quoted inside a JSON string is invisible to it and
253253
// would be re-parsed as a real tag on the second pass — then dropped,
@@ -320,6 +320,60 @@ describe('parseSpecialTags with <question>', () => {
320320
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
321321
})
322322

323+
it('drops a payload one typo away from valid instead of showing raw JSON', () => {
324+
// The first three are verbatim from production screenshots (2026-07-31): an
325+
// extra `}` before the array close, a missing opening quote on a key, and a
326+
// stray `]}` after the map closes; the fourth adds a trailing comma. Each
327+
// fails JSON.parse, so the old was-it-ever-JSON test called them prose and
328+
// rendered the whole payload verbatim — the markdown layer then swallowed
329+
// the tag markers and the reader saw a wall of raw JSON. They open `{"` or
330+
// `[{`, which marks them as attempted payloads: droppable, like any other
331+
// broken emission.
332+
const cases = [
333+
'Prose before. <question>{"type": "single_select", "prompt": "How should I proceed?", "options": [{"id": "a", "label": "Confirm the id"}}]}</question>',
334+
'Prose before. <question>{"type":"multi_select","prompt":"What should I build now?",options": [{"id":"lib","label":"Pattern library"}]}</question>',
335+
'Prose before. <options>{"1": {"title": "Define the criteria", "description": "Populate"}}]}</options>',
336+
'Prose before. <options>[{"title":"Ship it","description":"Open the PR"},]</options>',
337+
]
338+
for (const raw of cases) {
339+
const { segments, hasPendingTag } = parseSpecialTags(raw, false)
340+
expect(hasPendingTag, raw).toBe(false)
341+
expect(renderedText(segments), raw).toBe('Prose before. ')
342+
expect(
343+
segments.every((segment) => segment.type === 'text'),
344+
raw
345+
).toBe(true)
346+
}
347+
})
348+
349+
it('drops a broken inline payload rather than dumping it mid-sentence', () => {
350+
// Same treatment for the inline tag: a `{"`-opening body with a syntax
351+
// error reads as an attempted chip, and the sentence survives around the
352+
// hole exactly as it does for a wrong-shape payload today.
353+
const raw =
354+
'I saved <workspace_resource>{"type":"file",path:"a.md"}</workspace_resource> for you.'
355+
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('I saved for you.')
356+
})
357+
358+
it('renders nothing for a message that is only an unparsable payload', () => {
359+
// The discardedTag guard must cover the new class too: with every segment
360+
// discarded, the raw-content fallback would otherwise resurrect the exact
361+
// JSON the discard removed.
362+
const { segments } = parseSpecialTags(
363+
'<options>{"1": {"title": "a", "description": "b"}}]}</options>',
364+
false
365+
)
366+
expect(segments).toHaveLength(0)
367+
})
368+
369+
it('still shows an unparsable body that never opened like a payload', () => {
370+
// The other side of the attempted-payload line: a bare scalar opens with
371+
// its own first character, not `{"`/`[{`, so it reads as prose in quotes
372+
// and must render — same as the brace-wrapped prose cases above.
373+
const raw = 'see <options>"just a phrase"</options> end'
374+
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe(raw)
375+
})
376+
323377
it('does not flash the payload while the closing tag is still arriving', () => {
324378
// Each frame below is a real mid-stream state: the JSON value has closed, so
325379
// without tolerating an arriving close the trailing `</opt` reads as stray
@@ -363,7 +417,7 @@ describe('parseSpecialTags with <question>', () => {
363417
it('finds a nested tag an unbalanced quote hid from the blanked scan', () => {
364418
// One stray `"` is enough to make blankJsonStringLiterals treat the REST of
365419
// 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`
420+
// scan. The verdict then degrades from `foreign-markers` to `not-viable-json`
367421
// and resumes past the close, flattening both nested tags into one literal
368422
// span — so a card already on screen un-renders when the close arrives.
369423
//
@@ -901,8 +955,13 @@ describe('parser properties', () => {
901955
/**
902956
* Fragments that must survive verbatim. Every one is a shape the parser has to
903957
* 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.
958+
* Nothing here is eligible for `discard` — which makes "output equals input" a
959+
* legal assertion. That takes two properties, not one: no fragment is a
960+
* well-formed payload (`wrong-shape`), and the `{"`-opening bodies never land
961+
* in a marker-free matched pair (`not-parsable`) — their own close is
962+
* misspelled, truncated, or absent, so any close they borrow from a later
963+
* fragment drags that fragment's own opener into the body, and the
964+
* nested-marker rule settles the span before the attempted-payload test runs.
906965
*/
907966
const LOSSLESS_FRAGMENTS = [
908967
'Plain prose with no markup at all. ',

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

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -440,12 +440,13 @@ export function parseTextTagBody(body: string): string | null {
440440
/**
441441
* Whether `body` is syntactically valid JSON, regardless of its shape.
442442
*
443-
* Separates "the agent formed a payload that failed its shape guard" from "this
444-
* was never JSON" — the line that decides whether a failed body may be dropped
445-
* or must be shown (see {@link classifyBody}). Costs a second parse of a body
446-
* that already failed one, which is the rare path; the common cases never reach
447-
* it, since a valid payload returns earlier and prose is rejected by the cheaper
448-
* viability rule before this runs.
443+
* Separates "the agent formed a well-formed payload that failed its shape
444+
* guard" (`wrong-shape`) from "this body will not parse"; for the latter,
445+
* {@link wasAttemptedPayload} then decides whether it may be dropped or must be
446+
* shown (see {@link classifyBody}). Costs a second parse of a body that already
447+
* failed one, which is the rare path; the common cases never reach it, since a
448+
* valid payload returns earlier and prose is rejected by the cheaper viability
449+
* rule before this runs.
449450
*/
450451
function isParseableJson(body: string): boolean {
451452
try {
@@ -770,27 +771,29 @@ type TagResolution =
770771
| { outcome: 'segment'; segment: ContentSegment; resumeAt: number }
771772
/** Provably not a tag; render the span verbatim and resume after it. */
772773
| { outcome: 'literal'; resumeAt: number }
773-
/** A well-formed payload that failed its shape guard — dropped deliberately. */
774+
/** A payload the agent attempted and botched — dropped deliberately. */
774775
| { outcome: 'discard'; resumeAt: number }
775776
/** Still streaming and a close remains plausible; suppress the remainder. */
776777
| { outcome: 'pending' }
777778

778779
/**
779-
* Why a failed body was never an attempted payload — so the markers were literal
780-
* text and the span must be shown rather than swallowed. `null` means the body
781-
* really was a payload that failed its shape guard.
780+
* Mechanical evidence about a failed body — named for what was OBSERVED, never
781+
* for what it means. The semantic conclusion (attempted payload vs prose) is
782+
* drawn in {@link classifyBody}, which refines `not-viable-json` through
783+
* {@link wasAttemptedPayload}. `null` means the body parsed as JSON and simply
784+
* failed its shape guard.
782785
*
783-
* The two reasons resume differently, which is why they are distinguished
784-
* rather than collapsed into a boolean (see {@link resumeForClass}).
786+
* The two reasons lead to different resumes, which is why they are
787+
* distinguished rather than collapsed into a boolean (see {@link resumeForClass}).
785788
*/
786789
type LiteralTextVerdict =
787790
/**
788791
* The body carries a tag marker at `markerOffset` (an index into the body), so
789792
* the close we matched belongs to a different opener.
790793
*/
791794
| { reason: 'foreign-markers'; markerOffset: number }
792-
/** The tag wrapped prose that was never JSON to begin with. */
793-
| { reason: 'never-a-payload' }
795+
/** The body is not a viable JSON prefix (first char or bracket depth). */
796+
| { reason: 'not-viable-json' }
794797

795798
function literalTextReason(
796799
tagName: (typeof SPECIAL_TAG_NAMES)[number],
@@ -805,7 +808,7 @@ function literalTextReason(
805808
const scannable = isJsonBodied ? blankJsonStringLiterals(body) : body
806809
const marker = TAG_SHAPED_MARKER.exec(scannable)
807810
if (marker) return { reason: 'foreign-markers', markerOffset: marker.index }
808-
if (isJsonBodied && !isViableJsonPrefixOf(scannable)) return { reason: 'never-a-payload' }
811+
if (isJsonBodied && !isViableJsonPrefixOf(scannable)) return { reason: 'not-viable-json' }
809812
return null
810813
}
811814

@@ -906,10 +909,42 @@ type BodyClass =
906909
| { kind: 'prose-nested-marker' }
907910
/** Only a prefix was read, and it settled nothing. Says nothing about the rest. */
908911
| { kind: 'unexamined' }
909-
/** Not a payload at all — never JSON, or JSON that will not parse. */
910-
| { kind: 'never-json' }
911-
/** Parsed as JSON, then failed its shape guard. The only droppable class. */
912-
| { kind: 'broken-payload' }
912+
/**
913+
* Never an attempted payload — prose, prose-in-braces, a bare scalar, an
914+
* unquoted-key slip. The model's own words: showing them is mandatory,
915+
* dropping them deletes text the reader was meant to see.
916+
*/
917+
| { kind: 'not-a-payload' }
918+
/**
919+
* An attempted payload that will not parse — opens like JSON (see
920+
* {@link wasAttemptedPayload}) but carries a syntax error. Droppable: the
921+
* reader was never meant to see the JSON, and rendering it raw is the
922+
* failure this parser exists to prevent.
923+
*/
924+
| { kind: 'not-parsable' }
925+
/** Parsed as JSON, then failed its shape guard. Droppable, like `not-parsable`. */
926+
| { kind: 'wrong-shape' }
927+
928+
/**
929+
* Whether a body that will not parse was nonetheless an ATTEMPT at this tag's
930+
* JSON payload — the line between `not-parsable` (droppable) and
931+
* `not-a-payload` (must render).
932+
*
933+
* The test is the opener pair, whitespace-tolerant: every payload these tags
934+
* carry is an object of quoted keys or an array of objects/strings, so an
935+
* attempt opens `{"`, `[{`, or `["`. Prose falls outside it by construction —
936+
* `{the Q4 report}` opens `{t`, `{type: "file"}` opens `{t`, `{'type':'file'}`
937+
* opens `{'`, a bare scalar opens with its own first character — so the
938+
* wrapped-prose cases stay rendered while a payload one typo away from valid
939+
* (`{"type":"multi_select",options": …`) is recognized as the broken emission
940+
* it is. Named for the question it answers, not the check it performs: the
941+
* class names assert meaning, and this predicate is what earns the assertion.
942+
*/
943+
function wasAttemptedPayload(body: string): boolean {
944+
const opener = /^\s*([{[])\s*(["{])/.exec(body)
945+
if (!opener) return false
946+
return opener[1] === '{' ? opener[2] === '"' : true
947+
}
913948

914949
/**
915950
* Classify a complete body. Pure: no positions, no outcome, no resume.
@@ -943,29 +978,32 @@ function classifyBody(tagName: (typeof SPECIAL_TAG_NAMES)[number], body: string)
943978
}
944979
if (inspected.truncated) return { kind: 'unexamined' }
945980

946-
// Dropping text is only defensible for a payload the agent actually FORMED.
947-
// `{the Q4 report}` is prose in braces and `{type: "file"}` is an ordinary
948-
// model slip; bracket depth cannot tell either from a real payload, only a
949-
// parse can. Both routes to that answer are funnelled through one place so the
950-
// rescan below cannot be added to one and forgotten on the other.
951-
const neverJson =
952-
verdict?.reason === 'never-a-payload' || (isJsonBodied && !isParseableJson(body))
981+
// Dropping text is only defensible for a payload the agent actually
982+
// ATTEMPTED. A parse settles the well-formed case (`wrong-shape`); for a body
983+
// that will not parse, the opener decides via wasAttemptedPayload below.
984+
// Bracket depth can tell neither prose-in-braces nor a typo'd payload from a
985+
// real one, so both routes to "unparseable" are funnelled through one place
986+
// and the rescan below cannot be added to one and forgotten on the other.
987+
const unparseable =
988+
verdict?.reason === 'not-viable-json' || (isJsonBodied && !isParseableJson(body))
953989

954-
if (neverJson) {
990+
if (unparseable) {
955991
// literalTextReason blanked this body's quoted regions on the assumption it
956-
// was JSON. It never was, so that assumption is void — and a body with an
957-
// odd number of `"` blanks the WRONG regions, which can hide a real marker
958-
// and turn what should be `nested-marker` into `never-json`. The difference
959-
// is not academic: `never-json` resumes past the close, flattening a genuine
960-
// tag inside the span, so a card already on screen un-renders when the close
961-
// finally arrives. With the JSON premise gone, the raw text is the honest
962-
// evidence, and a marker in it means the close we matched belongs elsewhere.
992+
// was valid JSON. It is not, so that assumption is void — and a body with
993+
// an odd number of `"` blanks the WRONG regions, which can hide a real
994+
// marker and misread a mispaired span as this tag's own body. The
995+
// difference is not academic: both classes below resume past the close,
996+
// flattening or discarding a genuine tag inside the span, so a card already
997+
// on screen un-renders when the close finally arrives. With the JSON
998+
// premise gone, the raw text is the honest evidence, and a marker in it
999+
// means the close we matched belongs elsewhere. Only after both marker
1000+
// scans come up empty may the opener test decide the remaining two classes.
9631001
const rawMarker = TAG_SHAPED_MARKER.exec(inspected.text)
9641002
if (rawMarker) return { kind: 'nested-marker', offsetInBody: rawMarker.index }
965-
return { kind: 'never-json' }
1003+
return wasAttemptedPayload(body) ? { kind: 'not-parsable' } : { kind: 'not-a-payload' }
9661004
}
9671005

968-
return { kind: 'broken-payload' }
1006+
return { kind: 'wrong-shape' }
9691007
}
9701008

9711009
/**
@@ -978,8 +1016,9 @@ function classifyBody(tagName: (typeof SPECIAL_TAG_NAMES)[number], body: string)
9781016
function resumeForClass(cls: BodyClass, bodyStart: number, pastClose: number): number {
9791017
switch (cls.kind) {
9801018
case 'payload':
981-
case 'broken-payload':
982-
case 'never-json':
1019+
case 'wrong-shape':
1020+
case 'not-parsable':
1021+
case 'not-a-payload':
9831022
// The whole span was read and accounted for; continue after it.
9841023
return pastClose
9851024
case 'nested-marker':
@@ -1021,14 +1060,14 @@ function resolveMatchedPair(
10211060
switch (cls.kind) {
10221061
case 'payload':
10231062
return { outcome: 'segment', segment: cls.segment, resumeAt }
1024-
case 'broken-payload':
1025-
// Well-formed but the wrong shape — a broken emission. Showing the reader
1026-
// raw JSON is worse than showing nothing.
1063+
case 'wrong-shape':
1064+
case 'not-parsable':
1065+
// Showing the reader raw JSON is worse than showing nothing.
10271066
return { outcome: 'discard', resumeAt }
10281067
case 'nested-marker':
10291068
case 'prose-nested-marker':
10301069
case 'unexamined':
1031-
case 'never-json':
1070+
case 'not-a-payload':
10321071
return { outcome: 'literal', resumeAt }
10331072
}
10341073
}

0 commit comments

Comments
 (0)