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 09444ecccbf..ddc71a9ea95 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 @@ -149,13 +149,75 @@ describe('parseSpecialTags with ', () => { expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }]) }) - it('strips a trailing partial opening tag while streaming', () => { - const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { + // Verbatim from a real message (trace b095e080). The model explained the + // tag and ended with a backticked example containing a REAL closing tag, + // which closed the earlier opener and made everything between it the body. + // That body is not valid JSON, so the segment was dropped and the render + // resumed mid-sentence at ") is what actually produces the interactive + // chip." — three paragraphs silently gone. + const raw = + 'Here you go — with the ending tag intentionally malformed as ``:\n\n' + + '{"type": "file", "path": "files/notes.md", "title": "notes.md"}\n\n' + + "Since the closing tag doesn't match the opening ``, the chat won't " + + 'recognize it as a valid resource chip. A properly matched pair ' + + '(`...`) is what actually produces the interactive chip.' + + const rendered = parseSpecialTags(raw, false) + .segments.map((segment) => ('content' in segment ? segment.content : '')) + .join('') + + expect(rendered).toContain("Since the closing tag doesn't match") + expect(rendered).toContain('A properly matched pair') + expect(rendered).toContain('"path": "files/notes.md"') + // No segment renders as a resource chip — the body was never valid. + expect(parseSpecialTags(raw, false).segments.some((s) => s.type === 'workspace_resource')).toBe( + false + ) }) - it('drops a question tag with an invalid body but keeps surrounding text', () => { + it('still parses a valid tag that follows a rejected one', () => { + // Before the rewrite, rejecting an unclosed tag abandoned the rest of the + // message, so this tag was never parsed at all. + const { segments } = parseSpecialTags( + 'I use loosely here. Anyway: [{"title":"A","description":"d"}] done.', + false + ) + expect(segments.map((segment) => segment.type)).toContain('options') + }) + + it('loses nothing when the model writes no closing tag at all', () => { + // Verbatim from a real message (trace 220cc02d). No close tag exists, so no + // marker rule can fire — but the JSON value completes and prose follows, + // which settles it at the first space. Asserted as LOSSLESS: mid-stream and + // complete, every character survives. + const raw = + 'The dataset lives in {"type": "file", "path": "files/notes.md"} and I keep coming back to it whenever I need a quick reference. It never quite has everything.' + const join = (result: ReturnType) => + result.segments.map((segment) => ('content' in segment ? segment.content : '')).join('') + + const streaming = parseSpecialTags(raw, true) + expect(streaming.hasPendingTag).toBe(false) + expect(join(streaming)).toBe(raw) + expect(join(parseSpecialTags(raw, false))).toBe(raw) + }) + + it('keeps prose a tag wrapped instead of a payload', () => { + // Verbatim from a real message (trace 1206fd8a): a matched pair whose body + // is plain prose, never an attempted JSON payload. The sentence read + // "...once I wired up to handle the welcome sequence" with the subject gone. + const raw = + 'once I wired up the gmail-agent workflow to handle the welcome sequence.' + const rendered = parseSpecialTags(raw, false) + .segments.map((segment) => ('content' in segment ? segment.content : '')) + .join('') + expect(rendered).toContain('the gmail-agent workflow') + expect(rendered).toContain('to handle the welcome sequence') + }) + + it('still drops a marker-free malformed payload rather than showing raw JSON', () => { + // The complement of the case above: no tag markers in the body, so this is + // a genuinely broken emission from the agent, not swallowed prose. const { segments, hasPendingTag } = parseSpecialTags( 'Before. {"type":"single_select"} After.', false @@ -166,6 +228,182 @@ describe('parseSpecialTags with ', () => { { type: 'text', content: ' After.' }, ]) }) + + it('drops that same payload even when its JSON quotes tag syntax', () => { + // The marker scan must blank JSON strings the way the streaming path does. + // Scanning the raw body sees `` inside the prompt, calls the span + // literal text, and renders the raw payload — the outcome `discard` exists + // to prevent. + const { segments } = parseSpecialTags( + 'A [{"type":"single_select","prompt":"use here?"}] B', + false + ) + expect(segments).toEqual([ + { type: 'text', content: 'A ' }, + { type: 'text', content: ' B' }, + ]) + }) + + 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 ``. + for (const fragment of ['<', '[{"title":"a","description":"b"}]${fragment}`, + true + ) + expect(hasPendingTag).toBe(true) + expect(segments.map((s) => ('content' in s ? s.content : '')).join('')).toBe('see ') + } + }) + + 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 + // the rest of the message for the remainder of the stream. + const { hasPendingTag } = parseSpecialTags( + 'see {"type":"file","path":"a.md"} and then prose.', + true + ) + expect(hasPendingTag).toBe(false) + }) + + it('keeps a valid tag whose close an earlier broken tag would borrow', () => { + // The first opener misspells its close, so it reaches forward and matches + // the SECOND tag's close, swallowing a perfectly good resource into one + // literal span. Resuming past the opener re-scans the interior instead. + const raw = + 'See {"type":"file","path":"a.md"}\n' + + 'and a real one: {"type":"file","path":"b.md","title":"b"}' + const { segments } = parseSpecialTags(raw, false) + expect(segments.some((s) => s.type === 'workspace_resource')).toBe(true) + expect(segments.map((s) => ('content' in s ? s.content : '')).join('')).toContain( + '' + ) + }) + + it('still renders a matched pair whose body IS valid', () => { + const raw = + 'see {"type":"file","path":"files/a.md","title":"a.md"} ok' + const { segments } = parseSpecialTags(raw, false) + expect(segments.some((s) => s.type === 'workspace_resource')).toBe(true) + }) + + it('shows prose immediately mid-stream instead of blanking the rest', () => { + // The failure this replaces: everything after the marker stayed invisible + // for the remainder of the stream, then reappeared when it ended. + const content = 'The `` chip only renders for a real file.' + const { segments, hasPendingTag } = parseSpecialTags(content, true) + expect(hasPendingTag).toBe(false) + expect(segments.map((s) => ('content' in s ? s.content : s.type)).join('')).toContain( + 'chip only renders for a real file.' + ) + }) + + it('shows text once the JSON value has closed and stray content follows', () => { + // Verbatim shape from a real message (trace afbeefd0): the close tag was + // TRUNCATED to `{"type":"file","path":"files/notes.md"} ('content' in s ? s.content : '')).join('')).toContain( + 'I brew a cup of coffee' + ) + }) + + it('tolerates braces inside JSON strings when tracking depth', () => { + const raw = 'x {"title":"a } b","path":"files/a.md"' + expect(parseSpecialTags(raw, true).hasPendingTag).toBe(true) + }) + + it('still suppresses a JSON-bodied tag that is genuinely mid-stream', () => { + const { segments, hasPendingTag } = parseSpecialTags( + 'Here you go {"type":"file","id":"abc"', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments).toEqual([{ type: 'text', content: 'Here you go ' }]) + }) + + it('bails when a foreign closing tag appears inside the body', () => { + // Tags never nest, so a close for a different tag proves the opener was text. + // The array is left UNCLOSED on purpose: with a closed value the JSON rule + // would independently reject the trailing content, and this test would still + // pass with the nesting rule deleted. Unclosed, only nesting can explain it. + const { hasPendingTag } = parseSpecialTags('see [{"title":"a" more', true) + expect(hasPendingTag).toBe(false) + }) + + it('does not bail on tag syntax quoted inside a JSON string', () => { + // The false positive this guards: a question whose text legitimately quotes + // another tag. Bailing would show raw JSON that later snaps into a card. + const streaming = 'ok [{"type":"single_select","prompt":"Use the tag?"' + expect(parseSpecialTags(streaming, true).hasPendingTag).toBe(true) + }) + + it('resolves that same question correctly once it closes', () => { + // The other half of the guarantee: the body the streaming case refused to + // bail on does render as a question card, so nothing flickered for nothing. + const complete = + 'ok [{"type":"single_select","prompt":"Use the tag?","options":[{"id":"y","label":"Yes"},{"id":"n","label":"No"}]}]' + const { segments } = parseSpecialTags(complete, false) + expect(segments.some((s) => s.type === 'question')).toBe(true) + }) + + it('still bails on a marker outside the JSON strings', () => { + // Escapes must not end the string early, and a marker in real body position + // is still evidence. + const streaming = 'ok [{"prompt":"a \\" quote"} ' + expect(parseSpecialTags(streaming, true).hasPendingTag).toBe(false) + }) + + it('rejects an opener a nested one disproves, then judges the inner on its own', () => { + // Each opener is evaluated independently. The first is disproved by the + // nested opener and its text is released immediately; the second is a fresh + // candidate that nothing has ruled out yet, so it holds mid-stream. + const streaming = parseSpecialTags('a b c', true) + expect(streaming.hasPendingTag).toBe(true) + expect( + streaming.segments.map((segment) => ('content' in segment ? segment.content : '')).join('') + ).toBe('a b ') + + // Once the stream ends nothing can close it, so the whole line is shown. + const done = parseSpecialTags('a b c', false) + expect(done.hasPendingTag).toBe(false) + expect( + done.segments.map((segment) => ('content' in segment ? segment.content : '')).join('') + ).toBe('a b c') + }) + + it('keeps suppressing an unclosed thinking tag with prose — its body is not JSON', () => { + // Documents the deliberate gap: `thinking` bodies are prose, so the JSON + // heuristic cannot apply and only the nesting rule can rescue it. + const { hasPendingTag } = parseSpecialTags('a still reasoning about', true) + expect(hasPendingTag).toBe(true) + }) + + it('renders an unclosed tag as text once the message is complete', () => { + const content = + 'The `` file chip only renders when its path points to a real file.' + const { segments, hasPendingTag } = parseSpecialTags(content, false) + expect(hasPendingTag).toBe(false) + // Asserted on the joined text, not segment boundaries: the renderer + // concatenates adjacent text segments, so how the span is split is not + // observable to a reader. + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + expect(segments.map((segment) => ('content' in segment ? segment.content : '')).join('')).toBe( + content + ) + }) + + it('strips a trailing partial opening tag while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { 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 60227897d94..7dbae91aae6 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 @@ -465,35 +465,341 @@ function parseSpecialTagData( } /** - * Parses inline special tags (``, ``, ``) from streamed - * text content. Complete tags are extracted into typed segments; incomplete - * tags (still streaming) are suppressed from display and flagged via - * `hasPendingTag` so the caller can show a loading indicator. + * Any tag-shaped marker, including names that are not special tags at all — the + * model inventing `` is exactly the case that matters. + */ +const TAG_SHAPED_MARKER = /<\/?[a-zA-Z][\w-]*>/ + +/** + * The one tag whose body is prose rather than JSON (see {@link parseTextTagBody}), + * so a non-JSON body there says nothing about whether a close is still coming. + */ +const PROSE_BODY_TAG_NAME: (typeof SPECIAL_TAG_NAMES)[number] = 'thinking' + +/** + * Tags whose body must be JSON. + * + * Derived from {@link SPECIAL_TAG_NAMES} rather than hand-listed: a new tag is + * JSON-bodied by default, so forgetting to update this set cannot silently + * downgrade it to the weaker prose heuristics. Opting a tag out is an explicit + * edit to {@link PROSE_BODY_TAG_NAME}. + */ +const JSON_BODY_TAG_NAMES: ReadonlySet<(typeof SPECIAL_TAG_NAMES)[number]> = new Set( + SPECIAL_TAG_NAMES.filter((name) => name !== PROSE_BODY_TAG_NAME) +) + +/** + * How much of an unclosed body to inspect per parse. + * + * Both rules in {@link unclosedTagCannotResolve} decide on their FIRST piece of + * evidence — the first foreign marker, or the first character that breaks JSON + * viability — so a bounded window reaches the same verdict as the full remainder + * for any real payload. Unbounded, the check is O(remaining length) and runs + * once per opener inside a parse that re-runs for every streamed chunk, so a + * long reply repeatedly mentioning a tag name in prose costs seconds of + * main-thread time. Evidence past the window only defers the decision to a later + * chunk, which is the same conservative direction the rules already take. + */ +const MAX_UNCLOSED_BODY_SCAN = 4096 + +/** + * Strip the contents of JSON string literals from `body`, replacing them with + * spaces so every other index is preserved. + * + * A JSON tag body can legitimately quote tag syntax — a `` asking + * which tag to use, or a `` whose title mentions one. Those + * markers live inside a string and say nothing about whether the tag will + * close, so the nesting rule must not see them. Tracks escapes so a `\"` inside + * a string does not end it early. Handles an unterminated trailing string, which + * is the normal state mid-stream. + */ +function blankJsonStringLiterals(body: string): string { + let out = '' + let inString = false + let escaped = false + + for (const char of body) { + if (escaped) { + escaped = false + out += ' ' + continue + } + if (char === '\\' && inString) { + escaped = true + out += ' ' + continue + } + if (char === '"') { + inString = !inString + out += '"' + continue + } + out += inString ? ' ' : char + } + + return out +} + +/** + * True while `body` could still grow into a single valid JSON value. + * + * Checking only the first character is not enough: a body like + * `{"type":"file"}`, which no marker rule sees. + * + * Both are conservative: they only fire on content that could not have parsed. + * A false positive would merely show text early that a later chunk resolves + * into a tag — the end-of-stream parse still produces the correct final render. + */ +function unclosedTagCannotResolve( + tagName: (typeof SPECIAL_TAG_NAMES)[number], + body: string +): boolean { + const pending = dropArrivingClose(body, ``) + // For a JSON-bodied tag, ignore markers inside string literals: quoting tag + // syntax is legitimate content, and treating it as evidence would bail on a + // tag that goes on to close correctly — showing raw JSON that then snaps into + // a rendered card. + const isJsonBodied = JSON_BODY_TAG_NAMES.has(tagName) + const scannable = isJsonBodied ? blankJsonStringLiterals(pending) : pending + for (const name of SPECIAL_TAG_NAMES) { + // A close for this tag is absent by definition here, so this catches a + // FOREIGN close; the open check catches nesting, including self-nesting. + if (scannable.includes(``) || scannable.includes(`<${name}>`)) return true + } + + if (isJsonBodied && !isViableJsonPrefixOf(scannable)) return true + + return false +} + +/** + * Drop a trailing fragment that could still grow into `closeTag`. + * + * Mid-stream the closing marker arrives a character at a time, so a body sits at + * `]` completes. That fragment is an + * arriving close, not stray content — counting it as fatal is what made a + * perfectly valid tag show its raw payload as text until the final `>` landed. + * + * Only a fragment at the very END is dropped, so evidence that the close is + * genuinely wrong still lands immediately: a misspelled `` + * is not a prefix of ``, and a truncated ` 0; n--) { + if (body.endsWith(closeTag.slice(0, n))) return body.slice(0, -n) + } + return body +} + +/** + * How one opening tag resolved. Naming the four outcomes is the point: the + * parser previously decided each case inline, which is how "drop it" quietly + * became the fallback for situations that were never malformed payloads. + */ +type TagResolution = + /** Body parsed; emit the typed segment and resume after the closing tag. */ + | { outcome: 'segment'; segment: ContentSegment; resumeAt: number } + /** Provably not a tag; render the span verbatim and resume after it. */ + | { outcome: 'literal'; resumeAt: number } + /** A well-formed payload that failed its shape guard — dropped deliberately. */ + | { outcome: 'discard'; resumeAt: number } + /** Still streaming and a close remains plausible; suppress the remainder. */ + | { outcome: 'pending' } + +/** + * Why a failed body was never an attempted payload — so the markers were literal + * text and the span must be shown rather than swallowed. `null` means the body + * really was a payload that failed its shape guard. + * + * The two reasons resume differently, which is why they are distinguished + * rather than collapsed into a boolean (see {@link resolveTagAt}). + */ +type LiteralTextReason = + /** The body carries tag markers, so the close we matched belongs elsewhere. */ + | 'foreign-markers' + /** The tag wrapped prose that was never JSON to begin with. */ + | 'never-a-payload' + +function literalTextReason( + tagName: (typeof SPECIAL_TAG_NAMES)[number], + body: string +): LiteralTextReason | null { + const isJsonBodied = JSON_BODY_TAG_NAMES.has(tagName) + // Markers inside a JSON string are content, not evidence — a `` may + // legitimately quote tag syntax in its prompt. Scanning the raw body here + // would classify a broken payload as literal text and render it as raw JSON, + // which is exactly what `discard` exists to prevent. Mirrors the same blanking + // in unclosedTagCannotResolve, which judges the same body mid-stream. + const scannable = isJsonBodied ? blankJsonStringLiterals(body) : body + if (TAG_SHAPED_MARKER.test(scannable)) return 'foreign-markers' + if (isJsonBodied && !isViableJsonPrefixOf(scannable)) return 'never-a-payload' + return null +} + +/** + * `content.indexOf(needle, from)` memoized per needle. * - * Trailing partial opening tags (e.g. `, + content: string, + needle: string, + from: number +): number { + const cached = cache.get(needle) + if (cached !== undefined && (cached === -1 || cached >= from)) return cached + const idx = content.indexOf(needle, from) + cache.set(needle, idx) + return idx +} + +function resolveTagAt( + content: string, + openIndex: number, + tagName: (typeof SPECIAL_TAG_NAMES)[number], + isStreaming: boolean, + closeCache: Map +): TagResolution { + const openTag = `<${tagName}>` + const closeTag = `` + const bodyStart = openIndex + openTag.length + const closeIdx = memoizedIndexOf(closeCache, content, closeTag, bodyStart) + + if (closeIdx === -1) { + const inspectable = content.slice(bodyStart, bodyStart + MAX_UNCLOSED_BODY_SCAN) + if (isStreaming && !unclosedTagCannotResolve(tagName, inspectable)) { + return { outcome: 'pending' } + } + // Nothing can close it, so only the opener itself is literal. Resuming just + // past it (rather than abandoning the message) keeps a genuinely valid tag + // later in the same reply parseable. + return { outcome: 'literal', resumeAt: bodyStart } + } + + const resumeAt = closeIdx + closeTag.length + const body = content.slice(bodyStart, closeIdx) + + const parsed = parseSpecialTagData(tagName, body) + if (parsed) return { outcome: 'segment', segment: parsed, resumeAt } + + const reason = literalTextReason(tagName, body) + if (reason === 'foreign-markers') { + // A marker in the body proves the close we matched opened somewhere else — + // this opener reached past its own missing close and borrowed a later tag's. + // Resuming past the OPENER instead of past that borrowed close re-scans the + // interior, so the genuine tag inside still renders instead of being + // swallowed into one literal span. + return { outcome: 'literal', resumeAt: bodyStart } + } + if (reason === 'never-a-payload') return { outcome: 'literal', resumeAt } + + // A well-formed value that failed its shape guard is a broken emission from + // the agent; showing the user raw JSON there would be worse than nothing. + return { outcome: 'discard', resumeAt } +} + +/** + * Splits streamed text into renderable segments, extracting complete special + * tags and deciding what to do with the ones that never resolve. Incomplete + * tags are suppressed and flagged via `hasPendingTag` so the caller can show a + * loading indicator, and a trailing partial opening marker (` { + if (text.trim()) segments.push({ type: 'text', content: text }) + } + + const openerCache = new Map() + const closeCache = new Map() + while (cursor < content.length) { let nearestStart = -1 let nearestTagName: (typeof SPECIAL_TAG_NAMES)[number] | '' = '' for (const name of SPECIAL_TAG_NAMES) { - const idx = content.indexOf(`<${name}>`, cursor) + const idx = memoizedIndexOf(openerCache, content, `<${name}>`, cursor) if (idx !== -1 && (nearestStart === -1 || idx < nearestStart)) { nearestStart = idx nearestTagName = name } } - if (nearestStart === -1) { + if (nearestStart === -1 || nearestTagName === '') { let remaining = content.slice(cursor) if (isStreaming) { + // Hide a half-arrived opening marker so it does not flash as text. const partial = remaining.match(/<[a-z_-]*$/i) if (partial) { const fragment = partial[0].slice(1) @@ -507,41 +813,26 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS } } - if (remaining.trim()) { - segments.push({ type: 'text', content: remaining }) - } + pushText(remaining) break } - if (nearestStart > cursor) { - const text = content.slice(cursor, nearestStart) - if (text.trim()) { - segments.push({ type: 'text', content: text }) - } - } + pushText(content.slice(cursor, nearestStart)) - const openTag = `<${nearestTagName}>` - const closeTag = `` - const bodyStart = nearestStart + openTag.length - const closeIdx = content.indexOf(closeTag, bodyStart) + const resolution = resolveTagAt(content, nearestStart, nearestTagName, isStreaming, closeCache) - if (closeIdx === -1) { + if (resolution.outcome === 'pending') { hasPendingTag = true - cursor = content.length break } - const body = content.slice(bodyStart, closeIdx) - if (!nearestTagName) { - cursor = closeIdx + closeTag.length - continue - } - const parsedTag = parseSpecialTagData(nearestTagName, body) - if (parsedTag) { - segments.push(parsedTag) + if (resolution.outcome === 'segment') { + segments.push(resolution.segment) + } else if (resolution.outcome === 'literal') { + pushText(content.slice(nearestStart, resolution.resumeAt)) } - cursor = closeIdx + closeTag.length + cursor = resolution.resumeAt } if (segments.length === 0 && !hasPendingTag) {