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:
@@ -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) {
|
||||
|
||||
+26
-19
@@ -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]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
+49
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -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]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user