fix(ai): correct RICH_TEXT and MORPH_RELATION record filter operators (#21106)

## 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 <rich.roberts@talentpipe.ai>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Rich Roberts
2026-06-11 16:13:26 +01:00
committed by GitHub
parent 503c689f37
commit 41cdd83367
4 changed files with 106 additions and 6 deletions
@@ -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<string, unknown>,
) => ({ 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({});
});
});
});
@@ -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;
@@ -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] =
@@ -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