Skip to content

Commit 4069af9

Browse files
committed
fix(chat): gate narration seam repair on channel transitions, render nested inline markdown recursively
1 parent 8d3b21f commit 4069af9

4 files changed

Lines changed: 123 additions & 85 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,75 @@ import { isValidElement } from 'react'
55
import { describe, expect, it } from 'vitest'
66
import { renderInlineMarkdown } from './inline-markdown'
77

8-
function textOfPart(part: React.ReactNode): string {
9-
if (typeof part === 'string') return part
10-
if (isValidElement<{ children?: React.ReactNode }>(part)) return String(part.props.children)
8+
function flattenText(node: React.ReactNode): string {
9+
if (typeof node === 'string') return node
10+
if (Array.isArray(node)) return node.map(flattenText).join('')
11+
if (isValidElement<{ children?: React.ReactNode }>(node)) return flattenText(node.props.children)
1112
return ''
1213
}
1314

15+
function findByType(parts: React.ReactNode[], type: string): React.ReactNode {
16+
return parts.find((p) => isValidElement(p) && p.type === type)
17+
}
18+
1419
describe('renderInlineMarkdown', () => {
1520
it('renders **bold** spans as strong elements', () => {
1621
const parts = renderInlineMarkdown('The failing block is **ModalDenied** (a Slack block).')
17-
const bold = parts.find((p) => isValidElement(p) && p.type === 'strong')
22+
const bold = findByType(parts, 'strong')
1823
expect(bold).toBeDefined()
19-
expect(textOfPart(bold)).toBe('ModalDenied')
20-
expect(parts.some((p) => typeof p === 'string' && p.includes('*'))).toBe(false)
24+
expect(flattenText(bold)).toBe('ModalDenied')
25+
expect(flattenText(parts)).toBe('The failing block is ModalDenied (a Slack block).')
2126
})
2227

2328
it('renders `code` spans as mono elements', () => {
2429
const parts = renderInlineMarkdown('check the `webhook` payload')
25-
const code = parts.find((p) => isValidElement(p) && p.type === 'span')
26-
expect(textOfPart(code)).toBe('webhook')
27-
})
28-
29-
it('leaves unterminated markers verbatim', () => {
30-
expect(renderInlineMarkdown('a **dangling marker')).toEqual(['a **dangling marker'])
31-
expect(renderInlineMarkdown('a `dangling tick')).toEqual(['a `dangling tick'])
32-
})
33-
34-
it('passes plain text through untouched', () => {
35-
expect(renderInlineMarkdown('no markup here.')).toEqual(['no markup here.'])
30+
expect(flattenText(findByType(parts, 'span'))).toBe('webhook')
31+
expect(flattenText(parts)).toBe('check the webhook payload')
3632
})
3733

3834
it('renders *italic* spans as em elements', () => {
3935
const parts = renderInlineMarkdown('this is *important* context')
40-
const em = parts.find((p) => isValidElement(p) && p.type === 'em')
41-
expect(textOfPart(em)).toBe('important')
42-
})
43-
44-
it('does not italicize bare asterisks in math-like text', () => {
45-
expect(renderInlineMarkdown('2 * 3 * 4')).toEqual(['2 * 3 * 4'])
36+
expect(flattenText(findByType(parts, 'em'))).toBe('important')
4637
})
4738

4839
it('renders ***bold-italic*** as nested strong and em', () => {
4940
const parts = renderInlineMarkdown('a ***wrapped*** word')
50-
const bold = parts.find((p) => isValidElement(p) && p.type === 'strong')
41+
const bold = findByType(parts, 'strong')
5142
expect(bold).toBeDefined()
52-
expect(parts.some((p) => typeof p === 'string' && p.includes('*'))).toBe(false)
43+
expect(flattenText(parts)).toBe('a wrapped word')
5344
})
5445

5546
it('renders links as their label text', () => {
5647
const parts = renderInlineMarkdown('see [the docs](https://sim.ai/docs) for more')
57-
expect(parts.join('')).toBe('see the docs for more')
48+
expect(flattenText(parts)).toBe('see the docs for more')
49+
})
50+
51+
it('keeps emphasis markers inside code spans verbatim', () => {
52+
const parts = renderInlineMarkdown('pass `*args` and `**kwargs` through')
53+
const codeTexts = parts
54+
.filter((p) => isValidElement(p) && p.type === 'span')
55+
.map((p) => flattenText(p))
56+
expect(codeTexts).toEqual(['*args', '**kwargs'])
57+
expect(flattenText(parts)).toBe('pass *args and **kwargs through')
58+
})
59+
60+
it('renders nested markers inside emphasis and link labels', () => {
61+
expect(
62+
flattenText(renderInlineMarkdown('read [**the docs**](https://sim.ai/docs) first'))
63+
).toBe('read the docs first')
64+
expect(flattenText(renderInlineMarkdown('use *`glob`* patterns'))).toBe('use glob patterns')
65+
})
66+
67+
it('leaves unterminated markers verbatim', () => {
68+
expect(renderInlineMarkdown('a **dangling marker')).toEqual(['a **dangling marker'])
69+
expect(renderInlineMarkdown('a `dangling tick')).toEqual(['a `dangling tick'])
70+
})
71+
72+
it('does not italicize bare asterisks in math-like text', () => {
73+
expect(renderInlineMarkdown('2 * 3 * 4')).toEqual(['2 * 3 * 4'])
74+
})
75+
76+
it('passes plain text through untouched', () => {
77+
expect(renderInlineMarkdown('no markup here.')).toEqual(['no markup here.'])
5878
})
5979
})
Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ReactNode } from 'react'
1+
import { Fragment, type ReactNode } from 'react'
22

