Skip to content

Commit 7dc9148

Browse files
j15zclaude
andcommitted
fix(copilot): sanitize backticks in one pass so a flush code span survives
Review caught a case the previous fix missed. Two passes each decided independently which backticks belonged together, and with no space between a code span and a tag they disagreed: Open `config.json`<tag> ok -> lost the span's closing backtick Open <tag>`config.json` ok -> lost the span's opening backtick My test for this used a space between them, which is exactly why it passed. Both passes are now one left-to-right scan alternating between a code span and a tag with a stray backtick against it. A span consumes its own delimiters as the scan reaches them, so a flush neighbour keeps its pair with no special case — where a second pass had no way to know the backtick was already spoken for. This is the third arrangement of this file; the first two each handled the cases they were written for and broke a different one, which is what two passes guessing at the same question produces. A trailing backtick is only taken as a stray when no further backtick follows on the line. Otherwise it is the opener of the next span. Mutation-checked: removing that lookahead fails the new test and nothing else. Swapping the alternation order changes nothing any fixture covers, so the comment no longer claims the order is load-bearing — it distinguishes only a span that opens flush against a tag and closes elsewhere, which nothing pins. Thirteen shapes verified balanced, including all three flush variants, the fenced block, the neighbour with a space, both one-sided strays, and the mention shapes. 168KB of repeated openers: 0.18ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f599991 commit 7dc9148

2 files changed

Lines changed: 63 additions & 46 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ describe('sanitizeChatDisplayContent', () => {
5858
expect(sanitizeChatDisplayContent(content)).toBe(content)
5959
})
6060

61+
it('leaves a code span sitting flush against the tag alone', () => {
62+
// No space between them, so the span's delimiter is directly against the
63+
// tag and a pattern looking for "a backtick beside a tag" cannot tell the
64+
// two apart. A single pass settles it: a span consumes its own delimiters as
65+
// the scan reaches them, and a trailing backtick is only taken as a stray
66+
// when no further backtick follows on the line.
67+
const before =
68+
'Open `config.json`<workspace_resource>{"type":"file","path":"a.md","title":"a"}</workspace_resource> ok'
69+
const after =
70+
'Open <workspace_resource>{"type":"file","path":"a.md","title":"a"}</workspace_resource>`config.json` ok'
71+
const both =
72+
'Open `a`<workspace_resource>{"type":"file","path":"a.md","title":"a"}</workspace_resource>`b` ok'
73+
74+
expect(sanitizeChatDisplayContent(before)).toBe(before)
75+
expect(sanitizeChatDisplayContent(after)).toBe(after)
76+
expect(sanitizeChatDisplayContent(both)).toBe(both)
77+
})
78+
6179
it('does not break the closing fence of a code block containing a tag', () => {
6280
// Whitespace matching used to cross the newline and consume one of the three
6381
// closing backticks, so the block never closed and the rest of the message

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

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,62 @@ const HIDDEN_INLINE_REFERENCE_PATTERN =
22
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g
33

44
/**
5-
* A complete workspace-resource tag, with any backtick sitting directly against
6-
* either end. Replacing with the tag alone drops those backticks, which is what
7-
* lets the chip render when the model wrapped the tag in a code span.
5+
* A complete workspace-resource tag: opener, payload, closer.
86
*
9-
* Three constraints, each load-bearing:
7+
* Two constraints on the payload, both load-bearing:
108
*
11-
* - **The closer must be present.** Prose MENTIONING the tag name writes
12-
* `<workspace_resource>` with no closer at all. Stripping a mention's opening
13-
* backtick leaves the closing one unpaired, and it opens a code span that runs
14-
* to the next backtick — inverting every code span in the rest of the message.
15-
* - **No backtick between opener and closer.** A payload is JSON and carries
16-
* none, while a message explaining the syntax writes the opener and the closer
17-
* as two separately backticked spans. Without this, that prose reads as one
18-
* wrapped tag and loses its outer pair.
19-
* - **No `<workspace_resource>` between opener and closer**, via the negative
20-
* lookahead. This is a cost bound, not a correctness rule: a lazy scan that may
21-
* cross an opener restarts from every opener, so a message repeating the tag
22-
* name is quadratic — on the main thread, for every streamed chunk.
23-
*
24-
* Backticks must be DIRECTLY adjacent. Allowing whitespace between let the
25-
* pattern reach past the tag and take the delimiter off a neighbouring code
26-
* span — `` Open `config.json` <tag> `` lost a backtick that way, and `\s*`
27-
* crossing a newline broke the closing fence of a code block containing a tag.
9+
* - **No backtick.** A payload is JSON and carries none, so this is what tells a
10+
* real tag from prose MENTIONING the tag name — a message explaining the
11+
* syntax writes the opener and the closer as two separately backticked spans.
12+
* - **No nested opener**, via the negative lookahead. A cost bound rather than a
13+
* correctness rule: a lazy scan allowed to cross an opener restarts from every
14+
* opener, so a message repeating the tag name is quadratic — on the main
15+
* thread, for every streamed chunk.
2816
*
2917
* Accepted trade: a resource whose title or path itself contains a backtick is
3018
* not matched, so it renders as text rather than a chip. That costs one chip and
3119
* is rare; the failure it replaces corrupts a whole message and is common.
3220
*/
33-
const WORKSPACE_RESOURCE_TAG_WITH_BACKTICKS =
34-
/`?(<workspace_resource>(?:(?!<workspace_resource>)[^`])*?<\/workspace_resource>)`?/g
21+
const COMPLETE_TAG_SOURCE =
22+
'<workspace_resource>(?:(?!<workspace_resource>)[^`])*?<\\/workspace_resource>'
23+
24+
/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */
25+
const COMPLETE_WORKSPACE_RESOURCE_TAG = new RegExp(COMPLETE_TAG_SOURCE)
3526

