Skip to content

Commit 9775580

Browse files
committed
fix(chat): suppress broken payloads mid-stream; reserve marker rescan for mispaired quotes
1 parent 5e21e34 commit 9775580

2 files changed

Lines changed: 152 additions & 20 deletions

File tree

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

Lines changed: 53 additions & 7 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-
// not-viable-json 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', () => {
@@ -374,6 +377,32 @@ describe('parseSpecialTags with <question>', () => {
374377
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe(raw)
375378
})
376379

380+
it('treats brace-wrapped quoted prose as an attempted payload, by design', () => {
381+
// The deliberate edge of the opener heuristic, pinned so it stays a
382+
// decision: `{"..."}` reads as a payload the model started and botched (a
383+
// key with no value), not prose — prose the reader was meant to see arrives
384+
// unwrapped or in bare quotes, and both of those stay rendered (see the
385+
// cases above). The array twin parses as JSON, so it was already dropped as
386+
// `wrong-shape` before the opener heuristic existed.
387+
const braceWrapped = 'see <options>{"the Q4 report"}</options> end'
388+
expect(renderedText(parseSpecialTags(braceWrapped, false).segments)).toBe('see end')
389+
const arrayWrapped = 'see <options>["some list item"]</options> end'
390+
expect(renderedText(parseSpecialTags(arrayWrapped, false).segments)).toBe('see end')
391+
})
392+
393+
it('discards a broken payload whose strings legitimately mention tag syntax', () => {
394+
// The prompt quotes a tag name, so a raw scan sees a marker — but the
395+
// body's quotes are balanced, so the blanked scan already proved the marker
396+
// sits inside a string. Treating it as a nested tag would render the broken
397+
// payload as raw JSON, the exact failure `discard` exists to prevent. The
398+
// raw rescan is reserved for mispaired quotes, where blanked offsets lie.
399+
const raw =
400+
'Prose before. <question>{"type": "single_select", "prompt": "Use the <options> tag", "options": [{"id": "a", "label": "x"}}]}</question>'
401+
const { segments } = parseSpecialTags(raw, false)
402+
expect(renderedText(segments)).toBe('Prose before. ')
403+
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
404+
})
405+
377406
it('does not flash the payload while the closing tag is still arriving', () => {
378407
// Each frame below is a real mid-stream state: the JSON value has closed, so
379408
// without tolerating an arriving close the trailing `</opt` reads as stray
@@ -388,6 +417,23 @@ describe('parseSpecialTags with <question>', () => {
388417
}
389418
})
390419

420+
it('never flashes a broken payload at any streamed frame', () => {
421+
// A body that goes non-viable mid-stream (the stray `]}` lands before the
422+
// close does) used to release as literal text at that frame, then vanish
423+
// when the close arrived and classified it not-parsable — raw JSON painted
424+
// on screen only for the close to retract it. Suppression must hold at
425+
// EVERY frame from the completed opener on, and the settled parse must
426+
// agree with what the frames showed.
427+
const raw =
428+
'Prose before. <options>{"1": {"title": "Define the criteria", "description": "Populate"}}]}</options> after.'
429+
const bodyStart = raw.indexOf('<options>') + '<options>'.length
430+
for (let end = bodyStart; end <= raw.length; end++) {
431+
const { segments } = parseSpecialTags(raw.slice(0, end), true)
432+
expect(renderedText(segments), `frame ${end}`).not.toContain('{')
433+
}
434+
expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('Prose before. after.')
435+
})
436+
391437
it('still rejects a close whose name is wrong rather than merely unfinished', () => {
392438
// The counterpart to the case above: `</workflow_resource>` can never grow
393439
// into `</workspace_resource>`, so it settles immediately instead of hiding

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

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,33 @@ function blankJsonStringLiterals(body: string): string {
639639
return out
640640
}
641641

642+
/**
643+
* Whether `body` ends inside a JSON string literal, under the same quote and
644+
* escape rules as {@link blankJsonStringLiterals}.
645+
*
646+
* True means the body's quotes are mispaired, so blanking assigned at least one
647+
* region to the wrong side of a string boundary — the one condition under which
648+
* a marker missing from the blanked copy may still be real. With balanced
649+
* quotes the blanked scan already saw every marker outside a string, so a
650+
* marker visible only in the raw text really was quoted content (see
651+
* {@link classifyBody}).
652+
*/
653+
function endsInsideJsonString(body: string): boolean {
654+
let inString = false
655+
let escaped = false
656+
for (let i = 0; i < body.length; i++) {
657+
const char = body[i]
658+
if (escaped) {
659+
escaped = false
660+
} else if (char === '\\' && inString) {
661+
escaped = true
662+
} else if (char === '"') {
663+
inString = !inString
664+
}
665+
}
666+
return inString
667+
}
668+
642669
/**
643670
* True while `scannable` could still grow into a single valid JSON value.
644671
*
@@ -675,6 +702,33 @@ function isViableJsonPrefixOf(scannable: string): boolean {
675702
return true
676703
}
677704

705+
/**
706+
* Index just past the close of `scannable`'s top-level JSON value, or -1 while
707+
* the value is still open. Same depth rules as {@link isViableJsonPrefixOf},
708+
* and takes the same blanked form, so braces inside JSON strings do not count.
709+
*/
710+
function jsonValueEndIn(scannable: string): number {
711+
let depth = 0
712+
for (let i = 0; i < scannable.length; i++) {
713+
const char = scannable[i]
714+
if (char === '{' || char === '[') {
715+
depth++
716+
} else if (char === '}' || char === ']') {
717+
depth--
718+
if (depth <= 0) return i + 1
719+
}
720+
}
721+
return -1
722+
}
723+
724+
/**
725+
* Nothing but JSON punctuation and whitespace — what a fumbled payload's tail
726+
* looks like on the BLANKED body, where string contents are already spaces. A
727+
* letter or digit here means the model moved on to prose instead (see
728+
* {@link resolveTagAt}).
729+
*/
730+
const JSON_DEBRIS_ONLY = /^[\s[\]{}",:]*$/
731+
678732
/**
679733
* Whether `text` contains a marker for one of the tags this parser knows.
680734
*
@@ -989,17 +1043,24 @@ function classifyBody(tagName: (typeof SPECIAL_TAG_NAMES)[number], body: string)
9891043

9901044
if (unparseable) {
9911045
// literalTextReason blanked this body's quoted regions on the assumption it
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.
1001-
const rawMarker = TAG_SHAPED_MARKER.exec(inspected.text)
1002-
if (rawMarker) return { kind: 'nested-marker', offsetInBody: rawMarker.index }
1046+
// was valid JSON. It is not — but the blanked offsets only LIE when the
1047+
// body's quotes are mispaired: an odd `"` blanks the wrong regions, which
1048+
// can hide a real marker and misread a mispaired span as this tag's own
1049+
// body. The difference is not academic: both failure classes below resume
1050+
// past the close, flattening or discarding a genuine tag inside the span,
1051+
// so a card already on screen un-renders when the close finally arrives.
1052+
// With mispaired quotes the raw text is the honest evidence, and a marker
1053+
// in it means the close we matched belongs elsewhere. With BALANCED quotes
1054+
// the blanked scan above already saw every marker outside a string, so a
1055+
// marker visible only in the raw text is quoted content — rescanning would
1056+
// classify a broken payload whose strings legitimately mention tag syntax
1057+
// as nested markers and render it as raw JSON, the exact failure `discard`
1058+
// exists to prevent. Only after the marker question settles may the opener
1059+
// test decide the remaining two classes.
1060+
if (endsInsideJsonString(inspected.text)) {
1061+
const rawMarker = TAG_SHAPED_MARKER.exec(inspected.text)
1062+
if (rawMarker) return { kind: 'nested-marker', offsetInBody: rawMarker.index }
1063+
}
10031064
return wasAttemptedPayload(body) ? { kind: 'not-parsable' } : { kind: 'not-a-payload' }
10041065
}
10051066

@@ -1086,8 +1147,33 @@ function resolveTagAt(
10861147

10871148
if (closeIdx === -1) {
10881149
const inspected = inspectWithin(content, bodyStart)
1089-
if (isStreaming && !unclosedTagCannotResolve(tagName, inspected.text)) {
1090-
return { outcome: 'pending' }
1150+
if (isStreaming) {
1151+
if (!unclosedTagCannotResolve(tagName, inspected.text)) return { outcome: 'pending' }
1152+
// The body can no longer resolve, but it reads as a payload the model is
1153+
// still fumbling: it opens like an attempted payload and everything past
1154+
// its top-level value is JSON debris (a stray `}` or `]}`) — exactly the
1155+
// shapes the matched-pair path DISCARDS the moment a close arrives.
1156+
// Releasing such a body now paints raw JSON on screen only for the close
1157+
// to retract it, so keep suppressing while the stream runs; the settled
1158+
// parse still shows it if the close never comes. Two kinds of
1159+
// counter-evidence release immediately, because each means the model
1160+
// moved on and holding the remainder back would blank the rest of the
1161+
// message for the whole stream — the failure unclosedTagCannotResolve
1162+
// exists to prevent: a tag-shaped marker outside the payload's strings
1163+
// (a misspelled close, a new tag), or prose after the value closed (a
1164+
// mention flowing on, pinned by the trace 220cc02d and trace afbeefd0
1165+
// tests).
1166+
if (JSON_BODY_TAG_NAMES.has(tagName)) {
1167+
const pending = dropArrivingClose(inspected.text, closeTag)
1168+
const blanked = blankJsonStringLiterals(pending)
1169+
if (
1170+
wasAttemptedPayload(pending) &&
1171+
!TAG_SHAPED_MARKER.test(blanked) &&
1172+
JSON_DEBRIS_ONLY.test(blanked.slice(Math.max(0, jsonValueEndIn(blanked))))
1173+
) {
1174+
return { outcome: 'pending' }
1175+
}
1176+
}
10911177
}
10921178
// Nothing can close it, so only the opener itself is literal. Resuming just
10931179
// past it (rather than abandoning the message) keeps a genuinely valid tag

0 commit comments

Comments
 (0)