33
const INLINE_TOKEN =
44
/(\*{3}[^*\n]+\*{3}|\*\*[^*\n]+\*\*|\*[^\s*](?:[^*\n]*[^\s*])?\*|`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\))/g
@@ -7,43 +7,45 @@ const LINK_TOKEN = /^\[([^\]\n]+)\]\([^\s)]+\)$/
77

88
/**
99
* Minimal inline-markdown renderer for agent-group narration rows. Supports
10-
* `**bold**`, `*italic*`, `***bold-italic***`, `` `code` `` spans, and `[label](url)` links
11-
* (rendered as their label — narration is prose, not navigation). Everything
12-
* else, including unterminated markers, renders verbatim. Full Streamdown
13-
* rendering is intentionally avoided here — these rows re-render on every
14-
* streaming frame.
10+
* `**bold**`, `*italic*`, `***bold-italic***`, `` `code` `` spans, and
11+
* `[label](url)` links (rendered as their label — narration is prose, not
12+
* navigation). Emphasis contents and link labels are rendered recursively so
13+
* nested markers resolve; code spans stay verbatim. Everything else,
14+
* including unterminated markers, renders as-is. Full Streamdown rendering is
15+
* intentionally avoided here — these rows re-render on every streaming frame.
1516
*/
1617
export function renderInlineMarkdown(text: string): ReactNode[] {
17-
const parts = text.split(INLINE_TOKEN)
18-
return parts.map((part, i) => {
19-
if (part.length > 6 && part.startsWith('***') && part.endsWith('***')) {
20-
return (
21-
<strong key={i} className='font-semibold'>
22-
<em>{part.slice(3, -3)}</em>
23-
</strong>
24-
)
25-
}
26-
if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) {
27-
return (
28-
<strong key={i} className='font-semibold'>
29-
{part.slice(2, -2)}
30-
</strong>
31-
)
32-
}
33-
if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
34-
return (
35-
<span key={i} className='font-mono text-[12px]'>
36-
{part.slice(1, -1)}
37-
</span>
38-
)
39-
}
40-
if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) {
41-
return <em key={i}>{part.slice(1, -1)}</em>
42-
}
43-
const link = LINK_TOKEN.exec(part)
44-
if (link) {
45-
return link[1]
46-
}
47-
return part
48-
})
18+
return text.split(INLINE_TOKEN).map(renderToken)
19+
}
20+
21+
function renderToken(part: string, key: number): ReactNode {
22+
if (part.length > 6 && part.startsWith('***') && part.endsWith('***')) {
23+
return (
24+
<strong key={key} className='font-semibold'>
25+
<em>{renderInlineMarkdown(part.slice(3, -3))}</em>
26+
</strong>
27+
)
28+
}
29+
if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) {
30+
return (
31+
<strong key={key} className='font-semibold'>
32+
{renderInlineMarkdown(part.slice(2, -2))}
33+
</strong>
34+
)
35+
}
36+
if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
37+
return (
38+
<span key={key} className='font-mono text-[12px]'>
39+
{part.slice(1, -1)}
40+
</span>
41+
)
42+
}
43+
if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) {
44+
return <em key={key}>{renderInlineMarkdown(part.slice(1, -1))}</em>
45+
}
46+
const link = LINK_TOKEN.exec(part)
47+
if (link) {
48+
return <Fragment key={key}>{renderInlineMarkdown(link[1])}</Fragment>
49+
}
50+
return part
4951
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ describe('narration text seams', () => {
356356
expect(seam('日本語のテキストが分割', 'されても壊れない')).toBe(
357357
'日本語のテキストが分割されても壊れない'
358358
)
359+
expect(seam('released in v2.', '1 last week')).toBe('released in v2.1 last week')
360+
expect(seam('pi is 3.', '14 roughly')).toBe('pi is 3.14 roughly')
359361
})
360362

361363
it('does not double-space when the seam already has whitespace', () => {

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

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -137,22 +137,34 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment {
137137
}
138138
}
139139

140+
type NarrationChannel = 'thinking' | 'assistant'
141+
140142
/**
141143
* Appends narration content to a group, merging into the previous text item.
142-
* Distinct segments (e.g. a thinking run followed by a text run) can meet
143-
* without any whitespace at the seam, gluing sentences together. The merge
144-
* repairs only unambiguous sentence boundaries — trailing punctuation meeting
145-
* a fresh alphanumeric start — so a segment split mid-word, mid-URL, or in
146-
* text that takes no spaces (CJK) is never corrupted.
144+
* When a thinking run and a text run meet, their contents can glue together
145+
* without any whitespace at the seam. The merge repairs only that semantic
146+
* channel transition, and only at an unambiguous sentence boundary — trailing
147+
* punctuation meeting a fresh alphanumeric start. Same-channel continuations
148+
* (streamed chunks of one run, resume legs) are concatenated verbatim, so a
149+
* token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the
150+
* caller's per-parse tracker of each group's most recent narration channel.
147151
*/
148-
function appendTextItem(group: AgentGroupSegment, content: string): void {
152+
function appendTextItem(
153+
group: AgentGroupSegment,
154+
content: string,
155+
channel: NarrationChannel,
156+
lastChannelByGroup: Map<AgentGroupSegment, NarrationChannel>
157+
): void {
149158
const lastItem = group.items[group.items.length - 1]
150159
if (lastItem?.type === 'text') {
151-
const needsSpace = /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content)
160+
const isChannelSeam = lastChannelByGroup.get(group) !== channel
161+
const needsSpace =
162+
isChannelSeam && /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content)
152163
lastItem.content += (needsSpace ? ' ' : '') + content
153164
} else {
154165
group.items.push({ type: 'text', content })
155166
}
167+
lastChannelByGroup.set(group, channel)
156168
}
157169

158170
/**
@@ -166,6 +178,7 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
166178
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
167179
const segments: MessageSegment[] = []
168180
const groupsBySpanId = new Map<string, AgentGroupSegment>()
181+
const lastNarrationChannel = new Map<AgentGroupSegment, NarrationChannel>()
169182
// Stable per-run counters for React keys. The Nth top-level text run / Nth
170183
// mothership group keeps the same key across re-parses (text runs and groups
171184
// are append-only at the top level), so React never remounts the streaming
@@ -272,7 +285,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
272285
}
273286
if (!g) continue
274287
g.isDelegating = false
275-
appendTextItem(g, block.content)
288+
appendTextItem(
289+
g,
290+
block.content,
291+
block.type === 'subagent_thinking' ? 'thinking' : 'assistant',
292+
lastNarrationChannel
293+
)
276294
continue
277295
}
278296

@@ -288,7 +306,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
288306
if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId)
289307
if (g) {
290308
g.isDelegating = false
291-
appendTextItem(g, block.content)
309+
appendTextItem(g, block.content, 'assistant', lastNarrationChannel)
292310
continue
293311
}
294312
}
@@ -417,6 +435,7 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
417435
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
418436
const segments: MessageSegment[] = []
419437
const groupsByKey = new Map<string, AgentGroupSegment>()
438+
const lastNarrationChannel = new Map<AgentGroupSegment, NarrationChannel>()
420439
let activeGroupKey: string | null = null
421440

422441
const groupKey = (name: string, parentToolCallId: string | undefined) =>
@@ -488,12 +507,12 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
488507
const g = findGroupForSubagentChunk(block.parentToolCallId)
489508
if (!g) continue
490509
g.isDelegating = false
491-
const lastItem = g.items[g.items.length - 1]
492-
if (lastItem?.type === 'text') {
493-
lastItem.content += block.content
494-
} else {
495-
g.items.push({ type: 'text', content: block.content })
496-
}
510+
appendTextItem(
511+
g,
512+
block.content,
513+
block.type === 'subagent_thinking' ? 'thinking' : 'assistant',
514+
lastNarrationChannel
515+
)
497516
continue
498517
}
499518

@@ -511,12 +530,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
511530
const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId))
512531
if (g) {
513532
g.isDelegating = false
514-
const lastItem = g.items[g.items.length - 1]
515-
if (lastItem?.type === 'text') {
516-
lastItem.content += block.content
517-
} else {
518-
g.items.push({ type: 'text', content: block.content })
519-
}
533+
appendTextItem(g, block.content, 'assistant', lastNarrationChannel)
520534
continue
521535
}
522536
}

0 commit comments

Comments
 (0)