From 41cdd833670a60c5d262fbf432895d1055f0a7cf Mon Sep 17 00:00:00 2001 From: Rich Roberts Date: Thu, 11 Jun 2026 16:13:26 +0100 Subject: [PATCH] fix(ai): correct RICH_TEXT and MORPH_RELATION record filter operators (#21106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The AI find-records tool generates filter schemas via `generateFieldFilterZodSchema`. `RICH_TEXT` currently shares the `TEXT` case, so the agent is told it can use scalar text operators (`like`/`ilike`/`startsWith`/`endsWith`/`eq`/…) directly on a rich-text field. But `RICH_TEXT` is a **composite** type (`markdown` + `blocknote` sub-fields, see `rich-text.composite-type.ts`). Applying a scalar operator to the composite root throws at query time: ``` ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "ilike" field. ERROR [FindRecordsService] Failed to find records: Sub field "ilike" not found for composite type: RICH_TEXT ``` `FindRecordsService` catches and returns `success: false`, so the agent retries mid-turn — burning latency/tokens — and can **never** search rich-text body content (note bodies, `about`, etc.). ## Fix Give `RICH_TEXT` its own case in the filter-schema generator that exposes the `markdown` and `blocknote` sub-fields, each carrying the text operators — mirroring the existing composite patterns for `EMAILS` (`primaryEmail`), `PHONES` (`primaryPhoneNumber`), `LINKS` (`primaryLinkUrl`), `FULL_NAME`, and `ADDRESS`. So the agent now emits: ```jsonc { "noteBody": { "markdown": { "ilike": "%onboarding%" } } } // valid composite sub-field filter ``` instead of: ```jsonc { "noteBody": { "ilike": "%onboarding%" } } // throws on composite root ``` This both **stops the throw** and **makes rich-text content actually searchable** (the original intent). `TEXT` keeps its existing root-level scalar operators unchanged. ## Test Added `__tests__/field-filters.zod-schema.spec.ts`: - `RICH_TEXT` routes pattern operators onto `markdown` / `blocknote` - root-level scalar operators on `RICH_TEXT` are no longer accepted - `TEXT` root-level operators unchanged ## Notes - No DB/schema migration; render/tool-schema layer only. - Reproduced against `twentycrm/twenty:latest`; the faulting code is unchanged on `main` as of this PR. --------- Co-authored-by: Rich Roberts Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../field-filters.zod-schema.spec.ts | 72 +++++++++++++++++++ .../zod-schemas/field-filters.zod-schema.ts | 18 +++-- .../zod-schemas/record-filter.zod-schema.ts | 3 +- .../shared-filter-defs.zod-schema.ts | 19 +++++ 4 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts new file mode 100644 index 0000000000..3cac0557c2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/__tests__/field-filters.zod-schema.spec.ts @@ -0,0 +1,72 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; + +const fieldOfType = ( + type: FieldMetadataType, + name = 'body', + settings?: Record, +) => ({ type, name, settings }) as FieldMetadataEntity; + +describe('generateFieldFilterZodSchema', () => { + describe('TEXT', () => { + it('exposes scalar pattern operators at the field root', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.TEXT), + ); + + expect(schema).not.toBeNull(); + expect(schema!.parse({ ilike: '%foo%' })).toEqual({ ilike: '%foo%' }); + }); + }); + + describe('RICH_TEXT', () => { + // Regression for the AI find-records bug: RICH_TEXT is a composite + // (`markdown` / `blocknote` sub-fields). Advertising root-level scalar + // operators made the agent emit `{ ilike }`, which the query layer rejects + // with `Sub field "ilike" not found for composite type: RICH_TEXT`. + it('routes pattern operators onto the markdown sub-field', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.RICH_TEXT), + ); + + expect(schema).not.toBeNull(); + expect(schema!.parse({ markdown: { ilike: '%hello%' } })).toEqual({ + markdown: { ilike: '%hello%' }, + }); + }); + + it('no longer accepts scalar operators at the composite root', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.RICH_TEXT), + ); + + // Root-level operators are stripped (unknown keys), so the malformed + // `{ ilike }` filter never reaches the query runner. + expect(schema!.parse({ ilike: '%hello%' })).toEqual({}); + }); + }); + + describe('MORPH_RELATION', () => { + // Regression: morph relations (e.g. noteTarget.targetPerson) are filtered + // by their join column (`${name}Id`). Without a dedicated case they hit the + // text default and advertise like/ilike, which the runner rejects when it + // resolves the relation (`Object person doesn't have any "ilike" field`). + const uuid = '7def8b6a-ec89-48f1-9835-ec2f7c726ef0'; + + it('filters by the related record id and rejects text operators', () => { + const schema = generateFieldFilterZodSchema( + fieldOfType(FieldMetadataType.MORPH_RELATION, 'targetPerson', { + relationType: RelationType.MANY_TO_ONE, + }), + ); + + expect(schema).not.toBeNull(); + expect(schema!.parse({ eq: uuid })).toEqual({ eq: uuid }); + // `ilike` is not a valid operator for a relation id — stripped. + expect(schema!.parse({ ilike: '%Tom%' })).toEqual({}); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts index 80bc9fb486..2591aa65fe 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts @@ -3,9 +3,6 @@ import { z } from 'zod'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; -import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; import { AddressFilterSchema, ArrayFieldFilterSchema, @@ -19,9 +16,13 @@ import { NullCheckEnum, NumberFilterSchema, PhonesFilterSchema, + RichTextFilterSchema, TextFilterSchema, UuidFilterSchema, } from 'src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; export { NullCheckEnum }; @@ -33,9 +34,11 @@ export const generateFieldFilterZodSchema = ( return UuidFilterSchema; case FieldMetadataType.TEXT: - case FieldMetadataType.RICH_TEXT: return TextFilterSchema; + case FieldMetadataType.RICH_TEXT: + return RichTextFilterSchema; + case FieldMetadataType.NUMBER: case FieldMetadataType.NUMERIC: case FieldMetadataType.POSITION: @@ -130,9 +133,14 @@ export const generateFieldFilterZodSchema = ( case FieldMetadataType.LINKS: return LinksFilterSchema; + case FieldMetadataType.MORPH_RELATION: case FieldMetadataType.RELATION: if ( - isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && + (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) || + isFieldMetadataEntityOfType( + field, + FieldMetadataType.MORPH_RELATION, + )) && field.settings?.relationType === RelationType.MANY_TO_ONE ) { return UuidFilterSchema; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts index b23fb72a9c..3d8b8a828d 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts @@ -48,7 +48,8 @@ export const generateRecordFilterSchema = ({ } const isManyToOneRelationField = - isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && + (isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) || + isFieldMetadataEntityOfType(field, FieldMetadataType.MORPH_RELATION)) && field.settings?.relationType === RelationType.MANY_TO_ONE; filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] = diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts index f3c13d1799..4a0522776b 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts @@ -217,6 +217,25 @@ export const PhonesFilterSchema = z }) .optional(); +export const RichTextFilterSchema = z + .object({ + markdown: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + startsWith: z.string().optional().describe('Starts with'), + endsWith: z.string().optional().describe('Ends with'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + export const CurrencyFilterSchema = z .object({ amountMicros: z