Fix stray bracket after AI chat chips (#23798)

Chips in the AI chat sometimes rendered with a leftover `]` after them.

The reference marker is bracket-asymmetric: it opens with `[[` and
closes with `[[/kind]]`, so a complete reference holds four `[` and only
two `]`. The model balances that by writing `…[[/object]]]`, and the
parser ended the match exactly at the close tag, leaving the extra
bracket as prose next to the chip.

The parser now absorbs up to as many surplus `]` as the reference opened
with, and accepts an opener with extra `[` so an over-wrapped marker
doesn't leak one either. The system prompt also tells the model the
marker is complete as written.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23798?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-08-05 14:04:49 +02:00
committed by GitHub
parent f893b214e2
commit 76bf3651bb
12 changed files with 194 additions and 6 deletions
@@ -113,6 +113,22 @@ export const ExistingMetadata: Story = {
},
};
export const SurplusClosingBrackets: Story = {
args: {
text: `I created the ${formatChatReference({
kind: 'object',
objectNameSingular: 'company',
displayName: 'Companies',
})}] object.`,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Companies')).toBeVisible();
expect(canvasElement).toHaveTextContent('I created the Companies object.');
},
};
export const ProposedObject: Story = {
args: {
text: `As a Head of Partnerships, you seem to work across partner companies, key contacts, and commercial follow-ups, so I suggest creating a ${formatChatReference(
@@ -116,6 +116,32 @@ describe('TextWithChatReferences', () => {
expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument();
});
it('should not leave a surplus bracket after a chip', () => {
render(
<TextWithChatReferences text="Created [[object:opportunity:Opportunities[[/object]]]." />,
);
expect(screen.getByTestId('object-link')).toHaveTextContent(
'Opportunities',
);
expect(screen.getByText(/Created/)).toHaveTextContent(
'Created Opportunities.',
);
});
it('should not leave the extra brackets of an over-wrapped reference', () => {
render(
<TextWithChatReferences text="Created [[[object:opportunity:Opportunities[[/object]]]] now" />,
);
expect(screen.getByTestId('object-link')).toHaveTextContent(
'Opportunities',
);
expect(screen.getByText(/Created/)).toHaveTextContent(
'Created Opportunities now',
);
});
it('should route each reference kind to its own chip', () => {
render(
<TextWithChatReferences text="The [[view:44444444-4444-4444-4444-444444444444:Pipeline[[/view]] view of [[object:partner:Partners[[/object]] groups [[record:person:11111111-1111-1111-1111-111111111111:Alice[[/record]] by [[field:33333333-3333-3333-3333-333333333333:Stage[[/field]]" />,
@@ -0,0 +1 @@
export const CHAT_REFERENCE_OPEN_PATTERN = '\\[\\[+';
@@ -1,14 +1,15 @@
import { CHAT_REFERENCE_METADATA_NAME_PATTERN } from '@/ai/constants/ChatReferenceMetadataNamePattern';
import { CHAT_REFERENCE_OPEN_PATTERN } from '@/ai/constants/ChatReferenceOpenPattern';
import { CHAT_REFERENCE_UUID_PATTERN } from '@/ai/constants/ChatReferenceUuidPattern';
// The record alternative must stay last: its `record:` prefix is optional, so it
// matches the metadata markers too and would swallow them if tried first.
export const CHAT_REFERENCE_START_REGEX = new RegExp(
[
`\\[\\[object:(?<objectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
`\\[\\[field:(?<fieldMetadataItemId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`\\[\\[view:(?<viewId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`\\[\\[(?:record:)?(?<recordObjectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?<recordId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`${CHAT_REFERENCE_OPEN_PATTERN}object:(?<objectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
`${CHAT_REFERENCE_OPEN_PATTERN}field:(?<fieldMetadataItemId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`${CHAT_REFERENCE_OPEN_PATTERN}view:(?<viewId>${CHAT_REFERENCE_UUID_PATTERN}):`,
`${CHAT_REFERENCE_OPEN_PATTERN}(?:record:)?(?<recordObjectNameSingular>${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?<recordId>${CHAT_REFERENCE_UUID_PATTERN}):`,
].join('|'),
'g',
);
@@ -3,5 +3,6 @@ import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
export type ChatReferenceStart = {
index: number;
prefixLength: number;
openBracketLength: number;
identity: ChatReferenceIdentity;
};
@@ -260,4 +260,48 @@ describe('findChatReferences', () => {
it('should drop an unclosed reference', () => {
expect(findChatReferences('Open [[object:partner:Partners')).toEqual([]);
});
it('should consume a surplus bracket added after the closing tag', () => {
expect(
findChatReferences(
'Created [[object:opportunity:Opportunities[[/object]]].',
),
).toEqual([
{
kind: 'object',
fullMatch: '[[object:opportunity:Opportunities[[/object]]]',
index: 8,
objectNameSingular: 'opportunity',
displayName: 'Opportunities',
},
]);
});
it('should consume the extra brackets of an over-wrapped reference', () => {
expect(
findChatReferences('Created [[[object:partner:Partners[[/object]]]] now'),
).toEqual([
{
kind: 'object',
fullMatch: '[[[object:partner:Partners[[/object]]]]',
index: 8,
objectNameSingular: 'partner',
displayName: 'Partners',
},
]);
});
it('should not consume a bracket separated from the closing tag', () => {
expect(
findChatReferences('Created [[object:partner:Partners[[/object]] ] now'),
).toEqual([
{
kind: 'object',
fullMatch: '[[object:partner:Partners[[/object]]',
index: 8,
objectNameSingular: 'partner',
displayName: 'Partners',
},
]);
});
});
@@ -0,0 +1,46 @@
import { getSurplusCloseBracketLength } from '@/ai/utils/getSurplusCloseBracketLength';
describe('getSurplusCloseBracketLength', () => {
it('should return zero when the closing tag is not followed by a bracket', () => {
expect(
getSurplusCloseBracketLength({
textAfterClosing: ' next',
openBracketLength: 2,
}),
).toBe(0);
});
it('should return zero when a bracket is separated from the closing tag', () => {
expect(
getSurplusCloseBracketLength({
textAfterClosing: ' ] next',
openBracketLength: 2,
}),
).toBe(0);
});
it('should count the brackets following the closing tag', () => {
expect(
getSurplusCloseBracketLength({
textAfterClosing: ']. next',
openBracketLength: 2,
}),
).toBe(1);
});
it('should count no more brackets than the reference opened with', () => {
expect(
getSurplusCloseBracketLength({
textAfterClosing: ']]] next',
openBracketLength: 2,
}),
).toBe(2);
expect(
getSurplusCloseBracketLength({
textAfterClosing: ']]] next',
openBracketLength: 3,
}),
).toBe(3);
});
});
@@ -75,6 +75,22 @@ describe('protectChatReferencesForMarkdown', () => {
);
});
it('should drop a surplus bracket added after the closing tag', () => {
expect(
protectChatReferencesForMarkdown(
'Created [[object:opportunity:Opportunities[[/object]]].',
),
).toBe('Created [[object:opportunity:Opportunities[[/object]].');
});
it('should drop the extra brackets of an over-wrapped reference', () => {
expect(
protectChatReferencesForMarkdown(
'Created [[[object:opportunity:Opportunities[[/object]]]] now',
),
).toBe('Created [[object:opportunity:Opportunities[[/object]] now');
});
it('should rewrite every kind in a mixed string', () => {
expect(
protectChatReferencesForMarkdown(
@@ -3,6 +3,7 @@ import { type ChatReferenceMatch } from '@/ai/types/ChatReferenceMatch';
import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart';
import { findChatReferenceClosing } from '@/ai/utils/findChatReferenceClosing';
import { getChatReferenceStartFromMatch } from '@/ai/utils/getChatReferenceStartFromMatch';
import { getSurplusCloseBracketLength } from '@/ai/utils/getSurplusCloseBracketLength';
import { isDefined } from 'twenty-shared/utils';
export const findChatReferences = (text: string): ChatReferenceMatch[] => {
@@ -33,12 +34,18 @@ export const findChatReferences = (text: string): ChatReferenceMatch[] => {
return [];
}
const closingEnd = closing.index + closing.length;
const surplusCloseBracketLength = getSurplusCloseBracketLength({
textAfterClosing: displayNameWindow.slice(closingEnd),
openBracketLength: start.openBracketLength,
});
return [
{
...start.identity,
fullMatch: text.slice(
start.index,
displayNameStart + closing.index + closing.length,
displayNameStart + closingEnd + surplusCloseBracketLength,
),
index: start.index,
displayName: displayNameWindow.slice(0, closing.index),
@@ -1,6 +1,8 @@
import { type ChatReferenceStart } from '@/ai/types/ChatReferenceStart';
import { isDefined } from 'twenty-shared/utils';
const OPEN_BRACKETS_REGEX = /^\[+/;
export const getChatReferenceStartFromMatch = (
match: RegExpExecArray,
): ChatReferenceStart => {
@@ -12,7 +14,15 @@ export const getChatReferenceStartFromMatch = (
recordId,
} = match.groups ?? {};
const position = { index: match.index, prefixLength: match[0].length };
const openBracketsMatch = OPEN_BRACKETS_REGEX.exec(match[0]);
const position = {
index: match.index,
prefixLength: match[0].length,
openBracketLength: isDefined(openBracketsMatch)
? openBracketsMatch[0].length
: 0,
};
if (isDefined(objectNameSingular)) {
return { ...position, identity: { kind: 'object', objectNameSingular } };
@@ -0,0 +1,19 @@
import { isDefined } from 'twenty-shared/utils';
const SURPLUS_CLOSE_BRACKETS_REGEX = /^\]+/;
export const getSurplusCloseBracketLength = ({
textAfterClosing,
openBracketLength,
}: {
textAfterClosing: string;
openBracketLength: number;
}): number => {
const surplusMatch = SURPLUS_CLOSE_BRACKETS_REGEX.exec(textAfterClosing);
if (!isDefined(surplusMatch)) {
return 0;
}
return Math.min(surplusMatch[0].length, openBracketLength);
};
@@ -104,5 +104,6 @@ Whenever you name an object, a field, or a view in your prose, write it as a met
- The displayName is what the user reads, so use the human-readable label ("Annual Recurring Revenue"), not the technical name
- Field and view ids MUST be real UUIDs copied from a tool response - never invent one, and never reference a field or view before the tool that returns it has run
- Always close a reference with its own tag: \`[[/object]]\`, \`[[/field]]\`, \`[[/view]]\`. A mismatched closing tag drops the chip
- A reference is complete as written: never wrap it in extra square brackets, and never add \`]\` or \`]]\` after its closing tag
- Use metadata references only in paragraphs, lists, or markdown tables (\`| ... |\`); never in headings, code, links, or raw HTML`,
};