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,21 @@
export const AGENT_GQL_FIELDS = `
id
name
description
prompt
modelId
responseFormat
roleId
createdAt
updatedAt
`;
export const AGENT_TOOL_GQL_FIELDS = `
id
name
description
type
config
createdAt
updatedAt
`;
@@ -0,0 +1,251 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
import { createAgentOperation } from 'test/integration/graphql/utils/create-agent-operation-factory.util';
import { deleteAgentOperation } from 'test/integration/graphql/utils/delete-agent-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateAgentOperation } from 'test/integration/graphql/utils/update-agent-operation-factory.util';
describe('agentResolver', () => {
describe('createOneAgent', () => {
it('should create an agent successfully', async () => {
const operation = createAgentOperation({
name: 'Test AI Agent Admin',
description: 'A test AI agent created by admin',
prompt: 'You are a helpful AI assistant for testing.',
modelId: 'gpt-4o',
responseFormat: { type: 'json_object' },
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.createOneAgent).toBeDefined();
expect(response.body.data.createOneAgent.id).toBeDefined();
expect(response.body.data.createOneAgent.name).toBe(
'Test AI Agent Admin',
);
expect(response.body.data.createOneAgent.description).toBe(
'A test AI agent created by admin',
);
expect(response.body.data.createOneAgent.prompt).toBe(
'You are a helpful AI assistant for testing.',
);
expect(response.body.data.createOneAgent.modelId).toBe('gpt-4o');
expect(response.body.data.createOneAgent.responseFormat).toEqual({
type: 'json_object',
});
await makeGraphqlAPIRequest(
deleteAgentOperation(response.body.data.createOneAgent.id),
);
});
it('should validate required fields and return error', async () => {
const operation = createAgentOperation({
name: undefined as any,
description: 'Agent without required fields',
prompt: undefined as any,
modelId: undefined as any,
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain(
'Field "name" of required type "String!" was not provided',
);
});
});
describe('findOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Test Agent for Find',
description: 'A test agent for find operations',
prompt: 'You are a test agent for finding.',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
afterAll(async () => {
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
});
it('should find agent by ID successfully', async () => {
const queryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: testAgentId } },
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.findOneAgent).toBeDefined();
expect(response.body.data.findOneAgent.id).toBe(testAgentId);
expect(response.body.data.findOneAgent.name).toBe('Test Agent for Find');
});
it('should return 404 error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const queryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: nonExistentId } },
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
});
describe('findManyAgents', () => {
const testAgentIds: string[] = [];
beforeAll(async () => {
for (let i = 0; i < 3; i++) {
const operation = createAgentOperation({
name: `Test Agent ${i + 1}`,
description: `A test agent ${i + 1} for find many operations`,
prompt: `You are test agent ${i + 1}.`,
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentIds.push(response.body.data.createOneAgent.id);
}
});
afterAll(async () => {
for (const agentId of testAgentIds) {
await makeGraphqlAPIRequest(deleteAgentOperation(agentId));
}
});
it('should find all agents successfully', async () => {
const queryData = {
query: gql`
query FindManyAgents {
findManyAgents {
${AGENT_GQL_FIELDS}
}
}
`,
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.findManyAgents).toBeDefined();
expect(Array.isArray(response.body.data.findManyAgents)).toBe(true);
expect(response.body.data.findManyAgents.length).toBeGreaterThanOrEqual(
3,
);
const testAgentNames = response.body.data.findManyAgents
.filter((agent: any) => testAgentIds.includes(agent.id))
.map((agent: any) => agent.name);
expect(testAgentNames).toContain('Test Agent 1');
expect(testAgentNames).toContain('Test Agent 2');
expect(testAgentNames).toContain('Test Agent 3');
});
});
describe('updateOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Original Test Agent',
description: 'Original description',
prompt: 'Original prompt',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
afterAll(async () => {
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
});
it('should update an agent successfully', async () => {
const operation = updateAgentOperation({
id: testAgentId,
name: 'Updated Test Agent Admin',
description: 'Updated description',
prompt: 'Updated prompt for admin',
modelId: 'gpt-4o-mini',
});
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.updateOneAgent).toBeDefined();
expect(response.body.data.updateOneAgent.id).toBe(testAgentId);
expect(response.body.data.updateOneAgent.name).toBe(
'Updated Test Agent Admin',
);
expect(response.body.data.updateOneAgent.description).toBe(
'Updated description',
);
expect(response.body.data.updateOneAgent.prompt).toBe(
'Updated prompt for admin',
);
expect(response.body.data.updateOneAgent.modelId).toBe('gpt-4o-mini');
});
});
describe('deleteOneAgent', () => {
let testAgentId: string;
beforeAll(async () => {
const operation = createAgentOperation({
name: 'Agent to Delete',
description: 'This agent will be deleted',
prompt: 'You are an agent that will be deleted.',
modelId: 'gpt-4o',
});
const response = await makeGraphqlAPIRequest(operation);
testAgentId = response.body.data.createOneAgent.id;
});
it('should delete an agent successfully', async () => {
const operation = deleteAgentOperation(testAgentId);
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.data).toBeDefined();
expect(response.body.data.deleteOneAgent).toBeDefined();
expect(response.body.data.deleteOneAgent.id).toBe(testAgentId);
expect(response.body.data.deleteOneAgent.name).toBe('Agent to Delete');
const findQueryData = {
query: gql`
query FindOneAgent($input: AgentIdInput!) {
findOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: { input: { id: testAgentId } },
};
const findResponse = await makeGraphqlAPIRequest(findQueryData);
expect(findResponse.body.errors).toBeDefined();
expect(findResponse.body.errors[0].message).toContain('not found');
});
it('should return 404 error for non-existent agent', async () => {
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const operation = deleteAgentOperation(nonExistentId);
const response = await makeGraphqlAPIRequest(operation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('not found');
});
});
});
@@ -0,0 +1,33 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
export const createAgentOperation = ({
name,
description,
prompt,
modelId,
responseFormat,
}: {
name: string;
description?: string;
prompt: string;
modelId: string;
responseFormat?: object;
}) => ({
query: gql`
mutation CreateOneAgent($input: CreateAgentInput!) {
createOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: {
input: {
name,
description,
prompt,
modelId,
responseFormat,
},
},
});
@@ -0,0 +1,15 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
export const deleteAgentOperation = (id: string) => ({
query: gql`
mutation DeleteOneAgent($input: AgentIdInput!) {
deleteOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: {
input: { id },
},
});
@@ -0,0 +1,36 @@
import gql from 'graphql-tag';
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
export const updateAgentOperation = ({
id,
name,
description,
prompt,
modelId,
responseFormat,
}: {
id: string;
name?: string;
description?: string;
prompt?: string;
modelId?: string;
responseFormat?: object;
}) => ({
query: gql`
mutation UpdateOneAgent($input: UpdateAgentInput!) {
updateOneAgent(input: $input) {
${AGENT_GQL_FIELDS}
}
}
`,
variables: {
input: {
id,
...(name && { name }),
...(description && { description }),
...(prompt && { prompt }),
...(modelId && { modelId }),
...(responseFormat && { responseFormat }),
},
},
});
@@ -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);
}
};