Skip to content

Commit 30f2e68

Browse files
committed
refactor(copilot): resolve each special tag through four named outcomes
parseSpecialTags had grown five inline branches, each added for a specific malformation found in a trace. The shape made "drop it" the implicit fallback, which is how spans that were never malformed payloads ended up silently swallowed. Extracts resolveTagAt, returning one of four named outcomes — segment, literal, discard, pending — so each decision is explicit and the main loop just dispatches on it. Fixes a latent bug the old shape hid: rejecting an unclosed tag ran `break`, abandoning the rest of the message, so a genuinely valid tag after a literal mention was never parsed. Resolution now resumes just past the rejected opener and scanning continues. Test added. Two behavior notes: - Rejected spans are emitted in smaller pieces. The renderer concatenates adjacent text segments into one markdown string, so this is display-neutral; the two tests that asserted exact segment arrays now assert joined text. - Each opener is judged on its own evidence. Previously one verdict ended the whole parse, so a nested opener released everything; now the outer is released immediately and the inner is a fresh candidate that can still hold mid-stream. It resolves at end of stream either way.
1 parent b9c88ec commit 30f2e68

2 files changed

Lines changed: 118 additions & 72 deletions

File tree

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

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ describe('parseSpecialTags with <question>', () => {
176176
)
177177
})
178178

