Skip to content

Commit 6f4896b

Browse files
committed
improvement(copilot): show text as soon as an unclosed tag cannot resolve
Restoring the text only at end of stream left a real gap: once the model mentions a tag in prose, everything after it stays invisible for the rest of the stream and reappears in one jump when streaming stops. On a long reply that is most of the message. Two properties let us decide much earlier, both conservative — they only fire on content that could not have parsed: - Tags never nest, so any special-tag marker inside the body (opening OR closing, not just a mismatched close) proves the opener was literal text. - JSON-bodied tags must start with `{` or `[`, so the first non-space character settles it — prose after the marker is caught on the very next chunk rather than at the end. The second is per-tag, not global: `thinking` bodies are prose by design (parseTextTagBody), so the JSON rule cannot apply there and only the nesting rule can rescue it. A test documents that remaining gap rather than leaving it implicit. A false positive is cheap by construction: text shows early and the end-of-stream parse still produces the correct final render.
1 parent c423779 commit 6f4896b

2 files changed

Lines changed: 100 additions & 4 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,47 @@ describe('parseSpecialTags with <question>', () => {
149149
expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }])
150150
})
151151

152+
it('shows prose immediately mid-stream instead of blanking the rest', () => {
153+
// The failure this replaces: everything after the marker stayed invisible
154+
// for the remainder of the stream, then reappeared when it ended.
155+
const content = 'The `<workspace_resource>` chip only renders for a real file.'
156+
const { segments, hasPendingTag } = parseSpecialTags(content, true)
157+
expect(hasPendingTag).toBe(false)
158+
expect(segments.map((s) => ('content' in s ? s.content : s.type)).join('')).toContain(
159+
'chip only renders for a real file.'
160+
)
161+
})
162+
163+
it('still suppresses a JSON-bodied tag that is genuinely mid-stream', () => {
164+
const { segments, hasPendingTag } = parseSpecialTags(
165+
'Here you go <workspace_resource>{"type":"file","id":"abc"',
166+
true
167+
)
168+
expect(hasPendingTag).toBe(true)
169+
expect(segments).toEqual([{ type: 'text', content: 'Here you go ' }])
170+
})
171+
172+
it('bails when a foreign closing tag appears inside the body', () => {
173+
// Tags never nest, so a close for a different tag proves the opener was text.
174+
const { hasPendingTag } = parseSpecialTags(
175+
'see <options>[{"title":"a","description":"b"}] </question> more',
176+
true
177+
)
178+
expect(hasPendingTag).toBe(false)
179+
})
180+
181+
it('bails on a nested opening tag', () => {
182+
const { hasPendingTag } = parseSpecialTags('a <thinking>b <thinking> c', true)
183+
expect(hasPendingTag).toBe(false)
184+
})
185+
186+
it('keeps suppressing an unclosed thinking tag with prose — its body is not JSON', () => {
187+
// Documents the deliberate gap: `thinking` bodies are prose, so the JSON
188+
// heuristic cannot apply and only the nesting rule can rescue it.
189+
const { hasPendingTag } = parseSpecialTags('a <thinking>still reasoning about', true)
190+
expect(hasPendingTag).toBe(true)
191+
})
192+
152193
it('renders an unclosed tag as text once the message is complete', () => {
153194
const content =
154195
'The `<workspace_resource>` file chip only renders when its path points to a real file.'

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

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,56 @@ function parseSpecialTagData(
473473
* Trailing partial opening tags (e.g. `<opt`, `<usage_`) are also stripped
474474
* during streaming to prevent flashing raw markup.
475475
*/
476+
/**
477+
* Tags whose body must be JSON. `thinking` is the exception — its body is prose
478+
* (see {@link parseTextTagBody}), so a non-JSON body there says nothing about
479+
* whether a close is still coming.
480+
*/
481+
const JSON_BODY_TAG_NAMES = new Set<(typeof SPECIAL_TAG_NAMES)[number]>([
482+
'options',
483+
'usage_upgrade',
484+
'credential',
485+
'mothership-error',
486+
'workspace_resource',
487+
'question',
488+
])
489+
490+
/**
491+
* True when an opening tag with no close yet can NEVER resolve, so the text
492+
* after it should be shown immediately instead of held back until the stream
493+
* ends.
494+
*
495+
* Without this, a message that merely mentions a tag in prose goes blank from
496+
* that point on for the rest of the stream — the text is only restored once
497+
* streaming stops. Two facts let us decide early:
498+
*
499+
* 1. Tags never nest. Any other special-tag marker inside the body — opening or
500+
* closing — means this opener was literal text, not a real tag.
501+
* 2. JSON-bodied tags must start with `{` or `[`. The first non-space character
502+
* settles it, so prose after the marker is caught on the very next chunk.
503+
*
504+
* Both are conservative: they only fire on content that could not have parsed.
505+
* A false positive would merely show text early that a later chunk resolves
506+
* into a tag — the end-of-stream parse still produces the correct final render.
507+
*/
508+
function unclosedTagCannotResolve(
509+
tagName: (typeof SPECIAL_TAG_NAMES)[number],
510+
body: string
511+
): boolean {
512+
for (const name of SPECIAL_TAG_NAMES) {
513+
// A close for this tag is absent by definition here, so this catches a
514+
// FOREIGN close; the open check catches nesting, including self-nesting.
515+
if (body.includes(`</${name}>`) || body.includes(`<${name}>`)) return true
516+
}
517+
518+
if (JSON_BODY_TAG_NAMES.has(tagName)) {
519+
const firstChar = body.trimStart().charAt(0)
520+
if (firstChar !== '' && firstChar !== '{' && firstChar !== '[') return true
521+
}
522+
523+
return false
524+
}
525+
476526
export function parseSpecialTags(content: string, isStreaming: boolean): ParsedSpecialContent {
477527
const segments: ContentSegment[] = []
478528
let hasPendingTag = false
@@ -526,14 +576,19 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS
526576
const closeIdx = content.indexOf(closeTag, bodyStart)
527577

528578
if (closeIdx === -1) {
529-
if (isStreaming) {
579+
// Hold the text back only while a close is still plausible. A completed
580+
// message can never finish an unclosed tag, and mid-stream the heuristics
581+
// in unclosedTagCannotResolve rule it out early — otherwise a tag merely
582+
// mentioned in prose blanks the rest of the message until the stream ends.
583+
const stillResolvable =
584+
isStreaming &&
585+
nearestTagName !== '' &&
586+
!unclosedTagCannotResolve(nearestTagName, content.slice(bodyStart))
587+
if (stillResolvable) {
530588
hasPendingTag = true
531589
cursor = content.length
532590
break
533591
}
534-
// A completed message can never finish an unclosed tag — the marker was
535-
// literal text mentioning the tag (e.g. "the `<workspace_resource>`
536-
// chip"), not a real tag. Render the remainder instead of swallowing it.
537592
const remaining = content.slice(nearestStart)
538593
if (remaining.trim()) {
539594
segments.push({ type: 'text', content: remaining })

0 commit comments

Comments
 (0)