3627
/**
37-
* An inline code span, paired the way markdown pairs one: opening backtick to
38-
* the next backtick on the same line.
28+
* One left-to-right pass over the two things that can own a backtick: an inline
29+
* code span, and a tag with a stray backtick pressed against it.
30+
*
31+
* ONE pass is the design. Two separate passes each have to guess which backticks
32+
* belong together, and every previous arrangement of this file got a different
33+
* case wrong — a span two words away, a code fence, then a span sitting flush
34+
* against the tag. Here a span consumes its own delimiters as the scan reaches
35+
* them, so `` `config.json`<tag> `` keeps its pair without a special case.
3936
*
40-
* Pairing matters. A pattern that instead looked for "a backtick, then a tag,
41-
* then a backtick" would happily start at one span's delimiter and end at a
42-
* different span's, unwrapping the text between two unrelated spans — which is
43-
* how `` Open `config.json` <tag> then run `bun test` `` lost a backtick.
37+
* The trailing backtick is only taken when no further backtick follows on the
38+
* line; otherwise it is not a stray at all but the opener of the next span, and
39+
* `` <tag>`config.json` `` would lose that span's delimiter. A LEADING backtick
40+
* needs no such guard, because a backtick that closes a span is consumed as part
41+
* of that span. Of the two, only the trailing lookahead is pinned by a test —
42+
* swapping the alternatives changes behaviour only for a span that both opens
43+
* flush against a tag and closes elsewhere, which no fixture covers.
4444
*/
45-
const CODE_SPAN_PATTERN = /`([^`\n]*)`/g
46-
47-
/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */
48-
const COMPLETE_WORKSPACE_RESOURCE_TAG =
49-
/<workspace_resource>(?:(?!<workspace_resource>)[^`])*?<\/workspace_resource>/
45+
const CODE_SPAN_OR_FLANKED_TAG = new RegExp(
46+
`\`[^\`\\n]*\`|\`?(${COMPLETE_TAG_SOURCE})(?:\`(?![^\`\\n]*\`))?`,
47+
'g'
48+
)
5049

5150
export function sanitizeChatDisplayContent(content: string): string {
52-
return (
53-
content
54-
// A tag inside a code span: drop the delimiters, keep the content. The
55-
// parser extracts the tag either way, so leaving them would strand a pair
56-
// of backticks around a hole once the chip is lifted out.
57-
.replace(CODE_SPAN_PATTERN, (span, inner: string) =>
58-
COMPLETE_WORKSPACE_RESOURCE_TAG.test(inner) ? inner : span
59-
)
60-
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
61-
// A leftover backtick pressed against a tag, with no partner to pair with.
62-
.replace(WORKSPACE_RESOURCE_TAG_WITH_BACKTICKS, '$1')
63-
)
51+
return content
52+
.replace(CODE_SPAN_OR_FLANKED_TAG, (match, tag?: string) => {
53+
// A tag with stray backticks against it: keep the tag, drop the strays.
54+
if (tag !== undefined) return tag
55+
56+
// A code span. Unwrap it only when it genuinely holds a tag — the parser
57+
// lifts the tag out either way, so leaving the delimiters would strand a
58+
// pair of backticks around a hole. Anything else is someone else's span.
59+
const inner = match.slice(1, -1)
60+
return COMPLETE_WORKSPACE_RESOURCE_TAG.test(inner) ? inner : match
61+
})
62+
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
6463
}

0 commit comments

Comments
 (0)