feat(ai): add view management tools for AI chat (#16495)

## Summary

Adds a new **VIEW** tool category for the AI chat, enabling it to work
with views:

- **get-views**: List views in the workspace, optionally filtered by
object metadata ID
- **get-view-query-parameters**: Convert a view's filters and sorts into
GraphQL query parameters that can be passed to existing `find_*` data
tools
- **create-view**, **update-view**, **delete-view**: CRUD operations for
view management

### Key design decisions

1. **No pagination duplication**: Instead of creating a
`find-records-from-view` tool that would duplicate pagination logic,
`get-view-query-parameters` returns filter/sort parameters that the AI
can pass to existing record-fetching tools.

2. **Permission model**:
- Read tools (get-views, get-view-query-parameters) are available to all
users
   - Write tools require the `VIEW` permission
   - UNLISTED views can only be modified by their creator

3. **Leverages existing utilities**: Uses
`computeRecordGqlOperationFilter` from `twenty-shared` for filter
conversion.

### Files changed

- Added `ViewToolProvider`, `ViewToolsFactory`, and
`ViewQueryParamsService`
- Added `VIEW` to `ToolCategory` enum and tool registry
- Updated `chat-execution.service.ts` to include view tools in the
catalog and pass viewId in browsing context
- Extracted shared `formatValidationErrors` utility to reduce
duplication

## Test plan

- [x] Unit tests for `ViewToolsFactory` 
- [x] Unit tests for `ViewQueryParamsService`
- [x] Lint and typecheck pass
This commit is contained in:
Félix Malfait
2025-12-11 15:05:42 +01:00
committed by GitHub
parent 2943125410
commit 3cea19baf4
15 changed files with 1384 additions and 140 deletions
@@ -0,0 +1,431 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { OrderByDirection } from 'twenty-shared/types';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum';
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
describe('ViewToolsFactory', () => {
let viewToolsFactory: ViewToolsFactory;
let viewService: jest.Mocked<ViewService>;
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
const mockWorkspaceId = 'workspace-id';
const mockUserWorkspaceId = 'user-workspace-id';
const mockViewId = 'view-id';
const mockObjectMetadataId = 'object-metadata-id';
const mockObjectNameSingular = 'company';
const mockView = {
id: mockViewId,
name: 'All Companies',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconBuilding',
visibility: ViewVisibility.WORKSPACE,
position: 0,
createdByUserWorkspaceId: mockUserWorkspaceId,
};
const mockFlatObjectMetadataMaps = {
byId: {
[mockObjectMetadataId]: {
id: mockObjectMetadataId,
nameSingular: mockObjectNameSingular,
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
},
},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
const callExecute = async (tool: any, input: any) => {
return tool.execute(input);
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewToolsFactory,
{
provide: ViewService,
useValue: {
findByWorkspaceId: jest.fn(),
findByObjectMetadataId: jest.fn(),
findById: jest.fn(),
createOne: jest.fn(),
updateOne: jest.fn(),
deleteOne: jest.fn(),
},
},
{
provide: ViewQueryParamsService,
useValue: {
resolveViewToQueryParams: jest.fn(),
},
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
}),
},
},
],
}).compile();
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
viewService = module.get(ViewService);
viewQueryParamsService = module.get(ViewQueryParamsService);
_flatEntityMapsCacheService = module.get(
WorkspaceManyOrAllFlatEntityMapsCacheService,
);
});
it('should be defined', () => {
expect(viewToolsFactory).toBeDefined();
});
describe('generateReadTools', () => {
it('should generate get-views and get-view-query-parameters tools', () => {
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
expect(tools).toHaveProperty('get_views');
expect(tools).toHaveProperty('get_view_query_parameters');
expect(tools['get_views']).toHaveProperty('description');
expect(tools['get_views']).toHaveProperty('inputSchema');
expect(tools['get_views']).toHaveProperty('execute');
expect(tools['get_view_query_parameters']).toHaveProperty('description');
expect(tools['get_view_query_parameters']).toHaveProperty('inputSchema');
expect(tools['get_view_query_parameters']).toHaveProperty('execute');
});
describe('get_views tool', () => {
it('should return all views when no objectNameSingular filter', async () => {
const mockViews = [mockView];
viewService.findByWorkspaceId.mockResolvedValue(mockViews as any);
const tools = viewToolsFactory.generateReadTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['get_views'], {
input: { limit: 50 },
});
expect(viewService.findByWorkspaceId).toHaveBeenCalledWith(
mockWorkspaceId,
mockUserWorkspaceId,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: mockViewId,
name: 'All Companies',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconBuilding',
visibility: ViewVisibility.WORKSPACE,
position: 0,
});
});
it('should filter views by objectNameSingular', async () => {
const mockViews = [mockView];
viewService.findByObjectMetadataId.mockResolvedValue(mockViews as any);
const tools = viewToolsFactory.generateReadTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['get_views'], {
input: { objectNameSingular: mockObjectNameSingular, limit: 50 },
});
expect(viewService.findByObjectMetadataId).toHaveBeenCalledWith(
mockWorkspaceId,
mockObjectMetadataId,
mockUserWorkspaceId,
);
expect(result).toHaveLength(1);
});
it('should respect limit parameter', async () => {
const mockViews = [
{ ...mockView, id: 'view-1' },
{ ...mockView, id: 'view-2' },
{ ...mockView, id: 'view-3' },
];
viewService.findByWorkspaceId.mockResolvedValue(mockViews as any);
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
const result = await callExecute(tools['get_views'], {
input: { limit: 2 },
});
expect(result).toHaveLength(2);
});
});
describe('get-view-query-parameters tool', () => {
it('should return query parameters for a view', async () => {
const mockQueryParams = {
objectNameSingular: 'company',
filter: { name: { ilike: '%Acme%' } },
orderBy: [{ name: OrderByDirection.AscNullsFirst }],
viewName: 'All Companies',
viewType: ViewType.TABLE,
};
viewQueryParamsService.resolveViewToQueryParams.mockResolvedValue(
mockQueryParams,
);
const tools = viewToolsFactory.generateReadTools(
mockWorkspaceId,
mockUserWorkspaceId,
'workspace-member-id',
);
const result = await callExecute(tools['get_view_query_parameters'], {
input: { viewId: mockViewId },
});
expect(
viewQueryParamsService.resolveViewToQueryParams,
).toHaveBeenCalledWith(
mockViewId,
mockWorkspaceId,
'workspace-member-id',
);
expect(result).toEqual(mockQueryParams);
});
});
});
describe('generateWriteTools', () => {
it('should generate create-view, update-view, and delete-view tools', () => {
const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId);
expect(tools).toHaveProperty('create_view');
expect(tools).toHaveProperty('update_view');
expect(tools).toHaveProperty('delete_view');
});
describe('create_view tool', () => {
it('should create a new view', async () => {
const createdView = {
id: 'new-view-id',
name: 'New View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconTable',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['create_view'], {
input: {
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
},
});
expect(viewService.createOne).toHaveBeenCalledWith({
createViewInput: {
name: 'New View',
objectMetadataId: mockObjectMetadataId,
icon: 'IconTable',
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
},
workspaceId: mockWorkspaceId,
createdByUserWorkspaceId: mockUserWorkspaceId,
});
expect(result).toEqual({
id: 'new-view-id',
name: 'New View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.TABLE,
icon: 'IconTable',
visibility: ViewVisibility.WORKSPACE,
});
});
});
describe('update-view tool', () => {
it('should update a workspace view', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.WORKSPACE,
};
const updatedView = {
...existingView,
name: 'Updated Name',
};
viewService.findById.mockResolvedValue(existingView as any);
viewService.updateOne.mockResolvedValue(updatedView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
});
expect(viewService.updateOne).toHaveBeenCalled();
expect(result.name).toBe('Updated Name');
});
it('should allow updating own unlisted view', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.UNLISTED,
createdByUserWorkspaceId: mockUserWorkspaceId,
};
const updatedView = {
...existingView,
name: 'Updated Name',
};
viewService.findById.mockResolvedValue(existingView as any);
viewService.updateOne.mockResolvedValue(updatedView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
});
expect(result.name).toBe('Updated Name');
});
it('should reject updating another users unlisted view', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.UNLISTED,
createdByUserWorkspaceId: 'other-user-workspace-id',
};
viewService.findById.mockResolvedValue(existingView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
}),
).rejects.toThrow('You can only update your own unlisted views');
});
it('should throw error when view not found', async () => {
viewService.findById.mockResolvedValue(null);
const tools = viewToolsFactory.generateWriteTools(mockWorkspaceId);
await expect(
callExecute(tools['update_view'], {
input: {
id: 'non-existent-id',
name: 'Updated Name',
},
}),
).rejects.toThrow('View with id non-existent-id not found');
});
});
describe('delete-view tool', () => {
it('should delete a workspace view', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.WORKSPACE,
};
const deletedView = {
id: mockViewId,
name: 'All Companies',
};
viewService.findById.mockResolvedValue(existingView as any);
viewService.deleteOne.mockResolvedValue(deletedView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['delete_view'], {
input: { id: mockViewId },
});
expect(viewService.deleteOne).toHaveBeenCalledWith({
deleteViewInput: { id: mockViewId },
workspaceId: mockWorkspaceId,
});
expect(result).toEqual({
id: mockViewId,
name: 'All Companies',
deleted: true,
});
});
it('should reject deleting another users unlisted view', async () => {
const existingView = {
...mockView,
visibility: ViewVisibility.UNLISTED,
createdByUserWorkspaceId: 'other-user-workspace-id',
};
viewService.findById.mockResolvedValue(existingView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['delete_view'], {
input: { id: mockViewId },
}),
).rejects.toThrow('You can only delete your own unlisted views');
});
});
});
});
@@ -0,0 +1,340 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum';
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetViewsInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
objectNameSingular: z
.string()
.optional()
.describe(
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe('Maximum views to return.'),
}),
});
const GetViewQueryParamsInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
viewId: z
.string()
.uuid()
.describe('ID of the view to get query parameters for.'),
}),
});
const CreateViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
name: z.string().describe('View name'),
objectNameSingular: z
.string()
.describe(
'Object name this view is for (e.g., "task", "person", "company")',
),
icon: z
.string()
.optional()
.default('IconList')
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
type: z
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
.optional()
.default(ViewType.TABLE)
.describe('View type'),
visibility: z
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
}),
});
const UpdateViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('View ID to update'),
name: z.string().optional().describe('New view name'),
icon: z.string().optional().describe('New icon identifier'),
}),
});
const DeleteViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('View ID to delete'),
}),
});
@Injectable()
export class ViewToolsFactory {
constructor(
private readonly viewService: ViewService,
private readonly viewQueryParamsService: ViewQueryParamsService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
private async resolveObjectMetadataId(
workspaceId: string,
objectNameSingular: string,
): Promise<string> {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const objectMetadata = Object.values(flatObjectMetadataMaps.byId).find(
(obj) => obj?.nameSingular === objectNameSingular,
);
if (!objectMetadata) {
throw new Error(
`Object "${objectNameSingular}" not found. Use get_object_metadata to list available objects.`,
);
}
return objectMetadata.id;
}
generateReadTools(
workspaceId: string,
userWorkspaceId?: string,
currentWorkspaceMemberId?: string,
): ToolSet {
return {
get_views: {
description:
'List views in the workspace. Views define how records are displayed, filtered, and sorted.',
inputSchema: GetViewsInputSchema,
execute: async (parameters: {
input: { objectNameSingular?: string; limit?: number };
}) => {
let views;
if (parameters.input.objectNameSingular) {
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.input.objectNameSingular,
);
views = await this.viewService.findByObjectMetadataId(
workspaceId,
objectMetadataId,
userWorkspaceId,
);
} else {
views = await this.viewService.findByWorkspaceId(
workspaceId,
userWorkspaceId,
);
}
const limitedViews = views.slice(0, parameters.input.limit ?? 50);
return limitedViews.map((view) => ({
id: view.id,
name: view.name,
objectMetadataId: view.objectMetadataId,
type: view.type,
icon: view.icon,
visibility: view.visibility,
position: view.position,
}));
},
},
get_view_query_parameters: {
description:
'Get filter and sort parameters from a view. Use these parameters with find_* tools to query records matching the view.',
inputSchema: GetViewQueryParamsInputSchema,
execute: async (parameters: { input: { viewId: string } }) => {
return this.viewQueryParamsService.resolveViewToQueryParams(
parameters.input.viewId,
workspaceId,
currentWorkspaceMemberId,
);
},
},
};
}
generateWriteTools(workspaceId: string, userWorkspaceId?: string): ToolSet {
return {
create_view: {
description:
'Create a new view for an object. Views define how records are displayed.',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
input: {
name: string;
objectNameSingular: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
};
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.input.objectNameSingular,
);
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.input.name,
objectMetadataId,
icon: parameters.input.icon ?? 'IconList',
type: parameters.input.type ?? ViewType.TABLE,
visibility:
parameters.input.visibility ?? ViewVisibility.WORKSPACE,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,
});
return {
id: view.id,
name: view.name,
objectNameSingular: parameters.input.objectNameSingular,
type: view.type,
icon: view.icon,
visibility: view.visibility,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_view: {
description:
'Update an existing view. You can change the name and icon.',
inputSchema: UpdateViewInputSchema,
execute: async (parameters: {
input: {
id: string;
name?: string;
icon?: string;
};
}) => {
try {
const existingView = await this.viewService.findById(
parameters.input.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.input.id} not found`);
}
if (
existingView.visibility === ViewVisibility.UNLISTED &&
existingView.createdByUserWorkspaceId !== userWorkspaceId
) {
throw new Error('You can only update your own unlisted views');
}
const view = await this.viewService.updateOne({
updateViewInput: {
id: parameters.input.id,
name: parameters.input.name,
icon: parameters.input.icon,
},
workspaceId,
userWorkspaceId,
});
return {
id: view.id,
name: view.name,
objectMetadataId: view.objectMetadataId,
type: view.type,
icon: view.icon,
visibility: view.visibility,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view: {
description: 'Delete a view by its ID.',
inputSchema: DeleteViewInputSchema,
execute: async (parameters: { input: { id: string } }) => {
try {
const existingView = await this.viewService.findById(
parameters.input.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.input.id} not found`);
}
if (
existingView.visibility === ViewVisibility.UNLISTED &&
existingView.createdByUserWorkspaceId !== userWorkspaceId
) {
throw new Error('You can only delete your own unlisted views');
}
const view = await this.viewService.deleteOne({
deleteViewInput: { id: parameters.input.id },
workspaceId,
});
return {
id: view.id,
name: view.name,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderExceptionV2) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}