From ff5d4f1d3efe02bea9f3dcf4e911255dabe28677 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Fri, 10 Jul 2026 14:57:16 +0100 Subject: [PATCH] feat(core): Add agent favorites (#33899) --- .../api-types/src/schemas/favorites.schema.ts | 10 ++- .../cli/src/events/maps/relay.event-map.ts | 6 ++ ...e-reconstruction-integration-tools.test.ts | 2 + .../agents/__tests__/agents.service.test.ts | 26 +++++++ .../cli/src/modules/agents/agents.service.ts | 4 + .../__tests__/favorites.event-relay.test.ts | 15 ++++ .../__tests__/favorites.service.test.ts | 64 +++++++++++++++- .../favorites/favorites.event-relay.ts | 7 +- .../modules/favorites/favorites.service.ts | 33 ++++++++- .../N8nActionDropdown/ActionDropdown.vue | 2 +- .../src/app/stores/favorites.store.test.ts | 59 +++++++++++++++ .../src/app/stores/favorites.store.ts | 5 ++ .../agents/__tests__/AgentBuilderView.test.ts | 45 +++++++++++ .../agents/__tests__/AgentCard.test.ts | 74 +++++++++++++++++-- .../features/agents/components/AgentCard.vue | 12 +++ .../agents/views/AgentBuilderView.vue | 23 +++++- .../composables/useFavoriteNavItems.test.ts | 57 +++++++++++++- .../composables/useFavoriteNavItems.ts | 28 +++++++ 18 files changed, 455 insertions(+), 17 deletions(-) diff --git a/packages/@n8n/api-types/src/schemas/favorites.schema.ts b/packages/@n8n/api-types/src/schemas/favorites.schema.ts index 3d3e48e2d7c..5280ae2f1be 100644 --- a/packages/@n8n/api-types/src/schemas/favorites.schema.ts +++ b/packages/@n8n/api-types/src/schemas/favorites.schema.ts @@ -1,2 +1,8 @@ -export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder'; -export const FAVORITE_RESOURCE_TYPES = ['workflow', 'project', 'dataTable', 'folder'] as const; +export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder' | 'agent'; +export const FAVORITE_RESOURCE_TYPES = [ + 'workflow', + 'project', + 'dataTable', + 'folder', + 'agent', +] as const; diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index e4658a9e96e..8d4fb48afd6 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -1035,6 +1035,12 @@ export type RelayEventMap = { // #endregion + // region Agents + 'agent-deleted': { + agentId: string; + projectId: string; + }; + // #region Instance Policies 'instance-policies-updated': { user: UserLike } & ( diff --git a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts index d7b7b6fcb3b..bffa3cf7a41 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-runtime-reconstruction-integration-tools.test.ts @@ -21,6 +21,7 @@ import { mock } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; +import type { EventService } from '@/events/event.service'; import type { EphemeralNodeExecutor } from '@/node-execution'; import type { OauthService } from '@/oauth/oauth.service'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; @@ -287,6 +288,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => { agentTestChatService, agentTaskRepository, mock(), + mock(), ); service = agentExecutionOrchestratorService; markSharedTestSetupAsUsed( diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index 92329f93284..dd9b0b39e21 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -18,6 +18,7 @@ import { ChatIntegrationService } from '../integrations/chat-integration.service import type { AgentTaskRepository } from '../repositories/agent-task.repository'; import type { AgentRepository } from '../repositories/agent.repository'; import type { SubAgentCleanupService } from '../sub-agents/sub-agent-cleanup.service'; +import type { EventService } from '@/events/event.service'; const agentId = 'agent-1'; const projectId = 'project-1'; @@ -48,6 +49,7 @@ function makeService() { const agentTaskRepository = mock(); const chatIntegrationService = mock(); const subAgentCleanupService = mock(); + const eventService = mock(); agentRepository.save.mockImplementation(async (agent) => agent as Agent); agentTaskService.requestReconcile.mockResolvedValue(); @@ -66,6 +68,7 @@ function makeService() { testChatService, agentTaskRepository, subAgentCleanupService, + eventService, ); return { @@ -78,6 +81,7 @@ function makeService() { agentTaskRepository, chatIntegrationService, subAgentCleanupService, + eventService, }; } @@ -123,6 +127,7 @@ describe('AgentsService', () => { agentTaskService, chatIntegrationService, subAgentCleanupService, + eventService, } = makeService(); const agent = makeAgent({ integrations: [ @@ -156,6 +161,27 @@ describe('AgentsService', () => { expect(agentTaskService.requestReconcile).toHaveBeenCalledWith(agentId); expect(testChatService.clearAllTestChatMessages).toHaveBeenCalledWith(agentId); expect(agentKnowledgeService.destroySandbox).toHaveBeenCalledWith(projectId, agentId); + expect(eventService.emit).toHaveBeenCalledWith('agent-deleted', { agentId, projectId }); + }); + + it('emits agent-deleted only after the agent row is removed', async () => { + const { service, agentRepository, eventService } = makeService(); + agentRepository.findByIdAndProjectId.mockResolvedValue(makeAgent()); + + await service.delete(agentId, projectId); + + expect(eventService.emit).toHaveBeenCalledWith('agent-deleted', { agentId, projectId }); + expect(eventService.emit.mock.invocationCallOrder[0]).toBeGreaterThan( + agentRepository.remove.mock.invocationCallOrder[0], + ); + }); + + it('does not emit agent-deleted when the agent is missing', async () => { + const { service, agentRepository, eventService } = makeService(); + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + + await expect(service.delete(agentId, projectId)).resolves.toBe(false); + expect(eventService.emit).not.toHaveBeenCalled(); }); it('still deletes the agent when best-effort cleanup fails', async () => { diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index 26beac3b11d..eb7c58910c8 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -20,6 +20,7 @@ import { ChatIntegrationService } from './integrations/chat-integration.service' import { AgentTaskRepository } from './repositories/agent-task.repository'; import { AgentRepository } from './repositories/agent.repository'; import { SubAgentCleanupService } from './sub-agents/sub-agent-cleanup.service'; +import { EventService } from '@/events/event.service'; @Service() export class AgentsService { @@ -32,6 +33,7 @@ export class AgentsService { private readonly testChatService: AgentTestChatService, private readonly agentTaskRepository: AgentTaskRepository, private readonly subAgentCleanupService: SubAgentCleanupService, + private readonly eventService: EventService, ) {} async create(projectId: string, name: string): Promise { @@ -211,6 +213,8 @@ export class AgentsService { await this.subAgentCleanupService.removeSubAgentFromParents(agentId, projectId); + this.eventService.emit('agent-deleted', { agentId, projectId }); + try { const { AgentTaskService } = await import('./agent-task.service'); await Container.get(AgentTaskService).requestReconcile(agentId); diff --git a/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts b/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts index a28edce0743..bdef1ac966a 100644 --- a/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts +++ b/packages/cli/src/modules/favorites/__tests__/favorites.event-relay.test.ts @@ -76,4 +76,19 @@ describe('FavoritesEventRelay', () => { expect(favoritesService.deleteByResource).toHaveBeenCalledWith('proj1', 'project'); }); }); + + describe('agent-deleted', () => { + it('should delete favorites for the deleted agent', async () => { + const event: RelayEventMap['agent-deleted'] = { + agentId: 'agent1', + projectId: 'proj1', + }; + + eventService.emit('agent-deleted', event); + + await new Promise(setImmediate); + + expect(favoritesService.deleteByResource).toHaveBeenCalledWith('agent1', 'agent'); + }); + }); }); diff --git a/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts b/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts index 0834388cf9f..56e9719beda 100644 --- a/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts +++ b/packages/cli/src/modules/favorites/__tests__/favorites.service.test.ts @@ -12,6 +12,7 @@ import { mock } from 'vitest-mock-extended'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import type { AgentRepository } from '@/modules/agents/repositories/agent.repository'; import type { UserFavorite } from '../database/entities/user-favorite.entity'; import type { UserFavoriteRepository } from '../database/repositories/user-favorite.repository'; @@ -47,6 +48,7 @@ describe('FavoritesService', () => { const sharedWorkflowRepository = mock(); const dataTableRepository = mock(); const folderRepository = mock(); + const agentRepository = mock(); const service = new FavoritesService( repo, workflowRepository, @@ -54,6 +56,7 @@ describe('FavoritesService', () => { sharedWorkflowRepository, dataTableRepository, folderRepository, + agentRepository, ); afterEach(() => vi.clearAllMocks()); @@ -231,12 +234,48 @@ describe('FavoritesService', () => { expect(result).toHaveLength(0); }); + it('should enrich agent favorites with name and projectId for accessible agents', async () => { + const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' }); + repo.findByUser.mockResolvedValue([favorite]); + projectRepository.getAccessibleProjects.mockResolvedValue([ + { id: 'proj1', name: 'Project 1' } as never, + ]); + agentRepository.find.mockResolvedValue([ + { id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never, + ]); + + const result = await service.getEnrichedFavorites(makeUser()); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + resourceId: 'agent1', + resourceName: 'My Agent', + resourceProjectId: 'proj1', + }); + }); + + it('should exclude agent favorites for inaccessible projects', async () => { + const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' }); + repo.findByUser.mockResolvedValue([favorite]); + projectRepository.getAccessibleProjects.mockResolvedValue([ + { id: 'proj1', name: 'Project 1' } as never, + ]); + agentRepository.find.mockResolvedValue([ + { id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never, + ]); + + const result = await service.getEnrichedFavorites(makeUser()); + + expect(result).toHaveLength(0); + }); + it('should handle multiple resource types in one call', async () => { repo.findByUser.mockResolvedValue([ makeUserFavorite({ id: 1, resourceId: 'wf1', resourceType: 'workflow' }), makeUserFavorite({ id: 2, resourceId: 'proj1', resourceType: 'project' }), makeUserFavorite({ id: 3, resourceId: 'dt1', resourceType: 'dataTable' }), makeUserFavorite({ id: 4, resourceId: 'folder1', resourceType: 'folder' }), + makeUserFavorite({ id: 5, resourceId: 'agent1', resourceType: 'agent' }), ]); projectRepository.getAccessibleProjects.mockResolvedValue([ { id: 'proj1', name: 'My Project' } as never, @@ -249,10 +288,13 @@ describe('FavoritesService', () => { folderRepository.find.mockResolvedValue([ { id: 'folder1', name: 'My Folder', homeProject: { id: 'proj1' } } as never, ]); + agentRepository.find.mockResolvedValue([ + { id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never, + ]); const result = await service.getEnrichedFavorites(makeUser()); - expect(result).toHaveLength(4); + expect(result).toHaveLength(5); }); describe('global admin access', () => { @@ -328,6 +370,24 @@ describe('FavoritesService', () => { }); }); + it('should enrich agent favorites without explicit project membership', async () => { + const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' }); + repo.findByUser.mockResolvedValue([favorite]); + projectRepository.getAccessibleProjects.mockResolvedValue([]); + agentRepository.find.mockResolvedValue([ + { id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never, + ]); + + const result = await service.getEnrichedFavorites(makeAdmin()); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + resourceId: 'agent1', + resourceName: 'My Agent', + resourceProjectId: 'proj-other', + }); + }); + it('should not query for additional projects when admin already has membership', async () => { const favorite = makeUserFavorite({ resourceId: 'proj1', resourceType: 'project' }); repo.findByUser.mockResolvedValue([favorite]); @@ -417,6 +477,7 @@ describe('FavoritesService', () => { { resourceType: 'project' as const, repository: 'projectRepository' }, { resourceType: 'dataTable' as const, repository: 'dataTableRepository' }, { resourceType: 'folder' as const, repository: 'folderRepository' }, + { resourceType: 'agent' as const, repository: 'agentRepository' }, ])( 'should check existence using the correct repository for $resourceType', async ({ resourceType, repository }) => { @@ -425,6 +486,7 @@ describe('FavoritesService', () => { projectRepository, dataTableRepository, folderRepository, + agentRepository, }; repo.findOne.mockResolvedValue(null); repositories[repository as keyof typeof repositories].existsBy.mockResolvedValue(false); diff --git a/packages/cli/src/modules/favorites/favorites.event-relay.ts b/packages/cli/src/modules/favorites/favorites.event-relay.ts index ca92df6ffe7..c4489511471 100644 --- a/packages/cli/src/modules/favorites/favorites.event-relay.ts +++ b/packages/cli/src/modules/favorites/favorites.event-relay.ts @@ -22,9 +22,10 @@ export class FavoritesEventRelay extends EventRelay { await this.favoritesService.deleteByResource(dataTableId, 'dataTable'), 'folder-deleted': async ({ folderId }) => await this.favoritesService.deleteByResource(folderId, 'folder'), - 'team-project-deleted': async ({ projectId }) => { - await this.favoritesService.deleteByResource(projectId, 'project'); - }, + 'team-project-deleted': async ({ projectId }) => + await this.favoritesService.deleteByResource(projectId, 'project'), + 'agent-deleted': async ({ agentId }) => + await this.favoritesService.deleteByResource(agentId, 'agent'), }); } } diff --git a/packages/cli/src/modules/favorites/favorites.service.ts b/packages/cli/src/modules/favorites/favorites.service.ts index 045957fc189..0e0c9b71025 100644 --- a/packages/cli/src/modules/favorites/favorites.service.ts +++ b/packages/cli/src/modules/favorites/favorites.service.ts @@ -17,6 +17,8 @@ import { DataTableRepository } from '@/modules/data-table/data-table.repository' import { UserFavoriteRepository } from './database/repositories/user-favorite.repository'; +import { AgentRepository } from '../agents/repositories/agent.repository'; + type ResourceMeta = { name: string; projectId: string }; type Favorite = { resourceId: string; resourceType: FavoriteResourceType }; @@ -48,6 +50,7 @@ export class FavoritesService { private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly dataTableRepository: DataTableRepository, private readonly folderRepository: FolderRepository, + private readonly agentRepository: AgentRepository, ) {} async getEnrichedFavorites(user: User) { @@ -59,16 +62,18 @@ export class FavoritesService { project: idsOfType(favorites, 'project'), dataTable: idsOfType(favorites, 'dataTable'), folder: idsOfType(favorites, 'folder'), + agent: idsOfType(favorites, 'agent'), }; const accessibleProjects = await this.projectRepository.getAccessibleProjects(user.id); const accessibleProjectIds = new Set(accessibleProjects.map((p) => p.id)); - const [workflowNames, projectNames, dataTableMeta, folderMeta] = await Promise.all([ + const [workflowNames, projectNames, dataTableMeta, folderMeta, agentMeta] = await Promise.all([ this.enrichWorkflowFavorites(user, ids.workflow, accessibleProjectIds), this.enrichProjectFavorites(user, ids.project, accessibleProjects), this.enrichDataTableFavorites(user, ids.dataTable, accessibleProjectIds), this.enrichFolderFavorites(user, ids.folder, accessibleProjectIds), + this.enrichAgentFavorites(user, ids.agent, accessibleProjectIds), ]); return favorites.flatMap((fav) => { @@ -81,6 +86,8 @@ export class FavoritesService { return enrichWithMeta(fav, dataTableMeta.get(fav.resourceId)); case 'folder': return enrichWithMeta(fav, folderMeta.get(fav.resourceId)); + case 'agent': + return enrichWithMeta(fav, agentMeta.get(fav.resourceId)); default: return []; } @@ -183,6 +190,27 @@ export class FavoritesService { return result; } + private async enrichAgentFavorites( + user: User, + agentIds: string[], + accessibleProjectIds: Set, + ): Promise> { + const result = new Map(); + if (agentIds.length === 0) return result; + + const hasGlobalAccess = hasGlobalScope(user, 'agent:read'); + const agents = await this.agentRepository.find({ + where: { id: In(agentIds) }, + }); + + for (const agent of agents) { + if (hasGlobalAccess || accessibleProjectIds.has(agent.projectId)) { + result.set(agent.id, { name: agent.name, projectId: agent.projectId }); + } + } + return result; + } + async addFavorite(userId: string, resourceId: string, resourceType: FavoriteResourceType) { const existing = await this.userFavoriteRepository.findOne({ where: { userId, resourceId, resourceType }, @@ -220,6 +248,9 @@ export class FavoritesService { case 'folder': exists = await this.folderRepository.existsBy({ id: resourceId }); break; + case 'agent': + exists = await this.agentRepository.existsBy({ id: resourceId }); + break; } if (!exists) { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index e5bf61149bc..24df26039f0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -200,7 +200,7 @@ const getItemClasses = (item: ActionDropdownItem): Record => font-size: var(--font-size--2xs); line-height: 18px; padding: var(--spacing--3xs) var(--spacing--2xs); - + min-width: fit-content; &.disabled { .shortcut { opacity: 0.3; diff --git a/packages/frontend/editor-ui/src/app/stores/favorites.store.test.ts b/packages/frontend/editor-ui/src/app/stores/favorites.store.test.ts index 3909b1e0a64..47d14a69d8a 100644 --- a/packages/frontend/editor-ui/src/app/stores/favorites.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/favorites.store.test.ts @@ -195,6 +195,52 @@ describe('favorites.store', () => { expect(store.favorites).toHaveLength(1); expect(track).not.toHaveBeenCalled(); }); + + it('should add an agent favorite and re-fetch', async () => { + const newFavorite = makeFavorite({ + id: 3, + resourceId: 'agent-1', + resourceType: 'agent', + resourceName: 'My Agent', + }); + vi.mocked(favoritesApi.getFavorites) + .mockResolvedValueOnce([]) // initial fetch + .mockResolvedValueOnce([newFavorite]); // re-fetch after add + vi.mocked(favoritesApi.addFavorite).mockResolvedValue(newFavorite); + + const store = useFavoritesStore(); + await store.fetchFavorites(); + await store.toggleFavorite('agent-1', 'agent'); + + expect(favoritesApi.addFavorite).toHaveBeenCalledWith(expect.anything(), 'agent-1', 'agent'); + expect(store.favorites).toEqual([newFavorite]); + expect(track).toHaveBeenCalledWith('User toggled favorite', { + action: 'added', + resource_type: 'agent', + }); + }); + + it('should remove an agent favorite', async () => { + vi.mocked(favoritesApi.getFavorites).mockResolvedValue([ + makeFavorite({ id: 3, resourceId: 'agent-1', resourceType: 'agent' }), + ]); + vi.mocked(favoritesApi.removeFavorite).mockResolvedValue(true); + + const store = useFavoritesStore(); + await store.fetchFavorites(); + await store.toggleFavorite('agent-1', 'agent'); + + expect(favoritesApi.removeFavorite).toHaveBeenCalledWith( + expect.anything(), + 'agent-1', + 'agent', + ); + expect(store.favorites).toHaveLength(0); + expect(track).toHaveBeenCalledWith('User toggled favorite', { + action: 'removed', + resource_type: 'agent', + }); + }); }); describe('removeFavoriteLocally()', () => { @@ -293,6 +339,18 @@ describe('favorites.store', () => { expect(store.folderFavoriteIds).toEqual(['folder-1']); }); + it('should return agent favorite IDs', async () => { + vi.mocked(favoritesApi.getFavorites).mockResolvedValue([ + makeFavorite({ resourceId: 'wf-1', resourceType: 'workflow' }), + makeFavorite({ id: 2, resourceId: 'agent-1', resourceType: 'agent' }), + ]); + + const store = useFavoritesStore(); + await store.fetchFavorites(); + + expect(store.agentFavoriteIds).toEqual(['agent-1']); + }); + it('should return empty arrays when no favorites exist', () => { const store = useFavoritesStore(); @@ -300,6 +358,7 @@ describe('favorites.store', () => { expect(store.projectFavoriteIds).toEqual([]); expect(store.dataTableFavoriteIds).toEqual([]); expect(store.folderFavoriteIds).toEqual([]); + expect(store.agentFavoriteIds).toEqual([]); }); }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/favorites.store.ts b/packages/frontend/editor-ui/src/app/stores/favorites.store.ts index ac5070f8794..169f86976d3 100644 --- a/packages/frontend/editor-ui/src/app/stores/favorites.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/favorites.store.ts @@ -29,6 +29,10 @@ export const useFavoritesStore = defineStore(STORES.FAVORITES, () => { favorites.value.filter((f) => f.resourceType === 'folder').map((f) => f.resourceId), ); + const agentFavoriteIds = computed(() => + favorites.value.filter((f) => f.resourceType === 'agent').map((f) => f.resourceId), + ); + async function fetchFavorites() { if (initialized.value) return; const currentPushRef = rootStore.restApiContext.pushRef; @@ -96,6 +100,7 @@ export const useFavoritesStore = defineStore(STORES.FAVORITES, () => { projectFavoriteIds, dataTableFavoriteIds, folderFavoriteIds, + agentFavoriteIds, fetchFavorites, isFavorite, renameFavorite, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index f6357fc1d25..f753687d088 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -159,6 +159,17 @@ vi.mock('../composables/useAgentPermissions', () => ({ useAgentPermissions: () => agentPermissionsMock, })); +const favoritesStoreMock = vi.hoisted(() => ({ + isFavorite: vi.fn(() => false), + toggleFavorite: vi.fn().mockResolvedValue(undefined), + renameFavorite: vi.fn(), + removeFavoriteLocally: vi.fn(), +})); + +vi.mock('@/app/stores/favorites.store', () => ({ + useFavoritesStore: () => favoritesStoreMock, +})); + // Real ref so the view's `watch(config, ...)` fires and populates `localConfig`. // Tests that need an unbuilt agent flip this to empty instructions before render. interface TestAgentConfig { @@ -535,6 +546,10 @@ describe('AgentBuilderView — preview routing', () => { showErrorMock.mockReset(); fetchConfigMock.mockClear(); showErrorMock.mockReset(); + favoritesStoreMock.isFavorite.mockReturnValue(false); + favoritesStoreMock.toggleFavorite.mockClear(); + favoritesStoreMock.renameFavorite.mockClear(); + favoritesStoreMock.removeFavoriteLocally.mockClear(); }); it('renders the build chat in the editing experience without the old mode toggle', async () => { @@ -896,6 +911,10 @@ describe('AgentBuilderView — three-column shell', () => { uploadAgentFilesMock.mockResolvedValue([]); showErrorMock.mockReset(); fetchConfigMock.mockClear(); + favoritesStoreMock.isFavorite.mockReturnValue(false); + favoritesStoreMock.toggleFavorite.mockClear(); + favoritesStoreMock.renameFavorite.mockClear(); + favoritesStoreMock.removeFavoriteLocally.mockClear(); }); it('hides the build chat by default while keeping the editor visible', async () => { @@ -1217,6 +1236,27 @@ describe('AgentBuilderView — three-column shell', () => { ); }); + it('toggles the favorite from the header menu', async () => { + const wrapper = await renderView(); + + wrapper + .findComponent({ name: 'AgentBuilderHeader' }) + .vm.$emit('header-action', 'toggleFavorite'); + await flushPromises(); + + expect(favoritesStoreMock.toggleFavorite).toHaveBeenCalledWith('a1', 'agent'); + }); + + it('updates the favorite name in the sidebar when the agent is renamed', async () => { + const wrapper = await renderView(); + + wrapper + .findComponent({ name: 'AgentBuilderEditorColumn' }) + .vm.$emit('update:config', { name: 'Renamed Agent' }); + + expect(favoritesStoreMock.renameFavorite).toHaveBeenCalledWith('a1', 'agent', 'Renamed Agent'); + }); + it('exports the current agent config as a JSON file from the header menu', async () => { Object.defineProperty(URL, 'createObjectURL', { configurable: true, @@ -1284,6 +1324,11 @@ describe('AgentBuilderView — three-column shell', () => { expect((wrapper.vm as unknown as { agent: { name: string } }).agent.name).toBe( 'Imported agent', ); + expect(favoritesStoreMock.renameFavorite).toHaveBeenLastCalledWith( + 'a1', + 'agent', + 'Imported agent', + ); await (wrapper.vm as unknown as { flushAutosave: () => Promise }).flushAutosave(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts index b4ef13a8d93..4a890c30669 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts @@ -5,12 +5,17 @@ import { ref } from 'vue'; import type { AgentResource } from '../types'; import type { AgentVersion } from '../agent.types'; +const { deleteAgentMock, openAgentConfirmationModalMock } = vi.hoisted(() => ({ + deleteAgentMock: vi.fn().mockResolvedValue(undefined), + openAgentConfirmationModalMock: vi.fn(), +})); + vi.mock('../composables/useAgentApi', () => ({ - deleteAgent: vi.fn(), + deleteAgent: deleteAgentMock, })); vi.mock('../composables/useAgentConfirmationModal', () => ({ - useAgentConfirmationModal: () => ({ openAgentConfirmationModal: vi.fn() }), + useAgentConfirmationModal: () => ({ openAgentConfirmationModal: openAgentConfirmationModalMock }), })); vi.mock('../composables/useAgentPublish', () => ({ @@ -25,6 +30,16 @@ vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), })); +const favoritesStoreMock = { + isFavorite: vi.fn(() => false), + toggleFavorite: vi.fn().mockResolvedValue(undefined), + removeFavoriteLocally: vi.fn(), +}; + +vi.mock('@/app/stores/favorites.store', () => ({ + useFavoritesStore: () => favoritesStoreMock, +})); + const agentPermissionsMock = { canCreate: ref(true), canUpdate: ref(true), @@ -54,8 +69,9 @@ const STUBS = { N8nActionToggle: { name: 'N8nActionToggle', template: - '
', + '
', props: ['actions', 'theme'], + emits: ['action'], }, TimeAgo: { template: '' }, }; @@ -99,6 +115,11 @@ describe('AgentCard', () => { agentPermissionsMock.canDelete.value = true; agentPermissionsMock.canPublish.value = true; agentPermissionsMock.canUnpublish.value = true; + favoritesStoreMock.isFavorite.mockReturnValue(false); + favoritesStoreMock.toggleFavorite.mockClear(); + favoritesStoreMock.removeFavoriteLocally.mockClear(); + deleteAgentMock.mockClear(); + openAgentConfirmationModalMock.mockClear(); }); it('hides the read-only badge when canUpdate is true', async () => { @@ -115,13 +136,17 @@ describe('AgentCard', () => { expect(badge.text()).toBe('agents.list.readonly'); }); - it('hides the action toggle when no scopes grant any action', async () => { + it('shows only the favorite toggle when no scopes grant publish or delete', async () => { agentPermissionsMock.canDelete.value = false; agentPermissionsMock.canPublish.value = false; agentPermissionsMock.canUnpublish.value = false; const wrapper = await renderComponent(); - expect(wrapper.find('[data-test-id="agent-card-actions"]').exists()).toBe(false); + expect(wrapper.find('[data-test-id="agent-card-actions"]').exists()).toBe(true); + expect(wrapper.find('[data-action="toggleFavorite"]').exists()).toBe(true); + expect(wrapper.find('[data-action="publish"]').exists()).toBe(false); + expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(false); + expect(wrapper.find('[data-action="delete"]').exists()).toBe(false); }); it('shows Publish + Delete on an unpublished agent with full scopes', async () => { @@ -157,4 +182,43 @@ describe('AgentCard', () => { expect(wrapper.find('[data-action="publish"]').exists()).toBe(false); expect(wrapper.find('[data-action="delete"]').exists()).toBe(true); }); + + it('shows the favorite toggle action labeled "add" when the agent is not favorited', async () => { + favoritesStoreMock.isFavorite.mockReturnValue(false); + const wrapper = await renderComponent(); + + expect(favoritesStoreMock.isFavorite).toHaveBeenCalledWith('agent-1', 'agent'); + const toggle = wrapper.find('[data-action="toggleFavorite"]'); + expect(toggle.exists()).toBe(true); + expect(toggle.text()).toBe('favorites.add'); + }); + + it('shows the favorite toggle action labeled "remove" when the agent is favorited', async () => { + favoritesStoreMock.isFavorite.mockReturnValue(true); + const wrapper = await renderComponent(); + + const toggle = wrapper.find('[data-action="toggleFavorite"]'); + expect(toggle.exists()).toBe(true); + expect(toggle.text()).toBe('favorites.remove'); + }); + + it('toggles the favorite via the store when the toggle action is selected', async () => { + favoritesStoreMock.isFavorite.mockReturnValue(false); + const wrapper = await renderComponent(); + + await wrapper.find('[data-action="toggleFavorite"]').trigger('click'); + + expect(favoritesStoreMock.toggleFavorite).toHaveBeenCalledWith('agent-1', 'agent'); + }); + + it('removes the agent from local favorites after a successful delete', async () => { + openAgentConfirmationModalMock.mockResolvedValue('confirm'); + const wrapper = await renderComponent(); + + await wrapper.find('[data-action="delete"]').trigger('click'); + + expect(deleteAgentMock).toHaveBeenCalledWith({}, 'project-1', 'agent-1'); + expect(favoritesStoreMock.removeFavoriteLocally).toHaveBeenCalledWith('agent-1', 'agent'); + expect(wrapper.emitted('deleted')).toEqual([['agent-1']]); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentCard.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentCard.vue index cd9c8707b9f..a315a13ca6f 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentCard.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentCard.vue @@ -12,6 +12,7 @@ import { useAgentPermissions } from '../composables/useAgentPermissions'; import { useAgentPublish } from '../composables/useAgentPublish'; import { removeProjectAgentFromListCache } from '../composables/useProjectAgentsList'; import type { AgentResource } from '../types'; +import { useFavoritesStore } from '@/app/stores/favorites.store'; const props = defineProps<{ agent: AgentResource; @@ -35,6 +36,9 @@ const { canUpdate, canDelete, canPublish, canUnpublish } = useAgentPermissions( const isPublished = computed(() => props.agent.activeVersionId !== null); +const favoriteStore = useFavoritesStore(); +const isFavorite = computed(() => favoriteStore.isFavorite(props.agent.id, 'agent')); + const actions = computed(() => { const items: Array<{ value: string; label: string; divided?: boolean }> = []; @@ -44,6 +48,11 @@ const actions = computed(() => { items.push({ value: 'publish', label: locale.baseText('agents.list.actions.publish') }); } + items.push({ + value: 'toggleFavorite', + label: locale.baseText(isFavorite.value ? 'favorites.remove' : 'favorites.add'), + }); + if (canDelete.value) { items.push({ value: 'delete', @@ -73,6 +82,8 @@ async function onAction(action: string) { } else if (action === 'unpublish') { const updated = await unpublish(props.projectId, props.agent.id, props.agent.name); if (updated) emit('unpublished', updated); + } else if (action === 'toggleFavorite') { + await favoriteStore.toggleFavorite(props.agent.id, 'agent'); } else if (action === 'delete') { const confirmed = await openAgentConfirmationModal({ title: locale.baseText('agents.delete.modal.title', { @@ -87,6 +98,7 @@ async function onAction(action: string) { if (confirmed !== MODAL_CONFIRM) return; await deleteAgent(rootStore.restApiContext, props.projectId, props.agent.id); removeProjectAgentFromListCache(props.projectId, props.agent.id); + favoriteStore.removeFavoriteLocally(props.agent.id, 'agent'); emit('deleted', props.agent.id); } } diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index a55c6d6bcae..df28e4b5a8d 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -25,6 +25,7 @@ import { useToast } from '@/app/composables/useToast'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; +import { useFavoritesStore } from '@/app/stores/favorites.store'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { LOCAL_STORAGE_AGENT_BUILDER_CHAT_PANEL_WIDTH, MODAL_CONFIRM } from '@/app/constants'; import { useResizablePanel } from '@/app/composables/useResizablePanel'; @@ -103,6 +104,7 @@ const sessionsStore = useAgentSessionsStore(); const credentialsStore = useCredentialsStore(); const settingsStore = useSettingsStore(); const uiStore = useUIStore(); +const favoritesStore = useFavoritesStore(); // Gates the entire knowledge base feature (files panel + fetching) behind the // Daytona sandbox env vars on the backend (N8N_AGENTS_AI_SANDBOX_ENABLED + PROVIDER=daytona). @@ -128,6 +130,7 @@ const agentId = computed( () => (isArtifactMode.value ? props.artifactAgentId : undefined) ?? (route.params.agentId as string), ); +const isFavorite = computed(() => favoritesStore.isFavorite(agentId.value, 'agent')); const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissions(projectId); @@ -252,6 +255,7 @@ watch( function syncAgentIdentityFromConfig(c: AgentJsonConfig) { agentName.value = c.name; + favoritesStore.renameFavorite(agentId.value, 'agent', c.name); if (!agent.value) return; agent.value = { ...agent.value, @@ -734,8 +738,7 @@ function onConfigFieldUpdate(updates: Partial) { // Mirror identity edits onto the agent resource so the header reflects them // before the next fetch. if (updates.name !== undefined) { - agentName.value = updates.name; - if (agent.value) agent.value = { ...agent.value, name: updates.name }; + syncAgentIdentityFromConfig(localConfig.value); } configAutosave.scheduleAutosave({ projectId: projectId.value, @@ -873,6 +876,17 @@ const headerActions = computed(() => { }); } + if (agent.value) { + actions.push({ + id: 'toggleFavorite', + label: + isFavorite.value === true + ? locale.baseText('favorites.remove') + : locale.baseText('favorites.add'), + icon: isFavorite.value === true ? 'star-filled' : 'star', + }); + } + if (canDeleteAgent.value) { actions.push({ id: 'delete', @@ -930,6 +944,10 @@ async function onHeaderAction(action: string) { openImportJsonModal(); return; } + if (action === 'toggleFavorite') { + await favoritesStore.toggleFavorite(agentId.value, 'agent'); + return; + } if (action === 'delete') { const confirmed = await openAgentConfirmationModal({ title: locale.baseText('agents.delete.modal.title', { @@ -951,6 +969,7 @@ async function onHeaderAction(action: string) { try { await deleteAgent(rootStore.restApiContext, capturedProjectId, agentId.value); removeProjectAgentFromListCache(capturedProjectId, agentId.value); + favoritesStore.removeFavoriteLocally(agentId.value, 'agent'); } catch (error) { showError(error, 'Could not delete agent'); return; diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.test.ts index 509c774a3fc..4b882abe86a 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.test.ts @@ -7,6 +7,7 @@ import type { UserFavorite } from '@/app/api/favorites'; import { useFavoriteNavItems } from './useFavoriteNavItems'; import { VIEWS } from '@/app/constants'; import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants'; +import { AGENT_BUILDER_VIEW } from '@/features/agents/constants'; vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal(); @@ -206,13 +207,57 @@ describe('useFavoriteNavItems', () => { }); }); + describe('favoriteAgentItems', () => { + it('should map agent favorites with projectId to menu items', () => { + favoritesStore.favorites = [ + makeFavorite({ + resourceId: 'agent-1', + resourceType: 'agent', + resourceName: 'My Agent', + resourceProjectId: 'proj-1', + }), + ]; + + const { favoriteAgentItems } = useFavoriteNavItems(); + + expect(favoriteAgentItems.value).toHaveLength(1); + expect(favoriteAgentItems.value[0].resourceId).toBe('agent-1'); + expect(favoriteAgentItems.value[0].resourceType).toBe('agent'); + expect(favoriteAgentItems.value[0].menuItem).toMatchObject({ + id: 'favorite-agent-agent-1', + label: 'My Agent', + icon: 'robot', + route: { + to: { + name: AGENT_BUILDER_VIEW, + params: { projectId: 'proj-1', agentId: 'agent-1' }, + }, + }, + }); + }); + + it('should exclude agent favorites without resourceProjectId', () => { + favoritesStore.favorites = [ + makeFavorite({ + resourceId: 'agent-1', + resourceType: 'agent', + resourceName: 'My Agent', + resourceProjectId: undefined, + }), + ]; + + const { favoriteAgentItems } = useFavoriteNavItems(); + + expect(favoriteAgentItems.value).toHaveLength(0); + }); + }); + describe('favoriteGroups', () => { it('should return empty array when no favorites', () => { const { favoriteGroups } = useFavoriteNavItems(); expect(favoriteGroups.value).toHaveLength(0); }); - it('should include only groups with items', () => { favoritesStore.favorites = [ makeFavorite({ resourceId: 'wf-1', resourceType: 'workflow', resourceName: 'Workflow 1' }), @@ -224,7 +269,7 @@ describe('useFavoriteNavItems', () => { expect(favoriteGroups.value[0].type).toBe('workflow'); }); - it('should order groups: projects first, then folders, workflows, dataTables', () => { + it('should order groups: projects first, then folders, workflows, dataTables, agents', () => { favoritesStore.favorites = [ makeFavorite({ id: 1, resourceId: 'wf-1', resourceType: 'workflow' }), makeFavorite({ @@ -247,6 +292,13 @@ describe('useFavoriteNavItems', () => { resourceName: 'Folder 1', resourceProjectId: 'proj-1', }), + makeFavorite({ + id: 5, + resourceId: 'agent-1', + resourceType: 'agent', + resourceName: 'Agent 1', + resourceProjectId: 'proj-1', + }), ]; const { favoriteGroups } = useFavoriteNavItems(); @@ -256,6 +308,7 @@ describe('useFavoriteNavItems', () => { 'folder', 'workflow', 'dataTable', + 'agent', ]); }); }); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.ts index 65d5fdf75cc..403a3667713 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/composables/useFavoriteNavItems.ts @@ -7,6 +7,7 @@ import { DEFAULT_PROJECT_ICON } from '../projects.constants'; import type { Project } from '../projects.types'; import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants'; import type { FavoriteResourceType } from '@/app/api/favorites'; +import { AGENT_BUILDER_VIEW } from '@/features/agents/constants'; export type FavoriteGroupItem = { menuItem: IMenuItem; @@ -96,6 +97,26 @@ export function useFavoriteNavItems() { })), ); + const favoriteAgentItems = computed(() => + favoritesStore.favorites + .filter((f) => f.resourceType === 'agent' && f.resourceProjectId) + .map((f) => ({ + menuItem: { + id: `favorite-agent-${f.resourceId}`, + label: f.resourceName, + icon: 'robot' as IMenuItem['icon'], + route: { + to: { + name: AGENT_BUILDER_VIEW, + params: { projectId: f.resourceProjectId, agentId: f.resourceId }, + }, + }, + }, + resourceId: f.resourceId, + resourceType: 'agent', + })), + ); + const favoriteGroups = computed(() => { const groups: FavoriteGroup[] = []; if (favoriteProjectItems.value.length > 0) { @@ -122,6 +143,12 @@ export function useFavoriteNavItems() { items: favoriteDataTableItems.value, }); } + if (favoriteAgentItems.value.length > 0) { + groups.push({ + type: 'agent', + items: favoriteAgentItems.value, + }); + } return groups; }); @@ -150,6 +177,7 @@ export function useFavoriteNavItems() { favoriteProjectItems, favoriteDataTableItems, favoriteFolderItems, + favoriteAgentItems, favoriteGroups, activeTabId, onFavoriteProjectClick,