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:
+16
@@ -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(
|
||||
|
||||
+26
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+46
@@ -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);
|
||||
});
|
||||
});
|
||||
+16
@@ -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);
|
||||
};
|
||||
+1
@@ -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`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user