Skip to content

Commit c327392

Browse files
committed
fix(chat): preserve backticks inside citation JSON
1 parent 67e3f6d commit c327392

3 files changed

Lines changed: 103 additions & 60 deletions

File tree

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,39 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
56
import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers'
6-
import { sanitizeChatDisplayContent } from './chat-sanitize'
77

88
describe('sanitizeChatDisplayContent', () => {
9+
it.each(['source', 'workspace_resource'])('preserves backticks inside %s JSON', (name) => {
10+
const payload = JSON.stringify({
11+
url: 'https://example.com',
12+
type: 'file',
13+
path: 'notes`draft`.md',
14+
title: 'Use `config` and "quoted" text',
15+
})
16+
const tag = `<${name}>${payload}</${name}>`
17+
18+
expect(sanitizeChatDisplayContent(`\`${tag}\``)).toBe(tag)
19+
expect(sanitizeChatDisplayContent(`\`See ${tag}.\``)).toBe(`See ${tag}.`)
20+
expect(sanitizeChatDisplayContent(`\`${tag} done`)).toBe(`${tag} done`)
21+
expect(sanitizeChatDisplayContent(`${tag}\` done`)).toBe(`${tag} done`)
22+
expect(sanitizeChatDisplayContent(`\`a\`${tag}\`b\``)).toBe(`\`a\`${tag}\`b\``)
23+
expect(sanitizeChatDisplayContent(`\`\`\`json\n${tag}\n\`\`\``)).toBe(
24+
`\`\`\`json\n${tag}\n\`\`\``
25+
)
26+
})
27+
28+
it.each(['source', 'workspace_resource'])(
29+
'unwraps %s JSON that mentions the other chip tag',
30+
(name) => {
31+
const otherTag = name === 'source' ? 'workspace_resource' : 'source'
32+
const tag = `<${name}>${JSON.stringify({ title: `Use <${otherTag}>` })}</${name}>`
33+
34+
expect(sanitizeChatDisplayContent(`\`${tag}\``)).toBe(tag)
35+
}
36+
)
37+
938
it('unwraps workspace resource tags from inline code spans', () => {
1039
const content =
1140
'`I updated <workspace_resource>{"type":"workflow","id":"wf-1","title":"Workflow"}</workspace_resource>.`'
@@ -120,4 +149,15 @@ describe('sanitizeChatDisplayContent', () => {
120149
'<workspace_resource>{"type":"file","path":"a.md","title":"a"}</workspace_resource> done'
121150
)
122151
})
152+
153+
it.each(['source', 'workspace_resource'])(
154+
'stays linear on repeated %s tags with unterminated JSON strings',
155+
(name) => {
156+
expect(
157+
scalingRatioOver4x(sanitizeChatDisplayContent, (times) =>
158+
`<${name}>${String.fromCharCode(92, 34)}`.repeat(times)
159+
)
160+
).toBeLessThan(8)
161+
}
162+
)
123163
})
Lines changed: 55 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,65 @@
11
const HIDDEN_INLINE_REFERENCE_PATTERN =
22
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g
33

4-
/**
5-
* A complete inline-chip tag — `<workspace_resource>` or `<source>` — as
6-
* opener, payload, closer. Both are JSON-bodied tags the model places inside a
7-
* sentence, so both attract the same stray backticks.
8-
*
9-
* Two constraints on the payload, both load-bearing:
10-
*
11-
* - **No backtick.** A payload is JSON and carries none, so this is what tells a
12-
* real tag from prose MENTIONING the tag name — a message explaining the
13-
* syntax writes the opener and the closer as two separately backticked spans.
14-
* - **No nested opener**, via the negative lookahead. A cost bound rather than a
15-
* correctness rule: a lazy scan allowed to cross an opener restarts from every
16-
* opener, so a message repeating the tag name is quadratic — on the main
17-
* thread, for every streamed chunk.
18-
*
19-
* Accepted trade: a resource whose title or path itself contains a backtick is
20-
* not matched, so it renders as text rather than a chip. That costs one chip and
21-
* is rare; the failure it replaces corrupts a whole message and is common.
22-
*/
23-
const COMPLETE_TAG_SOURCE =
24-
'<(?<chipTag>workspace_resource|source)>(?:(?!<\\k<chipTag>>)[^`])*?<\\/\\k<chipTag>>'
25-
26-
/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */
27-
const COMPLETE_INLINE_CHIP_TAG = new RegExp(COMPLETE_TAG_SOURCE)
4+
/** A matching nested opener bounds every payload scan, including quoted strings. */
5+
function chipTagPattern(tag: 'workspace_resource' | 'source'): string {
6+
const noNestedTag = `(?!<${tag}>)`
7+
/** JSON strings own their backticks; valid escapes cannot consume a nested tag's opener. */
8+
const jsonString = `"(?:${noNestedTag}(?:\\\\(?:["\\\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\\\]))*"`
9+
return `<${tag}>(?:${noNestedTag}(?:${jsonString}|[^"\`]))*?</${tag}>`
10+
}
2811

29-
/**
30-
* One left-to-right pass over the two things that can own a backtick: an inline
31-
* code span, and a tag with a stray backtick pressed against it.
32-
*
33-
* ONE pass is the design. Two separate passes each have to guess which backticks
34-
* belong together, and every previous arrangement of this file got a different
35-
* case wrong — a span two words away, a code fence, then a span sitting flush
36-
* against the tag. Here a span consumes its own delimiters as the scan reaches
37-
* them, so `` `config.json`<tag> `` keeps its pair without a special case.
38-
*
39-
* The trailing backtick is only taken when no further backtick follows on the
40-
* line; otherwise it is not a stray at all but the opener of the next span, and
41-
* `` <tag>`config.json` `` would lose that span's delimiter. A LEADING backtick
42-
* needs no such guard, because a backtick that closes a span is consumed as part
43-
* of that span. Of the two, only the trailing lookahead is pinned by a test —
44-
* swapping the alternatives changes behaviour only for a span that both opens
45-
* flush against a tag and closes elsewhere, which no fixture covers.
46-
*/
47-
const CODE_SPAN_OR_FLANKED_TAG = new RegExp(
48-
`\`[^\`\\n]*\`|\`?(${COMPLETE_TAG_SOURCE})(?:\`(?![^\`\\n]*\`))?`,
12+
const INLINE_CHIP_OR_DELIMITER = new RegExp(
13+
`${chipTagPattern('workspace_resource')}|${chipTagPattern('source')}|\`|\\n`,
4914
'g'
5015
)
5116

17+
interface OpenCodeSpan {
18+
index: number
19+
containsChip: boolean
20+
touchesChip: boolean
21+
}
22+
23+
/** Unwraps chip-bearing spans without borrowing delimiters from neighboring code or fences. */
5224
export function sanitizeChatDisplayContent(content: string): string {
53-
return content
54-
.replace(CODE_SPAN_OR_FLANKED_TAG, (match, tag?: string) => {
55-
// A tag with stray backticks against it: keep the tag, drop the strays.
56-
if (tag !== undefined) return tag
25+
const removedDelimiters: number[] = []
26+
let openSpan: OpenCodeSpan | null = null
27+
let previousChipEnd = -1
28+
29+
const finishLine = () => {
30+
if (openSpan?.touchesChip) removedDelimiters.push(openSpan.index)
31+
openSpan = null
32+
}
33+
34+
for (const token of content.matchAll(INLINE_CHIP_OR_DELIMITER)) {
35+
const [value] = token
36+
const index = token.index
37+
if (value === '\n') {
38+
finishLine()
39+
previousChipEnd = -1
40+
} else if (value === '`') {
41+
if (openSpan) {
42+
if (openSpan.containsChip) removedDelimiters.push(openSpan.index, index)
43+
openSpan = null
44+
} else {
45+
openSpan = { index, containsChip: false, touchesChip: previousChipEnd === index }
46+
}
47+
} else {
48+
if (openSpan) {
49+
openSpan.containsChip = true
50+
openSpan.touchesChip ||= index === openSpan.index + 1
51+
}
52+
previousChipEnd = index + value.length
53+
}
54+
}
55+
finishLine()
5756

58-
// A code span. Unwrap it only when it genuinely holds a tag — the parser
59-
// lifts the tag out either way, so leaving the delimiters would strand a
60-
// pair of backticks around a hole. Anything else is someone else's span.
61-
const inner = match.slice(1, -1)
62-
return COMPLETE_INLINE_CHIP_TAG.test(inner) ? inner : match
63-
})
64-
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
57+
const parts: string[] = []
58+
let cursor = 0
59+
for (const index of removedDelimiters) {
60+
parts.push(content.slice(cursor, index))
61+
cursor = index + 1
62+
}
63+
parts.push(content.slice(cursor))
64+
return parts.join('').replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
6565
}

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)