179+
it('still parses a valid tag that follows a rejected one', () => {
180+
// Before the rewrite, rejecting an unclosed tag abandoned the rest of the
181+
// message, so this <options> tag was never parsed at all.
182+
const { segments } = parseSpecialTags(
183+
'I use <thinking> loosely here. Anyway: <options>[{"title":"A","description":"d"}]</options> done.',
184+
false
185+
)
186+
expect(segments.map((segment) => segment.type)).toContain('options')
187+
})
188+
179189
it('keeps prose a tag wrapped instead of a payload', () => {
180190
// Verbatim from a real message (trace 1206fd8a): a matched pair whose body
181191
// is plain prose, never an attempted JSON payload. The sentence read
@@ -279,9 +289,22 @@ describe('parseSpecialTags with <question>', () => {
279289
expect(parseSpecialTags(streaming, true).hasPendingTag).toBe(false)
280290
})
281291

282-
it('bails on a nested opening tag', () => {
283-
const { hasPendingTag } = parseSpecialTags('a <thinking>b <thinking> c', true)
284-
expect(hasPendingTag).toBe(false)
292+
it('rejects an opener a nested one disproves, then judges the inner on its own', () => {
293+
// Each opener is evaluated independently. The first is disproved by the
294+
// nested opener and its text is released immediately; the second is a fresh
295+
// candidate that nothing has ruled out yet, so it holds mid-stream.
296+
const streaming = parseSpecialTags('a <thinking>b <thinking> c', true)
297+
expect(streaming.hasPendingTag).toBe(true)
298+
expect(
299+
streaming.segments.map((segment) => ('content' in segment ? segment.content : '')).join('')
300+
).toBe('a <thinking>b ')
301+
302+
// Once the stream ends nothing can close it, so the whole line is shown.
303+
const done = parseSpecialTags('a <thinking>b <thinking> c', false)
304+
expect(done.hasPendingTag).toBe(false)
305+
expect(
306+
done.segments.map((segment) => ('content' in segment ? segment.content : '')).join('')
307+
).toBe('a <thinking>b <thinking> c')
285308
})
286309

287310
it('keeps suppressing an unclosed thinking tag with prose — its body is not JSON', () => {
@@ -296,14 +319,13 @@ describe('parseSpecialTags with <question>', () => {
296319
'The `<workspace_resource>` file chip only renders when its path points to a real file.'
297320
const { segments, hasPendingTag } = parseSpecialTags(content, false)
298321
expect(hasPendingTag).toBe(false)
299-
expect(segments).toEqual([
300-
{ type: 'text', content: 'The `' },
301-
{
302-
type: 'text',
303-
content:
304-
'<workspace_resource>` file chip only renders when its path points to a real file.',
305-
},
306-
])
322+
// Asserted on the joined text, not segment boundaries: the renderer
323+
// concatenates adjacent text segments, so how the span is split is not
324+
// observable to a reader.
325+
expect(segments.every((segment) => segment.type === 'text')).toBe(true)
326+
expect(segments.map((segment) => ('content' in segment ? segment.content : '')).join('')).toBe(
327+
content
328+
)
307329
})
308330

309331
it('strips a trailing partial opening tag while streaming', () => {

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

Lines changed: 85 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -604,11 +604,83 @@ function unclosedTagCannotResolve(
604604
return false
605605
}
606606

607+
/**
608+
* How one opening tag resolved. Naming the four outcomes is the point: the
609+
* parser previously decided each case inline, which is how "drop it" quietly
610+
* became the fallback for situations that were never malformed payloads.
611+
*/
612+
type TagResolution =
613+
/** Body parsed; emit the typed segment and resume after the closing tag. */
614+
| { outcome: 'segment'; segment: ContentSegment; resumeAt: number }
615+
/** Provably not a tag; render the span verbatim and resume after it. */
616+
| { outcome: 'literal'; resumeAt: number }
617+
/** A well-formed payload that failed its shape guard — dropped deliberately. */
618+
| { outcome: 'discard'; resumeAt: number }
619+
/** Still streaming and a close remains plausible; suppress the remainder. */
620+
| { outcome: 'pending' }
621+
622+
/**
623+
* True when a failed body was never an attempted payload — so the markers were
624+
* literal text and the span must be shown rather than swallowed.
625+
*
626+
* Either the close we matched belongs to a different opener (the body carries
627+
* tag markers), or the tag wrapped prose that was never JSON to begin with.
628+
*/
629+
function bodyIsLiteralText(tagName: (typeof SPECIAL_TAG_NAMES)[number], body: string): boolean {
630+
if (TAG_SHAPED_MARKER.test(body)) return true
631+
return JSON_BODY_TAG_NAMES.has(tagName) && !isViableJsonPrefix(body)
632+
}
633+
634+
function resolveTagAt(
635+
content: string,
636+
openIndex: number,
637+
tagName: (typeof SPECIAL_TAG_NAMES)[number],
638+
isStreaming: boolean
639+
): TagResolution {
640+
const openTag = `<${tagName}>`
641+
const closeTag = `</${tagName}>`
642+
const bodyStart = openIndex + openTag.length
643+
const closeIdx = content.indexOf(closeTag, bodyStart)
644+
645+
if (closeIdx === -1) {
646+
if (isStreaming && !unclosedTagCannotResolve(tagName, content.slice(bodyStart))) {
647+
return { outcome: 'pending' }
648+
}
649+
// Nothing can close it, so only the opener itself is literal. Resuming just
650+
// past it (rather than abandoning the message) keeps a genuinely valid tag
651+
// later in the same reply parseable.
652+
return { outcome: 'literal', resumeAt: bodyStart }
653+
}
654+
655+
const resumeAt = closeIdx + closeTag.length
656+
const body = content.slice(bodyStart, closeIdx)
657+
658+
const parsed = parseSpecialTagData(tagName, body)
659+
if (parsed) return { outcome: 'segment', segment: parsed, resumeAt }
660+
661+
if (bodyIsLiteralText(tagName, body)) return { outcome: 'literal', resumeAt }
662+
663+
// A well-formed value that failed its shape guard is a broken emission from
664+
// the agent; showing the user raw JSON there would be worse than nothing.
665+
return { outcome: 'discard', resumeAt }
666+
}
667+
668+
/**
669+
* Splits streamed text into renderable segments, extracting complete special
670+
* tags and deciding what to do with the ones that never resolve.
671+
*
672+
* Adjacent text segments are concatenated by the renderer, so emitting a span
673+
* as several pieces is display-neutral.
674+
*/
607675
export function parseSpecialTags(content: string, isStreaming: boolean): ParsedSpecialContent {
608676
const segments: ContentSegment[] = []
609677
let hasPendingTag = false
610678
let cursor = 0
611679

680+
const pushText = (text: string) => {
681+
if (text.trim()) segments.push({ type: 'text', content: text })
682+
}
683+
612684
while (cursor < content.length) {
613685
let nearestStart = -1
614686
let nearestTagName: (typeof SPECIAL_TAG_NAMES)[number] | '' = ''
@@ -621,10 +693,11 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
621693
}
622694
}
623695

624-
if (nearestStart === -1) {
696+
if (nearestStart === -1 || nearestTagName === '') {
625697
let remaining = content.slice(cursor)
626698

627699
if (isStreaming) {
700+
// Hide a half-arrived opening marker so it does not flash as text.
628701
const partial = remaining.match(/<[a-z_-]*$/i)
629702
if (partial) {
630703
const fragment = partial[0].slice(1)
@@ -638,75 +711,26 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
638711
}
639712
}
640713

641-
if (remaining.trim()) {
642-
segments.push({ type: 'text', content: remaining })
643-
}
714+
pushText(remaining)
644715
break
645716
}
646717

647-
if (nearestStart > cursor) {
648-
const text = content.slice(cursor, nearestStart)
649-
if (text.trim()) {
650-
segments.push({ type: 'text', content: text })
651-
}
652-
}
718+
pushText(content.slice(cursor, nearestStart))
653719

654-
const openTag = `<${nearestTagName}>`
655-
const closeTag = `</${nearestTagName}>`
656-
const bodyStart = nearestStart + openTag.length
657-
const closeIdx = content.indexOf(closeTag, bodyStart)
658-
659-
if (closeIdx === -1) {
660-
// Hold the text back only while a close is still plausible. A completed
661-
// message can never finish an unclosed tag, and mid-stream the heuristics
662-
// in unclosedTagCannotResolve rule it out early — otherwise a tag merely
663-
// mentioned in prose blanks the rest of the message until the stream ends.
664-
const stillResolvable =
665-
isStreaming &&
666-
nearestTagName !== '' &&
667-
!unclosedTagCannotResolve(nearestTagName, content.slice(bodyStart))
668-
if (stillResolvable) {
669-
hasPendingTag = true
670-
cursor = content.length
671-
break
672-
}
673-
const remaining = content.slice(nearestStart)
674-
if (remaining.trim()) {
675-
segments.push({ type: 'text', content: remaining })
676-
}
720+
const resolution = resolveTagAt(content, nearestStart, nearestTagName, isStreaming)
721+
722+
if (resolution.outcome === 'pending') {
723+
hasPendingTag = true
677724
break
678725
}
679726

680-
const body = content.slice(bodyStart, closeIdx)
681-
if (!nearestTagName) {
682-
cursor = closeIdx + closeTag.length
683-
continue
684-
}
685-
const parsedTag = parseSpecialTagData(nearestTagName, body)
686-
if (parsedTag) {
687-
segments.push(parsedTag)
688-
} else if (
689-
// The close we matched was not this opener's: the model was explaining tag
690-
// syntax and a later example closed an earlier opener, making paragraphs
691-
// of prose the "body".
692-
TAG_SHAPED_MARKER.test(body) ||
693-
// Or the tag wrapped prose that was never an attempted payload at all.
694-
(JSON_BODY_TAG_NAMES.has(nearestTagName) && !isViableJsonPrefix(body))
695-
) {
696-
// Either way the markers were literal text, so dropping the span loses
697-
// real content and resumes mid-sentence. Emit it verbatim — Streamdown
698-
// escapes the markers, so it reads as literal text.
699-
//
700-
// A body that IS a well-formed JSON value and merely fails its shape
701-
// guard keeps being dropped: that is a genuinely malformed payload from
702-
// the agent, and showing the user raw JSON there would be a regression.
703-
const literal = content.slice(nearestStart, closeIdx + closeTag.length)
704-
if (literal.trim()) {
705-
segments.push({ type: 'text', content: literal })
706-
}
727+
if (resolution.outcome === 'segment') {
728+
segments.push(resolution.segment)
729+
} else if (resolution.outcome === 'literal') {
730+
pushText(content.slice(nearestStart, resolution.resumeAt))
707731
}
708732

709-
cursor = closeIdx + closeTag.length
733+
cursor = resolution.resumeAt
710734
}
711735

712736
if (segments.length === 0 && !hasPendingTag) {

0 commit comments

Comments
 (0)