diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts index e2a9ee06d49..1b3e5d105d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers' import { sanitizeChatDisplayContent } from './chat-sanitize' describe('sanitizeChatDisplayContent', () => { @@ -19,4 +20,96 @@ describe('sanitizeChatDisplayContent', () => { expect(sanitizeChatDisplayContent(content)).toBe('Read and found the issue.') }) + + it('leaves a backticked mention of the tag name alone', () => { + // Unwrapping exists so stray backticks cannot stop a real chip rendering. A + // MENTION is not a tag: there is no payload and no closing marker, just the + // name written in prose. Stripping its opening backtick leaves the closing + // one unpaired, which opens a code span that swallows the rest of the + // message — every later `code` toggles to the wrong state. + const content = 'The `` tag needs a real `path` to render.' + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + + it('keeps backticks balanced across a message that mentions the tag repeatedly', () => { + const content = + 'Use `` for files.\n\nThe `` chip needs an `id`.' + const backticks = (text: string) => (text.match(/`/g) || []).length + + expect(backticks(sanitizeChatDisplayContent(content))).toBe(backticks(content)) + }) + + it('leaves prose that mentions the opener and the closer separately alone', () => { + // The balanced unwrap reads a backticked opener, prose, and a backticked + // closer as ONE wrapped tag, and strips the outer pair. This is the shape a + // message explaining the tag syntax naturally takes. + const content = 'Use `` then close with `` at the end.' + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + + it('leaves a neighbouring code span alone', () => { + // Only a backtick DIRECTLY against the tag is the tag's wrapping. Allowing + // whitespace between let the pattern reach past the tag and take the + // delimiter off an unrelated span, breaking its pair. + const content = + 'Open `config.json` {"type":"file","path":"a.md","title":"a"} then run `bun test`.' + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + + it('leaves a code span sitting flush against the tag alone', () => { + // No space between them, so the span's delimiter is directly against the + // tag and a pattern looking for "a backtick beside a tag" cannot tell the + // two apart. A single pass settles it: a span consumes its own delimiters as + // the scan reaches them, and a trailing backtick is only taken as a stray + // when no further backtick follows on the line. + const before = + 'Open `config.json`{"type":"file","path":"a.md","title":"a"} ok' + const after = + 'Open {"type":"file","path":"a.md","title":"a"}`config.json` ok' + const both = + 'Open `a`{"type":"file","path":"a.md","title":"a"}`b` ok' + + expect(sanitizeChatDisplayContent(before)).toBe(before) + expect(sanitizeChatDisplayContent(after)).toBe(after) + expect(sanitizeChatDisplayContent(both)).toBe(both) + }) + + it('does not break the closing fence of a code block containing a tag', () => { + // Whitespace matching used to cross the newline and consume one of the three + // closing backticks, so the block never closed and the rest of the message + // rendered as code. + const content = + 'Example:\n```md\n{"type":"file","path":"a.md","title":"a"}\n```\nDone.' + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + + it('stays linear on a message that repeats the tag name without ever closing it', () => { + // A lazy scan allowed to cross an opener restarts from every opener, which + // is quadratic — 154ms for this input before the bound, on the main thread, + // for every streamed chunk. + // + // Asserted as a scaling ratio, not a wall-clock ceiling — see + // {@link scalingRatioOver4x} for why. + expect(scalingRatioOver4x((content) => sanitizeChatDisplayContent(content))).toBeLessThan(8) + }) + + it('still unwraps a real tag that carries a stray backtick on one side only', () => { + // The case the unpaired strip is actually for: the model backticked the + // opener but not the closer (or vice versa), which would block the chip. + const leading = + '`{"type":"file","path":"a.md","title":"a"} done' + const trailing = + '{"type":"file","path":"a.md","title":"a"}` done' + + expect(sanitizeChatDisplayContent(leading)).toBe( + '{"type":"file","path":"a.md","title":"a"} done' + ) + expect(sanitizeChatDisplayContent(trailing)).toBe( + '{"type":"file","path":"a.md","title":"a"} done' + ) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts index 7f6849878cd..4e0792df0ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts @@ -1,12 +1,63 @@ const HIDDEN_INLINE_REFERENCE_PATTERN = /`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g -const WORKSPACE_RESOURCE_CODE_SPAN_PATTERN = - /`([^`\n]*[\s\S]*?<\/workspace_resource>[^`\n]*)`/g + +/** + * A complete workspace-resource tag: opener, payload, closer. + * + * Two constraints on the payload, both load-bearing: + * + * - **No backtick.** A payload is JSON and carries none, so this is what tells a + * real tag from prose MENTIONING the tag name — a message explaining the + * syntax writes the opener and the closer as two separately backticked spans. + * - **No nested opener**, via the negative lookahead. A cost bound rather than a + * correctness rule: a lazy scan allowed to cross an opener restarts from every + * opener, so a message repeating the tag name is quadratic — on the main + * thread, for every streamed chunk. + * + * Accepted trade: a resource whose title or path itself contains a backtick is + * not matched, so it renders as text rather than a chip. That costs one chip and + * is rare; the failure it replaces corrupts a whole message and is common. + */ +const COMPLETE_TAG_SOURCE = + '(?:(?!)[^`])*?<\\/workspace_resource>' + +/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */ +const COMPLETE_WORKSPACE_RESOURCE_TAG = new RegExp(COMPLETE_TAG_SOURCE) + +/** + * One left-to-right pass over the two things that can own a backtick: an inline + * code span, and a tag with a stray backtick pressed against it. + * + * ONE pass is the design. Two separate passes each have to guess which backticks + * belong together, and every previous arrangement of this file got a different + * case wrong — a span two words away, a code fence, then a span sitting flush + * against the tag. Here a span consumes its own delimiters as the scan reaches + * them, so `` `config.json` `` keeps its pair without a special case. + * + * The trailing backtick is only taken when no further backtick follows on the + * line; otherwise it is not a stray at all but the opener of the next span, and + * `` `config.json` `` would lose that span's delimiter. A LEADING backtick + * needs no such guard, because a backtick that closes a span is consumed as part + * of that span. Of the two, only the trailing lookahead is pinned by a test — + * swapping the alternatives changes behaviour only for a span that both opens + * flush against a tag and closes elsewhere, which no fixture covers. + */ +const CODE_SPAN_OR_FLANKED_TAG = new RegExp( + `\`[^\`\\n]*\`|\`?(${COMPLETE_TAG_SOURCE})(?:\`(?![^\`\\n]*\`))?`, + 'g' +) export function sanitizeChatDisplayContent(content: string): string { return content - .replace(WORKSPACE_RESOURCE_CODE_SPAN_PATTERN, '$1') + .replace(CODE_SPAN_OR_FLANKED_TAG, (match, tag?: string) => { + // A tag with stray backticks against it: keep the tag, drop the strays. + if (tag !== undefined) return tag + + // A code span. Unwrap it only when it genuinely holds a tag — the parser + // lifts the tag out either way, so leaving the delimiters would strand a + // pair of backticks around a hole. Anything else is someone else's span. + const inner = match.slice(1, -1) + return COMPLETE_WORKSPACE_RESOURCE_TAG.test(inner) ? inner : match + }) .replace(HIDDEN_INLINE_REFERENCE_PATTERN, '') - .replace(/`(\s*)/g, '$1') - .replace(/(<\/workspace_resource>\s*)`/g, '$1') } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts new file mode 100644 index 00000000000..7d2dab3b83e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts @@ -0,0 +1,39 @@ +/** + * Repeated mention of a tag name that never closes — the shape both the parser + * and the display sanitizer used to be quadratic on, because a scan allowed to + * cross an opener restarts from every opener. + */ +function buildRepeatedTagMentions(times: number): string { + return 'The tag is used here. '.repeat(times) +} + +/** Fastest of five runs, so a single scheduling hiccup cannot skew the sample. */ +function fastest(run: (content: string) => void, content: string): number { + let best = Number.POSITIVE_INFINITY + for (let attempt = 0; attempt < 5; attempt++) { + const startedAt = performance.now() + run(content) + best = Math.min(best, performance.now() - startedAt) + } + return best +} + +/** + * How much slower `run` gets when its input grows 4x. + * + * Complexity is asserted as a RATIO rather than a wall-clock ceiling. A fixed + * millisecond bound measures the machine as much as the algorithm: it fails on a + * loaded CI box, and set generously enough not to, it lets a genuine quadratic + * through at the single size it happens to sample. Quadratic costs ~16x for 4x + * the input; linear costs ~4x. + */ +export function scalingRatioOver4x(run: (content: string) => void): number { + // Warm up first — the JIT would otherwise charge the whole compile to the + // small sample and flatter the ratio. + fastest(run, buildRepeatedTagMentions(2_000)) + + const small = fastest(run, buildRepeatedTagMentions(2_000)) + const large = fastest(run, buildRepeatedTagMentions(8_000)) + + return large / small +} 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..66b5c599b75 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 @@ -14,11 +14,61 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: vi.fn(() => ({ data: null, isPending: false })), })) +import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers' +import type { + ContentSegment, + IndexOfCache, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' import { + memoizedIndexOf, parseQuestionTagBody, parseSpecialTags, + SPECIAL_TAG_NAMES, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +/** + * What a reader actually sees: the renderer concatenates adjacent text segments + * into one markdown string, so how a span is split across segments is not + * observable. Assert on this rather than on segment-array shape. + */ +function renderedText(segments: ContentSegment[]): string { + return segments.map((segment) => ('content' in segment ? segment.content : '')).join('') +} + +/** + * What the reader can actually see. Mirrors chat-content.tsx: adjacent text + * segments concatenate, a `thinking` segment renders NOTHING, and every other + * segment is a card. Distinct from {@link renderedText}, which counts a thinking + * body as text — using that here would hide a tag whose close swallows content + * the stream had already put on screen. + */ +function visibleView(segments: ContentSegment[]) { + return { + text: segments + .map((segment) => + segment.type === 'thinking' ? '' : 'content' in segment ? segment.content : ' CARD' + ) + .join(''), + cardCount: segments.filter((segment) => segment.type !== 'text' && segment.type !== 'thinking') + .length, + } +} + +/** + * Replays `content` the way it streams — one growing prefix per frame — and + * returns what the reader would see on each. A parser bug that only shows up + * between frames (a card that renders and then un-renders, text that appears and + * then vanishes) is invisible to a single end-state assertion. + */ +function replayFrames(content: string, step = 1) { + const frames: ReturnType[] = [] + for (let end = 1; end <= content.length; end += step) { + frames.push(visibleView(parseSpecialTags(content.slice(0, end), true).segments)) + } + frames.push(visibleView(parseSpecialTags(content, false).segments)) + return frames +} + const SINGLE_SELECT = { type: 'single_select', prompt: 'How should I handle the duplicate emails?', @@ -149,22 +199,514 @@ 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 = renderedText(parseSpecialTags(raw, false).segments) + + 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', () => { + 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 streaming = parseSpecialTags(raw, true) + expect(streaming.hasPendingTag).toBe(false) + expect(renderedText(streaming.segments)).toBe(raw) + expect(renderedText(parseSpecialTags(raw, false).segments)).toBe(raw) + }) + + 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. + 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) + }) + + 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 = renderedText(parseSpecialTags(raw, false).segments) + expect(rendered).toContain('the gmail-agent workflow') + expect(rendered).toContain('to handle the welcome sequence') + }) + + it('shows a body that will not parse at all, rather than dropping it', () => { + // `discard` is only defensible for a payload the agent actually FORMED — + // valid JSON that failed its shape guard. Bracket depth cannot tell prose + // wrapped in braces from a real payload, so without an actual parse these + // were deleted: the first is a resource name someone wrote in braces, the + // other two are the commonest JSON slips a model makes. + const cases = [ + 'I saved {the Q4 report} for you.', + 'See {type: "file", path: "a.md"} ok', + "See {'type':'file'} ok", + ] + for (const raw of cases) { + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments)).toBe(raw) + } + }) + + 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 ) expect(hasPendingTag).toBe(false) - expect(segments).toEqual([ - { type: 'text', content: 'Before. ' }, - { type: 'text', content: ' After.' }, - ]) + // Asserted on the rendered text, not the segment array: how the surviving + // prose is split across text segments is display-neutral, so pinning the + // array shape would break on a behavior-preserving change to the split. + expect(renderedText(segments)).toBe('Before. After.') + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + 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 payload, calls the span + // literal text, and renders the raw JSON — the outcome `discard` exists to + // prevent. + // + // The quoted marker deliberately sits in a field OTHER than `prompt`: a + // recoverable prompt is surfaced as text before this path is reached, so a + // fixture carrying one would assert the recovery rather than the blanking + // this test exists for. Matches the prompt-less body used above. + const { segments } = parseSpecialTags( + 'A [{"type":"single_select","title":"use here?"}] B', + false + ) + expect(renderedText(segments)).toBe('A B') + 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 ``. + for (const fragment of ['<', '[{"title":"a","description":"b"}]${fragment}`, + true + ) + expect(hasPendingTag).toBe(true) + expect(renderedText(segments)).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 raw = + 'see {"type":"file","path":"a.md"} and then prose.' + const { hasPendingTag, segments } = parseSpecialTags(raw, true) + expect(hasPendingTag).toBe(false) + // Asserted on the text too, not just the flag: a wrong resumeAt keeps the + // flag correct while dropping the prose, which is the defect class this + // whole change exists to prevent. + expect(renderedText(segments)).toBe(raw) + }) + + 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(renderedText(segments)).toContain('') + }) + + 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` + // 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. + // + // Blanking is only meaningful while the body might BE json. Once viability + // has proved it never was, the raw text is the honest evidence. + const raw = + 'Saved the notes file "notes.md and here is what to do next: ' + + '[{"title":"Ship it","description":"Open the PR"}]\n' + + 'Full path: {"type":"file","path":"files/a.md","title":"a.md"}' + + const { segments } = parseSpecialTags(raw, false) + expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) + expect(segments.filter((segment) => segment.type === 'workspace_resource')).toHaveLength(1) + // Balancing the quote must reach the same two cards — the quote is the only + // difference, so this pins that it was never load-bearing for the outcome. + const balanced = raw.replace('the notes file "notes.md', 'the notes file notes.md') + const control = parseSpecialTags(balanced, false).segments + expect(control.filter((segment) => segment.type === 'options')).toHaveLength(1) + expect(control.filter((segment) => segment.type === 'workspace_resource')).toHaveLength(1) + }) + + it('never un-renders that card as the closing tag arrives', () => { + // The frame-level face of the case above, and the invariant it broke: the + // options card is on screen for many frames before the final `>` lands. A + // card that renders must never revert to raw text. + const raw = + 'Saved the notes file "notes.md and here is what to do next: ' + + '[{"title":"Ship it","description":"Open the PR"}]\n' + + 'Full path: {"type":"file","path":"files/a.md","title":"a.md"}' + + let sawCard = false + for (let end = 1; end <= raw.length; end++) { + const { segments } = parseSpecialTags(raw.slice(0, end), true) + const hasCard = segments.some((segment) => segment.type === 'options') + if (hasCard) sawCard = true + expect(!sawCard || hasCard, `options card retracted at frame ${end}`).toBe(true) + } + expect(sawCard).toBe(true) + // ...and the settled parse still has it. + expect(parseSpecialTags(raw, false).segments.some((s) => s.type === 'options')).toBe(true) + }) + + it('does not delete tag syntax quoted inside the body it rescans', () => { + // The rescan decides on the BLANKED body, so a tag quoted inside a JSON + // string is invisible to it. Resuming at the opener would re-scan that + // quoted text raw, re-parse it as a real tag, and drop it — deleting text. + // Resuming at the MARKER skips the quoted region, so it survives verbatim. + const inner = + '{\\"type\\":\\"link\\",\\"value\\":\\"https://x.example/p\\"}' + const raw = `A {"prompt":"${inner}"} B` + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments)).toBe(raw) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('keeps the blank line between two rejected spans', () => { + // The renderer concatenates adjacent text segments into one markdown string, + // so a dropped whitespace-only span silently merges two paragraphs. + const raw = + 'prose one\n\nprose two' + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments)).toBe(raw) + }) + + it('shows an oversized body it only partly inspected rather than discarding it', () => { + // Only the first MAX_UNCLOSED_BODY_SCAN characters are scanned. Finding no + // reason within that window is not evidence the body was a real payload, so + // the span must be shown — discarding would delete text never examined. + const body = `{"type":"file","path":"a.md","note":"${'x'.repeat(5000)}` + const raw = `see ${body} end` + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments)).toBe(raw) + }) + + it('settles a prose mention at any length, but defers a payload that closes past the window', () => { + // The scan window's accepted blind spot, pinned so it stays a decision. + // + // A mention in prose settles at its FIRST character however long the message + // runs — prose does not open with `{`, so viability fails immediately. + const mention = `see ${'long prose. '.repeat(600)}` + expect(parseSpecialTags(mention, true).hasPendingTag).toBe(false) + + // But a JSON body whose top-level value closes BEYOND the window still reads + // as a viable prefix, so the tail stays hidden until the stream ends. Needs a + // payload several times larger than any tag emits, and it is lossless once + // complete — the cost of bounding a scan that otherwise stalls the main + // thread. + const oversized = `see {"type":"file","note":"${'x'.repeat(5000)}"} and then prose.` + expect(parseSpecialTags(oversized, true).hasPendingTag).toBe(true) + expect(renderedText(parseSpecialTags(oversized, false).segments)).toBe(oversized) + }) + + it('still finds a valid tag sitting past the scan window inside a borrowed body', () => { + // The first opener has no close of its own, so it borrows the inner tag's. + // Its body is marker-free prose for far longer than the scan window, so the + // truncated inspection sees only prose and can say nothing about the rest. + // + // Resuming past the borrowed close would flatten the inner tag to text purely + // because of where it fell relative to the window. Resuming at the first + // uninspected character finds it. Asserted at three lengths so the boundary + // itself is covered, not just one side of it. + const inner = + '{"type":"file","path":"files/b.md","title":"b.md"}' + const build = (proseChars: number) => + `See ${'prose word '.repeat(Math.ceil(proseChars / 11))}${inner} end` + + for (const proseChars of [1_000, 6_000, 60_000]) { + const raw = build(proseChars) + const { segments } = parseSpecialTags(raw, false) + expect(segments.filter((segment) => segment.type === 'workspace_resource')).toHaveLength(1) + // The prose around it survives too — the span is emitted, not skipped. + expect(renderedText(segments)).toContain('See prose word') + expect(renderedText(segments)).toContain(' end') + } + }) + + it('still finds a valid tag whose opener STRADDLES the scan-window edge', () => { + // The window edge is an arbitrary cut, so an opener can begin just before it + // and finish just after. The test above steps in 11-character units and so + // lands on only a few offsets; the straddle needs every offset in the band. + // + // Resuming exactly at the edge left the opener's `<` behind the cursor, and + // the opener scan only looks FORWARD — so the tag was never found and its + // payload rendered as raw JSON text, on a COMPLETED message. Every offset + // across the band must still produce the card. + const inner = + '{"type":"file","path":"files/b.md","title":"b.md"}' + + for (let filler = 4_060; filler <= 4_110; filler++) { + const raw = `See ${'z'.repeat(filler)}${inner} end` + const { segments } = parseSpecialTags(raw, false) + expect( + segments.filter((segment) => segment.type === 'workspace_resource'), + `filler ${filler}` + ).toHaveLength(1) + // Nothing is duplicated or dropped by the rewind either. + expect(renderedText(segments), `filler ${filler}`).toContain(' end') + } + }) + + 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', () => { + 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"} { + const raw = 'x {"title":"a } b","path":"files/a.md"' + expect(parseSpecialTags(raw, true).hasPendingTag).toBe(true) + }) + + it('does not let an escaped quote end a string early and skew the depth', () => { + // If `\"` were read as the closing quote, the following `}` would count as a + // real close, the top-level value would look finished, and the trailing text + // would settle the tag as unresolvable mid-payload. + 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 a prose body', () => { + // Tags never nest, so a close for a different tag proves the opener was text. + // Asserted on `thinking` because that is the only tag the nesting rule still + // serves: a JSON body has no need of it, since a marker outside a string + // literal is content the viability rule already rejects, and one inside is + // legitimate quoted syntax that must not count as evidence. + const raw = 'see weighing it more' + const { hasPendingTag, segments } = parseSpecialTags(raw, true) + expect(hasPendingTag).toBe(false) + expect(renderedText(segments)).toBe(raw) + }) + + 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('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(renderedText(streaming.segments)).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(renderedText(done.segments)).toBe('a b c') + }) + + it('keeps reasoning suppressed when the body merely contains angle brackets', () => { + // The nesting rule keys on tag NAMES, not on anything tag-shaped. Reasoning + // that mentions `
` or a generic is still reasoning; releasing it would + // put the model's thinking on screen for an incidental angle bracket. + const { segments } = parseSpecialTags('a weighing a
here b', false) + + expect(segments.some((segment) => segment.type === 'thinking')).toBe(true) + expect(visibleView(segments).text).toBe('a b') + }) + + it('renders nothing for a message that is only a discarded payload', () => { + // `discard` emits no segment, so this is the one case that can end the parse + // with an empty segment list. The fallback for an empty list is to emit the + // raw content — which would put back the exact raw JSON the discard removed. + const { segments } = parseSpecialTags('{"type":"single_select"}', false) + + expect(visibleView(segments).text).toBe('') + }) + + it('settles a long prose mention without scanning the whole window', () => { + // Viability rejects on the first non-whitespace character when it is not `{` + // or `[` — the common case. Testing that before blanking avoids copying a + // full window per opener per chunk: 43ms to 2ms on this input. + // + // Asserted as a scaling ratio, not a wall-clock ceiling — see + // {@link scalingRatioOver4x} for why. + expect(scalingRatioOver4x((content) => parseSpecialTags(content, true))).toBeLessThan(8) + }) + + it('does not let a late thinking close swallow content already on screen', () => { + // A nested marker disproves the outer mid-stream, so its text is + // released and the inner tag renders as a card. A prose body has no shape to + // fail — any non-empty text qualifies — so when finally arrives it + // would be accepted as a segment, and everything already on screen would be + // swallowed into it and suppressed. The nesting rule has to apply on the + // matched-pair path too, not just while streaming. + const raw = 'a b [{"title":"x","description":"y"}] c d' + + const settled = parseSpecialTags(raw, false) + expect(settled.segments.some((segment) => segment.type === 'options')).toBe(true) + expect(settled.segments.some((segment) => segment.type === 'thinking')).toBe(false) + + // And nothing retracts across the stream: no rendered card un-renders. + const frames = replayFrames(raw) + let previous = 0 + for (const frame of frames) { + expect(frame.cardCount).toBeGreaterThanOrEqual(previous) + previous = frame.cardCount + } + }) + + it('hides an unclosed thinking body while streaming, then shows it once complete', () => { + // A DELIBERATE trade, not an oversight. `thinking` bodies are prose, so the + // JSON viability rule cannot apply and only the nesting rule can disprove the + // opener — mid-stream the default is therefore to HIDE, since a close is + // still plausible and releasing early would flash reasoning that is about to + // become a suppressed segment. + // + // Once the stream ends the body is shown as text, which does leak the model's + // reasoning for a message whose close never arrived. Accepted: forgetting the + // close is rare, and the alternative — keeping it hidden — would swallow the + // answer whenever the model opened `` and then wrote the reply + // without closing, which is the text-loss bug this whole change removes. + const raw = 'a still reasoning about' + const streaming = parseSpecialTags(raw, true) + expect(streaming.hasPendingTag).toBe(true) + expect(renderedText(streaming.segments)).toBe('a ') + + const complete = parseSpecialTags(raw, false) + expect(complete.hasPendingTag).toBe(false) + expect(renderedText(complete.segments)).toBe(raw) + }) + + it('never retracts rendered text or a card across streamed frames', () => { + // Frame-to-frame stability, which no end-state assertion can see. Replays a + // real message one character at a time: text already shown must never + // disappear, and a card once rendered must never revert to raw text. + const content = + 'Updated {"type":"file","path":"files/a.md","title":"a.md"} ' + + 'and left `` alone. ' + + '[{"title":"Ship it","description":"open the PR"}]' + + const frames = replayFrames(content) + + // Card count is monotonically non-decreasing. Appending to the buffer can + // only add closes AFTER the ones already matched, so no earlier opener's + // resolution can change — a card that renders must never un-render. + let previous = 0 + for (const frame of frames) { + expect(frame.cardCount).toBeGreaterThanOrEqual(previous) + previous = frame.cardCount + } + + // The settled parse is the richest: both tags resolved, prose intact. + const settled = frames[frames.length - 1] + expect(settled.cardCount).toBe(2) + expect(settled.text).toContain('and left `` alone.') + }) + + 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) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + expect(renderedText(segments)).toBe(content) + }) + + it('strips a trailing partial opening tag while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { } ) }) + +describe('memoizedIndexOf', () => { + const CONTENT = + 'Use for files. Use for cards. ' + + '[{"title":"Ship","description":"go"}] and again.' + // Every opener the parser actually searches for, not a hand-picked few: the + // cache is keyed per needle, so a needle absent from CONTENT exercises the + // cached -1 path and a repeated one exercises reuse as the cursor advances. + const NEEDLES = SPECIAL_TAG_NAMES.map((name) => `<${name}>`) + + it('matches plain indexOf as the cursor advances', () => { + const cache: IndexOfCache = new Map() + for (let from = 0; from <= CONTENT.length; from++) { + for (const needle of NEEDLES) { + expect(memoizedIndexOf(cache, CONTENT, needle, from)).toBe(CONTENT.indexOf(needle, from)) + } + } + }) + + it('stays correct when the cursor moves BACKWARD', () => { + // The cache is only reused when the new `from` is at or beyond the offset the + // entry was searched at. Without that guard a cached hit — or a cached -1 — + // is returned for a region it never examined, and the parser silently + // mis-parses rather than failing loudly. + // + // parseSpecialTags never walks backward today, so this cannot be provoked + // through the public API; the point is that a future change to a resume point + // costs a redundant scan instead of a wrong answer. + const cache: IndexOfCache = new Map() + const offsets = [70, 5, 100, 0, 45, 62, 12, CONTENT.length, 40, 3] + for (const from of offsets) { + for (const needle of NEEDLES) { + expect(memoizedIndexOf(cache, CONTENT, needle, from)).toBe(CONTENT.indexOf(needle, from)) + } + } + }) + + it('caches an absent needle instead of rescanning', () => { + const cache: IndexOfCache = new Map() + expect(memoizedIndexOf(cache, CONTENT, '', 0)).toBe(-1) + // Same offset or later: answerable from the entry, since absence from 0 + // implies absence from anywhere after it. + expect(memoizedIndexOf(cache, CONTENT, '', 30)).toBe(-1) + expect(cache.get('')).toEqual({ idx: -1, from: 0 }) + }) +}) + +/** + * Property tests over generated messages. + * + * The example tests above each pin one shape that was once broken. They cannot + * cover the space: a body is judged on body kind, close state, JSON state, marker + * placement, size against the scan window, and streaming mode — a product of + * roughly six hundred combinations, each needing both an outcome and a resume + * point. Every regression found in review so far was a cell nobody had written an + * example for. + * + * These assert invariants instead, over messages composed from fragments, so a + * new combination is covered without a new test. Seeded so a failure reproduces. + */ +describe('parser properties', () => { + /** mulberry32 — deterministic, so a failing case is reproducible from its seed. */ + function makeRng(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + /** + * One valid payload per card-rendering tag, keyed by tag name so + * {@link SPECIAL_TAG_NAMES} can be checked for full coverage below. Hand-picking + * a subset is how three of these went unexercised by every invariant without + * anything failing to say so. + */ + const VALID_TAG_BY_NAME: Record = { + workspace_resource: + '{"type":"file","path":"files/a.md","title":"a.md"}', + options: '[{"title":"Ship it","description":"Open the PR"}]', + question: `${JSON.stringify(SINGLE_SELECT)}`, + credential: + '{"type":"link","provider":"slack","value":"https://x.example/p"}', + usage_upgrade: + '{"reason":"monthly cap","action":"upgrade_plan","message":"You hit your limit."}', + 'mothership-error': + '{"message":"The tool call failed.","code":"E_TOOL"}', + } + + /** Renders nothing rather than a card, so it cannot carry a card invariant. */ + const TAGS_WITHOUT_CARDS = ['thinking'] + + const VALID_TAGS = Object.values(VALID_TAG_BY_NAME) + + it('covers every tag the parser knows', () => { + // The invariants below are only as good as this list. Deriving the check from + // SPECIAL_TAG_NAMES makes adding a tag without a fixture fail loudly here, + // instead of quietly leaving it outside every property in this file. + expect(new Set([...Object.keys(VALID_TAG_BY_NAME), ...TAGS_WITHOUT_CARDS])).toEqual( + new Set(SPECIAL_TAG_NAMES) + ) + }) + + /** + * 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. + */ + const LOSSLESS_FRAGMENTS = [ + 'Plain prose with no markup at all. ', + 'A `` mention in prose. ', + 'Talking about `` and `` together. ', + '{"type":"file","path":"a.md"} misspelled close. ', + '{"type":"file","path":"a.md"}{"type":"file","path":"a.md"} and then prose, no close. ', + '{the Q4 report} braces round prose. ', + '{type: "file", path: "a.md"} unquoted keys. ', + "{'type':'file'} single quotes. ", + 'the gmail-agent workflow prose body. ', + 'reasoning with a nested marker after. ', + 'the notes file "notes.md unbalanced quote after. ', + 'notes "unbalanced then marker tail. ', + '\n\nA paragraph break above. ', + `${'long filler prose. '.repeat(300)}crossing the scan window. `, + ] + + const pick = (rng: () => number, xs: T[]): T => xs[Math.floor(rng() * xs.length)] + + function buildLossless(rng: () => number, pool = LOSSLESS_FRAGMENTS): string { + const n = 1 + Math.floor(rng() * 5) + return Array.from({ length: n }, () => pick(rng, pool)).join('') + } + + /** + * The same shapes without the window-crossing filler. + * + * Frame replay parses every prefix, so message length multiplies into parse + * count — the filler fragment alone took that property to ~1M parses and 1.7s, + * 95% of this file's runtime. Retraction is a property of what happens AT a + * frame boundary, so it is exercised by the boundaries, not by message size. + * The scan window still gets its coverage from the other properties, which + * parse each message once. + */ + const SHORT_FRAGMENTS = LOSSLESS_FRAGMENTS.filter((fragment) => fragment.length < 200) + + it('never loses a character of a message with nothing droppable in it', () => { + // The headline guarantee. Only a well-formed payload that failed its shape + // guard may be removed, and no fragment here is one. + for (let seed = 1; seed <= 400; seed++) { + const raw = buildLossless(makeRng(seed)) + const { segments } = parseSpecialTags(raw, false) + expect(renderedText(segments), `seed ${seed}`).toBe(raw) + } + }) + + it('renders every valid tag as a card whatever surrounds it', () => { + // A valid tag was flattened to text purely because of how much + // prose preceded it, which no fixed example set would have found. + for (let seed = 1; seed <= 400; seed++) { + const rng = makeRng(seed) + const tags = Array.from({ length: 1 + Math.floor(rng() * 3) }, () => pick(rng, VALID_TAGS)) + const parts: string[] = [] + for (const tag of tags) { + // Several fragments, not one: the interesting shapes need an unclosed + // opener AND enough prose after it to push the valid tag past the scan + // window, so the opener borrows that tag's close from beyond what the + // parser inspected. One fragment between tags can never build that. + const run = 1 + Math.floor(rng() * 3) + for (let i = 0; i < run; i++) parts.push(pick(rng, LOSSLESS_FRAGMENTS)) + parts.push(tag) + } + parts.push(pick(rng, LOSSLESS_FRAGMENTS)) + const raw = parts.join('') + + const { segments } = parseSpecialTags(raw, false) + const cards = segments.filter( + (segment) => segment.type !== 'text' && segment.type !== 'thinking' + ) + expect(cards, `seed ${seed}`).toHaveLength(tags.length) + } + }) + + it('never un-renders a card or retracts text across streamed frames', () => { + // Content already on screen disappeared when a later close + // arrived. Only visible across frames, never in an end-state assertion. + // + // Text may shrink slightly at a frame edge: a half-arrived opening marker is + // deliberately hidden so it does not flash. That is bounded by the longest + // opener, so anything beyond it is a real retraction. + const LONGEST_OPENER = ''.length + 1 + + for (let seed = 1; seed <= 120; seed++) { + const rng = makeRng(seed) + const raw = `${buildLossless(rng, SHORT_FRAGMENTS)}${pick(rng, VALID_TAGS)}${buildLossless(rng, SHORT_FRAGMENTS)}` + + let previousCards = 0 + let previousText = '' + for (const frame of replayFrames(raw, 7)) { + expect(frame.cardCount, `seed ${seed}: card un-rendered`).toBeGreaterThanOrEqual( + previousCards + ) + + const stable = previousText.slice(0, Math.max(0, previousText.length - LONGEST_OPENER)) + expect(frame.text.startsWith(stable), `seed ${seed}: text retracted`).toBe(true) + + previousCards = frame.cardCount + previousText = frame.text + } + } + }) + + it('settles to at least what the last streaming frame showed', () => { + // The stream ending must only ever reveal more. A settled parse that renders + // fewer cards than the frame before it is a retraction the user watches happen. + for (let seed = 1; seed <= 200; seed++) { + const rng = makeRng(seed) + const raw = `${buildLossless(rng)}${pick(rng, VALID_TAGS)}${buildLossless(rng)}` + + const lastFrame = visibleView(parseSpecialTags(raw, true).segments) + const settled = visibleView(parseSpecialTags(raw, false).segments) + + expect(settled.cardCount, `seed ${seed}`).toBeGreaterThanOrEqual(lastFrame.cardCount) + expect(settled.text.length, `seed ${seed}`).toBeGreaterThanOrEqual(lastFrame.text.length) + } + }) +}) 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..28207435d56 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 @@ -196,7 +196,12 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'question', ] as const -const SPECIAL_TAG_NAMES = [ +/** + * Every tag the parser resolves. Exported so tests can assert their fixtures + * cover all of them rather than hand-picking a subset that silently drifts — + * the same treatment the sibling `*_TYPES` unions above already get. + */ +export const SPECIAL_TAG_NAMES = [ 'thinking', 'options', 'usage_upgrade', @@ -392,6 +397,25 @@ export function parseTextTagBody(body: string): string | null { return body.trim() ? body : 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. + */ +function isParseableJson(body: string): boolean { + try { + JSON.parse(body) + return true + } catch { + return false + } +} + export function parseTagAttributes(openTag: string): Record { const attributes: Record = {} const attributePattern = /([A-Za-z_:][A-Za-z0-9_:-]*)="([^"]*)"/g @@ -465,35 +489,582 @@ 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 a body to inspect per parse, on both the unclosed and matched-pair + * paths. + * + * The rules in {@link unclosedTagCannotResolve} and {@link literalTextReason} + * 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 payload a tag actually carries. + * Unbounded, the check is O(body length) and runs once per opener inside a parse + * that re-runs for every streamed chunk. A long reply repeatedly mentioning a + * tag name, or one whose misspelled early close stretches a single body across + * most of the message, is then quadratic in the length of the reply. + * + * The window's one blind spot, and why it is accepted: a JSON body whose + * top-level value closes BEYOND the window, followed by prose and no closing tag, + * still reads as a viable prefix, so the remainder stays hidden until the stream + * ends rather than settling mid-stream. It is lossless — the completed parse + * renders every character — and it needs a payload several times larger than any + * tag emits (a `` runs ~100 characters, a `` card + * under ~1500). A mention in prose settles at its first character at any length, + * because prose does not open with `{`. Widening or removing the window to close + * that gap would trade a measured, reachable main-thread freeze for a + * hypothetical one. + */ +const MAX_UNCLOSED_BODY_SCAN = 4096 + +/** + * Length of the longest marker the scans can match. + * + * Derived from {@link SPECIAL_TAG_NAMES} rather than hand-counted, so adding a + * longer tag name cannot silently shrink the rewind in {@link resumeForClass}. + * Closing markers are the longer of the two forms, so they set the bound. + */ +const LONGEST_TAG_MARKER = Math.max(...SPECIAL_TAG_NAMES.map((name) => ``.length)) + +/** + * 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. + * + * Index preservation is load-bearing, not decorative: {@link resumeForClass} takes an + * offset found in the blanked copy and applies it to the RAW body. Iteration is by + * code point, so a blanked astral character must emit `char.length` spaces — + * emitting one would shrink the output and shift every later offset left. + */ +function blankJsonStringLiterals(body: string): string { + // With no quote there is no string literal, so the loop below would copy the + // body to itself character by character. Both callers reach here on bodies + // that are usually plain prose, and this runs per opener per streamed chunk. + if (!body.includes('"')) return body + + let out = '' + let inString = false + let escaped = false + + for (const char of body) { + if (escaped) { + escaped = false + out += ' '.repeat(char.length) + continue + } + if (char === '\\' && inString) { + escaped = true + out += ' ' + continue + } + if (char === '"') { + inString = !inString + out += '"' + continue + } + out += inString ? ' '.repeat(char.length) : char + } + + return out +} + +/** + * True while `scannable` could still grow into a single valid JSON value. + * + * Checking only the first character is not enough: a body like + * `{"type":"file"}` or `Promise`; only a marker the parser + * would itself act on proves the enclosing opener was text. Shared so the + * streaming and matched-pair paths cannot answer the same question differently + * — them disagreeing is what let a late close swallow content already on screen. + * + * The match is by substring, so a generic is safe only when its parameter is not + * itself a tag name: `Promise` does not match, `Promise` does. The + * narrowing is not worth its cost — it needs a `` body, which the agent + * no longer emits (reasoning arrives as structured thinking blocks), discussing a + * type named exactly after a tag; and the boundary check that would fix it wants a + * lookbehind, unavailable on the Safari versions this app still supports. + */ +function hasSpecialTagMarker(text: string): boolean { + return SPECIAL_TAG_NAMES.some((name) => text.includes(``) || text.includes(`<${name}>`)) +} + +/** + * True when an opening tag with no close yet can NEVER resolve, so the text + * after it should be shown immediately instead of held back until the stream + * ends. Without it, a message that merely mentions a tag in prose goes blank + * from that point on until streaming stops. + * + * One rule decides it, chosen by body kind: + * + * - **JSON-bodied tags** must stay a viable JSON prefix. Depth is tracked rather + * than testing the first character alone, so a body whose top-level value has + * already closed is caught the moment stray content follows it — a mention in + * prose (no `{` at all), a misspelled close like ``, a + * truncated ``) + + if (!JSON_BODY_TAG_NAMES.has(tagName)) return hasSpecialTagMarker(pending) + + // Cheap rejection before the expensive one. isViableJsonPrefixOf decides on + // the first non-whitespace character when it is not `{` or `[` — which is the + // common case, a tag name mentioned in prose — so testing it here avoids + // blanking up to a full window of text only to throw the copy away. + const firstChar = pending.trimStart().charAt(0) + if (firstChar !== '' && firstChar !== '{' && firstChar !== '[') return true + + // Blank string literals first so braces and brackets inside JSON strings do + // not throw off the depth count. + return !isViableJsonPrefixOf(blankJsonStringLiterals(pending)) +} + +/** + * 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 resumeForClass}). + */ +type LiteralTextVerdict = + /** + * The body carries a tag marker at `markerOffset` (an index into the body), so + * the close we matched belongs to a different opener. + */ + | { reason: 'foreign-markers'; markerOffset: number } + /** The tag wrapped prose that was never JSON to begin with. */ + | { reason: 'never-a-payload' } + +function literalTextReason( + tagName: (typeof SPECIAL_TAG_NAMES)[number], + body: string +): LiteralTextVerdict | 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 + const marker = TAG_SHAPED_MARKER.exec(scannable) + if (marker) return { reason: 'foreign-markers', markerOffset: marker.index } + if (isJsonBodied && !isViableJsonPrefixOf(scannable)) return { reason: 'never-a-payload' } + return null +} + +/** One memoized `indexOf` result, with the `from` it was computed at. */ +interface IndexOfCacheEntry { + /** Result of `content.indexOf(needle, from)`, or -1 when absent from that point on. */ + idx: number + /** The offset the search started at. The entry says nothing about content before it. */ + from: number +} + +export type IndexOfCache = Map + +/** + * `content.indexOf(needle, from)` memoized per needle. + * + * The opener scan and the close lookup search the same handful of markers over + * and over as the cursor advances. A needle absent from the message resolves to + * -1 once and is never searched again; a present one is re-searched only when + * the cursor passes its last hit. Unmemoized, each lookup rescans to the end of + * the buffer for every opener, which is quadratic on a message that mentions a + * tag name many times — and this parse re-runs for every streamed chunk. + * + * A cached result is only valid from the offset it was searched at, so the entry + * carries that offset and is reused only when the new `from` is at or beyond it: + * + * - `idx === -1` means no hit at or after `entry.from`, so there is none at or + * after any later `from` either. + * - `idx >= from` means the first hit at or after `entry.from` is still ahead of + * `from`, so nothing lies between them and it is still the first hit. + * + * Storing `from` is what makes this correct for ANY call order rather than only + * for a monotonically advancing cursor. The cursor is monotonic today — every + * non-pending outcome resumes strictly past its opener — but that is a property + * of {@link resolveTagAt}'s resume points, and one of them deliberately resumes + * back inside a span it already examined. A future adjustment that let the cursor + * regress would, without this check, return a stale index and silently mis-parse + * rather than fail loudly. With it, the worst case is a redundant scan. + */ +export function memoizedIndexOf( + cache: IndexOfCache, + content: string, + needle: string, + from: number +): number { + const entry = cache.get(needle) + if (entry && from >= entry.from && (entry.idx === -1 || entry.idx >= from)) return entry.idx + const idx = content.indexOf(needle, from) + cache.set(needle, { idx, from }) + return idx +} + +/** + * How much of a body may be inspected, and whether that is all of it. + * + * The read budget, isolated from what the body turns out to BE. Both the + * unclosed and matched-pair paths spend it through this one function, so they + * cannot drift out of agreement about how much of a body may be read. + */ +interface InspectedBody { + /** The prefix actually examined. */ + text: string + /** True when `text` is only a prefix, so no verdict drawn from it covers the rest. */ + truncated: boolean +} + +function inspectWithin(source: string, start = 0): InspectedBody { + const end = start + MAX_UNCLOSED_BODY_SCAN + return end < source.length + ? { text: source.slice(start, end), truncated: true } + : { text: start === 0 ? source : source.slice(start), truncated: false } +} + +/** + * What a matched body turned out to BE — independent of what the parser does + * about it, and of where it resumes. + * + * A closed set, and that is the whole point: {@link resolveMatchedPair} and + * {@link resumeForClass} each switch over it exhaustively, so adding a case + * fails to compile until BOTH questions are answered for it. Every regression + * review found on this parser was one of those two answers changing without the + * other, which is a mistake this shape makes unrepresentable. + */ +type BodyClass = + /** Parsed, and matched its shape guard. */ + | { kind: 'payload'; segment: ContentSegment } + /** A tag marker at `offsetInBody` proves the close we matched belongs elsewhere. */ + | { kind: 'nested-marker'; offsetInBody: number } + /** + * The same proof, in a PROSE body. Separate because it resumes differently: a + * prose body is never blanked, so nothing is hidden from the scan and rescanning + * from the opener is safe, and these bodies are small enough that the extra pass + * is free. Resuming at the marker instead would also be correct and would emit + * one text segment rather than two — display-identical, since the renderer + * concatenates them — but it is a behaviour change and does not belong in a + * refactor. + */ + | { kind: 'prose-nested-marker' } + /** Only a prefix was read, and it settled nothing. Says nothing about the rest. */ + | { kind: 'unexamined' } + /** Not a payload at all — never JSON, or JSON that will not parse. */ + | { kind: 'never-json' } + /** Parsed as JSON, then failed its shape guard. The only droppable class. */ + | { kind: 'broken-payload' } + +/** + * Classify a complete body. Pure: no positions, no outcome, no resume. + * + * Order is behavioural, not stylistic. The prose-nesting rule precedes the parse + * because a prose body has no shape to fail — any non-empty text qualifies — so a + * late close would otherwise swallow whatever the streaming path already showed. + * The budget precedes the remaining rules so an unread remainder is never + * mistaken for evidence. + */ +function classifyBody(tagName: (typeof SPECIAL_TAG_NAMES)[number], body: string): BodyClass { + const isJsonBodied = JSON_BODY_TAG_NAMES.has(tagName) + + if (!isJsonBodied) { + // The same predicate the streaming path uses, so the two cannot disagree + // about whether this body was ever a tag. Tag NAMES, not anything + // tag-shaped: reasoning that mentions `
` or `Promise` is still + // reasoning, and releasing it as prose would put the model's thinking on + // screen for an incidental angle bracket. + if (hasSpecialTagMarker(body)) return { kind: 'prose-nested-marker' } + } + + const parsed = parseSpecialTagData(tagName, body) + if (parsed) return { kind: 'payload', segment: parsed } + + const inspected = inspectWithin(body) + const verdict = literalTextReason(tagName, inspected.text) + + if (verdict?.reason === 'foreign-markers') { + return { kind: 'nested-marker', offsetInBody: verdict.markerOffset } + } + if (inspected.truncated) return { kind: 'unexamined' } + + // Dropping text is only defensible for a payload the agent actually FORMED. + // `{the Q4 report}` is prose in braces and `{type: "file"}` is an ordinary + // model slip; bracket depth cannot tell either from a real payload, only a + // parse can. Both routes to that answer are funnelled through one place so the + // rescan below cannot be added to one and forgotten on the other. + const neverJson = + verdict?.reason === 'never-a-payload' || (isJsonBodied && !isParseableJson(body)) + + if (neverJson) { + // literalTextReason blanked this body's quoted regions on the assumption it + // was JSON. It never was, so that assumption is void — and a body with an + // odd number of `"` blanks the WRONG regions, which can hide a real marker + // and turn what should be `nested-marker` into `never-json`. The difference + // is not academic: `never-json` resumes past the close, flattening a genuine + // tag inside the span, so a card already on screen un-renders when the close + // finally arrives. With the JSON premise gone, the raw text is the honest + // evidence, and a marker in it means the close we matched belongs elsewhere. + const rawMarker = TAG_SHAPED_MARKER.exec(inspected.text) + if (rawMarker) return { kind: 'nested-marker', offsetInBody: rawMarker.index } + return { kind: 'never-json' } + } + + return { kind: 'broken-payload' } +} + +/** + * Where scanning continues, given what the body was. The third concern, kept + * apart from the other two so a change to one cannot silently alter another. + * + * Every branch is strictly greater than the opener, which is what guarantees the + * cursor advances and {@link memoizedIndexOf}'s cache stays coherent. + */ +function resumeForClass(cls: BodyClass, bodyStart: number, pastClose: number): number { + switch (cls.kind) { + case 'payload': + case 'broken-payload': + case 'never-json': + // The whole span was read and accounted for; continue after it. + return pastClose + case 'nested-marker': + // Resume AT the marker, not past the borrowed close and not at the opener. + // Past the close would skip a genuine tag after it; the opener would rescan + // a region the blanked scan could not see into, re-parsing tag syntax + // quoted inside a JSON string and dropping it. + return bodyStart + cls.offsetInBody + case 'prose-nested-marker': + // Rescan the whole body: nothing was blanked, so no marker is hidden. + return bodyStart + case 'unexamined': + // Resume just short of the first character NOT read: the last marker's + // worth of the window is held back rather than emitted as text, so it is + // re-scanned on the next pass instead of being flattened. Nothing is lost + // — the caller emits up to wherever this resumes. It still advances nearly + // a full window per step, so a long body costs a bounded number of + // re-entries. + // + // The rewind is load-bearing: the window edge is an arbitrary cut, so an + // opener can straddle it. Resuming exactly at the edge leaves that opener's + // `<` behind the cursor, and the opener scan only looks FORWARD — so the tag + // is never found and renders as raw payload text, on a completed message. + // Backing off by the longest marker guarantees any straddling opener is + // re-scanned from its `<`. + return bodyStart + MAX_UNCLOSED_BODY_SCAN - (LONGEST_TAG_MARKER - 1) + } +} + +function resolveMatchedPair( + tagName: (typeof SPECIAL_TAG_NAMES)[number], + body: string, + bodyStart: number, + pastClose: number +): TagResolution { + const cls = classifyBody(tagName, body) + const resumeAt = resumeForClass(cls, bodyStart, pastClose) + + switch (cls.kind) { + case 'payload': + return { outcome: 'segment', segment: cls.segment, resumeAt } + case 'broken-payload': + // Well-formed but the wrong shape — a broken emission. Showing the reader + // raw JSON is worse than showing nothing. + return { outcome: 'discard', resumeAt } + case 'nested-marker': + case 'prose-nested-marker': + case 'unexamined': + case 'never-json': + return { outcome: 'literal', resumeAt } + } +} + +function resolveTagAt( + content: string, + openIndex: number, + tagName: (typeof SPECIAL_TAG_NAMES)[number], + isStreaming: boolean, + closeCache: IndexOfCache +): TagResolution { + const openTag = `<${tagName}>` + const closeTag = `` + const bodyStart = openIndex + openTag.length + const closeIdx = memoizedIndexOf(closeCache, content, closeTag, bodyStart) + + if (closeIdx === -1) { + const inspected = inspectWithin(content, bodyStart) + if (isStreaming && !unclosedTagCannotResolve(tagName, inspected.text)) { + 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 } + } + + return resolveMatchedPair( + tagName, + content.slice(bodyStart, closeIdx), + bodyStart, + closeIdx + closeTag.length + ) +} + +/** + * 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) segments.push({ type: 'text', content: text }) + } + + const openerCache: IndexOfCache = new Map() + const closeCache: IndexOfCache = new Map() + let discardedTag = false + 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) { + // Only the name is tested: the two are assigned together above, so an empty + // name and a -1 start are the same state — and the name is the one that + // needs narrowing before resolveTagAt below. + if (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,44 +1078,37 @@ 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)) + } else { + // `discard` deliberately emits nothing. Remembering that it happened is + // what keeps the fallback below from undoing it. + discardedTag = true } - cursor = closeIdx + closeTag.length + cursor = resolution.resumeAt } - if (segments.length === 0 && !hasPendingTag) { + // A message with no segments is normally a message with nothing in it, and + // emitting the raw content is the right floor. But a discard produces no + // segment BY DESIGN, so without this guard a message that is only a broken + // payload falls through and renders the exact raw JSON the discard removed. + if (segments.length === 0 && !hasPendingTag && !discardedTag) { segments.push({ type: 'text', content }) }