Skip to content

Commit 2d5b329

Browse files
committed
fix(chat): preserve literal code around source chips
1 parent cc599a4 commit 2d5b329

3 files changed

Lines changed: 158 additions & 51 deletions

File tree

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,48 @@ import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/c
66
import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers'
77

88
describe('sanitizeChatDisplayContent', () => {
9+
it.each(['source', 'workspace_resource'])(
10+
'unwraps %s JSON that mentions the other chip tag',
11+
(name) => {
12+
const otherTag = name === 'source' ? 'workspace_resource' : 'source'
13+
const tag = `<${name}>${JSON.stringify({ title: `Use <${otherTag}>` })}</${name}>`
14+
15+
expect(sanitizeChatDisplayContent(`\`${tag}\``)).toBe(tag)
16+
}
17+
)
18+
19+
it.each([2, 3, 4])('preserves a %i-backtick code span containing a chip', (length) => {
20+
const delimiter = '`'.repeat(length)
21+
const tag = '<source>{"url":"https://example.com","title":"Use `config`"}</source>'
22+
const content = `${delimiter}${tag}${delimiter}`
23+
24+
expect(sanitizeChatDisplayContent(content)).toBe(content)
25+
expect(sanitizeChatDisplayContent(`${delimiter}json\n\`${tag}\`\n${delimiter}`)).toBe(
26+
`${delimiter}json\n\`${tag}\`\n${delimiter}`
27+
)
28+
expect(sanitizeChatDisplayContent(`${content} then \`${tag}\``)).toBe(`${content} then ${tag}`)
29+
})
30+
31+
it('does not let an unmatched backtick run suppress later citations', () => {
32+
const prefix = 'Use `` for two backticks.\n'
33+
const tag = '<source>{"url":"https://example.com"}</source>'
34+
35+
expect(sanitizeChatDisplayContent(`${prefix}\`${tag}\``)).toBe(`${prefix}${tag}`)
36+
})
37+
38+
it('preserves fences closed by a longer run and unwraps citations after them', () => {
39+
const tag = '<source>{"url":"https://example.com"}</source>'
40+
const block = `\`\`\`json\n\`${tag}\`\n\`\`\`\`\n`
41+
42+
expect(sanitizeChatDisplayContent(`${block}\`${tag}\``)).toBe(`${block}${tag}`)
43+
})
44+
45+
it('leaves an unclosed streaming fence literal', () => {
46+
const content = '```json\n`<source>{"url":"https://example.com"}</source>`'
47+
48+
expect(sanitizeChatDisplayContent(content)).toBe(content)
49+
})
50+
951
it('unwraps workspace resource tags from inline code spans', () => {
1052
const content =
1153
'`I updated <workspace_resource>{"type":"workflow","id":"wf-1","title":"Workflow"}</workspace_resource>.`'
@@ -159,4 +201,24 @@ describe('sanitizeChatDisplayContent', () => {
159201
'<workspace_resource>{"type":"file","path":"a.md","title":"a"}</workspace_resource> done'
160202
)
161203
})
204+
205+
it.each(['source', 'workspace_resource'])(
206+
'stays linear on repeated %s tags with unterminated JSON strings',
207+
(name) => {
208+
expect(
209+
scalingRatioOver4x(sanitizeChatDisplayContent, (times) =>
210+
`<${name}>{${String.fromCharCode(92, 34)}`.repeat(times)
211+
)
212+
).toBeLessThan(8)
213+
}
214+
)
215+
216+
it.each(['<source>"', '<source>{"key":"'])(
217+
'stays linear on repeated quoted payload prefix %s',
218+
(prefix) => {
219+
expect(
220+
scalingRatioOver4x(sanitizeChatDisplayContent, (times) => prefix.repeat(times))
221+
).toBeLessThan(8)
222+
}
223+
)
162224
})
Lines changed: 89 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,108 @@
11
const HIDDEN_INLINE_REFERENCE_PATTERN =
22
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g
33

4-
/** JSON strings own their escaped quotes, backticks, and any quoted tag markers. */
5-
const JSON_STRING_SOURCE = String.raw`"(?:[^"\\\r\n]|\\[^\r\n])*"`
4+
/** JSON strings own their escaped quotes, backticks, and quoted tag markers. */
5+
const JSON_STRING_SOURCE = '"(?:\\\\(?:["\\\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\\\\\r\\n])*"'
66

7-
/**
8-
* Complete chip tags consume JSON strings atomically. Outside strings, a new
9-
* opener or backtick ends the candidate, so prose mentions cannot join into a
10-
* tag and repeated unclosed openers cannot repeatedly scan the same suffix.
11-
*/
12-
const COMPLETE_TAG_SOURCE = `<(?<chipTag>workspace_resource|source)>\\s*\\{(?:${JSON_STRING_SOURCE}|[^"\`<])*?\\}\\s*</\\k<chipTag>>`
7+
/** Unquoted openers, backticks, and invalid backslashes bound failed payload scans. */
8+
const COMPLETE_TAG_SOURCE = `<(?<chipTag>workspace_resource|source)>\\s*\\{(?:${JSON_STRING_SOURCE}|[^"\`<\\\\])*?\\}\\s*</\\k<chipTag>>`
139

14-
const CHIP_OR_CODE_DELIMITER = new RegExp(`${COMPLETE_TAG_SOURCE}|\`|\n`, 'g')
10+
const INLINE_CHIP_OR_DELIMITER = new RegExp(`${COMPLETE_TAG_SOURCE}|\`+|\\n`, 'g')
1511

16-
/**
17-
* Pair Markdown delimiters outside chip payloads in one forward pass. A pair
18-
* containing a chip is unwrapped; a lone delimiter is removed only when flush
19-
* against a chip. Neighbouring code spans and multiline fences keep their pairs.
20-
*/
21-
export function sanitizeChatDisplayContent(content: string): string {
22-
const removedDelimiters: number[] = []
23-
let openingTick = -1
24-
let containsChip = false
25-
let adjacentToChip = false
26-
let lastChipEnd = -1
12+
interface OpenCodeSpan {
13+
index: number
14+
containsChip: boolean
15+
touchesChip: boolean
16+
}
2717

28-
for (const match of content.matchAll(CHIP_OR_CODE_DELIMITER)) {
29-
const index = match.index
30-
if (match.groups?.chipTag) {
31-
if (openingTick !== -1) {
32-
containsChip = true
33-
adjacentToChip ||= index === openingTick + 1
34-
}
35-
lastChipEnd = index + match[0].length
36-
continue
18+
/** Only matched multi-backtick runs are code; an unmatched run remains ordinary prose. */
19+
function unwrapInlineChips(content: string): string {
20+
const remainingRuns = new Map<number, number>()
21+
for (const [value] of content.matchAll(INLINE_CHIP_OR_DELIMITER)) {
22+
if (value.startsWith('`') && value.length > 1) {
23+
remainingRuns.set(value.length, (remainingRuns.get(value.length) ?? 0) + 1)
3724
}
25+
}
26+
const removedDelimiters: number[] = []
27+
let openSpan: OpenCodeSpan | null = null
28+
let previousChipEnd = -1
29+
let protectedRunLength: number | null = null
3830

39-
if (match[0] === '\n') {
40-
if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick)
41-
openingTick = -1
42-
lastChipEnd = -1
43-
continue
44-
}
31+
const finishLine = () => {
32+
if (openSpan?.touchesChip) removedDelimiters.push(openSpan.index)
33+
openSpan = null
34+
}
4535

46-
if (openingTick === -1) {
47-
openingTick = index
48-
containsChip = false
49-
adjacentToChip = lastChipEnd === index
36+
for (const token of content.matchAll(INLINE_CHIP_OR_DELIMITER)) {
37+
const [value] = token
38+
const index = token.index
39+
if (value === '\n') {
40+
finishLine()
41+
previousChipEnd = -1
42+
} else if (value.startsWith('`')) {
43+
if (value.length > 1) {
44+
remainingRuns.set(value.length, (remainingRuns.get(value.length) ?? 1) - 1)
45+
}
46+
if (protectedRunLength !== null) {
47+
if (value.length === protectedRunLength) protectedRunLength = null
48+
continue
49+
}
50+
if (value.length > 1) {
51+
if (!openSpan && remainingRuns.get(value.length)) protectedRunLength = value.length
52+
continue
53+
}
54+
if (openSpan) {
55+
if (openSpan.containsChip) removedDelimiters.push(openSpan.index, index)
56+
openSpan = null
57+
} else {
58+
openSpan = { index, containsChip: false, touchesChip: previousChipEnd === index }
59+
}
5060
} else {
51-
if (containsChip) removedDelimiters.push(openingTick, index)
52-
openingTick = -1
61+
if (openSpan) {
62+
openSpan.containsChip = true
63+
openSpan.touchesChip ||= index === openSpan.index + 1
64+
}
65+
previousChipEnd = index + value.length
5366
}
5467
}
55-
56-
if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick)
68+
finishLine()
5769

5870
const parts: string[] = []
59-
let start = 0
71+
let cursor = 0
6072
for (const index of removedDelimiters) {
61-
parts.push(content.slice(start, index))
62-
start = index + 1
73+
parts.push(content.slice(cursor, index))
74+
cursor = index + 1
75+
}
76+
parts.push(content.slice(cursor))
77+
return parts.join('')
78+
}
79+
80+
/** Fenced blocks are literal, including unclosed streaming fences and longer closing runs. */
81+
export function sanitizeChatDisplayContent(content: string): string {
82+
const parts: string[] = []
83+
let cursor = 0
84+
let fenceStart: number | null = null
85+
let fenceLength = 0
86+
87+
for (const line of content.matchAll(/^ {0,3}(`{3,})([^`\n]*)(?:\n|$)/gm)) {
88+
if (fenceStart === null) {
89+
fenceStart = line.index
90+
fenceLength = line[1].length
91+
} else if (line[1].length >= fenceLength && line[2].trim() === '') {
92+
const end = line.index + line[0].length
93+
parts.push(
94+
unwrapInlineChips(content.slice(cursor, fenceStart)),
95+
content.slice(fenceStart, end)
96+
)
97+
cursor = end
98+
fenceStart = null
99+
}
100+
}
101+
102+
if (fenceStart === null) {
103+
parts.push(unwrapInlineChips(content.slice(cursor)))
104+
} else {
105+
parts.push(unwrapInlineChips(content.slice(cursor, fenceStart)), content.slice(fenceStart))
63106
}
64-
parts.push(content.slice(start))
65107
return parts.join('').replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
66108
}

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ function fastest(run: (content: string) => void, content: string): number {
2727
* through at the single size it happens to sample. Quadratic costs ~16x for 4x
2828
* the input; linear costs ~4x.
2929
*/
30-
export function scalingRatioOver4x(run: (content: string) => void): number {
30+
export function scalingRatioOver4x(
31+
run: (content: string) => void,
32+
buildContent: (times: number) => string = buildRepeatedTagMentions
33+
): number {
3134
// Warm up first — the JIT would otherwise charge the whole compile to the
3235
// small sample and flatter the ratio.
33-
fastest(run, buildRepeatedTagMentions(2_000))
36+
fastest(run, buildContent(2_000))
3437

35-
const small = fastest(run, buildRepeatedTagMentions(2_000))
36-
const large = fastest(run, buildRepeatedTagMentions(8_000))
38+
const small = fastest(run, buildContent(2_000))
39+
const large = fastest(run, buildContent(8_000))
3740

3841
return large / small
3942
}

0 commit comments

Comments
 (0)