From 08d20ba52874dfb165f8ce7235f606347c057098 Mon Sep 17 00:00:00 2001 From: James Rushford Date: Fri, 5 Sep 2025 15:17:28 +0200 Subject: [PATCH] Add duplicate agent button in agent list --- .../set_item_to_duplicate.mutation.graphql | 3 + .../queries/item_to_duplicate.query.graphql | 7 + .../ai/catalog/graphql/typedefs.graphql | 19 ++ ee/app/assets/javascripts/ai/catalog/index.js | 32 ++- .../ai/catalog/pages/ai_catalog_agents.vue | 46 +++- .../pages/ai_catalog_agents_duplicate.vue | 119 +++++++++ .../ai/catalog/router/constants.js | 1 + .../javascripts/ai/catalog/router/index.js | 10 + .../pages/ai_catalog_agents_duplicate_spec.js | 243 ++++++++++++++++++ .../catalog/pages/ai_catalog_agents_spec.js | 85 +++++- .../frontend/ai/catalog/router/index_spec.js | 9 + locale/gitlab.pot | 12 + 12 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 ee/app/assets/javascripts/ai/catalog/graphql/mutations/set_item_to_duplicate.mutation.graphql create mode 100644 ee/app/assets/javascripts/ai/catalog/graphql/queries/item_to_duplicate.query.graphql create mode 100644 ee/app/assets/javascripts/ai/catalog/graphql/typedefs.graphql create mode 100644 ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_duplicate.vue create mode 100644 ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_duplicate_spec.js diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/mutations/set_item_to_duplicate.mutation.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/mutations/set_item_to_duplicate.mutation.graphql new file mode 100644 index 00000000000000..3bcdf8c1c8c8ae --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/graphql/mutations/set_item_to_duplicate.mutation.graphql @@ -0,0 +1,3 @@ +mutation setItemToDuplicate($item: ItemToDuplicateInput!) { + setItemToDuplicate(item: $item) @client +} diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/queries/item_to_duplicate.query.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/queries/item_to_duplicate.query.graphql new file mode 100644 index 00000000000000..fa516a72bc47fd --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/graphql/queries/item_to_duplicate.query.graphql @@ -0,0 +1,7 @@ +query catalogItemToDuplicate { + itemToDuplicate @client { + id + type + data + } +} diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/typedefs.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/typedefs.graphql new file mode 100644 index 00000000000000..b448be3c167878 --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/graphql/typedefs.graphql @@ -0,0 +1,19 @@ +extend type Query { + itemToDuplicate: ItemToDuplicate +} + +extend type Mutation { + setItemToDuplicate(item: ItemToDuplicateInput!): ItemToDuplicate +} + +type ItemToDuplicate { + id: String + type: String + data: JSON +} + +input ItemToDuplicateInput { + id: String + type: String + data: JSON +} diff --git a/ee/app/assets/javascripts/ai/catalog/index.js b/ee/app/assets/javascripts/ai/catalog/index.js index 80f369e71f943c..c0f728a364377c 100644 --- a/ee/app/assets/javascripts/ai/catalog/index.js +++ b/ee/app/assets/javascripts/ai/catalog/index.js @@ -4,6 +4,8 @@ import { GlToast } from '@gitlab/ui'; import createDefaultClient from '~/lib/graphql'; import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; import AiCatalogBreadcrumbs from './router/ai_catalog_breadcrumbs.vue'; +import typeDefs from './graphql/typedefs.graphql'; +import itemToDuplicateQuery from './graphql/queries/item_to_duplicate.query.graphql'; import AiCatalogApp from './ai_catalog_app.vue'; import { createRouter } from './router'; @@ -22,8 +24,36 @@ export const initAiCatalog = (selector = '#js-ai-catalog') => { Vue.use(GlToast); const router = createRouter(aiCatalogIndexPath); + + const resolvers = { + Mutation: { + setItemToDuplicate(_, { item }, { cache }) { + cache.writeQuery({ + query: itemToDuplicateQuery, + data: { itemToDuplicate: item }, + }); + return item; + }, + }, + }; + const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient(resolvers, { + typeDefs, + cacheConfig: { + typePolicies: { + Query: { + fields: { + itemToDuplicate: { + read(currentState) { + return currentState || null; + }, + }, + }, + }, + }, + }, + }), }); injectVueAppBreadcrumbs(router, AiCatalogBreadcrumbs); diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue index 208545870d8938..a7ae81d1e28e75 100644 --- a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue @@ -12,15 +12,21 @@ import { TYPENAME_AI_CATALOG_ITEM } from 'ee/graphql_shared/constants'; import aiCatalogAgentsQuery from '../graphql/queries/ai_catalog_agents.query.graphql'; import aiCatalogAgentQuery from '../graphql/queries/ai_catalog_agent.query.graphql'; import deleteAiCatalogAgentMutation from '../graphql/mutations/delete_ai_catalog_agent.mutation.graphql'; +import setItemToDuplicateMutation from '../graphql/mutations/set_item_to_duplicate.mutation.graphql'; import AiCatalogListHeader from '../components/ai_catalog_list_header.vue'; import AiCatalogList from '../components/ai_catalog_list.vue'; import AiCatalogItemDrawer from '../components/ai_catalog_item_drawer.vue'; import { AI_CATALOG_AGENTS_EDIT_ROUTE, AI_CATALOG_AGENTS_RUN_ROUTE, + AI_CATALOG_AGENTS_DUPLICATE_ROUTE, AI_CATALOG_SHOW_QUERY_PARAM, } from '../router/constants'; -import { AGENT_VISIBILITY_LEVEL_DESCRIPTIONS, PAGE_SIZE } from '../constants'; +import { + AGENT_VISIBILITY_LEVEL_DESCRIPTIONS, + PAGE_SIZE, + AI_CATALOG_TYPE_AGENT, +} from '../constants'; export default { name: 'AiCatalogAgents', @@ -136,6 +142,11 @@ export default { }, icon: 'rocket-launch', }, + { + text: s__('AICatalog|Duplicate'), + action: () => this.handleDuplicate(itemId), + icon: 'duplicate', + }, { text: s__('AICatalog|Edit'), to: { @@ -203,6 +214,39 @@ export default { last: null, }); }, + findAgentInList(numberId) { + return this.aiCatalogAgents.find( + (n) => getIdFromGraphQLId(n.id).toString() === String(numberId), + ); + }, + async handleDuplicate(itemId) { + try { + const agent = this.findAgentInList(itemId); + + if (!agent) { + throw new Error(s__('AICatalog|Agent not found.')); + } + + await this.$apollo.mutate({ + mutation: setItemToDuplicateMutation, + variables: { + item: { + id: itemId, + type: AI_CATALOG_TYPE_AGENT, + data: agent, + }, + }, + }); + + this.$router.push({ + name: AI_CATALOG_AGENTS_DUPLICATE_ROUTE, + params: { id: itemId }, + }); + } catch (error) { + this.errors = [error.message]; + Sentry.captureException(error); + } + }, handlePrevPage() { this.$apollo.queries.aiCatalogAgents.refetch({ ...this.$apollo.queries.aiCatalogAgents.variables, diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_duplicate.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_duplicate.vue new file mode 100644 index 00000000000000..52dcb6c0a677f5 --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents_duplicate.vue @@ -0,0 +1,119 @@ + + + diff --git a/ee/app/assets/javascripts/ai/catalog/router/constants.js b/ee/app/assets/javascripts/ai/catalog/router/constants.js index 94760baa70faf3..57cc425d853eed 100644 --- a/ee/app/assets/javascripts/ai/catalog/router/constants.js +++ b/ee/app/assets/javascripts/ai/catalog/router/constants.js @@ -3,6 +3,7 @@ export const AI_CATALOG_AGENTS_ROUTE = '/agents'; export const AI_CATALOG_AGENTS_EDIT_ROUTE = '/agents/:id/edit'; export const AI_CATALOG_AGENTS_RUN_ROUTE = '/agents/:id/run'; export const AI_CATALOG_AGENTS_NEW_ROUTE = '/agents/new'; +export const AI_CATALOG_AGENTS_DUPLICATE_ROUTE = '/agents/:id/duplicate'; export const AI_CATALOG_FLOWS_ROUTE = '/flows'; export const AI_CATALOG_FLOWS_EDIT_ROUTE = '/flows/:id/edit'; export const AI_CATALOG_FLOWS_NEW_ROUTE = '/flows/new'; diff --git a/ee/app/assets/javascripts/ai/catalog/router/index.js b/ee/app/assets/javascripts/ai/catalog/router/index.js index 13647e9a062a9a..412808150b2502 100644 --- a/ee/app/assets/javascripts/ai/catalog/router/index.js +++ b/ee/app/assets/javascripts/ai/catalog/router/index.js @@ -7,6 +7,7 @@ import AiCatalogAgent from '../pages/ai_catalog_agent.vue'; import AiCatalogAgentsEdit from '../pages/ai_catalog_agents_edit.vue'; import AiCatalogAgentsRun from '../pages/ai_catalog_agents_run.vue'; import AiCatalogAgentsNew from '../pages/ai_catalog_agents_new.vue'; +import AiCatalogAgentsDuplicate from '../pages/ai_catalog_agents_duplicate.vue'; import AiCatalogFlow from '../pages/ai_catalog_flow.vue'; import AiCatalogFlows from '../pages/ai_catalog_flows.vue'; import AiCatalogFlowsEdit from '../pages/ai_catalog_flows_edit.vue'; @@ -17,6 +18,7 @@ import { AI_CATALOG_AGENTS_EDIT_ROUTE, AI_CATALOG_AGENTS_RUN_ROUTE, AI_CATALOG_AGENTS_NEW_ROUTE, + AI_CATALOG_AGENTS_DUPLICATE_ROUTE, AI_CATALOG_FLOWS_ROUTE, AI_CATALOG_FLOWS_NEW_ROUTE, AI_CATALOG_FLOWS_EDIT_ROUTE, @@ -84,6 +86,14 @@ export const createRouter = (base) => { text: s__('AICatalog|Run agent'), }, }, + { + name: AI_CATALOG_AGENTS_DUPLICATE_ROUTE, + path: 'duplicate', + component: AiCatalogAgentsDuplicate, + meta: { + text: s__('AICatalog|Duplicate agent'), + }, + }, ], }, ], diff --git a/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_duplicate_spec.js b/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_duplicate_spec.js new file mode 100644 index 00000000000000..61ef56de738fe5 --- /dev/null +++ b/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_duplicate_spec.js @@ -0,0 +1,243 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import createAiCatalogAgent from 'ee/ai/catalog/graphql/mutations/create_ai_catalog_agent.mutation.graphql'; +import aiCatalogAgentQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_agent.query.graphql'; +import AiCatalogAgentsDuplicate from 'ee/ai/catalog/pages/ai_catalog_agents_duplicate.vue'; +import AiCatalogAgentForm from 'ee/ai/catalog/components/ai_catalog_agent_form.vue'; +import { + AI_CATALOG_AGENTS_ROUTE, + AI_CATALOG_SHOW_QUERY_PARAM, +} from 'ee/ai/catalog/router/constants'; +import { + mockAgent, + mockCreateAiCatalogAgentSuccessMutation, + mockCreateAiCatalogAgentErrorMutation, + mockAiCatalogAgentResponse, + mockAiCatalogAgentNullResponse, +} from '../mock_data'; + +Vue.use(VueApollo); +jest.mock('~/sentry/sentry_browser_wrapper'); + +describe('AiCatalogAgentsDuplicate', () => { + let wrapper; + let createAiCatalogAgentMock; + let aiCatalogAgentQueryMock; + + const mockToast = { + show: jest.fn(), + }; + const mockRouter = { + push: jest.fn(), + }; + const agentId = 1; + const routeParams = { id: agentId }; + + const createComponent = () => { + createAiCatalogAgentMock = jest.fn().mockResolvedValue(mockCreateAiCatalogAgentSuccessMutation); + aiCatalogAgentQueryMock = jest.fn().mockResolvedValue(mockAiCatalogAgentResponse); + + const apolloProvider = createMockApollo([ + [createAiCatalogAgent, createAiCatalogAgentMock], + [aiCatalogAgentQuery, aiCatalogAgentQueryMock], + ]); + + wrapper = shallowMountExtended(AiCatalogAgentsDuplicate, { + apolloProvider, + mocks: { + $route: { + params: routeParams, + }, + $router: mockRouter, + $toast: mockToast, + }, + }); + }; + + const findForm = () => wrapper.findComponent(AiCatalogAgentForm); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Initial Load', () => { + it('fetches the original agent data', () => { + expect(aiCatalogAgentQueryMock).toHaveBeenCalledWith({ + id: mockAgent.id, + }); + }); + + it('renders the form with loading state initially', () => { + expect(findForm().exists()).toBe(true); + expect(findForm().props('isLoading')).toBe(true); + }); + }); + + describe('Form Initial Values', () => { + beforeEach(async () => { + await waitForPromises(); + }); + + it('sets initial values based on the original agent', () => { + const expectedInitialValues = { + name: `Copy of ${mockAgent.name}`, + description: mockAgent.description, + systemPrompt: mockAgent.latestVersion.systemPrompt, + userPrompt: mockAgent.latestVersion.userPrompt, + tools: mockAgent.latestVersion.tools.nodes.map((t) => t.id), + }; + + expect(findForm().props('initialValues')).toEqual(expectedInitialValues); + }); + }); + + describe('Form Submit', () => { + const { name, description, project } = mockAgent; + const formValues = { + name: `${name} (Copy)`, + description, + projectId: project.id, + systemPrompt: 'A new system prompt', + userPrompt: 'A new user prompt', + public: false, + release: true, + }; + + const submitForm = () => findForm().vm.$emit('submit', formValues); + + beforeEach(async () => { + await waitForPromises(); + }); + + it('sends a create request', () => { + submitForm(); + + expect(createAiCatalogAgentMock).toHaveBeenCalledTimes(1); + expect(createAiCatalogAgentMock).toHaveBeenCalledWith({ + input: formValues, + }); + }); + + it('sets a loading state on the form while submitting', async () => { + expect(findForm().props('isLoading')).toBe(false); + + await submitForm(); + + expect(findForm().props('isLoading')).toBe(true); + }); + + describe('when request fails', () => { + beforeEach(async () => { + createAiCatalogAgentMock.mockRejectedValue(new Error()); + submitForm(); + await waitForPromises(); + }); + + it('sets error messages and captures exception', () => { + expect(findForm().props('errors')).toEqual(['The agent could not be added. Try again.']); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(findForm().props('isLoading')).toBe(false); + }); + + it('allows user to dismiss errors', async () => { + await findForm().vm.$emit('dismiss-errors'); + + expect(findForm().props('errors')).toEqual([]); + }); + }); + + describe('when request succeeds but returns error', () => { + beforeEach(async () => { + createAiCatalogAgentMock.mockResolvedValue(mockCreateAiCatalogAgentErrorMutation); + submitForm(); + await waitForPromises(); + }); + + it('shows an alert', () => { + expect(findForm().props('errors')).toEqual([ + mockCreateAiCatalogAgentErrorMutation.data.aiCatalogAgentCreate.errors[0], + ]); + expect(findForm().props('isLoading')).toBe(false); + }); + }); + + describe('when request succeeds', () => { + beforeEach(async () => { + submitForm(); + await waitForPromises(); + }); + + it('shows toast', () => { + expect(mockToast.show).toHaveBeenCalledWith('Agent created successfully.'); + }); + + it('navigates to agents page with show query', async () => { + await waitForPromises(); + expect(mockRouter.push).toHaveBeenCalledWith({ + name: AI_CATALOG_AGENTS_ROUTE, + query: { [AI_CATALOG_SHOW_QUERY_PARAM]: 1 }, + }); + }); + }); + }); + + describe('Error Handling', () => { + describe('when agent query fails', () => { + it('captures the exception', async () => { + const error = new Error('Agent not found.'); + + const apolloProvider = createMockApollo([ + [createAiCatalogAgent, createAiCatalogAgentMock], + [aiCatalogAgentQuery, jest.fn().mockRejectedValue(error)], + ]); + + wrapper = shallowMountExtended(AiCatalogAgentsDuplicate, { + apolloProvider, + mocks: { + $route: { + params: routeParams, + }, + $router: mockRouter, + $toast: mockToast, + }, + }); + + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); + + describe('when agent query returns null', () => { + it('handles null response gracefully', async () => { + const apolloProvider = createMockApollo([ + [createAiCatalogAgent, createAiCatalogAgentMock], + [aiCatalogAgentQuery, jest.fn().mockResolvedValue(mockAiCatalogAgentNullResponse)], + ]); + + wrapper = shallowMountExtended(AiCatalogAgentsDuplicate, { + apolloProvider, + mocks: { + $route: { + params: routeParams, + }, + $router: mockRouter, + $toast: mockToast, + }, + }); + + await waitForPromises(); + + expect(findForm().props('initialValues')).toEqual({}); + }); + }); + }); +}); diff --git a/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js b/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js index 3d654871ba4e12..3db440301e9f95 100644 --- a/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js +++ b/ee/spec/frontend/ai/catalog/pages/ai_catalog_agents_spec.js @@ -14,6 +14,7 @@ import aiCatalogAgentQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_agent. import aiCatalogAgentsQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_agents.query.graphql'; import deleteAiCatalogAgentMutation from 'ee/ai/catalog/graphql/mutations/delete_ai_catalog_agent.mutation.graphql'; import { TYPENAME_AI_CATALOG_ITEM } from 'ee/graphql_shared/constants'; +import { AI_CATALOG_AGENTS_DUPLICATE_ROUTE } from 'ee/ai/catalog/router/constants'; import { mockAgent, mockAgents, @@ -42,13 +43,21 @@ describe('AiCatalogAgents', () => { const mockToast = { show: jest.fn(), }; + const mockSetItemToDuplicateMutation = jest.fn(); const createComponent = ({ $route = { query: {} } } = {}) => { - mockApollo = createMockApollo([ - [aiCatalogAgentQuery, mockAgentQueryHandler], - [aiCatalogAgentsQuery, mockCatalogItemsQueryHandler], - [deleteAiCatalogAgentMutation, deleteCatalogItemMutationHandler], - ]); + mockApollo = createMockApollo( + [ + [aiCatalogAgentQuery, mockAgentQueryHandler], + [aiCatalogAgentsQuery, mockCatalogItemsQueryHandler], + [deleteAiCatalogAgentMutation, deleteCatalogItemMutationHandler], + ], + { + Mutation: { + setItemToDuplicate: mockSetItemToDuplicateMutation, + }, + }, + ); wrapper = shallowMountExtended(AiCatalogAgents, { apolloProvider: mockApollo, @@ -63,6 +72,7 @@ describe('AiCatalogAgents', () => { const findErrorsAlert = () => wrapper.findComponent(ErrorsAlert); const findAiCatalogList = () => wrapper.findComponent(AiCatalogList); const findAiCatalogItemDrawer = () => wrapper.findComponent(AiCatalogItemDrawer); + const agentNotFoundErrorMessage = 'Agent not found.'; afterEach(() => { jest.clearAllMocks(); @@ -245,7 +255,7 @@ describe('AiCatalogAgents', () => { }); it('displays permission error message', () => { - expect(findErrorsAlert().props('errors')).toStrictEqual(['Agent not found.']); + expect(findErrorsAlert().props('errors')).toStrictEqual([agentNotFoundErrorMessage]); }); it('does not log to Sentry for permission issues', () => { @@ -337,6 +347,69 @@ describe('AiCatalogAgents', () => { }); }); + describe('on duplicating an agent', () => { + const duplicateAgent = async (index = 1) => { + await waitForPromises(); + await wrapper.vm.handleDuplicate(getIdFromGraphQLId(mockAgents[index].id)); + }; + + beforeEach(() => { + createComponent(); + }); + + it('calls setItemToDuplicate mutation with correct variables', async () => { + mockSetItemToDuplicateMutation.mockResolvedValue({ data: {} }); + + await duplicateAgent(); + + expect(mockSetItemToDuplicateMutation).toHaveBeenCalledWith( + {}, + { + item: { + id: getIdFromGraphQLId(mockAgents[1].id), + type: 'AGENT', + data: mockAgents[1], + }, + }, + expect.anything(), + expect.anything(), + ); + }); + + describe('when request succeeds', () => { + it('navigates to duplicate route', async () => { + mockSetItemToDuplicateMutation.mockResolvedValue({ data: {} }); + + await duplicateAgent(); + + expect(mockRouter.push).toHaveBeenCalledWith({ + name: AI_CATALOG_AGENTS_DUPLICATE_ROUTE, + params: { id: getIdFromGraphQLId(mockAgents[1].id) }, + }); + }); + }); + + describe('when agent is not found', () => { + it('shows error message and logs to Sentry', async () => { + // Create component with empty agents list + mockCatalogItemsQueryHandler.mockResolvedValue({ + data: { + aiCatalogItems: { + nodes: [], + pageInfo: mockPageInfo, + }, + }, + }); + createComponent(); + + await duplicateAgent(); + + expect(findErrorsAlert().props('errors')).toStrictEqual([agentNotFoundErrorMessage]); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + }); + describe('pagination', () => { it('passes pageInfo to list component', async () => { createComponent(); diff --git a/ee/spec/frontend/ai/catalog/router/index_spec.js b/ee/spec/frontend/ai/catalog/router/index_spec.js index 6c9eea97777aa1..da0da020498d35 100644 --- a/ee/spec/frontend/ai/catalog/router/index_spec.js +++ b/ee/spec/frontend/ai/catalog/router/index_spec.js @@ -2,6 +2,7 @@ import { createRouter } from 'ee/ai/catalog/router'; import { AI_CATALOG_AGENTS_EDIT_ROUTE, AI_CATALOG_AGENTS_RUN_ROUTE, + AI_CATALOG_AGENTS_DUPLICATE_ROUTE, AI_CATALOG_SHOW_QUERY_PARAM, } from 'ee/ai/catalog/router/constants'; @@ -43,6 +44,14 @@ describe('AI Catalog Router', () => { }); }); + describe('/agents/:id/duplicate', () => { + it('renders child route', async () => { + await router.push(`/agents/${agentId}/duplicate`); + + expect(router.currentRoute.name).toBe(AI_CATALOG_AGENTS_DUPLICATE_ROUTE); + }); + }); + describe('Non-numeric /agents/:id route', () => { it('redirects to index for non-numeric id', async () => { await router.push('/agents/abc'); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c9e9c482c96790..448237dca6cf51 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2521,6 +2521,9 @@ msgstr "" msgid "AICatalog|Copy flow config" msgstr "" +msgid "AICatalog|Copy of" +msgstr "" + msgid "AICatalog|Copy user prompt" msgstr "" @@ -2548,6 +2551,15 @@ msgstr "" msgid "AICatalog|Description is required." msgstr "" +msgid "AICatalog|Duplicate" +msgstr "" + +msgid "AICatalog|Duplicate agent" +msgstr "" + +msgid "AICatalog|Duplicate this agent with all its settings and configuration." +msgstr "" + msgid "AICatalog|Edit" msgstr "" -- GitLab