From b11137573f666d43ecc63670e3f8cfbf7bfaa346 Mon Sep 17 00:00:00 2001 From: Darby Frey <dfrey@gitlab.com> Date: Mon, 15 Apr 2024 15:16:48 +0000 Subject: [PATCH] Adds ability to destroy AI Agents --- doc/api/graphql/reference/index.md | 25 +++++ .../ml/ai_agents/components/agent_delete.vue | 104 ++++++++++++++++++ .../ml/ai_agents/components/agent_list.vue | 7 ++ .../javascripts/ml/ai_agents/constants.js | 2 - .../javascripts/ml/ai_agents/event_hub.js | 3 + .../destroy_ai_agent.mutation.graphql | 5 + .../ml/ai_agents/views/edit_agent.vue | 64 ++++++++--- .../ml/ai_agents/views/show_agent.vue | 7 +- ee/app/graphql/ee/types/mutation_type.rb | 1 + ee/app/graphql/mutations/ai/agents/base.rb | 4 + ee/app/graphql/mutations/ai/agents/destroy.rb | 35 ++++++ ee/app/graphql/mutations/ai/agents/update.rb | 2 +- .../ai/agents/destroy_agent_service.rb | 29 +++++ .../frontend/ml/ai_agents/graphql/mocks.js | 19 ++++ .../ml/ai_agents/views/edit_agent_spec.js | 62 +++++++++++ .../api/graphql/ai/agents/destroy_spec.rb | 50 +++++++++ .../ai/agents/destroy_agent_service_spec.rb | 32 ++++++ locale/gitlab.pot | 24 ++++ 18 files changed, 455 insertions(+), 20 deletions(-) create mode 100644 ee/app/assets/javascripts/ml/ai_agents/components/agent_delete.vue create mode 100644 ee/app/assets/javascripts/ml/ai_agents/event_hub.js create mode 100644 ee/app/assets/javascripts/ml/ai_agents/graphql/mutations/destroy_ai_agent.mutation.graphql create mode 100644 ee/app/graphql/mutations/ai/agents/destroy.rb create mode 100644 ee/app/services/ai/agents/destroy_agent_service.rb create mode 100644 ee/spec/requests/api/graphql/ai/agents/destroy_spec.rb create mode 100644 ee/spec/services/ai/agents/destroy_agent_service_spec.rb diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 595b850afdee5..8ffa906cbd367 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1493,6 +1493,31 @@ Input type: `AiAgentCreateInput` | <a id="mutationaiagentcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationaiagentcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.aiAgentDestroy` + +DETAILS: +**Introduced** in GitLab 16.11. +**Status**: Experiment. + +Input type: `AiAgentDestroyInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationaiagentdestroyagentid"></a>`agentId` | [`AiAgentID!`](#aiagentid) | Global ID of the AI Agent to be deleted. | +| <a id="mutationaiagentdestroyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationaiagentdestroyprojectpath"></a>`projectPath` | [`ID!`](#id) | Project to which the agent belongs. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationaiagentdestroyagent"></a>`agent` | [`AiAgent`](#aiagent) | Agent after mutation. | +| <a id="mutationaiagentdestroyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationaiagentdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationaiagentdestroymessage"></a>`message` | [`String`](#string) | AI Agent deletion result message. | + ### `Mutation.aiAgentUpdate` DETAILS: diff --git a/ee/app/assets/javascripts/ml/ai_agents/components/agent_delete.vue b/ee/app/assets/javascripts/ml/ai_agents/components/agent_delete.vue new file mode 100644 index 0000000000000..c4d6faef87031 --- /dev/null +++ b/ee/app/assets/javascripts/ml/ai_agents/components/agent_delete.vue @@ -0,0 +1,104 @@ +<script> +import { GlCard, GlButton, GlSprintf, GlModal, GlModalDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + components: { + GlCard, + GlButton, + GlSprintf, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: ['projectPath'], + props: { + agentVersion: { + type: Object, + required: false, + default: () => ({}), + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + modalTitle: s__('AIAgent|Delete agent?'), + sectionTitle: s__('AIAgent|Delete this agent'), + sectionBody: s__( + 'AIAgent|This action permanently deletes the %{codeStart}%{agentName}%{codeEnd} AI Agent.', + ), + deleteAgent: s__('AIAgent|Delete Agent'), + deleteAgentConfirmation: s__( + 'AIAgent|AI Agent %{codeStart}%{agentName}%{codeEnd} will be permanently deleted. Are you sure?', + ), + cancel: __('Cancel'), + }, + data() { + return { + deleteProps: { + text: this.$options.i18n.deleteAgent, + attributes: { category: 'primary', variant: 'danger' }, + }, + cancelProps: { + text: this.$options.i18n.cancel, + }, + }; + }, + deleteModalId: 'deleteModalId', + methods: { + onSubmitDeleteModal() { + this.$emit('destroy', { + projectPath: this.projectPath, + agentId: this.agentVersion.id, + }); + }, + }, +}; +</script> + +<template> + <gl-card + class="gl-new-card" + header-class="gl-new-card-header" + body-class="gl-bg-red-50 gl-px-5 gl-py-4" + > + <template #header> + <div class="gl-new-card-title-wrapper"> + <h4 class="gl-new-card-title danger-title">{{ $options.i18n.sectionTitle }}</h4> + </div> + </template> + <p> + <gl-sprintf :message="$options.i18n.sectionBody"> + <template #code> + <code>{{ agentVersion.name }}</code> + </template> + </gl-sprintf> + </p> + + <gl-button + v-gl-modal="$options.deleteModalId" + variant="danger" + class="gl-mt-3 d-block" + :loading="loading" + >{{ $options.i18n.deleteAgent }}</gl-button + > + <gl-modal + :ref="$options.deleteModalId" + :modal-id="$options.deleteModalId" + :title="$options.i18n.modalTitle" + :action-primary="deleteProps" + :action-cancel="cancelProps" + @primary="onSubmitDeleteModal" + > + <gl-sprintf :message="$options.i18n.deleteAgentConfirmation"> + <template #code> + <code>{{ agentVersion.name }}</code> + </template> + </gl-sprintf> + </gl-modal> + </gl-card> +</template> diff --git a/ee/app/assets/javascripts/ml/ai_agents/components/agent_list.vue b/ee/app/assets/javascripts/ml/ai_agents/components/agent_list.vue index 365473ce99681..69e359a0664c1 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/components/agent_list.vue +++ b/ee/app/assets/javascripts/ml/ai_agents/components/agent_list.vue @@ -5,6 +5,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__ } from '~/locale'; import { ROUTE_SHOW_AGENT } from '../constants'; import getAiAgents from '../graphql/queries/get_ai_agents.query.graphql'; +import eventHub from '../event_hub'; const GRAPHQL_PAGE_SIZE = 30; @@ -37,6 +38,7 @@ export default { apollo: { agents: { query: getAiAgents, + notifyOnNetworkStatusChange: true, variables() { return this.queryVariables; }, @@ -66,6 +68,11 @@ export default { return this.agents?.nodes ?? []; }, }, + created() { + eventHub.$on('agents-changed', () => { + this.$apollo.queries.agents.refresh(); + }); + }, ROUTE_SHOW_AGENT, methods: { fetchPage(pageInfo) { diff --git a/ee/app/assets/javascripts/ml/ai_agents/constants.js b/ee/app/assets/javascripts/ml/ai_agents/constants.js index ca23eee663de7..7c59dda09c4d6 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/constants.js +++ b/ee/app/assets/javascripts/ml/ai_agents/constants.js @@ -8,8 +8,6 @@ export const ROUTE_AGENT_SETTINGS = 'edit'; export const I18N_AGENT_NAME_LABEL = s__('AIAgent|Agent name'); export const I18N_PROMPT_LABEL = s__('AIAgent|Prompt'); export const I18N_CREATE_AGENT = s__('AIAgent|Create agent'); -export const I18N_UPDATE_AGENT = s__('AIAgent|Update agent'); export const I18N_EDIT_AGENT = s__('AIAgent|Edit Ai Agent'); -export const I18N_DEFAULT_NOT_FOUND_ERROR = s__('AIAgents|The requested agent was not found.'); export const I18N_DEFAULT_SAVE_ERROR = s__('AIAgents|An error has occurred when saving the agent.'); diff --git a/ee/app/assets/javascripts/ml/ai_agents/event_hub.js b/ee/app/assets/javascripts/ml/ai_agents/event_hub.js new file mode 100644 index 0000000000000..e31806ad199a1 --- /dev/null +++ b/ee/app/assets/javascripts/ml/ai_agents/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/ee/app/assets/javascripts/ml/ai_agents/graphql/mutations/destroy_ai_agent.mutation.graphql b/ee/app/assets/javascripts/ml/ai_agents/graphql/mutations/destroy_ai_agent.mutation.graphql new file mode 100644 index 0000000000000..172a418b5d926 --- /dev/null +++ b/ee/app/assets/javascripts/ml/ai_agents/graphql/mutations/destroy_ai_agent.mutation.graphql @@ -0,0 +1,5 @@ +mutation destroyAiAgent($projectPath: ID!, $agentId: AiAgentID!) { + aiAgentDestroy(input: { projectPath: $projectPath, agentId: $agentId }) { + errors + } +} diff --git a/ee/app/assets/javascripts/ml/ai_agents/views/edit_agent.vue b/ee/app/assets/javascripts/ml/ai_agents/views/edit_agent.vue index 8827291a61b04..3967fb73a1ecb 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/views/edit_agent.vue +++ b/ee/app/assets/javascripts/ml/ai_agents/views/edit_agent.vue @@ -2,15 +2,14 @@ import { GlExperimentBadge, GlLoadingIcon, GlEmptyState, GlAlert } from '@gitlab/ui'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { s__ } from '~/locale'; import getLatestAiAgentVersion from '../graphql/queries/get_latest_ai_agent_version.query.graphql'; import AgentForm from '../components/agent_form.vue'; +import DeleteAgent from '../components/agent_delete.vue'; import updateAiAgent from '../graphql/mutations/update_ai_agent.mutation.graphql'; -import { - I18N_UPDATE_AGENT, - I18N_DEFAULT_SAVE_ERROR, - I18N_DEFAULT_NOT_FOUND_ERROR, - I18N_EDIT_AGENT, -} from '../constants'; +import destroyAiAgent from '../graphql/mutations/destroy_ai_agent.mutation.graphql'; +import { I18N_EDIT_AGENT } from '../constants'; +import eventHub from '../event_hub'; export default { name: 'EditAiAgent', @@ -21,10 +20,8 @@ export default { AgentForm, GlEmptyState, GlAlert, + DeleteAgent, }, - I18N_UPDATE_AGENT, - I18N_DEFAULT_SAVE_ERROR, - I18N_DEFAULT_NOT_FOUND_ERROR, I18N_EDIT_AGENT, inject: ['projectPath'], data() { @@ -48,6 +45,12 @@ export default { }, }, }, + i18n: { + updateAgent: s__('AIAgent|Update agent'), + saveError: s__('AIAgents|An error has occurred when saving the agent.'), + notFoundError: s__('AIAgents|The requested agent was not found.'), + destroyError: s__('AIAgents|An error has occurred when deleting the agent.'), + }, computed: { isLoading() { return this.$apollo.queries.latestAgentVersion.loading; @@ -86,7 +89,34 @@ export default { } } catch (error) { Sentry.captureException(error); - this.errorMessage = this.$options.I18N_DEFAULT_SAVE_ERROR; + this.errorMessage = this.$options.i18n.saveError; + this.loading = false; + } + }, + async destroyAgent(requestData) { + this.errorMessage = ''; + this.loading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: destroyAiAgent, + variables: requestData, + }); + + this.loading = false; + + const [error] = data?.aiAgentDestroy?.errors || []; + + if (error) { + this.errorMessage = data.aiAgentDestroy.errors.join(', '); + } else { + eventHub.$emit('agents-changed'); + this.$router.push({ + name: 'list', + }); + } + } catch (error) { + Sentry.captureException(error); + this.errorMessage = this.$options.i18n.destroyError; this.loading = false; } }, @@ -102,10 +132,7 @@ export default { {{ errorMessage }} </gl-alert> - <gl-empty-state - v-else-if="agentVersionNotFound" - :title="$options.I18N_DEFAULT_NOT_FOUND_ERROR" - /> + <gl-empty-state v-else-if="agentVersionNotFound" :title="$options.i18n.notFoundError" /> <div v-else> <title-area> @@ -126,11 +153,18 @@ export default { :agent-version="latestAgentVersion" :agent-name-value="latestAgentVersion.name" :agent-prompt-value="latestAgentVersion.latestVersion.prompt" - :button-label="$options.I18N_UPDATE_AGENT" + :button-label="$options.i18n.updateAgent" :error-message="errorMessage" :loading="loading" @submit="updateAgent" /> + + <delete-agent + :project-path="projectPath" + :agent-version="latestAgentVersion" + :loading="loading" + @destroy="destroyAgent" + /> </div> </div> </template> diff --git a/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue b/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue index 7e1ea0271278f..30c2adc367c35 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue +++ b/ee/app/assets/javascripts/ml/ai_agents/views/show_agent.vue @@ -1,6 +1,7 @@ <script> import { GlExperimentBadge, GlDuoChat, GlEmptyState, GlLoadingIcon, GlButton } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { s__ } from '~/locale'; import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { renderMarkdown } from '~/notes/utils'; @@ -9,7 +10,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; import { GENIE_CHAT_MODEL_ROLES } from 'ee/ai/constants'; -import { ROUTE_AGENT_SETTINGS, I18N_DEFAULT_NOT_FOUND_ERROR } from 'ee/ml/ai_agents/constants'; +import { ROUTE_AGENT_SETTINGS } from 'ee/ml/ai_agents/constants'; import getLatestAiAgentVersion from 'ee/ml/ai_agents/graphql/queries/get_latest_ai_agent_version.query.graphql'; export default { @@ -25,7 +26,6 @@ export default { GlButton, }, ROUTE_AGENT_SETTINGS, - I18N_DEFAULT_NOT_FOUND_ERROR, provide() { return { projectPath: this.projectPath, @@ -86,6 +86,9 @@ export default { isLoading: false, }; }, + i18n: { + not_found_error: s__('AIAgents|The requested agent was not found.'), + }, computed: { isAgentLoading() { return this.$apollo.queries.agentWithVersion.loading; diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 9efd2d6cf05e4..e31e169568c57 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -158,6 +158,7 @@ module MutationType mount_mutation ::Mutations::AuditEvents::Streaming::HTTP::NamespaceFilters::Delete mount_mutation ::Mutations::Ai::Agents::Create, alpha: { milestone: '16.8' } mount_mutation ::Mutations::Ai::Agents::Update, alpha: { milestone: '16.10' } + mount_mutation ::Mutations::Ai::Agents::Destroy, alpha: { milestone: '16.11' } mount_mutation ::Mutations::ComplianceManagement::Standards::RefreshAdherenceChecks mount_mutation ::Mutations::Groups::SavedReplies::Create, alpha: { milestone: '16.10' } mount_mutation ::Mutations::Groups::SavedReplies::Update, alpha: { milestone: '16.10' } diff --git a/ee/app/graphql/mutations/ai/agents/base.rb b/ee/app/graphql/mutations/ai/agents/base.rb index 3ff84c5d36599..ae389a20fe338 100644 --- a/ee/app/graphql/mutations/ai/agents/base.rb +++ b/ee/app/graphql/mutations/ai/agents/base.rb @@ -14,6 +14,10 @@ class Base < BaseMutation Types::Ai::Agents::AgentType, null: true, description: 'Agent after mutation.' + + def find_agent(agent_id) + ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(agent_id)) + end end end end diff --git a/ee/app/graphql/mutations/ai/agents/destroy.rb b/ee/app/graphql/mutations/ai/agents/destroy.rb new file mode 100644 index 0000000000000..bf8cfdae17a48 --- /dev/null +++ b/ee/app/graphql/mutations/ai/agents/destroy.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module Ai + module Agents + class Destroy < Base + graphql_name 'AiAgentDestroy' + + include FindsProject + + argument :agent_id, ::Types::GlobalIDType[::Ai::Agent], + required: true, + description: 'Global ID of the AI Agent to be deleted.' + + field :message, GraphQL::Types::String, + null: true, + description: 'AI Agent deletion result message.' + + def resolve(**args) + authorized_find!(args[:project_path]) + agent = find_agent(args[:agent_id]) + + return { errors: ['AI Agent not found'] } unless agent + + result = ::Ai::Agents::DestroyAgentService.new(agent, current_user).execute + + { + message: result.success? ? result[:message] : nil, + errors: result.error? ? Array.wrap(result[:message]) : [] + } + end + end + end + end +end diff --git a/ee/app/graphql/mutations/ai/agents/update.rb b/ee/app/graphql/mutations/ai/agents/update.rb index 001e4d3bc79d4..2d421631d64c2 100644 --- a/ee/app/graphql/mutations/ai/agents/update.rb +++ b/ee/app/graphql/mutations/ai/agents/update.rb @@ -22,7 +22,7 @@ class Update < Base def resolve(**args) authorized_find!(args[:project_path]) - agent = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(args[:agent_id])) + agent = find_agent(args[:agent_id]) updated_agent = ::Ai::Agents::UpdateAgentService.new(agent, args[:name], args[:prompt]).execute diff --git a/ee/app/services/ai/agents/destroy_agent_service.rb b/ee/app/services/ai/agents/destroy_agent_service.rb new file mode 100644 index 0000000000000..e783eaa0710dd --- /dev/null +++ b/ee/app/services/ai/agents/destroy_agent_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ai + module Agents + class DestroyAgentService + def initialize(ai_agent, user) + @ai_agent = ai_agent + @user = user + end + + def execute + @ai_agent.destroy + success + rescue ActiveRecord::RecordNotDestroyed => msg + error(msg) + end + + private + + def success + ServiceResponse.success(message: _('AI Agent was successfully deleted')) + end + + def error(msg) + ServiceResponse.error(message: format(_('Failed to delete AI Agent: %{msg}'), msg: msg)) + end + end + end +end diff --git a/ee/spec/frontend/ml/ai_agents/graphql/mocks.js b/ee/spec/frontend/ml/ai_agents/graphql/mocks.js index a1a394237f86f..a876204eb63d8 100644 --- a/ee/spec/frontend/ml/ai_agents/graphql/mocks.js +++ b/ee/spec/frontend/ml/ai_agents/graphql/mocks.js @@ -131,3 +131,22 @@ export const getLatestAiAgentErrorResponse = { }, ], }; + +export const destroyAiAgentsResponses = { + success: { + data: { + aiAgentDestroy: { + message: 'AI Agent was successfully deleted', + errors: [], + }, + }, + }, + error: { + data: { + aiAgentDestroy: { + message: null, + errors: ['AI Agent not found'], + }, + }, + }, +}; diff --git a/ee/spec/frontend/ml/ai_agents/views/edit_agent_spec.js b/ee/spec/frontend/ml/ai_agents/views/edit_agent_spec.js index a55d7c46b7f26..10c2192245555 100644 --- a/ee/spec/frontend/ml/ai_agents/views/edit_agent_spec.js +++ b/ee/spec/frontend/ml/ai_agents/views/edit_agent_spec.js @@ -7,6 +7,7 @@ import { GlFormFields, GlAlert, GlEmptyState, + GlModal, } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -14,6 +15,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import EditAgent from 'ee/ml/ai_agents/views/edit_agent.vue'; import getLatestAiAgentVersionQuery from 'ee/ml/ai_agents/graphql/queries/get_latest_ai_agent_version.query.graphql'; import updateAiAgentMutation from 'ee/ml/ai_agents/graphql/mutations/update_ai_agent.mutation.graphql'; +import destroyAiAgentMutation from 'ee/ml/ai_agents//graphql/mutations/destroy_ai_agent.mutation.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -22,6 +24,7 @@ import { getLatestAiAgentResponse, getLatestAiAgentErrorResponse, getLatestAiAgentNotFoundResponse, + destroyAiAgentsResponses, } from '../graphql/mocks'; Vue.use(VueApollo); @@ -56,6 +59,7 @@ describe('ee/ml/ai_agents/views/edit_agent', () => { const findTitleArea = () => wrapper.findComponent(TitleArea); const findBadge = () => wrapper.findComponent(GlExperimentBadge); const findButton = () => wrapper.findComponent(GlButton); + const findDeleteModal = () => wrapper.findComponent(GlModal); const findForm = () => wrapper.findComponent(GlForm); const findInput = () => wrapper.findComponent(GlFormInput); const findTextarea = () => wrapper.findComponent(GlFormTextarea); @@ -213,4 +217,62 @@ describe('ee/ml/ai_agents/views/edit_agent', () => { expect($router.push).not.toHaveBeenCalled(); }); }); + + describe('when successfully destroying the agent data', () => { + let resolver; + + beforeEach(async () => { + resolver = jest.fn().mockResolvedValueOnce(destroyAiAgentsResponses.success); + apolloMocks = [ + [getLatestAiAgentVersionQuery, jest.fn().mockResolvedValueOnce(getLatestAiAgentResponse)], + [destroyAiAgentMutation, resolver], + ]; + + createComponent(); + await waitForPromises(); + }); + + it('submits the destroy request', () => { + findDeleteModal().vm.$emit('primary'); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentId: 'gid://gitlab/Ai::Agent/1', + projectPath: 'path/to/project', + }), + ); + }); + + it('navigates to the new page when result is successful', async () => { + findDeleteModal().vm.$emit('primary'); + await waitForPromises(); + + expect($router.push).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'list', + }), + ); + }); + }); + + describe('when destroying the agent fails', () => { + let resolver; + + it('shows errors when result is a top level error', async () => { + resolver = jest.fn().mockResolvedValueOnce(destroyAiAgentsResponses.error); + apolloMocks = [ + [getLatestAiAgentVersionQuery, jest.fn().mockResolvedValueOnce(getLatestAiAgentResponse)], + [destroyAiAgentMutation, resolver], + ]; + + createComponent(); + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + await waitForPromises(); + + expect(findErrorAlert().text()).toBe('AI Agent not found'); + expect($router.push).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ee/spec/requests/api/graphql/ai/agents/destroy_spec.rb b/ee/spec/requests/api/graphql/ai/agents/destroy_spec.rb new file mode 100644 index 0000000000000..5b4f5cda51b8a --- /dev/null +++ b/ee/spec/requests/api/graphql/ai/agents/destroy_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Destroy an AI agent', feature_category: :mlops do + include GraphqlHelpers + + let_it_be(:agent_version) { create(:ai_agent_version) } + let_it_be(:agent) { agent_version.agent } + let_it_be(:project) { agent.project } + let_it_be(:current_user) { project.owner } + + let(:input) { { project_path: project.full_path, agent_id: agent.to_global_id } } + + let(:mutation) { graphql_mutation(:ai_agent_destroy, input) } + let(:mutation_response) { graphql_mutation_response(:ai_agent_destroy) } + + before do + stub_licensed_features(ai_agents: true) + end + + context 'when user is not allowed write changes' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :write_ai_agents, project) + .and_return(false) + end + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user is allowed write changes' do + it 'destroys an agent' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['message']).to include( + "AI Agent was successfully deleted" + ) + end + + context 'when id is not found' do + err_msg = "AI Agent not found" + let(:input) { { project_path: project.full_path, agent_id: "gid://gitlab/Ai::Agent/99999" } } + + it_behaves_like 'a mutation that returns errors in the response', errors: [err_msg] + end + end +end diff --git a/ee/spec/services/ai/agents/destroy_agent_service_spec.rb b/ee/spec/services/ai/agents/destroy_agent_service_spec.rb new file mode 100644 index 0000000000000..8123c30f87ad4 --- /dev/null +++ b/ee/spec/services/ai/agents/destroy_agent_service_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ai::Agents::DestroyAgentService, feature_category: :mlops do + let_it_be(:user) { create(:user) } + let_it_be(:agent_version) { create(:ai_agent_version) } + let_it_be(:agent) { agent_version.agent } + + let(:service) { described_class.new(agent, user) } + + describe '#execute' do + subject(:service_result) { service.execute } + + context 'when agent fails to delete' do + it 'returns nil' do + exception = ActiveRecord::RecordNotDestroyed.new(agent) + allow(agent).to receive(:destroy).and_raise(exception) + + expect(service_result).to be_error + expect(service_result.message).to eq("Failed to delete AI Agent: #{exception.message}") + end + end + + context 'when an agent exists' do + it 'destroys the agent', :aggregate_failures do + expect { service_result }.to change { Ai::Agent.count }.by(-1).and change { Ai::AgentVersion.count }.by(-1) + expect(service_result).to be_success + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3024a686d1703..cc14d0767e61d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1987,6 +1987,9 @@ msgstr "" msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'" msgstr "" +msgid "AI Agent was successfully deleted" +msgstr "" + msgid "AI actions" msgstr "" @@ -1999,6 +2002,9 @@ msgstr "" msgid "AIAgents|Agent Settings" msgstr "" +msgid "AIAgents|An error has occurred when deleting the agent." +msgstr "" + msgid "AIAgents|An error has occurred when saving the agent." msgstr "" @@ -2014,6 +2020,9 @@ msgstr "" msgid "AIAgents|Update the name and prompt for this agent." msgstr "" +msgid "AIAgent|AI Agent %{codeStart}%{agentName}%{codeEnd} will be permanently deleted. Are you sure?" +msgstr "" + msgid "AIAgent|Agent" msgstr "" @@ -2026,6 +2035,15 @@ msgstr "" msgid "AIAgent|Create agent" msgstr "" +msgid "AIAgent|Delete Agent" +msgstr "" + +msgid "AIAgent|Delete agent?" +msgstr "" + +msgid "AIAgent|Delete this agent" +msgstr "" + msgid "AIAgent|Edit Ai Agent" msgstr "" @@ -2035,6 +2053,9 @@ msgstr "" msgid "AIAgent|Settings" msgstr "" +msgid "AIAgent|This action permanently deletes the %{codeStart}%{agentName}%{codeEnd} AI Agent." +msgstr "" + msgid "AIAgent|Try out your agent" msgstr "" @@ -21305,6 +21326,9 @@ msgstr "" msgid "Failed to create wiki" msgstr "" +msgid "Failed to delete AI Agent: %{msg}" +msgstr "" + msgid "Failed to delete branch target" msgstr "" -- GitLab