From 76bf3651bb36df8cabbb55d73b66b324dc41ece5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:04:49 +0200 Subject: [PATCH] Fix stray bracket after AI chat chips (#23798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Review in cubic --- .../__stories__/ChatReferenceChip.stories.tsx | 16 +++++++ .../__tests__/TextWithChatReferences.test.tsx | 26 +++++++++++ .../ai/constants/ChatReferenceOpenPattern.ts | 1 + .../ai/constants/ChatReferenceStartRegex.ts | 9 ++-- .../modules/ai/types/ChatReferenceStart.ts | 1 + .../__tests__/findChatReferences.test.ts | 44 ++++++++++++++++++ .../getSurplusCloseBracketLength.test.ts | 46 +++++++++++++++++++ .../protectChatReferencesForMarkdown.test.ts | 16 +++++++ .../modules/ai/utils/findChatReferences.ts | 9 +++- .../utils/getChatReferenceStartFromMatch.ts | 12 ++++- .../ai/utils/getSurplusCloseBracketLength.ts | 19 ++++++++ .../constants/chat-system-prompts.const.ts | 1 + 12 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/constants/ChatReferenceOpenPattern.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/__tests__/getSurplusCloseBracketLength.test.ts create mode 100644 packages/twenty-front/src/modules/ai/utils/getSurplusCloseBracketLength.ts 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`, };