Improve AI agent chat, tool display, and workflow agent management (#17876)

## Summary

- **Fix token renewal endpoint**: Use `/metadata` instead of `/graphql`
for token renewal in agent chat, fixing auth issues
- **Improve tool display**: Add `load_skills` support, show formatted
tool names (underscores → spaces) with finish/loading states, display
tool icons during loading, and support custom loading messages from tool
input
- **Refactor workflow agent management**: Replace direct
`AgentRepository` access with `AgentService` for create/delete/find
operations in workflow steps, improving encapsulation and consistency
- **Simplify Apollo client usage**: Remove explicit Apollo client
override in `useGetToolIndex`, add `AgentChatProvider` to
`AppRouterProviders`
- **Fix load-skill tool**: Change parameter type from `string` to `json`
for proper schema parsing
- **Update agent-chat-streaming**: Use `AgentService` for agent
resolution and tool registration instead of direct repository queries

## Test plan

- [ ] Verify AI agent chat works end-to-end (send message, receive
response)
- [ ] Verify tool steps display correctly with icons and proper messages
during loading and after completion
- [ ] Verify workflow AI agent step creation and deletion works
correctly
- [ ] Verify workflow version cloning preserves agent configuration
- [ ] Verify token renewal works when tokens expire during agent chat


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-13 10:27:38 +01:00
committed by GitHub
parent 5c3c2e08a6
commit 21c51ec251
39 changed files with 815 additions and 656 deletions
@@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -10,7 +10,6 @@ import { LogicFunctionMetadataService } from 'src/engine/metadata-modules/logic-
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@@ -28,9 +27,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
let globalWorkspaceOrmManager: jest.Mocked<GlobalWorkspaceOrmManager>;
let logicFunctionMetadataService: jest.Mocked<LogicFunctionMetadataService>;
let codeStepBuildService: jest.Mocked<CodeStepBuildService>;
let agentRepository: jest.Mocked<any>;
let agentService: jest.Mocked<AgentService>;
let roleTargetRepository: jest.Mocked<any>;
let roleRepository: jest.Mocked<any>;
let objectMetadataRepository: jest.Mocked<any>;
let workflowCommonWorkspaceService: jest.Mocked<WorkflowCommonWorkspaceService>;
let aiAgentRoleService: jest.Mocked<AiAgentRoleService>;
@@ -96,21 +94,17 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
destroyOne: jest.fn(),
} as unknown as jest.Mocked<LogicFunctionMetadataService>;
agentRepository = {
findOne: jest.fn(),
delete: jest.fn(),
};
agentService = {
deleteManyAgents: jest.fn().mockResolvedValue([]),
findOneAgentById: jest.fn(),
createOneAgent: jest.fn(),
} as unknown as jest.Mocked<AgentService>;
roleTargetRepository = {
findOne: jest.fn(),
count: jest.fn(),
};
roleRepository = {
findOne: jest.fn(),
delete: jest.fn(),
};
objectMetadataRepository = {
findOne: jest.fn(),
};
@@ -146,17 +140,13 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
useValue: codeStepBuildService,
},
{
provide: getRepositoryToken(AgentEntity),
useValue: agentRepository,
provide: AgentService,
useValue: agentService,
},
{
provide: getRepositoryToken(RoleTargetEntity),
useValue: roleTargetRepository,
},
{
provide: getRepositoryToken(RoleEntity),
useValue: roleRepository,
},
{
provide: getRepositoryToken(ObjectMetadataEntity),
useValue: objectMetadataRepository,
@@ -239,15 +229,13 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
},
} as unknown as WorkflowAction;
agentRepository.findOne.mockResolvedValue({ id: 'agent-id' });
await service.runWorkflowVersionStepDeletionSideEffects({
step,
workspaceId: mockWorkspaceId,
});
expect(agentRepository.delete).toHaveBeenCalledWith({
id: 'agent-id',
expect(agentService.deleteManyAgents).toHaveBeenCalledWith({
ids: ['agent-id'],
workspaceId: mockWorkspaceId,
});
});
@@ -272,7 +260,6 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
},
} as unknown as WorkflowAction;
agentRepository.findOne.mockResolvedValue({ id: 'agent-id' });
roleTargetRepository.findOne.mockResolvedValue({
id: 'role-target-id',
roleId: 'role-id',
@@ -283,8 +270,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
workspaceId: mockWorkspaceId,
});
expect(agentRepository.delete).toHaveBeenCalledWith({
id: 'agent-id',
expect(agentService.deleteManyAgents).toHaveBeenCalledWith({
ids: ['agent-id'],
workspaceId: mockWorkspaceId,
});
expect(
@@ -17,7 +17,7 @@ import { v4 } from 'uuid';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { type WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { LogicFunctionMetadataService } from 'src/engine/metadata-modules/logic-function/services/logic-function-metadata.service';
@@ -66,8 +66,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly logicFunctionMetadataService: LogicFunctionMetadataService,
private readonly codeStepBuildService: CodeStepBuildService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly agentService: AgentService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@InjectRepository(ObjectMetadataEntity)
@@ -98,27 +97,24 @@ export class WorkflowVersionStepOperationsWorkspaceService {
break;
}
const agent = await this.agentRepository.findOne({
where: { id: step.settings.input.agentId, workspaceId },
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId: step.settings.input.agentId,
workspaceId,
},
});
if (isDefined(agent)) {
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId: agent.id,
workspaceId,
},
await this.agentService.deleteManyAgents({
ids: [step.settings.input.agentId],
workspaceId,
});
if (isDefined(roleTarget?.roleId) && isDefined(roleTarget?.id)) {
await this.aiAgentRoleService.deleteAgentOnlyRoleIfUnused({
roleId: roleTarget.roleId,
roleTargetId: roleTarget.id,
workspaceId,
});
await this.agentRepository.delete({ id: agent.id, workspaceId });
if (isDefined(roleTarget?.roleId) && isDefined(roleTarget?.id)) {
await this.aiAgentRoleService.deleteAgentOnlyRoleIfUnused({
roleId: roleTarget.roleId,
roleTargetId: roleTarget.id,
workspaceId,
});
}
}
break;
}
@@ -415,27 +411,20 @@ export class WorkflowVersionStepOperationsWorkspaceService {
workspaceId,
});
const newAgent = await this.agentRepository.save({
name: 'workflow-service-agent' + v4(),
label: 'Workflow Agent' + workflowVersion.workflowId.substring(0, 4),
icon: 'IconRobot',
description: '',
prompt:
'You are a helpful AI assistant. Complete the task based on the workflow context.',
modelId: DEFAULT_SMART_MODEL,
responseFormat: { type: 'text' },
const newAgent = await this.agentService.createOneAgent(
{
label:
'Workflow Agent' + workflowVersion.workflowId.substring(0, 4),
icon: 'IconRobot',
description: '',
prompt:
'You are a helpful AI assistant. Complete the task based on the workflow context.',
modelId: DEFAULT_SMART_MODEL,
responseFormat: { type: 'text' },
isCustom: true,
},
workspaceId,
isCustom: true,
});
if (!isDefined(newAgent)) {
throw new WorkflowVersionStepException(
'Failed to create AI Agent step',
WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE,
);
}
await this.workspaceCacheService.flush(workspaceId, ['flatAgentMaps']);
);
return {
builtStep: {
@@ -676,30 +665,34 @@ export class WorkflowVersionStepOperationsWorkspaceService {
};
}
case WorkflowActionType.AI_AGENT: {
const existingAgent = await this.agentRepository.findOne({
where: { id: step.settings.input.agentId, workspaceId },
});
const agentId = step.settings.input.agentId;
if (!isDefined(existingAgent)) {
if (!isDefined(agentId)) {
throw new WorkflowVersionStepException(
'Agent not found for cloning',
'Agent ID is required for cloning',
WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE,
);
}
const clonedAgent = await this.agentRepository.save({
name: 'workflow-service-agent' + v4(),
label: existingAgent.label,
icon: existingAgent.icon,
description: existingAgent.description,
prompt: existingAgent.prompt,
modelId: existingAgent.modelId,
responseFormat: existingAgent.responseFormat,
const existingAgent = await this.agentService.findOneAgentById({
id: agentId,
workspaceId,
isCustom: true,
modelConfiguration: existingAgent.modelConfiguration,
});
const clonedAgent = await this.agentService.createOneAgent(
{
label: existingAgent.label,
icon: existingAgent.icon ?? undefined,
description: existingAgent.description ?? undefined,
prompt: existingAgent.prompt,
modelId: existingAgent.modelId,
responseFormat: existingAgent.responseFormat ?? undefined,
modelConfiguration: existingAgent.modelConfiguration ?? undefined,
isCustom: true,
},
workspaceId,
);
return {
...step,
id: v4(),
@@ -3,7 +3,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -27,10 +27,10 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl
WorkflowCommonModule,
CodeStepBuildModule,
AiAgentRoleModule,
AiAgentModule,
WorkspaceCacheModule,
NestjsQueryTypeOrmModule.forFeature([
ObjectMetadataEntity,
AgentEntity,
RoleTargetEntity,
RoleEntity,
]),