feat(ai-tool): resolve and default icons in AI metadata tools (#23480)

## Context

Objects and fields created through the AI chat / MCP metadata tools
almost never get an icon, so they all render with the meaningless `123`
fallback icon. Two causes:

- The `icon` tool input was described only as `"Icon name"`, so the
model had no idea what the value space is and mostly skipped an optional
field it couldn't fill confidently.
- Any invalid name is silently swapped for `Icon123` by
`useIcons.getIcon` on the frontend, so near-misses were
indistinguishable from unset.

## What this PR does

**Guide the model** (icon names are Tabler names, which LLMs know well):
- `icon` / `targetFieldIcon` schema descriptions now state the
convention with examples (`IconBuildingSkyscraper`, `IconPaw`, …) and
ask for one to always be set
- The `metadata-building` skill gains an "Icons" section; the MCP server
instructions gain a one-line reminder

**Normalize server-side** (new `resolveIconName` util, used by all
create/update/batch metadata tool executes incl.
`relationCreationPayload.targetFieldIcon`):
- Fixes shape mistakes: raw tabler slugs (`"building-skyscraper"`),
separators, missing or lowercased `Icon` prefix
- Deliberately does NOT validate existence against the full ~4.2k icon
registry — an unknown name is harmless since the frontend falls back to
its default icon, exactly as for icons stored via the API today
- Unusable input (empty/garbage) resolves to nothing: creates fall back
to a default, updates keep the existing icon

**Fall back sensibly for fields**:
- New `FIELD_TYPE_DEFAULT_ICONS` in `twenty-shared/constants` maps every
`FieldMetadataType` to a sensible icon (mirroring the settings UI type
illustrations), applied when the model provides no usable icon — an
AI-created field always gets a meaningful icon
- Lives in twenty-shared so the frontend can reuse it later (e.g. as
`getIcon`'s custom default for fields)

The REST/GraphQL metadata APIs are untouched — this only affects the AI
tool layer.

## Test plan

- `resolve-icon-name.util.spec.ts` — canonical pass-through,
slug/prefix/separator fixes, unknown-name pass-through (FE fallback
contract), unusable inputs, icon-key dropping on updates
- `FieldTypeDefaultIcons.test.ts` — every field type mapped, all values
canonically shaped (values hand-checked against the twenty-ui
`ALL_ICONS` registry)
- `nx typecheck` twenty-server + twenty-shared, oxlint/oxfmt clean on
changed files

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23480?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-29 11:39:55 +02:00
committed by GitHub
parent 2304d19c8e
commit 5ebcce0a51
8 changed files with 275 additions and 21 deletions
@@ -0,0 +1,46 @@
import { normalizeIconName } from 'src/engine/core-modules/tool-provider/utils/normalize-icon-name.util';
describe('normalizeIconName', () => {
it('should return a canonical icon name unchanged', () => {
expect(normalizeIconName('IconBuildingSkyscraper')).toBe(
'IconBuildingSkyscraper',
);
expect(normalizeIconName('Icon123')).toBe('Icon123');
});
it('should fix a lowercased or separated Icon prefix', () => {
expect(normalizeIconName('iconPaw')).toBe('IconPaw');
expect(normalizeIconName('icon user')).toBe('IconUser');
});
it('should normalize raw tabler slugs without the Icon prefix', () => {
expect(normalizeIconName('building-skyscraper')).toBe(
'IconBuildingSkyscraper',
);
expect(normalizeIconName('paw')).toBe('IconPaw');
expect(normalizeIconName('currency_dollar')).toBe('IconCurrencyDollar');
});
it('should normalize uppercase slugs without breaking camelCase words', () => {
expect(normalizeIconName('BUILDING_SKYSCRAPER')).toBe(
'IconBuildingSkyscraper',
);
expect(normalizeIconName('ICONUSER')).toBe('IconUser');
expect(normalizeIconName('buildingSkyscraper')).toBe(
'IconBuildingSkyscraper',
);
});
it('should normalize unknown names to a renderable shape for the frontend fallback', () => {
expect(normalizeIconName('IconDoesNotExist')).toBe('IconDoesNotExist');
expect(normalizeIconName('not a real icon')).toBe('IconNotARealIcon');
});
it('should return undefined for missing or unusable input', () => {
expect(normalizeIconName(undefined)).toBeUndefined();
expect(normalizeIconName('')).toBeUndefined();
expect(normalizeIconName(' ')).toBeUndefined();
expect(normalizeIconName('!!!')).toBeUndefined();
expect(normalizeIconName('icon')).toBeUndefined();
});
});
@@ -0,0 +1,37 @@
import { isNonEmptyString } from '@sniptt/guards';
const ICON_NAME_PATTERN = /^Icon[A-Za-z0-9]+$/;
export const normalizeIconName = (
requestedIconName: string | undefined,
): string | undefined => {
if (!isNonEmptyString(requestedIconName)) {
return undefined;
}
const trimmedIconName = requestedIconName.trim();
if (ICON_NAME_PATTERN.test(trimmedIconName)) {
return trimmedIconName;
}
const words = trimmedIconName
.replace(/^icon[\s_-]*/i, '')
.split(/[^A-Za-z0-9]+/)
.filter((word) => word.length > 0)
.map((word) =>
word === word.toUpperCase()
? word.charAt(0) + word.slice(1).toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1),
);
if (words.length === 0) {
return undefined;
}
const normalizedIconName = `Icon${words.join('')}`;
return ICON_NAME_PATTERN.test(normalizedIconName)
? normalizedIconName
: undefined;
};
@@ -1,12 +1,14 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { FIELD_TYPE_DEFAULT_ICONS } from 'twenty-shared/constants';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { z } from 'zod';
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { normalizeIconName } from 'src/engine/core-modules/tool-provider/utils/normalize-icon-name.util';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { getObjectMetadataIdByName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-object-metadata-id-by-name.util';
@@ -57,7 +59,12 @@ const CreateFieldMetadataInputSchema = z.object({
name: z.string().describe('Field name (camelCase)'),
label: z.string().describe('Display label'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
icon: z
.string()
.optional()
.describe(
'Tabler icon name, PascalCase with "Icon" prefix (e.g. IconCurrencyDollar, IconCalendarTime, IconPaw). Set one matching the field meaning; falls back to a type-based default.',
),
isNullable: z.boolean().optional().describe('Nullable'),
isUnique: z.boolean().optional().describe('Unique constraint'),
defaultValue: z.unknown().optional().describe('Default value'),
@@ -79,7 +86,10 @@ const UpdateFieldMetadataInputSchema = z.object({
name: z.string().optional().describe('Field name'),
label: z.string().optional().describe('Display label'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
icon: z
.string()
.optional()
.describe('Tabler icon name (e.g. IconCurrencyDollar)'),
isActive: z.boolean().optional().describe('Active state'),
isNullable: z.boolean().optional().describe('Nullable'),
isUnique: z.boolean().optional().describe('Unique constraint'),
@@ -120,11 +130,18 @@ const CreateManyRelationFieldsInputSchema = z.object({
name: z.string().describe('Field name (camelCase)'),
label: z.string().describe('Display label'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
icon: z
.string()
.optional()
.describe('Tabler icon name for the relation field (e.g. IconUsers)'),
type: z.nativeEnum(RelationType).describe('MANY_TO_ONE or ONE_TO_MANY'),
targetObjectMetadataId: z.string().uuid().describe('Target object ID'),
targetFieldLabel: z.string().describe('Inverse field label'),
targetFieldIcon: z.string().describe('Inverse field icon'),
targetFieldIcon: z
.string()
.describe(
'Inverse field Tabler icon name (e.g. IconBuildingSkyscraper)',
),
}),
)
.min(1)
@@ -132,6 +149,29 @@ const CreateManyRelationFieldsInputSchema = z.object({
.describe('Relations to create (max 20).'),
});
const normalizeRelationCreationPayloadIcon = (
relationCreationPayload: unknown,
): unknown => {
if (
!isDefined(relationCreationPayload) ||
typeof relationCreationPayload !== 'object' ||
Array.isArray(relationCreationPayload)
) {
return relationCreationPayload;
}
const { targetFieldIcon } = relationCreationPayload as {
targetFieldIcon?: string;
};
return {
...relationCreationPayload,
targetFieldIcon:
normalizeIconName(targetFieldIcon) ??
FIELD_TYPE_DEFAULT_ICONS[FieldMetadataType.RELATION],
};
};
@Injectable()
export class FieldMetadataToolsFactory {
constructor(
@@ -249,9 +289,20 @@ export class FieldMetadataToolsFactory {
relationCreationPayload?: unknown;
}) => {
try {
const { icon, relationCreationPayload, ...createFieldInput } =
parameters;
const flatFieldMetadata =
await this.fieldMetadataService.createOneField({
createFieldInput: parameters as Parameters<
createFieldInput: {
...createFieldInput,
icon:
normalizeIconName(icon) ??
FIELD_TYPE_DEFAULT_ICONS[parameters.type],
relationCreationPayload: normalizeRelationCreationPayloadIcon(
relationCreationPayload,
),
} as Parameters<
typeof this.fieldMetadataService.createOneField
>[0]['createFieldInput'],
workspaceId,
@@ -291,11 +342,18 @@ export class FieldMetadataToolsFactory {
isLabelSyncedWithName?: boolean;
}) => {
try {
const { id, ...update } = parameters;
const { id, icon, ...update } = parameters;
const normalizedIcon = normalizeIconName(icon);
const flatFieldMetadata =
await this.fieldMetadataService.updateOneField({
updateFieldInput: { id, ...update } as Parameters<
updateFieldInput: {
id,
...update,
...(isDefined(normalizedIcon)
? { icon: normalizedIcon }
: {}),
} as Parameters<
typeof this.fieldMetadataService.updateOneField
>[0]['updateFieldInput'],
workspaceId,
@@ -359,7 +417,17 @@ export class FieldMetadataToolsFactory {
}) => {
try {
await this.fieldMetadataService.createManyFields({
createFieldInputs: parameters.fields as Parameters<
createFieldInputs: parameters.fields.map(
({ icon, relationCreationPayload, ...createFieldInput }) => ({
...createFieldInput,
icon:
normalizeIconName(icon) ??
FIELD_TYPE_DEFAULT_ICONS[createFieldInput.type],
relationCreationPayload: normalizeRelationCreationPayloadIcon(
relationCreationPayload,
),
}),
) as Parameters<
typeof this.fieldMetadataService.createManyFields
>[0]['createFieldInputs'],
workspaceId,
@@ -396,9 +464,17 @@ export class FieldMetadataToolsFactory {
}) => {
try {
await Promise.all(
parameters.fields.map(async ({ id, ...update }) => {
parameters.fields.map(async ({ id, icon, ...update }) => {
const normalizedIcon = normalizeIconName(icon);
await this.fieldMetadataService.updateOneField({
updateFieldInput: { id, ...update } as Parameters<
updateFieldInput: {
id,
...update,
...(isDefined(normalizedIcon)
? { icon: normalizedIcon }
: {}),
} as Parameters<
typeof this.fieldMetadataService.updateOneField
>[0]['updateFieldInput'],
workspaceId,
@@ -439,12 +515,16 @@ export class FieldMetadataToolsFactory {
name: relation.name,
label: relation.label,
description: relation.description,
icon: relation.icon,
icon:
normalizeIconName(relation.icon) ??
FIELD_TYPE_DEFAULT_ICONS[FieldMetadataType.RELATION],
relationCreationPayload: {
type: relation.type,
targetObjectMetadataId: relation.targetObjectMetadataId,
targetFieldLabel: relation.targetFieldLabel,
targetFieldIcon: relation.targetFieldIcon,
targetFieldIcon:
normalizeIconName(relation.targetFieldIcon) ??
FIELD_TYPE_DEFAULT_ICONS[FieldMetadataType.RELATION],
},
})) as Parameters<
typeof this.fieldMetadataService.createManyFields
@@ -8,6 +8,7 @@ import { z } from 'zod';
import { METADATA_TOOL_EXCLUDED_FIELD_NAMES } from 'src/engine/core-modules/tool-provider/constants/metadata-tool-excluded-field-names.constant';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { normalizeIconName } from 'src/engine/core-modules/tool-provider/utils/normalize-icon-name.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
@@ -65,7 +66,12 @@ const CreateObjectMetadataInputSchema = z.object({
labelSingular: z.string().describe('Singular label (e.g. "Company")'),
labelPlural: z.string().describe('Plural label (e.g. "Companies")'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
icon: z
.string()
.optional()
.describe(
'Tabler icon name, PascalCase with "Icon" prefix (e.g. IconBuildingSkyscraper, IconPaw, IconTargetArrow). Always set one matching what the object represents.',
),
shortcut: z.string().optional().describe('Keyboard shortcut'),
isRemote: z.boolean().optional().describe('Remote object'),
isLabelSyncedWithName: z
@@ -81,7 +87,10 @@ const UpdateObjectMetadataInputSchema = z.object({
nameSingular: z.string().optional().describe('Singular name'),
namePlural: z.string().optional().describe('Plural name'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
icon: z
.string()
.optional()
.describe('Tabler icon name (e.g. IconBuildingSkyscraper)'),
shortcut: z.string().optional().describe('Keyboard shortcut'),
isActive: z.boolean().optional().describe('Active state'),
labelIdentifierFieldMetadataId: z
@@ -240,9 +249,14 @@ export class ObjectMetadataToolsFactory {
isLabelSyncedWithName?: boolean;
}) => {
try {
const { icon, ...createObjectInput } = parameters;
const flatObjectMetadata =
await this.objectMetadataService.createOneObject({
createObjectInput: parameters as Parameters<
createObjectInput: {
...createObjectInput,
icon: normalizeIconName(icon),
} as Parameters<
typeof this.objectMetadataService.createOneObject
>[0]['createObjectInput'],
workspaceId,
@@ -280,11 +294,20 @@ export class ObjectMetadataToolsFactory {
isLabelSyncedWithName?: boolean;
}) => {
try {
const { id, ...update } = parameters;
const { id, icon, ...update } = parameters;
const normalizedIcon = normalizeIconName(icon);
const flatObjectMetadata =
await this.objectMetadataService.updateOneObject({
updateObjectInput: { id, update },
updateObjectInput: {
id,
update: {
...update,
...(isDefined(normalizedIcon)
? { icon: normalizedIcon }
: {}),
},
},
workspaceId,
});
@@ -340,9 +363,12 @@ export class ObjectMetadataToolsFactory {
}) => {
try {
await Promise.all(
parameters.objects.map(async (createObjectInput) => {
parameters.objects.map(async ({ icon, ...createObjectInput }) => {
await this.objectMetadataService.createOneObject({
createObjectInput: createObjectInput as Parameters<
createObjectInput: {
...createObjectInput,
icon: normalizeIconName(icon),
} as Parameters<
typeof this.objectMetadataService.createOneObject
>[0]['createObjectInput'],
workspaceId,
@@ -381,9 +407,19 @@ export class ObjectMetadataToolsFactory {
}) => {
try {
await Promise.all(
parameters.objects.map(async ({ id, ...update }) => {
parameters.objects.map(async ({ id, icon, ...update }) => {
const normalizedIcon = normalizeIconName(icon);
await this.objectMetadataService.updateOneObject({
updateObjectInput: { id, update },
updateObjectInput: {
id,
update: {
...update,
...(isDefined(normalizedIcon)
? { icon: normalizedIcon }
: {}),
},
},
workspaceId,
});
}),
@@ -622,6 +622,12 @@ You help users manage their workspace data model by creating, updating, and orga
- Choose appropriate field types for the data being stored
- Consider relationships between objects when designing the data model
## Icons
- Always set the \`icon\` property when creating objects and fields — otherwise they render with a meaningless default icon
- Icons are Tabler icon names: PascalCase with an \`Icon\` prefix (e.g. \`IconBuildingSkyscraper\`, \`IconPaw\`, \`IconCurrencyDollar\`)
- Pick an icon matching the meaning: a Pets object → \`IconPaw\`, a budget field → \`IconCurrencyDollar\`, a deadline field → \`IconCalendarDue\`
## Approach
- Ask clarifying questions to understand the user's data modeling needs
@@ -0,0 +1,29 @@
import { FieldMetadataType } from '@/types';
export const FIELD_TYPE_DEFAULT_ICONS: Record<FieldMetadataType, string> = {
[FieldMetadataType.ACTOR]: 'IconSettings',
[FieldMetadataType.ADDRESS]: 'IconMap',
[FieldMetadataType.ARRAY]: 'IconBrackets',
[FieldMetadataType.BOOLEAN]: 'IconToggleLeft',
[FieldMetadataType.CURRENCY]: 'IconCurrencyDollar',
[FieldMetadataType.DATE]: 'IconCalendarEvent',
[FieldMetadataType.DATE_TIME]: 'IconCalendarTime',
[FieldMetadataType.EMAILS]: 'IconMail',
[FieldMetadataType.FILES]: 'IconFile',
[FieldMetadataType.FULL_NAME]: 'IconUser',
[FieldMetadataType.LINKS]: 'IconLink',
[FieldMetadataType.MORPH_RELATION]: 'IconRelationOneToMany',
[FieldMetadataType.MULTI_SELECT]: 'IconTags',
[FieldMetadataType.NUMBER]: 'IconNumbers',
[FieldMetadataType.NUMERIC]: 'IconNumbers',
[FieldMetadataType.PHONES]: 'IconPhone',
[FieldMetadataType.POSITION]: 'IconArrowsSort',
[FieldMetadataType.RATING]: 'IconStar',
[FieldMetadataType.RAW_JSON]: 'IconJson',
[FieldMetadataType.RELATION]: 'IconRelationOneToMany',
[FieldMetadataType.RICH_TEXT]: 'IconBlockquote',
[FieldMetadataType.SELECT]: 'IconTag',
[FieldMetadataType.TEXT]: 'IconAbc',
[FieldMetadataType.TS_VECTOR]: 'IconSearch',
[FieldMetadataType.UUID]: 'IconId',
};
@@ -0,0 +1,19 @@
import { FieldMetadataType } from '@/types';
import { FIELD_TYPE_DEFAULT_ICONS } from '../FieldTypeDefaultIcons';
describe('FIELD_TYPE_DEFAULT_ICONS', () => {
it('should define a default icon for every field type', () => {
Object.values(FieldMetadataType).forEach((fieldMetadataType) => {
expect(FIELD_TYPE_DEFAULT_ICONS[fieldMetadataType]).toEqual(
expect.any(String),
);
});
});
it('should only contain canonically shaped icon names', () => {
Object.values(FIELD_TYPE_DEFAULT_ICONS).forEach((iconName) => {
expect(iconName).toMatch(/^Icon[A-Za-z0-9]+$/);
});
});
});
@@ -37,6 +37,7 @@ export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountA
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY } from './FieldMetadataTypesNotSupportedInGroupBy';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { FIELD_TYPE_DEFAULT_ICONS } from './FieldTypeDefaultIcons';
export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues';
export { GIN_COMPATIBLE_FIELD_TYPES } from './GinCompatibleFieldTypes';
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';