diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 66b5c599b75..bdd324efa15 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -246,17 +246,20 @@ describe('parseSpecialTags with ', () => { }) it('does not rescan the interior of a body that carried no markers', () => { - // Pins WHY the two literal reasons resume at different offsets. A - // never-a-payload body resumes past the CLOSE; resuming past the opener - // instead would rescan the interior, and since the marker scan runs on the - // blanked body, a tag quoted inside a JSON string is invisible to it and - // would be re-parsed as a real tag on the second pass — then dropped, - // deleting the very text this parser exists to preserve. + // Pins WHY a settled span resumes past the CLOSE, never past the opener. + // Resuming past the opener would rescan the interior, and since the marker + // scan runs on the blanked body, a tag quoted inside a JSON string is + // invisible to it and would be re-parsed as a REAL tag on the second pass — + // painting the quoted JSON verbatim as raw text (its escaped quotes cannot + // re-parse as a card), the exact failure `discard` exists to prevent. The + // span itself opens `{"` and will not parse (trailing junk), so it is an + // attempted payload and is discarded whole; what must never happen is a + // partial re-parse of its quoted interior. const raw = 'A {"a":"{\\"k\\":{\\"title\\":\\"x\\",\\"description\\":\\"y\\"}}"} junk B' const { segments } = parseSpecialTags(raw, false) expect(segments.every((segment) => segment.type === 'text')).toBe(true) - expect(renderedText(segments)).toBe(raw) + expect(renderedText(segments)).toBe('A B') }) it('keeps prose a tag wrapped instead of a payload', () => { @@ -320,6 +323,88 @@ describe('parseSpecialTags with ', () => { expect(segments.every((segment) => segment.type === 'text')).toBe(true) }) + it('drops a payload one typo away from valid instead of showing raw JSON', () => { + // The first three are verbatim from production screenshots (2026-07-31): an + // extra `}` before the array close, a missing opening quote on a key, and a + // stray `]}` after the map closes; the fourth adds a trailing comma. Each + // fails JSON.parse, so the old was-it-ever-JSON test called them prose and + // rendered the whole payload verbatim — the markdown layer then swallowed + // the tag markers and the reader saw a wall of raw JSON. They open `{"` or + // `[{` and carry key-value colons, which marks them as attempted payloads: + // droppable, like any other broken emission. + const cases = [ + 'Prose before. {"type": "single_select", "prompt": "How should I proceed?", "options": [{"id": "a", "label": "Confirm the id"}}]}', + 'Prose before. {"type":"multi_select","prompt":"What should I build now?",options": [{"id":"lib","label":"Pattern library"}]}', + 'Prose before. {"1": {"title": "Define the criteria", "description": "Populate"}}]}', + 'Prose before. [{"title":"Ship it","description":"Open the PR"},]', + ] + for (const raw of cases) { + const { segments, hasPendingTag } = parseSpecialTags(raw, false) + expect(hasPendingTag, raw).toBe(false) + expect(renderedText(segments), raw).toBe('Prose before. ') + expect( + segments.every((segment) => segment.type === 'text'), + raw + ).toBe(true) + } + }) + + it('drops a broken inline payload rather than dumping it mid-sentence', () => { + // Same treatment for the inline tag: a `{"`-opening body with a syntax + // error reads as an attempted chip, and the sentence survives around the + // hole exactly as it does for a wrong-shape payload today. + const raw = + 'I saved {"type":"file",path:"a.md"} for you.' + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('I saved for you.') + }) + + it('renders nothing for a message that is only an unparsable payload', () => { + // The discardedTag guard must cover the new class too: with every segment + // discarded, the raw-content fallback would otherwise resurrect the exact + // JSON the discard removed. + const { segments } = parseSpecialTags( + '{"1": {"title": "a", "description": "b"}}]}', + false + ) + expect(segments).toHaveLength(0) + }) + + it('still shows an unparsable body that never opened like a payload', () => { + // The other side of the attempted-payload line: a bare scalar opens with + // its own first character, not `{"`/`[{`, so it reads as prose in quotes + // and must render — same as the brace-wrapped prose cases above. + const raw = 'see "just a phrase" end' + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe(raw) + }) + + it('renders brace-wrapped quoted prose — an opener alone is not an attempt', () => { + // The attempted-payload call takes BOTH kinds of evidence: the `{"` opener + // and a key-value colon outside string literals. `{"the Q4 report"}` has + // the opener but no colon — prose in costume, so it renders; a colon + // inside the quotes changes nothing. The array twin parses as JSON, so it + // was dropped as `wrong-shape` before this heuristic existed and still is — + // that verdict comes from a real parse, not from the opener. + const braceWrapped = 'see {"the Q4 report"} end' + expect(renderedText(parseSpecialTags(braceWrapped, false).segments)).toBe(braceWrapped) + const quotedColon = 'see {"ratio: 4:5"} end' + expect(renderedText(parseSpecialTags(quotedColon, false).segments)).toBe(quotedColon) + const arrayWrapped = 'see ["some list item"] end' + expect(renderedText(parseSpecialTags(arrayWrapped, false).segments)).toBe('see end') + }) + + it('discards a broken payload whose strings legitimately mention tag syntax', () => { + // The prompt quotes a tag name, so a raw scan sees a marker — but the + // body's quotes are balanced, so the blanked scan already proved the marker + // sits inside a string. Treating it as a nested tag would render the broken + // payload as raw JSON, the exact failure `discard` exists to prevent. The + // raw rescan is reserved for mispaired quotes, where blanked offsets lie. + const raw = + 'Prose before. {"type": "single_select", "prompt": "Use the tag", "options": [{"id": "a", "label": "x"}}]}' + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments)).toBe('Prose before. ') + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + it('does not flash the payload while the closing tag is still arriving', () => { // Each frame below is a real mid-stream state: the JSON value has closed, so // without tolerating an arriving close the trailing `', () => { } }) + it('never flashes a broken payload at any streamed frame', () => { + // A body that goes non-viable mid-stream (the stray `]}` lands before the + // close does) used to release as literal text at that frame, then vanish + // when the close arrived and classified it not-parsable — raw JSON painted + // on screen only for the close to retract it. Suppression must hold at + // EVERY frame from the completed opener on, and the settled parse must + // agree with what the frames showed. + const raw = + 'Prose before. {"1": {"title": "Define the criteria", "description": "Populate"}}]} after.' + const bodyStart = raw.indexOf('') + ''.length + for (let end = bodyStart; end <= raw.length; end++) { + const { segments } = parseSpecialTags(raw.slice(0, end), true) + expect(renderedText(segments), `frame ${end}`).not.toContain('{') + } + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe('Prose before. after.') + }) + it('still rejects a close whose name is wrong rather than merely unfinished', () => { // The counterpart to the case above: `` can never grow // into ``, so it settles immediately instead of hiding @@ -363,7 +465,7 @@ describe('parseSpecialTags with ', () => { it('finds a nested tag an unbalanced quote hid from the blanked scan', () => { // One stray `"` is enough to make blankJsonStringLiterals treat the REST of // the body as a string literal, hiding the real `` marker from the - // scan. The verdict then degrades from `foreign-markers` to `never-a-payload` + // scan. The verdict then degrades from `foreign-markers` to `not-viable-json` // and resumes past the close, flattening both nested tags into one literal // span — so a card already on screen un-renders when the close arrives. // @@ -901,8 +1003,13 @@ describe('parser properties', () => { /** * Fragments that must survive verbatim. Every one is a shape the parser has to * reject: prose mentions, malformed closes, bodies that never were payloads. - * None is a valid tag and none is a well-formed payload, so nothing here is - * eligible for `discard` — which makes "output equals input" a legal assertion. + * Nothing here is eligible for `discard` — which makes "output equals input" a + * legal assertion. That takes two properties, not one: no fragment is a + * well-formed payload (`wrong-shape`), and the `{"`-opening bodies never land + * in a marker-free matched pair (`not-parsable`) — their own close is + * misspelled, truncated, or absent, so any close they borrow from a later + * fragment drags that fragment's own opener into the body, and the + * nested-marker rule settles the span before the attempted-payload test runs. */ const LOSSLESS_FRAGMENTS = [ 'Plain prose with no markup at all. ', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 2dd4e87562d..e801fba175f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -440,12 +440,13 @@ export function parseTextTagBody(body: string): string | null { /** * Whether `body` is syntactically valid JSON, regardless of its shape. * - * Separates "the agent formed a payload that failed its shape guard" from "this - * was never JSON" — the line that decides whether a failed body may be dropped - * or must be shown (see {@link classifyBody}). Costs a second parse of a body - * that already failed one, which is the rare path; the common cases never reach - * it, since a valid payload returns earlier and prose is rejected by the cheaper - * viability rule before this runs. + * Separates "the agent formed a well-formed payload that failed its shape + * guard" (`wrong-shape`) from "this body will not parse"; for the latter, + * {@link wasAttemptedPayload} then decides whether it may be dropped or must be + * shown (see {@link classifyBody}). Costs a second parse of a body that already + * failed one, which is the rare path; the common cases never reach it, since a + * valid payload returns earlier and prose is rejected by the cheaper viability + * rule before this runs. */ function isParseableJson(body: string): boolean { try { @@ -608,8 +609,8 @@ const LONGEST_TAG_MARKER = Math.max(...SPECIAL_TAG_NAMES.map((name) => `