Skip to content

Commit 336ff5c

Browse files
committed
fix(copilot): ignore tag syntax quoted inside a JSON tag body
The nesting rule treated any tag marker in the body as proof the opener was literal text. But a JSON body can legitimately quote tag syntax — a `<question>` asking which tag to use, a `<workspace_resource>` whose title mentions one — and that is exactly the "model explains its own tags" situation this whole fix exists for. Bailing there is the one expensive false positive in the design: the raw JSON renders and STAYS for the rest of the stream, then snaps into a card when the real close arrives. More jarring than the bug being fixed. For JSON-bodied tags the nesting scan now runs over a copy with string literals blanked (escape-aware, and tolerant of the unterminated trailing string that is normal mid-stream). Markers in real body position still count. `thinking` is unaffected — its body is prose, so there are no strings to confuse it. Tests cover both halves: the streaming case does not bail, and the same body resolves to a question card once it closes.
1 parent 3645d61 commit 336ff5c

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,29 @@ describe('parseSpecialTags with <question>', () => {
178178
expect(hasPendingTag).toBe(false)
179179
})
180180

181+
it('does not bail on tag syntax quoted inside a JSON string', () => {
182+
// The false positive this guards: a question whose text legitimately quotes
183+
// another tag. Bailing would show raw JSON that later snaps into a card.
184+
const streaming = 'ok <question>[{"type":"single_select","prompt":"Use the </options> tag?"'
185+
expect(parseSpecialTags(streaming, true).hasPendingTag).toBe(true)
186+
})
187+
188+
it('resolves that same question correctly once it closes', () => {
189+
// The other half of the guarantee: the body the streaming case refused to
190+
// bail on does render as a question card, so nothing flickered for nothing.
191+
const complete =
192+
'ok <question>[{"type":"single_select","prompt":"Use the </options> tag?","options":[{"id":"y","label":"Yes"},{"id":"n","label":"No"}]}]</question>'
193+
const { segments } = parseSpecialTags(complete, false)
194+
expect(segments.some((s) => s.type === 'question')).toBe(true)
195+
})
196+
197+
it('still bails on a marker outside the JSON strings', () => {
198+
// Escapes must not end the string early, and a marker in real body position
199+
// is still evidence.
200+
const streaming = 'ok <question>[{"prompt":"a \\" quote"} </options>'
201+
expect(parseSpecialTags(streaming, true).hasPendingTag).toBe(false)
202+
})
203+
181204
it('bails on a nested opening tag', () => {
182205
const { hasPendingTag } = parseSpecialTags('a <thinking>b <thinking> c', true)
183206
expect(hasPendingTag).toBe(false)

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,44 @@ const JSON_BODY_TAG_NAMES = new Set<(typeof SPECIAL_TAG_NAMES)[number]>([
487487
'question',
488488
])
489489

490+
/**
491+
* Strip the contents of JSON string literals from `body`, replacing them with
492+
* spaces so every other index is preserved.
493+
*
494+
* A JSON tag body can legitimately quote tag syntax — a `<question>` asking
495+
* which tag to use, or a `<workspace_resource>` whose title mentions one. Those
496+
* markers live inside a string and say nothing about whether the tag will
497+
* close, so the nesting rule must not see them. Tracks escapes so a `\"` inside
498+
* a string does not end it early. Handles an unterminated trailing string, which
499+
* is the normal state mid-stream.
500+
*/
501+
function blankJsonStringLiterals(body: string): string {
502+
let out = ''
503+
let inString = false
504+
let escaped = false
505+
506+
for (const char of body) {
507+
if (escaped) {
508+
escaped = false
509+
out += ' '
510+
continue
511+
}
512+
if (char === '\\' && inString) {
513+
escaped = true
514+
out += ' '
515+
continue
516+
}
517+
if (char === '"') {
518+
inString = !inString
519+
out += '"'
520+
continue
521+
}
522+
out += inString ? ' ' : char
523+
}
524+
525+
return out
526+
}
527+
490528
/**
491529
* True when an opening tag with no close yet can NEVER resolve, so the text
492530
* after it should be shown immediately instead of held back until the stream
@@ -509,10 +547,15 @@ function unclosedTagCannotResolve(
509547
tagName: (typeof SPECIAL_TAG_NAMES)[number],
510548
body: string
511549
): boolean {
550+
// For a JSON-bodied tag, ignore markers inside string literals: quoting tag
551+
// syntax is legitimate content, and treating it as evidence would bail on a
552+
// tag that goes on to close correctly — showing raw JSON that then snaps into
553+
// a rendered card.
554+
const scannable = JSON_BODY_TAG_NAMES.has(tagName) ? blankJsonStringLiterals(body) : body
512555
for (const name of SPECIAL_TAG_NAMES) {
513556
// A close for this tag is absent by definition here, so this catches a
514557
// FOREIGN close; the open check catches nesting, including self-nesting.
515-
if (body.includes(`</${name}>`) || body.includes(`<${name}>`)) return true
558+
if (scannable.includes(`</${name}>`) || scannable.includes(`<${name}>`)) return true
516559
}
517560

518561
if (JSON_BODY_TAG_NAMES.has(tagName)) {

0 commit comments

Comments
 (0)