feat: Add agent role assignment and database CRUD tools for AI agent nodes (#12888)

This PR introduces a significant enhancement to the role-based
permission system by extending it to support AI agents, enabling them to
perform database operations based on assigned permissions.

## Key Changes

### 1. Database Schema Migration
- **Table Rename**: `userWorkspaceRole` → `roleTargets` to better
reflect its expanded purpose
- **New Column**: Added `agentId` (UUID, nullable) to support AI agent
role assignments
- **Constraint Updates**: 
- Made `userWorkspaceId` nullable to accommodate agent-only role
assignments
- Added check constraint `CHK_role_targets_either_agent_or_user`
ensuring either `agentId` OR `userWorkspaceId` is set (not both)

### 2. Entity & Service Layer Updates
- **RoleTargetsEntity**: Updated with new `agentId` field and constraint
validation
- **AgentRoleService**: New service for managing agent role assignments
with validation
- **AgentService**: Enhanced to include role information when retrieving
agents
- **RoleResolver**: Added GraphQL mutations for `assignRoleToAgent` and
`removeRoleFromAgent`

### 3. AI Agent CRUD Operations
- **Permission-Based Tool Generation**: AI agents now receive database
tools based on their assigned role permissions
- **Dynamic Tool Creation**: The `AgentToolService` generates CRUD tools
(`create_*`, `find_*`, `update_*`, `soft_delete_*`, `destroy_*`) for
each object based on role permissions
- **Granular Permissions**: Supports both global role permissions
(`canReadAllObjectRecords`) and object-specific permissions
(`canReadObjectRecords`)

### 4. Frontend Integration
- **Role Assignment UI**: Added hooks and components for
assigning/removing roles from agents

## Demo


https://github.com/user-attachments/assets/41732267-742e-416c-b423-b687c2614c82

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: Vicky Wang <157669812+vickywxng@users.noreply.github.com>
Co-authored-by: Vicky Wang <vw92@cornell.edu>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-29 22:18:14 +02:00
committed by GitHub
co-authored by Antoine Moreaux Lucas Bordeau Charles Bochet Guillim Charles Bochet Weiko github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> github-actions Félix Malfait Marie martmull Thomas Trompette Etienne Baptiste Devessier nitin Paul Rastoin prastoin Vicky Wang Vicky Wang Raphaël Bosi
parent 317336ab71
commit 74b6466a57
53 changed files with 4804 additions and 478 deletions
@@ -0,0 +1,954 @@
import {
AgentToolTestContext,
createAgentToolTestModule,
createMockRepository,
createTestRecord,
createTestRecords,
expectErrorResult,
expectSuccessResult,
setupBasicPermissions,
setupRepositoryMock,
} from './utils/agent-tool-test-utils';
describe('AgentToolService Integration', () => {
let context: AgentToolTestContext;
beforeEach(async () => {
context = await createAgentToolTestModule();
});
afterEach(async () => {
await context.module.close();
});
describe('Tool Generation', () => {
it('should generate complete tool set for agent with full permissions', async () => {
const roleWithFullPermissions = {
...context.testRole,
canDestroyAllObjectRecords: true,
};
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(roleWithFullPermissions);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: true,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
expect(tools).toBeDefined();
expect(Object.keys(tools)).toHaveLength(8);
expect(Object.keys(tools)).toContain('create_testObject');
expect(Object.keys(tools)).toContain('update_testObject');
expect(Object.keys(tools)).toContain('find_testObject');
expect(Object.keys(tools)).toContain('find_one_testObject');
expect(Object.keys(tools)).toContain('soft_delete_testObject');
expect(Object.keys(tools)).toContain('soft_delete_many_testObject');
expect(Object.keys(tools)).toContain('destroy_testObject');
expect(Object.keys(tools)).toContain('destroy_many_testObject');
});
it('should generate read-only tools for agent with read permissions only', async () => {
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(context.testRole);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: false,
canSoftDelete: false,
canDestroy: false,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
expect(tools).toBeDefined();
expect(Object.keys(tools)).toHaveLength(2);
expect(Object.keys(tools)).toContain('find_testObject');
expect(Object.keys(tools)).toContain('find_one_testObject');
expect(Object.keys(tools)).not.toContain('create_testObject');
expect(Object.keys(tools)).not.toContain('update_testObject');
});
it('should return empty tools for agent without role', async () => {
const agentWithoutRole = { ...context.testAgent, roleId: null };
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(agentWithoutRole as any);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
expect(tools).toEqual({});
});
it('should return empty tools when role does not exist', async () => {
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest.spyOn(context.roleRepository, 'findOne').mockResolvedValue(null);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
expect(tools).toEqual({});
});
it('should filter out workflow-related objects', async () => {
const workflowObject = {
...context.testObjectMetadata,
nameSingular: 'workflow',
namePlural: 'workflows',
};
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(context.testRole);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[workflowObject.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: false,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([workflowObject]);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
expect(tools).toEqual({});
});
});
describe('Create Record Operations', () => {
it('should create a record successfully', async () => {
const mockRepository = createMockRepository();
const testRecord = createTestRecord('test-record-id', {
name: 'Test Record',
description: 'Test description',
});
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.save.mockResolvedValue(testRecord);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const createTool = tools['create_testObject'];
expect(createTool).toBeDefined();
if (!createTool.execute) {
throw new Error(
'Create tool is missing or does not have an execute method',
);
}
const result = await createTool.execute(
{ name: 'Test Record', description: 'Test description' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Test Record',
},
],
},
);
expectSuccessResult(result, 'Successfully created testObject');
expect(result.record).toEqual(testRecord);
expect(mockRepository.save).toHaveBeenCalledWith({
name: 'Test Record',
description: 'Test description',
});
});
it('should handle create record errors gracefully', async () => {
const mockRepository = createMockRepository();
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.save.mockRejectedValue(
new Error('Database constraint violation'),
);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const createTool = tools['create_testObject'];
expect(createTool).toBeDefined();
if (!createTool.execute) {
throw new Error(
'Create tool is missing or does not have an execute method',
);
}
const result = await createTool.execute(
{ name: 'Test Record' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Test Record',
},
],
},
);
expectErrorResult(
result,
'Database constraint violation',
'Failed to create testObject',
);
});
});
describe('Find Record Operations', () => {
it('should find records with basic parameters', async () => {
const mockRepository = createMockRepository();
const testRecords = createTestRecords(3);
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.find.mockResolvedValue(testRecords);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const findTool = tools['find_testObject'];
expect(findTool).toBeDefined();
if (!findTool.execute) {
throw new Error(
'Find tool is missing or does not have an execute method',
);
}
const result = await findTool.execute(
{ limit: 10, offset: 0 },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Find records',
},
],
},
);
expectSuccessResult(result, 'Found 3 testObject records');
expect(result.records).toEqual(testRecords);
expect(result.count).toBe(3);
expect(mockRepository.find).toHaveBeenCalledWith({
where: {},
take: 10,
skip: 0,
order: { createdAt: 'DESC' },
});
});
it('should find one record by ID', async () => {
const mockRepository = createMockRepository();
const testRecord = createTestRecord('test-record-id', {
name: 'Test Record',
});
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.findOne.mockResolvedValue(testRecord);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const findOneTool = tools['find_one_testObject'];
expect(findOneTool).toBeDefined();
if (!findOneTool.execute) {
throw new Error(
'Find one tool is missing or does not have an execute method',
);
}
const result = await findOneTool.execute(
{ id: 'test-record-id' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Find one record',
},
],
},
);
expectSuccessResult(result, 'Found testObject record');
expect(result.record).toEqual(testRecord);
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'test-record-id' },
});
});
it('should handle find one record not found', async () => {
const mockRepository = createMockRepository();
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.findOne.mockResolvedValue(null);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const findOneTool = tools['find_one_testObject'];
expect(findOneTool).toBeDefined();
if (!findOneTool.execute) {
throw new Error(
'Find one tool is missing or does not have an execute method',
);
}
const result = await findOneTool.execute(
{ id: 'non-existent-id' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Find one record',
},
],
},
);
expectErrorResult(
result,
'Record not found',
'Failed to find testObject: Record with ID non-existent-id not found',
);
});
it('should handle find one record without ID', async () => {
setupBasicPermissions(context);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const findOneTool = tools['find_one_testObject'];
expect(findOneTool).toBeDefined();
if (!findOneTool.execute) {
throw new Error(
'Find one tool is missing or does not have an execute method',
);
}
const result = await findOneTool.execute(
{},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Find one record',
},
],
},
);
expectErrorResult(
result,
'Record ID is required',
'Failed to find testObject: Record ID is required',
);
});
});
describe('Update Record Operations', () => {
it('should update a record successfully', async () => {
const mockRepository = createMockRepository();
const existingRecord = createTestRecord('test-record-id', {
name: 'Old Name',
description: 'Old description',
});
const updatedRecord = createTestRecord('test-record-id', {
name: 'New Name',
description: 'New description',
});
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
jest
.spyOn(context.objectMetadataService, 'findOneWithinWorkspace')
.mockResolvedValue(context.testObjectMetadata);
mockRepository.findOne
.mockResolvedValueOnce(existingRecord)
.mockResolvedValueOnce(updatedRecord);
mockRepository.update.mockResolvedValue({ affected: 1 } as any);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const updateTool = tools['update_testObject'];
expect(updateTool).toBeDefined();
if (!updateTool.execute) {
throw new Error(
'Update tool is missing or does not have an execute method',
);
}
const result = await updateTool.execute(
{
id: 'test-record-id',
name: 'New Name',
description: 'New description',
},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Update record',
},
],
},
);
expectSuccessResult(result, 'Successfully updated testObject');
expect(result.record).toEqual(updatedRecord);
expect(mockRepository.update).toHaveBeenCalledWith('test-record-id', {
name: 'New Name',
description: 'New description',
});
});
it('should handle update record not found', async () => {
const mockRepository = createMockRepository();
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.findOne.mockResolvedValue(null);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const updateTool = tools['update_testObject'];
expect(updateTool).toBeDefined();
if (!updateTool.execute) {
throw new Error(
'Update tool is missing or does not have an execute method',
);
}
const result = await updateTool.execute(
{
id: 'non-existent-id',
name: 'New Name',
},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Update record',
},
],
},
);
expectErrorResult(
result,
'Record not found',
'Failed to update testObject: Record with ID non-existent-id not found',
);
});
});
describe('Soft Delete Operations', () => {
it('should soft delete a single record', async () => {
const mockRepository = createMockRepository();
const existingRecord = createTestRecord('test-record-id', {
name: 'Test Record',
});
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.findOne.mockResolvedValue(existingRecord);
mockRepository.softDelete.mockResolvedValue({ affected: 1 } as any);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const softDeleteTool = tools['soft_delete_testObject'];
expect(softDeleteTool).toBeDefined();
if (!softDeleteTool.execute) {
throw new Error(
'Soft delete tool is missing or does not have an execute method',
);
}
const result = await softDeleteTool.execute(
{ id: 'test-record-id' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Soft delete record',
},
],
},
);
expectSuccessResult(result, 'Successfully soft deleted testObject');
expect(mockRepository.softDelete).toHaveBeenCalledWith('test-record-id');
});
it('should soft delete multiple records', async () => {
const mockRepository = createMockRepository();
const existingRecords = createTestRecords(3);
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.find.mockResolvedValue(existingRecords);
mockRepository.softDelete.mockResolvedValue({ affected: 3 } as any);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const softDeleteManyTool = tools['soft_delete_many_testObject'];
expect(softDeleteManyTool).toBeDefined();
if (!softDeleteManyTool.execute) {
throw new Error(
'Soft delete many tool is missing or does not have an execute method',
);
}
const result = await softDeleteManyTool.execute(
{
filter: { id: { in: ['record-1', 'record-2', 'record-3'] } },
},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Soft delete many records',
},
],
},
);
expectSuccessResult(
result,
'Successfully soft deleted 3 testObject records',
);
expect(mockRepository.softDelete).toHaveBeenCalledWith({
id: expect.any(Object),
});
});
});
describe('Destroy Operations', () => {
it('should destroy a single record', async () => {
const roleWithDestroyPermission = {
...context.testRole,
canDestroyAllObjectRecords: true,
};
const mockRepository = createMockRepository();
const existingRecord = createTestRecord('test-record-id', {
name: 'Test Record',
});
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(roleWithDestroyPermission);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: true,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
setupRepositoryMock(context, mockRepository);
mockRepository.findOne.mockResolvedValue(existingRecord);
mockRepository.remove.mockResolvedValue(existingRecord);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const destroyTool = tools['destroy_testObject'];
expect(destroyTool).toBeDefined();
if (!destroyTool.execute) {
throw new Error(
'Destroy tool is missing or does not have an execute method',
);
}
const result = await destroyTool.execute(
{ id: 'test-record-id' },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Destroy record',
},
],
},
);
expectSuccessResult(result, 'Successfully destroyed testObject');
expect(mockRepository.remove).toHaveBeenCalledWith(existingRecord);
});
it('should destroy multiple records', async () => {
const roleWithDestroyPermission = {
...context.testRole,
canDestroyAllObjectRecords: true,
};
const mockRepository = createMockRepository();
const existingRecords = createTestRecords(3);
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(roleWithDestroyPermission);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: true,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
setupRepositoryMock(context, mockRepository);
mockRepository.find.mockResolvedValue(existingRecords);
mockRepository.remove.mockResolvedValue(existingRecords);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const destroyManyTool = tools['destroy_many_testObject'];
expect(destroyManyTool).toBeDefined();
if (!destroyManyTool.execute) {
throw new Error(
'Destroy many tool is missing or does not have an execute method',
);
}
const result = await destroyManyTool.execute(
{
filter: { id: { in: ['record-1', 'record-2', 'record-3'] } },
},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Destroy many records',
},
],
},
);
expectSuccessResult(
result,
'Successfully destroyed 3 testObject records',
);
});
});
describe('Edge Cases', () => {
it('should handle empty search criteria in find records', async () => {
const mockRepository = createMockRepository();
const testRecords = createTestRecords(2);
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.find.mockResolvedValue(testRecords);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const findTool = tools['find_testObject'];
expect(findTool).toBeDefined();
if (!findTool.execute) {
throw new Error(
'Find tool is missing or does not have an execute method',
);
}
const result = await findTool.execute(
{},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Find records',
},
],
},
);
expectSuccessResult(result, 'Found 2 testObject records');
expect(mockRepository.find).toHaveBeenCalledWith({
where: {},
take: 100,
skip: 0,
order: { createdAt: 'DESC' },
});
});
it('should handle null and undefined values in search criteria', async () => {
const mockRepository = createMockRepository();
const testRecords = createTestRecords(1);
setupBasicPermissions(context);
setupRepositoryMock(context, mockRepository);
mockRepository.find.mockResolvedValue(testRecords);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
const findTool = tools['find_testObject'];
expect(findTool).toBeDefined();
if (!findTool.execute) {
throw new Error(
'Find tool is missing or does not have an execute method',
);
}
const result = await findTool.execute(
{
name: null,
description: undefined,
status: '',
validField: 'valid value',
},
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'Find records',
},
],
},
);
expectSuccessResult(result, 'Found 1 testObject records');
expect(mockRepository.find).toHaveBeenCalledWith({
where: { validField: 'valid value' },
take: 100,
skip: 0,
order: { createdAt: 'DESC' },
});
});
it('should handle multiple object metadata with different permissions', async () => {
const secondObjectMetadata = {
...context.testObjectMetadata,
id: 'second-object-id',
nameSingular: 'secondObject',
namePlural: 'secondObjects',
labelSingular: 'Second Object',
labelPlural: 'Second Objects',
};
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent as any);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(context.testRole);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: false,
canDestroy: false,
},
[secondObjectMetadata.id]: {
canRead: true,
canUpdate: false,
canSoftDelete: true,
canDestroy: false,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata, secondObjectMetadata]);
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
context.testWorkspaceId,
);
expect(tools).toBeDefined();
expect(Object.keys(tools)).toHaveLength(8);
expect(Object.keys(tools)).toContain('create_testObject');
expect(Object.keys(tools)).toContain('update_testObject');
expect(Object.keys(tools)).toContain('find_testObject');
expect(Object.keys(tools)).toContain('find_one_testObject');
expect(Object.keys(tools)).toContain('soft_delete_secondObject');
expect(Object.keys(tools)).toContain('soft_delete_many_secondObject');
expect(Object.keys(tools)).not.toContain('soft_delete_testObject');
expect(Object.keys(tools)).not.toContain('create_secondObject');
});
});
});
@@ -0,0 +1,262 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
export interface AgentToolTestContext {
module: TestingModule;
agentToolService: AgentToolService;
agentService: AgentService;
objectMetadataService: ObjectMetadataService;
roleRepository: Repository<RoleEntity>;
workspacePermissionsCacheService: WorkspacePermissionsCacheService;
twentyORMGlobalManager: TwentyORMGlobalManager;
testAgent: AgentEntity & { roleId: string | null };
testRole: RoleEntity;
testObjectMetadata: ObjectMetadataEntity;
testWorkspaceId: string;
testAgentId: string;
testRoleId: string;
}
export const createAgentToolTestModule =
async (): Promise<AgentToolTestContext> => {
const testWorkspaceId = 'test-workspace-id';
const testAgentId = 'test-agent-id';
const testRoleId = 'test-role-id';
const module = await Test.createTestingModule({
providers: [
AgentToolService,
{
provide: AgentService,
useValue: {
findOneAgent: jest.fn(),
},
},
{
provide: getRepositoryToken(RoleEntity, 'core'),
useValue: {
findOne: jest.fn(),
},
},
{
provide: ObjectMetadataService,
useValue: {
findManyWithinWorkspace: jest.fn(),
findOneWithinWorkspace: jest.fn(),
},
},
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn(),
},
},
{
provide: WorkspaceEventEmitter,
useValue: {
emit: jest.fn(),
emitDatabaseBatchEvent: jest.fn(),
emitCustomBatchEvent: jest.fn(),
},
},
{
provide: WorkspacePermissionsCacheService,
useValue: {
getRolesPermissionsFromCache: jest.fn(),
},
},
],
}).compile();
const agentToolService = module.get<AgentToolService>(AgentToolService);
const agentService = module.get<AgentService>(AgentService);
const objectMetadataService = module.get<ObjectMetadataService>(
ObjectMetadataService,
);
const roleRepository = module.get<Repository<RoleEntity>>(
getRepositoryToken(RoleEntity, 'core'),
);
const workspacePermissionsCacheService =
module.get<WorkspacePermissionsCacheService>(
WorkspacePermissionsCacheService,
);
const twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
const testAgent: AgentEntity & { roleId: string | null } = {
id: testAgentId,
name: 'Test Agent',
description: 'Test agent for integration tests',
prompt: 'You are a test agent',
modelId: 'gpt-4o',
responseFormat: {},
workspaceId: testWorkspaceId,
workspace: {} as any,
roleId: testRoleId,
createdAt: new Date(),
updatedAt: new Date(),
};
const testRole: RoleEntity = {
id: testRoleId,
label: 'Test Role',
description: 'Test role for integration tests',
canUpdateAllSettings: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
isEditable: true,
} as RoleEntity;
const testObjectMetadata = {
id: 'test-object-id',
standardId: null,
dataSourceId: 'test-data-source-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
description: 'Test object for integration tests',
icon: 'IconTest',
targetTableName: 'test_objects',
isActive: true,
isSystem: false,
isCustom: false,
isRemote: false,
isAuditLogged: true,
isSearchable: false,
shortcut: '',
isLabelSyncedWithName: false,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
fields: [],
indexMetadatas: [],
targetRelationFields: [],
dataSource: {} as any,
objectPermissions: [],
};
return {
module,
agentToolService,
agentService,
objectMetadataService,
roleRepository,
workspacePermissionsCacheService,
twentyORMGlobalManager,
testAgent,
testRole,
testObjectMetadata,
testWorkspaceId,
testAgentId,
testRoleId,
};
};
export const createMockRepository = () => ({
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
remove: jest.fn(),
delete: jest.fn(),
});
export const setupBasicPermissions = (context: AgentToolTestContext) => {
jest
.spyOn(context.agentService, 'findOneAgent')
.mockResolvedValue(context.testAgent);
jest
.spyOn(context.roleRepository, 'findOne')
.mockResolvedValue(context.testRole);
jest
.spyOn(
context.workspacePermissionsCacheService,
'getRolesPermissionsFromCache',
)
.mockResolvedValue({
data: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canRead: true,
canUpdate: true,
canSoftDelete: true,
canDestroy: false,
},
},
},
version: '1.0',
});
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([context.testObjectMetadata]);
};
export const setupRepositoryMock = (
context: AgentToolTestContext,
mockRepository: any,
) => {
jest
.spyOn(context.twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(mockRepository);
};
export const createTestRecord = (
id: string,
data: Record<string, any> = {},
) => ({
id,
name: `Test Record ${id}`,
createdAt: new Date(),
updatedAt: new Date(),
...data,
});
export const createTestRecords = (
count: number,
baseData: Record<string, any> = {},
) => {
return Array.from({ length: count }, (_, i) =>
createTestRecord(`record-${i + 1}`, baseData),
);
};
export const expectSuccessResult = (result: any, expectedMessage?: string) => {
expect(result.success).toBe(true);
if (expectedMessage) {
expect(result.message).toContain(expectedMessage);
}
};
export const expectErrorResult = (
result: any,
expectedError?: string,
expectedMessage?: string,
) => {
expect(result.success).toBe(false);
if (expectedError) {
expect(result.error).toBe(expectedError);
}
if (expectedMessage) {
expect(result.message).toContain(expectedMessage);
}
};