diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx
index 63d878b4a6..d8541b169a 100644
--- a/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx
+++ b/packages/twenty-front/src/modules/ai/components/__stories__/ChatReferenceChip.stories.tsx
@@ -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(
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx
index 1a5c753740..5eed72153f 100644
--- a/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/TextWithChatReferences.test.tsx
@@ -116,6 +116,32 @@ describe('TextWithChatReferences', () => {
expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument();
});
+ it('should not leave a surplus bracket after a chip', () => {
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
,
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts
new file mode 100644
index 0000000000..b55da758a6
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts
@@ -0,0 +1 @@
+export const CHAT_REFERENCE_OPEN_PATTERN = '\\[\\[+';
diff --git a/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts
index ea049fc9c4..52a98c7187 100644
--- a/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts
+++ b/packages/twenty-front/src/modules/ai/constants/ChatReferenceStartRegex.ts
@@ -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:(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
- `\\[\\[field:(?${CHAT_REFERENCE_UUID_PATTERN}):`,
- `\\[\\[view:(?${CHAT_REFERENCE_UUID_PATTERN}):`,
- `\\[\\[(?:record:)?(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_UUID_PATTERN}):`,
+ `${CHAT_REFERENCE_OPEN_PATTERN}object:(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):`,
+ `${CHAT_REFERENCE_OPEN_PATTERN}field:(?${CHAT_REFERENCE_UUID_PATTERN}):`,
+ `${CHAT_REFERENCE_OPEN_PATTERN}view:(?${CHAT_REFERENCE_UUID_PATTERN}):`,
+ `${CHAT_REFERENCE_OPEN_PATTERN}(?:record:)?(?${CHAT_REFERENCE_METADATA_NAME_PATTERN}):(?${CHAT_REFERENCE_UUID_PATTERN}):`,
].join('|'),
'g',
);
diff --git a/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts
index 5ff0f5295e..b59b2c1eaa 100644
--- a/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts
+++ b/packages/twenty-front/src/modules/ai/types/ChatReferenceStart.ts
@@ -3,5 +3,6 @@ import { type ChatReferenceIdentity } from '@/ai/types/ChatReferenceIdentity';
export type ChatReferenceStart = {
index: number;
prefixLength: number;
+ openBracketLength: number;
identity: ChatReferenceIdentity;
};
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts
index 8bd57ad54d..44b1894495 100644
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/findChatReferences.test.ts
@@ -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',
+ },
+ ]);
+ });
});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts
new file mode 100644
index 0000000000..641d73f0d6
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts
index c0de47f92a..cb521374fc 100644
--- a/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/protectChatReferencesForMarkdown.test.ts
@@ -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(
diff --git a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
index 7df580ce4c..0eea298426 100644
--- a/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
+++ b/packages/twenty-front/src/modules/ai/utils/findChatReferences.ts
@@ -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),
diff --git a/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts
index a9266827aa..ce0f475314 100644
--- a/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts
+++ b/packages/twenty-front/src/modules/ai/utils/getChatReferenceStartFromMatch.ts
@@ -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 } };
diff --git a/packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts b/packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts
new file mode 100644
index 0000000000..3baa361d27
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts
@@ -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);
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
index 09f7145609..b92d0e0c2d 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts
@@ -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`,
};