fix(ai-chat) - fix AI record references when display names contain markdown characters (#23113)

<img width="516" height="86" alt="Screenshot 2026-07-21 at 16 41 16"
src="https://github.com/user-attachments/assets/6e14b933-b48e-49ab-856b-400efdeba53a"
/>




## Summary
- Switch record references from `[[record:object:id:label]]` to
`[[record:object:id:label[[/record]]` so labels can include `]`,
backticks, brackets, and other markdown-significant characters
- Parse references with an explicit close tag (still accepting legacy
`]]`), escape labels before markdown lexing, and serialize mentions
through a shared formatter
- Update the AI chat system prompt so the model emits the new format

## Test plan
- [ ] Ask AI about a record whose name contains `` ` ``, `[`, `]`, or
`]]` and confirm it renders as a chip, not broken markdown
- [ ] Confirm legacy `[[record:...]]` references still chip correctly
- [ ] Mention a record in the chat editor and verify serialized text
uses `[[/record]]`
- [ ] Run:
- `npx jest src/modules/ai/utils/__tests__/findRecordReferences.test.ts
src/modules/ai/utils/__tests__/formatRecordReference.test.ts
src/modules/ai/utils/__tests__/protectRecordReferencesForMarkdown.test.ts
src/modules/ai/components/__tests__/TextWithRecordLinks.test.tsx
--config=packages/twenty-front/jest.config.mjs`
  - mention extension tests for `MentionTag` / `MentionSuggestion`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23113?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:
Etienne
2026-07-21 17:00:29 +02:00
committed by GitHub
parent f5b06d20e4
commit 8f704e87e3
16 changed files with 345 additions and 72 deletions
@@ -7,6 +7,7 @@ import {
} from '@/ai/components/LazyMarkdownRendererStyledComponents';
import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock';
import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown';
import { marked } from 'marked';
import {
cloneElement,
@@ -179,11 +180,16 @@ const MemoizedMarkdownBlock = memo(
);
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
const markdownBlocks = useMemo(
() => marked.lexer(text).map((token) => token.raw),
const protectedText = useMemo(
() => protectRecordReferencesForMarkdown(text),
[text],
);
const markdownBlocks = useMemo(
() => marked.lexer(protectedText).map((token) => token.raw),
[protectedText],
);
return (
<StyledMarkdownContainer
className="markdown-section"
@@ -49,21 +49,3 @@ export const RecordLink = ({
/>
);
};
export const RECORD_REFERENCE_REGEX =
/\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g;
export const parseRecordReference = (match: string) => {
const regex = /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/;
const result = regex.exec(match);
if (!result) {
return null;
}
return {
objectNameSingular: result[1],
recordId: result[2],
displayName: result[3],
};
};
@@ -1,42 +1,36 @@
import {
parseRecordReference,
RECORD_REFERENCE_REGEX,
RecordLink,
} from '@/ai/components/RecordLink';
import { RecordLink } from '@/ai/components/RecordLink';
import { findRecordReferences } from '@/ai/utils/findRecordReferences';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
type TextWithRecordLinksProps = {
text: string;
};
export const TextWithRecordLinks = ({ text }: TextWithRecordLinksProps) => {
const references = findRecordReferences(text);
if (references.length === 0) {
return <>{text}</>;
}
const parts: ReactNode[] = [];
let lastIndex = 0;
RECORD_REFERENCE_REGEX.lastIndex = 0;
let match;
while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
for (const reference of references) {
if (reference.index > lastIndex) {
parts.push(text.slice(lastIndex, reference.index));
}
const parsed = parseRecordReference(match[0]);
parts.push(
<RecordLink
key={reference.index}
objectNameSingular={reference.objectNameSingular}
recordId={reference.recordId}
displayName={reference.displayName}
/>,
);
if (isDefined(parsed)) {
parts.push(
<RecordLink
key={match.index}
objectNameSingular={parsed.objectNameSingular}
recordId={parsed.recordId}
displayName={parsed.displayName}
/>,
);
}
lastIndex = match.index + match[0].length;
lastIndex = reference.index + reference.fullMatch.length;
}
if (lastIndex < text.length) {
@@ -3,22 +3,6 @@ import { render, screen } from '@testing-library/react';
import { TextWithRecordLinks } from '@/ai/components/TextWithRecordLinks';
jest.mock('@/ai/components/RecordLink', () => ({
RECORD_REFERENCE_REGEX:
/\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g,
parseRecordReference: (match: string) => {
const regex = /\[\[(?:record:)?([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/;
const result = regex.exec(match);
if (!result) {
return null;
}
return {
objectNameSingular: result[1],
recordId: result[2],
displayName: result[3],
};
},
RecordLink: ({
displayName,
objectNameSingular,
@@ -44,9 +28,9 @@ describe('TextWithRecordLinks', () => {
expect(screen.queryByTestId('record-link')).not.toBeInTheDocument();
});
it('should replace record references with RecordLink chips', () => {
it('should replace tagged record references with RecordLink chips', () => {
render(
<TextWithRecordLinks text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next" />,
<TextWithRecordLinks text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next" />,
);
expect(screen.getByTestId('record-link')).toHaveTextContent('Acme');
@@ -54,9 +38,18 @@ describe('TextWithRecordLinks', () => {
expect(screen.queryByText(/\[\[record:company:/)).not.toBeInTheDocument();
});
it('should still replace legacy ]] record references with RecordLink chips', () => {
render(
<TextWithRecordLinks text="Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next" />,
);
expect(screen.getByTestId('record-link')).toHaveTextContent('Acme');
expect(screen.getByText(/Contact/)).toHaveTextContent('Contact Acme next');
});
it('should replace multiple record references in option-style labels', () => {
render(
<TextWithRecordLinks text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice]] into [[person:22222222-2222-2222-2222-222222222222:Bob]]" />,
<TextWithRecordLinks text="Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[person:22222222-2222-2222-2222-222222222222:Bob[[/record]]" />,
);
const recordLinks = screen.getAllByTestId('record-link');
@@ -66,4 +59,18 @@ describe('TextWithRecordLinks', () => {
expect(recordLinks[1]).toHaveTextContent('Bob');
expect(screen.queryByText(/\[\[/)).not.toBeInTheDocument();
});
it('should chip tagged labels that contain backticks, brackets, colons, and ]]', () => {
render(
<TextWithRecordLinks text="See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]] and [[record:company:b1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]] then [[record:person:c1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]" />,
);
const recordLinks = screen.getAllByTestId('record-link');
expect(recordLinks).toHaveLength(3);
expect(recordLinks[0]).toHaveTextContent('Workflow `UPDATE_RECORD` step');
expect(recordLinks[1]).toHaveTextContent('[test] ]] [test] [test] ###');
expect(recordLinks[2]).toHaveTextContent('Doe: Jane');
expect(screen.queryByText(/\[\[record:/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1 @@
export const RECORD_REFERENCE_CLOSE_TAG = '[[/record]]';
@@ -0,0 +1,7 @@
export type RecordReferenceMatch = {
fullMatch: string;
index: number;
objectNameSingular: string;
recordId: string;
displayName: string;
};
@@ -0,0 +1,102 @@
import { findRecordReferences } from '@/ai/utils/findRecordReferences';
describe('findRecordReferences', () => {
it('should leave plain text without matches', () => {
expect(findRecordReferences('Which company should we contact?')).toEqual(
[],
);
});
it('should find a tagged record reference', () => {
expect(
findRecordReferences(
'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next',
),
).toEqual([
{
fullMatch:
'[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]]',
index: 8,
objectNameSingular: 'company',
recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
displayName: 'Acme',
},
]);
});
it('should include ]] inside a tagged display name', () => {
expect(
findRecordReferences(
'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]], created on July 21',
),
).toEqual([
{
fullMatch:
'[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]',
index: 15,
objectNameSingular: 'company',
recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
displayName: '[test] ]] [test] [test] ###',
},
]);
});
it('should still support legacy ]] terminators', () => {
expect(
findRecordReferences(
'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next',
),
).toEqual([
{
fullMatch:
'[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]]',
index: 8,
objectNameSingular: 'company',
recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
displayName: 'Acme',
},
]);
});
it('should still support legacy ]] inside display names', () => {
expect(
findRecordReferences(
'The company is [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]], created on July 21',
),
).toEqual([
{
fullMatch:
'[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###]]',
index: 15,
objectNameSingular: 'company',
recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
displayName: '[test] ]] [test] [test] ###',
},
]);
});
it('should find multiple tagged references without consuming into the next one', () => {
expect(
findRecordReferences(
'Merge [[person:11111111-1111-1111-1111-111111111111:Alice[[/record]] into [[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]',
),
).toEqual([
{
fullMatch:
'[[person:11111111-1111-1111-1111-111111111111:Alice[[/record]]',
index: 6,
objectNameSingular: 'person',
recordId: '11111111-1111-1111-1111-111111111111',
displayName: 'Alice',
},
{
fullMatch:
'[[record:person:22222222-2222-2222-2222-222222222222:Bob[[/record]]',
index: 74,
objectNameSingular: 'person',
recordId: '22222222-2222-2222-2222-222222222222',
displayName: 'Bob',
},
]);
});
});
@@ -0,0 +1,15 @@
import { formatRecordReference } from '@/ai/utils/formatRecordReference';
describe('formatRecordReference', () => {
it('should use the [[/record]] close tag', () => {
expect(
formatRecordReference({
objectNameSingular: 'company',
recordId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
displayName: '[test] ]] [test]',
}),
).toBe(
'[[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test][[/record]]',
);
});
});
@@ -0,0 +1,49 @@
import { protectRecordReferencesForMarkdown } from '@/ai/utils/protectRecordReferencesForMarkdown';
describe('protectRecordReferencesForMarkdown', () => {
it('should leave plain text unchanged', () => {
expect(
protectRecordReferencesForMarkdown('Which company should we contact?'),
).toBe('Which company should we contact?');
});
it('should rewrite legacy refs to the tagged format', () => {
expect(
protectRecordReferencesForMarkdown(
'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme]] next',
),
).toBe(
'Contact [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Acme[[/record]] next',
);
});
it('should escape backticks in labels and close with the tag', () => {
expect(
protectRecordReferencesForMarkdown(
'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow `UPDATE_RECORD` step[[/record]]',
),
).toBe(
'See [[record:workflow:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Workflow \\`UPDATE\\_RECORD\\` step[[/record]]',
);
});
it('should escape square brackets and ]] inside labels', () => {
expect(
protectRecordReferencesForMarkdown(
'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:[test] ]] [test] [test] ###[[/record]]',
),
).toBe(
'Open [[record:company:a1b2c3d4-e5f6-7890-abcd-ef1234567890:\\[test\\] \\]\\] \\[test\\] \\[test\\] \\#\\#\\#[[/record]]',
);
});
it('should leave colons in labels unchanged', () => {
expect(
protectRecordReferencesForMarkdown(
'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]',
),
).toBe(
'Ping [[record:person:a1b2c3d4-e5f6-7890-abcd-ef1234567890:Doe: Jane[[/record]]',
);
});
});
@@ -0,0 +1,62 @@
import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag';
import { type RecordReferenceMatch } from '@/ai/types/RecordReferenceMatch';
const RECORD_REFERENCE_START_REGEX =
/\[\[(?:record:)?([a-zA-Z]+):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/g;
export const findRecordReferences = (text: string): RecordReferenceMatch[] => {
const starts: Array<{
index: number;
prefixLength: number;
objectNameSingular: string;
recordId: string;
}> = [];
RECORD_REFERENCE_START_REGEX.lastIndex = 0;
let startMatch;
while ((startMatch = RECORD_REFERENCE_START_REGEX.exec(text)) !== null) {
starts.push({
index: startMatch.index,
prefixLength: startMatch[0].length,
objectNameSingular: startMatch[1],
recordId: startMatch[2],
});
}
return starts.flatMap((start, startIndex) => {
const displayNameStart = start.index + start.prefixLength;
const windowEnd =
startIndex + 1 < starts.length
? starts[startIndex + 1].index
: text.length;
const window = text.slice(displayNameStart, windowEnd);
const closeTagIndexInWindow = window.indexOf(RECORD_REFERENCE_CLOSE_TAG);
const closingIndexInWindow =
closeTagIndexInWindow !== -1
? closeTagIndexInWindow
: window.lastIndexOf(']]');
if (closingIndexInWindow === -1) {
return [];
}
const closeLength =
closeTagIndexInWindow !== -1 ? RECORD_REFERENCE_CLOSE_TAG.length : 2;
const displayName = window.slice(0, closingIndexInWindow);
const fullMatchEnd = displayNameStart + closingIndexInWindow + closeLength;
return [
{
fullMatch: text.slice(start.index, fullMatchEnd),
index: start.index,
objectNameSingular: start.objectNameSingular,
recordId: start.recordId,
displayName,
},
];
});
};
@@ -0,0 +1,12 @@
import { RECORD_REFERENCE_CLOSE_TAG } from '@/ai/constants/RecordReferenceCloseTag';
export const formatRecordReference = ({
objectNameSingular,
recordId,
displayName,
}: {
objectNameSingular: string;
recordId: string;
displayName: string;
}): string =>
`[[record:${objectNameSingular}:${recordId}:${displayName}${RECORD_REFERENCE_CLOSE_TAG}`;
@@ -0,0 +1,26 @@
import { findRecordReferences } from '@/ai/utils/findRecordReferences';
import { formatRecordReference } from '@/ai/utils/formatRecordReference';
const escapeMarkdown = (text: string): string =>
text.replace(/([\\`*_{}[\]()#+\-.!|~>])/g, '\\$1');
export const protectRecordReferencesForMarkdown = (text: string): string => {
const references = findRecordReferences(text);
let result = text;
for (let index = references.length - 1; index >= 0; index--) {
const reference = references[index];
const replacement = formatRecordReference({
objectNameSingular: reference.objectNameSingular,
recordId: reference.recordId,
displayName: escapeMarkdown(reference.displayName),
});
result =
result.slice(0, reference.index) +
replacement +
result.slice(reference.index + reference.fullMatch.length);
}
return result;
};
@@ -1,3 +1,4 @@
import { formatRecordReference } from '@/ai/utils/formatRecordReference';
import { MentionChip } from '@/mention/components/MentionChip';
import { Node } from '@tiptap/core';
import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
@@ -57,6 +58,10 @@ export const MentionTag = Node.create({
renderText: ({ node }) => {
const { objectNameSingular, recordId, label } = node.attrs;
return `[[record:${objectNameSingular}:${recordId}:${label}]]`;
return formatRecordReference({
objectNameSingular,
recordId,
displayName: label,
});
},
});
@@ -82,7 +82,7 @@ describe('MentionSuggestion', () => {
const text = editor.getText();
expect(text).toContain('[[record:company:test-id:Acme]]');
expect(text).toContain('[[record:company:test-id:Acme[[/record]]');
});
it('should accept @ character in editor content', () => {
@@ -67,7 +67,9 @@ describe('MentionTag', () => {
const text = editor.getText();
expect(text).toBe('Hello [[record:company:abc-123:Acme Corp]] world');
expect(text).toBe(
'Hello [[record:company:abc-123:Acme Corp[[/record]] world',
);
});
it('should handle mentions with empty label', () => {
@@ -93,7 +95,7 @@ describe('MentionTag', () => {
const text = editor.getText();
expect(text).toBe('[[record:person:id-456:]]');
expect(text).toBe('[[record:person:id-456:[[/record]]');
});
it('should serialize multiple mentions in the same paragraph', () => {
@@ -130,7 +132,7 @@ describe('MentionTag', () => {
const text = editor.getText();
expect(text).toBe(
'[[record:person:r1:Alice]] and [[record:company:r2:Beta Inc]]',
'[[record:person:r1:Alice[[/record]] and [[record:company:r2:Beta Inc[[/record]]',
);
});
});
@@ -156,7 +158,9 @@ describe('MentionTag', () => {
const text = editor.getText();
expect(text).toContain('[[record:opportunity:test-id:Big Deal]]');
expect(text).toContain(
'[[record:opportunity:test-id:Big Deal[[/record]]',
);
});
});
@@ -79,7 +79,8 @@ Format responses with markdown for clarity (headings, lists, code blocks, tables
Record References - IMPORTANT:
- Tool responses include a "recordReferences" array with clickable links
- ONLY use record references that are returned by tools - NEVER make up IDs
- Copy the exact format from the tool response: [[record:objectName:recordId:displayName]]
- Copy the exact format from the tool response: [[record:objectName:recordId:displayName[[/record]]
- Example: [[record:company:abc12345-1234-5678-abcd-123456789012:Acme Corp[[/record]]
- Use record references only in paragraphs, lists, or markdown tables (\`| ... |\`); never in headings, code, links, or raw HTML
- The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012")
- DO NOT create record references before calling the tool