fix(ai): MCPCore findRecord tool (#14033)

Fix #13545

---------

Co-authored-by: lhdu <lhdu@bigquant.ai>
Co-authored-by: huxianc <47603705+huxianc@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Antoine Moreaux
2025-08-22 12:05:56 +02:00
committed by GitHub
parent c89dba9a9b
commit 91ce3d4e0c
9 changed files with 697 additions and 137 deletions
@@ -11,6 +11,7 @@ import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-ada
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
@@ -28,6 +29,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
TypeOrmModule.forFeature([RoleEntity], 'core'),
TokenModule,
FeatureFlagModule,
RecordTransformerModule,
ObjectMetadataModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheStorageModule,
@@ -3,9 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { AIBillingService } from './ai-billing.service';
import { AiModelRegistryService } from './ai-model-registry.service';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
describe('AIBillingService', () => {
let service: AIBillingService;
@@ -11,8 +11,7 @@ import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { McpService } from './mcp.service';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
describe('McpService', () => {
let service: McpService;
@@ -0,0 +1,157 @@
import { Test } from '@nestjs/testing';
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
const createMockToolRegistry = () => ({
getAllToolTypes: jest.fn(),
getTool: jest.fn(),
});
const createMockPermissions = () => ({
hasToolPermission: jest.fn<
Promise<boolean>,
[string, string, PermissionFlagType]
>(),
});
describe('ToolAdapterService', () => {
let mockRegistry: ReturnType<typeof createMockToolRegistry>;
let mockPermissions: ReturnType<typeof createMockPermissions>;
let service: ToolAdapterService;
// Shared tools
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
result: { echoed: input },
}));
const unflaggedTool: Tool = {
description: 'HTTP Request tool',
parameters: { type: 'object', properties: {} },
execute: unflaggedToolExecute,
};
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
result: { sent: input },
}));
const flaggedTool: Tool = {
description: 'Send Email tool',
parameters: { type: 'object', properties: {} },
execute: flaggedToolExecute,
flag: PermissionFlagType.SEND_EMAIL_TOOL,
};
beforeEach(async () => {
jest.clearAllMocks();
mockRegistry = createMockToolRegistry();
mockPermissions = createMockPermissions();
// Setup mock tool responses
mockRegistry.getAllToolTypes.mockReturnValue([
ToolType.HTTP_REQUEST,
ToolType.SEND_EMAIL,
]);
mockRegistry.getTool.mockImplementation((type: ToolType) => {
if (type === ToolType.HTTP_REQUEST) return unflaggedTool;
if (type === ToolType.SEND_EMAIL) return flaggedTool;
throw new Error('Tool not found in mock');
});
const moduleRef = await Test.createTestingModule({
providers: [
ToolAdapterService,
{
provide: ToolRegistryService,
useValue: mockRegistry,
},
{
provide: PermissionsService,
useValue: mockPermissions,
},
],
}).compile();
service = moduleRef.get(ToolAdapterService);
});
it('should include unflagged tools regardless of role/workspace', async () => {
const toolsNoContext = await service.getTools();
expect(Object.keys(toolsNoContext)).toContain('http_request');
const toolsWithPartialContext = await service.getTools('role-1');
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
});
it('should not include flagged tools when role/workspace are missing', async () => {
const toolsNoContext = await service.getTools();
expect(Object.keys(toolsNoContext)).not.toContain('send_email');
const toolsRoleOnly = await service.getTools('role-1');
expect(Object.keys(toolsRoleOnly)).not.toContain('send_email');
const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1');
expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email');
});
it('should include flagged tools when permission is granted', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
const tools = await service.getTools('role-1', 'ws-1');
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
'role-1',
'ws-1',
PermissionFlagType.SEND_EMAIL_TOOL,
);
expect(Object.keys(tools)).toContain('send_email');
});
it('should exclude flagged tools when permission is denied', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
const tools = await service.getTools('role-1', 'ws-1');
expect(Object.keys(tools)).not.toContain('send_email');
});
it('should lowercase tool type keys in the returned ToolSet', async () => {
const tools = await service.getTools();
const keys = Object.keys(tools);
expect(keys).toContain('http_request');
expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is
});
it('should forward execute input correctly and return underlying result', async () => {
const tools = await service.getTools();
const input = { url: 'https://example.com', method: 'GET' } as ToolInput;
const result = await tools['http_request'].execute?.(
{ input },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'content',
},
],
},
);
// Ensure wrapper forwards only parameters.input
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
expect(result).toEqual({ result: { echoed: input } });
});
});
@@ -0,0 +1,213 @@
import { Test } from '@nestjs/testing';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
// Minimal mock repository type
const createMockRepository = () => ({
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
remove: jest.fn(),
});
describe('ToolService', () => {
const workspaceId = 'ws_1';
const roleId = 'role_1';
let service: ToolService;
let ormManager: TwentyORMGlobalManager;
let permissionsCacheService: WorkspacePermissionsCacheService;
let transformer: RecordInputTransformerService;
let workspaceCache: WorkspaceCacheStorageService;
const testObject = getMockObjectMetadataEntity({
workspaceId: '',
id: 'obj_1',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
isActive: true,
isSystem: false,
fields: [],
});
const mockRepo = createMockRepository();
beforeEach(async () => {
jest.resetAllMocks();
const moduleRef = await Test.createTestingModule({
providers: [
ToolService,
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo),
},
},
{
provide: ObjectMetadataService,
useValue: {
findManyWithinWorkspace: jest.fn().mockResolvedValue([testObject]),
},
},
{
provide: WorkspacePermissionsCacheService,
useValue: {
getRolesPermissionsFromCache: jest.fn().mockResolvedValue({
data: {
[roleId]: {
[testObject.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: false,
restrictedFields: {},
},
},
},
}),
},
},
{
provide: RecordInputTransformerService,
useValue: {
process: jest.fn(async ({ recordInput }) => recordInput),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({
byId: {
[testObject.id]: {
...testObject,
fieldsById: {},
fieldIdByJoinColumnName: {},
fieldIdByName: {},
indexMetadatas: [],
},
},
idByNameSingular: { [testObject.nameSingular]: testObject.id },
}),
},
},
],
}).compile();
service = moduleRef.get(ToolService);
ormManager = moduleRef.get(TwentyORMGlobalManager);
permissionsCacheService = moduleRef.get(WorkspacePermissionsCacheService);
transformer = moduleRef.get(RecordInputTransformerService);
workspaceCache = moduleRef.get(WorkspaceCacheStorageService);
});
describe('listTools', () => {
it('should return tools based on role permissions', async () => {
const tools = await service.listTools(roleId, workspaceId);
expect(
permissionsCacheService.getRolesPermissionsFromCache,
).toHaveBeenCalledWith({ workspaceId });
// Verify tool keys
expect(tools['create_testObject']).toBeDefined();
expect(tools['update_testObject']).toBeDefined();
expect(tools['find_testObject']).toBeDefined();
expect(tools['find_one_testObject']).toBeDefined();
expect(tools['soft_delete_testObject']).toBeDefined();
expect(tools['soft_delete_many_testObject']).toBeDefined();
// Ensure the execute functions are wired
expect(typeof tools['create_testObject'].execute).toBe('function');
});
});
describe('createRecord', () => {
it('should create a record successfully', async () => {
const record = { id: 'r1', name: 'Test' };
mockRepo.save.mockResolvedValue(record);
const result = await service.createRecord(
'testObject',
{ name: 'Test' },
workspaceId,
roleId,
);
expect(result.success).toBe(true);
expect(result.record).toEqual(record);
expect(ormManager.getRepositoryForWorkspace).toHaveBeenCalledWith(
workspaceId,
'testObject',
{ roleId },
);
expect(workspaceCache.getObjectMetadataMapsOrThrow).toHaveBeenCalledWith(
workspaceId,
);
expect(transformer.process).toHaveBeenCalled();
expect(mockRepo.save).toHaveBeenCalledWith({ name: 'Test' });
});
});
describe('updateRecord', () => {
it('should return error when id is missing', async () => {
const result = await (service as any).updateRecord(
'testObject',
{ name: 'No ID' },
workspaceId,
roleId,
);
expect(result.success).toBe(false);
expect(result.error).toBe('Record ID is required for update');
});
});
describe('findRecords', () => {
it('should return records and count', async () => {
const records = [{ id: 'a' }, { id: 'b' }];
mockRepo.find.mockResolvedValue(records);
const result = await (service as any).findRecords(
'testObject',
{},
workspaceId,
roleId,
);
expect(result.success).toBe(true);
expect(result.records).toEqual(records);
expect(result.count).toBe(2);
expect(mockRepo.find).toHaveBeenCalled();
});
});
describe('softDeleteManyRecords', () => {
it('should error when filter is invalid', async () => {
const result = await (service as any).softDeleteManyRecords(
'testObject',
{},
workspaceId,
roleId,
);
expect(result.success).toBe(false);
expect(result.error).toBe(
'Filter with record IDs is required for bulk soft delete',
);
});
});
});
@@ -1,18 +1,9 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import { buildWhereConditions } from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import {
generateBulkDeleteToolSchema,
generateFindOneToolSchema,
@@ -22,8 +13,10 @@ import {
} from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ToolService {
@@ -31,6 +24,8 @@ export class ToolService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly objectMetadataService: ObjectMetadataService,
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly recordInputTransformerService: RecordInputTransformerService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async listTools(roleId: string, workspaceId: string): Promise<ToolSet> {
@@ -167,7 +162,7 @@ export class ToolService {
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
const whereConditions = this.buildWhereConditions(searchCriteria);
const whereConditions = buildWhereConditions(searchCriteria);
const records = await repository.find({
where: whereConditions,
@@ -191,124 +186,6 @@ export class ToolService {
}
}
private buildWhereConditions(
searchCriteria: Record<string, unknown>,
): Record<string, unknown> {
const whereConditions: Record<string, unknown> = {};
Object.entries(searchCriteria).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
if (typeof value === 'object' && !Array.isArray(value)) {
const nestedConditions = this.buildNestedWhereConditions(
value as Record<string, unknown>,
);
if (Object.keys(nestedConditions).length > 0) {
whereConditions[key] = nestedConditions;
} else {
const filterCondition = this.parseFilterCondition(
value as Record<string, unknown>,
);
if (filterCondition !== null) {
whereConditions[key] = filterCondition;
}
}
return;
}
whereConditions[key] = value;
});
return whereConditions;
}
private buildNestedWhereConditions(
nestedValue: Record<string, unknown>,
): Record<string, unknown> {
const nestedConditions: Record<string, unknown> = {};
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
if (
nestedFieldValue === undefined ||
nestedFieldValue === null ||
nestedFieldValue === ''
) {
return;
}
if (
typeof nestedFieldValue === 'object' &&
!Array.isArray(nestedFieldValue)
) {
const filterCondition = this.parseFilterCondition(
nestedFieldValue as Record<string, unknown>,
);
if (filterCondition !== null) {
nestedConditions[nestedKey] = filterCondition;
}
} else {
nestedConditions[nestedKey] = nestedFieldValue;
}
});
return nestedConditions;
}
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
if ('eq' in filterValue) {
return filterValue.eq;
}
if ('neq' in filterValue) {
return Not(filterValue.neq);
}
if ('gt' in filterValue) {
return MoreThan(filterValue.gt);
}
if ('gte' in filterValue) {
return MoreThanOrEqual(filterValue.gte);
}
if ('lt' in filterValue) {
return LessThan(filterValue.lt);
}
if ('lte' in filterValue) {
return LessThanOrEqual(filterValue.lte);
}
if ('in' in filterValue) {
return In(filterValue.in as string[]);
}
if ('like' in filterValue) {
return Like(filterValue.like as string);
}
if ('ilike' in filterValue) {
return ILike(filterValue.ilike as string);
}
if ('startsWith' in filterValue) {
return Like(`${filterValue.startsWith}%`);
}
if ('is' in filterValue) {
if (filterValue.is === 'NULL') {
return IsNull();
}
if (filterValue.is === 'NOT_NULL') {
return Not(IsNull());
}
}
if ('isEmptyArray' in filterValue) {
return [];
}
if ('containsIlike' in filterValue) {
return Like(`%${filterValue.containsIlike}%`);
}
return null;
}
private async findOneRecord(
objectName: string,
parameters: Record<string, unknown>,
@@ -373,7 +250,29 @@ export class ToolService {
{ roleId },
);
const createdRecord = await repository.save(parameters);
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
workspaceId,
);
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName);
if (!objectMetadataItemWithFieldsMaps) {
return {
success: false,
error: 'Object metadata not found',
message: `Failed to create ${objectName}: Object metadata not found`,
};
}
const transformedCreateData =
await this.recordInputTransformerService.process({
recordInput: parameters,
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
});
const createdRecord = await repository.save(transformedCreateData);
return {
success: true,
@@ -425,7 +324,29 @@ export class ToolService {
};
}
await repository.update(id as string, updateData);
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow(
workspaceId,
);
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(objectMetadataMaps, objectName);
if (!objectMetadataItemWithFieldsMaps) {
return {
success: false,
error: 'Object metadata not found',
message: `Failed to update ${objectName}: Object metadata not found`,
};
}
const transformedUpdateData =
await this.recordInputTransformerService.process({
recordInput: updateData,
objectMetadataMapItem: objectMetadataItemWithFieldsMaps,
});
await repository.update(id as string, transformedUpdateData);
const updatedRecord = await repository.findOne({
where: { id: id as string },
@@ -0,0 +1,120 @@
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import {
buildWhereConditions,
parseFilterCondition,
} from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
describe('find-records-filters.utils', () => {
describe('parseFilterCondition', () => {
it('should handle eq', () => {
expect(parseFilterCondition({ eq: 10 })).toBe(10);
});
it('should handle neq', () => {
expect(parseFilterCondition({ neq: 5 })).toEqual(Not(5));
});
it('should handle gt/gte/lt/lte', () => {
expect(parseFilterCondition({ gt: 1 })).toEqual(MoreThan(1));
expect(parseFilterCondition({ gte: 2 })).toEqual(MoreThanOrEqual(2));
expect(parseFilterCondition({ lt: 3 })).toEqual(LessThan(3));
expect(parseFilterCondition({ lte: 4 })).toEqual(LessThanOrEqual(4));
});
it('should handle in', () => {
expect(parseFilterCondition({ in: ['a', 'b'] })).toEqual(In(['a', 'b']));
});
it('should handle like/ilike', () => {
expect(parseFilterCondition({ like: '%foo%' })).toEqual(Like('%foo%'));
expect(parseFilterCondition({ ilike: '%bar%' })).toEqual(ILike('%bar%'));
});
it('should handle startsWith', () => {
expect(parseFilterCondition({ startsWith: 'pre' })).toEqual(Like('pre%'));
});
it('should handle is NULL and NOT_NULL', () => {
expect(parseFilterCondition({ is: 'NULL' })).toEqual(IsNull());
expect(parseFilterCondition({ is: 'NOT_NULL' })).toEqual(Not(IsNull()));
});
it('should handle isEmptyArray', () => {
expect(parseFilterCondition({ isEmptyArray: true })).toEqual([]);
});
it('should handle containsIlike (uses Like with wildcards)', () => {
const result = parseFilterCondition({ containsIlike: 'mid' });
expect(result).toEqual(Like('%mid%'));
});
});
describe('buildWhereConditions', () => {
it('should build where conditions from mixed criteria', () => {
const where = buildWhereConditions({
// primitive values
id: '123',
active: true,
count: 7,
// skip falsy-empty values
skipUndefined: undefined,
skipNull: null,
skipEmptyString: '',
// operator objects
name: { ilike: '%alpha%' },
createdAt: { gte: '2024-01-01' },
score: { lte: 99 },
tags: { in: ['a', 'b'] },
prefix: { startsWith: 'PRE' },
nothing: { is: 'NULL' },
notNothing: { is: 'NOT_NULL' },
contains: { containsIlike: 'mid' },
// nested relation-like object
account: {
name: { startsWith: 'ACME' },
size: { gte: 100 },
country: '', // should be omitted
},
// arrays should pass through when not an operator object
list: [1, 2, 3],
});
expect(where.id).toBe('123');
expect(where.active).toBe(true);
expect(where.count).toBe(7);
expect(where.name).toEqual(ILike('%alpha%'));
expect(where.createdAt).toEqual(MoreThanOrEqual('2024-01-01'));
expect(where.score).toEqual(LessThanOrEqual(99));
expect(where.tags).toEqual(In(['a', 'b']));
expect(where.prefix).toEqual(Like('PRE%'));
expect(where.nothing).toEqual(IsNull());
expect(where.notNothing).toEqual(Not(IsNull()));
expect(where.contains).toEqual(Like('%mid%'));
expect(where.account).toEqual({
name: Like('ACME%'),
size: MoreThanOrEqual(100),
});
expect(where.list).toEqual([1, 2, 3]);
// Ensure skipped values are not present
expect('skipUndefined' in where).toBe(false);
expect('skipNull' in where).toBe(false);
expect('skipEmptyString' in where).toBe(false);
});
});
});
@@ -0,0 +1,112 @@
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
type FilterObject = Record<string, unknown>;
type WhereRecord = Record<string, unknown>;
const isSkippableValue = (value: unknown): boolean =>
!isDefined(value) || value === '';
const isPlainObject = (value: unknown): value is FilterObject =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseFilterCondition = (filterValue: FilterObject): unknown => {
if ('eq' in filterValue) {
return filterValue.eq;
}
if ('neq' in filterValue) {
return Not(filterValue.neq as unknown);
}
if ('gt' in filterValue) {
return MoreThan(filterValue.gt as unknown as number | string | Date);
}
if ('gte' in filterValue) {
return MoreThanOrEqual(
filterValue.gte as unknown as number | string | Date,
);
}
if ('lt' in filterValue) {
return LessThan(filterValue.lt as unknown as number | string | Date);
}
if ('lte' in filterValue) {
return LessThanOrEqual(
filterValue.lte as unknown as number | string | Date,
);
}
if ('in' in filterValue) {
const values = (filterValue as { in: unknown }).in;
return Array.isArray(values) ? In(values as unknown[]) : null;
}
if ('like' in filterValue) {
return Like(filterValue.like as string);
}
if ('ilike' in filterValue) {
return ILike(filterValue.ilike as string);
}
if ('startsWith' in filterValue) {
return Like(`${String(filterValue.startsWith)}%`);
}
if ('is' in filterValue) {
const v = (filterValue as { is: unknown }).is;
if (v === 'NULL') return IsNull();
if (v === 'NOT_NULL') return Not(IsNull());
}
if ('isEmptyArray' in filterValue) {
return [];
}
if ('containsIlike' in filterValue) {
return Like(`%${String(filterValue.containsIlike)}%`);
}
return null;
};
export const buildWhereConditions = (
searchCriteria: FilterObject,
): WhereRecord => {
return Object.entries(searchCriteria).reduce<WhereRecord>(
(acc, [key, value]) => {
if (isSkippableValue(value)) {
return acc;
}
if (isPlainObject(value)) {
// Direct operator-based condition (eq, ilike, etc.)
const filterCondition = parseFilterCondition(value as FilterObject);
if (isDefined(filterCondition)) {
acc[key] = filterCondition;
return acc;
}
// Otherwise, try to build nested conditions
const nestedConditions = buildWhereConditions(value);
if (Object.keys(nestedConditions).length > 0) {
acc[key] = nestedConditions;
}
return acc;
}
acc[key] = value as unknown;
return acc;
},
{},
);
};
@@ -21,6 +21,8 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
export interface AgentToolTestContext {
module: TestingModule;
@@ -82,6 +84,18 @@ export const createAgentToolTestModule =
provide: ToolService,
useClass: ToolService,
},
{
provide: RecordInputTransformerService,
useValue: {
process: jest.fn(async ({ recordInput }) => recordInput),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
getObjectMetadataMapsOrThrow: jest.fn(),
},
},
{
provide: ToolAdapterService,
useClass: ToolAdapterService,
@@ -212,6 +226,29 @@ export const createAgentToolTestModule =
fieldPermissions: [],
});
// Ensure ToolService input transformation has access to minimal metadata maps
const workspaceCacheStorageService =
module.get<WorkspaceCacheStorageService>(WorkspaceCacheStorageService);
// Return a barebones object metadata map where fields are unknown (so transformer is a no-op)
const getMapsMock =
workspaceCacheStorageService.getObjectMetadataMapsOrThrow as jest.Mock;
getMapsMock.mockResolvedValue({
byId: {
[testObjectMetadata.id]: {
...testObjectMetadata,
fieldsById: {},
fieldIdByJoinColumnName: {},
fieldIdByName: {},
indexMetadatas: [],
},
},
idByNameSingular: {
[testObjectMetadata.nameSingular]: testObjectMetadata.id,
},
});
return {
module,
agentToolService,