diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index c2ce7af0094ccab147f1cd1463cf7b9dd22fd6fa..9fce6b6c1d9fc133913bc775e0a4b1b2a2286c04 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1477,6 +1477,32 @@ 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.aiAgentUpdate` + +DETAILS: +**Introduced** in GitLab 16.10. +**Status**: Experiment. + +Input type: `AiAgentUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationaiagentupdateagentid"></a>`agentId` | [`AiAgentID!`](#aiagentid) | ID of the agent. | +| <a id="mutationaiagentupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationaiagentupdatename"></a>`name` | [`String`](#string) | Name of the agent. | +| <a id="mutationaiagentupdateprojectpath"></a>`projectPath` | [`ID!`](#id) | Project to which the agent belongs. | +| <a id="mutationaiagentupdateprompt"></a>`prompt` | [`String`](#string) | Prompt for the agent. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationaiagentupdateagent"></a>`agent` | [`AiAgent`](#aiagent) | Agent after mutation. | +| <a id="mutationaiagentupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationaiagentupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.alertSetAssignees` Input type: `AlertSetAssigneesInput` diff --git a/ee/app/assets/javascripts/ml/ai_agents/components/agent_form.vue b/ee/app/assets/javascripts/ml/ai_agents/components/agent_form.vue index 5eaebdd5aba9a1b46f1d9aecc4422b28d2007f73..e3d4701d2668d2118942ed94f90c37731ba95c33 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/components/agent_form.vue +++ b/ee/app/assets/javascripts/ml/ai_agents/components/agent_form.vue @@ -29,6 +29,21 @@ export default { required: false, default: false, }, + agentId: { + type: String, + required: false, + default: '', + }, + agentNameValue: { + type: String, + required: false, + default: '', + }, + agentPromptValue: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -44,8 +59,8 @@ export default { }, }, formValues: { - name: '', - prompt: '', + name: this.agentNameValue, + prompt: this.agentPromptValue, }, }; }, @@ -53,6 +68,7 @@ export default { onSubmit() { this.$emit('submit', { projectPath: this.projectPath, + agentId: this.agentId, name: this.formValues.name, prompt: this.formValues.prompt, }); diff --git a/ee/app/assets/javascripts/ml/ai_agents/constants.js b/ee/app/assets/javascripts/ml/ai_agents/constants.js index 97208891ccf7e4ac00bce91b45e1373625239cbf..289ae6e63f02617e0c8b6198b6725a935ae2e77e 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/constants.js +++ b/ee/app/assets/javascripts/ml/ai_agents/constants.js @@ -3,9 +3,13 @@ import { s__ } from '~/locale'; export const ROUTE_LIST_AGENTS = 'list'; export const ROUTE_NEW_AGENT = 'create'; export const ROUTE_SHOW_AGENT = 'show'; +export const ROUTE_EDIT_AGENT = 'edit'; export const I18N_AGENT_NAME_LABEL = s__('AIAgent|Agent name'); export const I18N_PROMPT_LABEL = s__('AIAgent|Prompt (optional)'); 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/graphql/mutations/update_ai_agent.mutation.graphql b/ee/app/assets/javascripts/ml/ai_agents/graphql/mutations/update_ai_agent.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..d802d1b6c6190f88d474b42aa285deb059e06487 --- /dev/null +++ b/ee/app/assets/javascripts/ml/ai_agents/graphql/mutations/update_ai_agent.mutation.graphql @@ -0,0 +1,17 @@ +mutation updateAiAgent($projectPath: ID!, $agentId: AiAgentID!, $name: String, $prompt: String) { + aiAgentUpdate( + input: { projectPath: $projectPath, agentId: $agentId, name: $name, prompt: $prompt } + ) { + agent { + id + routeId + name + latestVersion { + id + prompt + model + } + } + errors + } +} diff --git a/ee/app/assets/javascripts/ml/ai_agents/graphql/queries/get_latest_ai_agent_version.query.graphql b/ee/app/assets/javascripts/ml/ai_agents/graphql/queries/get_latest_ai_agent_version.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..e09b71f7ab07bb2fe6e287cee74390b1cdaca2b5 --- /dev/null +++ b/ee/app/assets/javascripts/ml/ai_agents/graphql/queries/get_latest_ai_agent_version.query.graphql @@ -0,0 +1,14 @@ +query getLatestAiAgentVersion($fullPath: ID!, $agentId: AiAgentID!) { + project(fullPath: $fullPath) { + id + aiAgent(id: $agentId) { + id + name + latestVersion { + id + prompt + model + } + } + } +} diff --git a/ee/app/assets/javascripts/ml/ai_agents/router.js b/ee/app/assets/javascripts/ml/ai_agents/router.js index e384cb49e4e71f46ec9b7d9d0f3a1ff2c3f5938b..0ea65dcfcbe6235ee7d88d77ad1d8fd5b98817f6 100644 --- a/ee/app/assets/javascripts/ml/ai_agents/router.js +++ b/ee/app/assets/javascripts/ml/ai_agents/router.js @@ -3,7 +3,13 @@ import VueRouter from 'vue-router'; import ListAgents from 'ee/ml/ai_agents/views/list_agents.vue'; import ShowAgent from 'ee/ml/ai_agents/views/show_agent.vue'; import CreateAgent from 'ee/ml/ai_agents/views/create_agent.vue'; -import { ROUTE_LIST_AGENTS, ROUTE_NEW_AGENT, ROUTE_SHOW_AGENT } from './constants'; +import EditAgent from 'ee/ml/ai_agents/views/edit_agent.vue'; +import { + ROUTE_LIST_AGENTS, + ROUTE_NEW_AGENT, + ROUTE_SHOW_AGENT, + ROUTE_EDIT_AGENT, +} from './constants'; Vue.use(VueRouter); @@ -27,6 +33,11 @@ export default function createRouter(base) { path: '/:agentId', component: ShowAgent, }, + { + name: ROUTE_EDIT_AGENT, + path: '/:agentId/edit', + component: EditAgent, + }, ], }); 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 new file mode 100644 index 0000000000000000000000000000000000000000..db2727dbae09d97758be587c48ce0465269a3144 --- /dev/null +++ b/ee/app/assets/javascripts/ml/ai_agents/views/edit_agent.vue @@ -0,0 +1,129 @@ +<script> +import { GlExperimentBadge, GlLoadingIcon, GlAlert, GlEmptyState } from '@gitlab/ui'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import getLatestAiAgentVersion from '../graphql/queries/get_latest_ai_agent_version.query.graphql'; +import AgentForm from '../components/agent_form.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'; + +export default { + name: 'EditAiAgent', + components: { + TitleArea, + GlExperimentBadge, + GlLoadingIcon, + GlAlert, + AgentForm, + GlEmptyState, + }, + I18N_UPDATE_AGENT, + I18N_DEFAULT_SAVE_ERROR, + I18N_DEFAULT_NOT_FOUND_ERROR, + I18N_EDIT_AGENT, + inject: ['projectPath'], + data() { + return { + errorMessage: '', + loading: false, + }; + }, + apollo: { + latestAgentVersion: { + query: getLatestAiAgentVersion, + variables() { + return this.queryVariables; + }, + update(data) { + return data.project?.aiAgent ?? {}; + }, + error(error) { + this.errorMessage = error.message; + Sentry.captureException(error); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.latestAgentVersion.loading; + }, + queryVariables() { + return { + fullPath: this.projectPath, + agentId: `gid://gitlab/Ai::Agent/${this.$route.params.agentId}`, + }; + }, + }, + methods: { + async updateAgent(requestData) { + this.errorMessage = ''; + this.loading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: updateAiAgent, + variables: requestData, + }); + + this.loading = false; + + const [error] = data?.aiAgentUpdate?.errors || []; + + if (error) { + this.errorMessage = data.aiAgentUpdate.errors.join(', '); + } else { + this.$router.push({ + name: 'show', + params: { agentId: data?.aiAgentUpdate?.agent?.routeId }, + }); + } + } catch (error) { + Sentry.captureException(error); + this.errorMessage = this.$options.I18N_DEFAULT_SAVE_ERROR; + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div> + <gl-empty-state + v-if="latestAgentVersion === undefined" + :title="$options.I18N_DEFAULT_NOT_FOUND_ERROR" + /> + + <gl-loading-icon v-else-if="isLoading" size="lg" class="gl-my-5" /> + + <gl-alert v-else-if="errorMessage" :dismissible="false" variant="danger" class="gl-mb-3"> + {{ errorMessage }} + </gl-alert> + + <div v-else> + <title-area> + <template #title> + <div class="gl-flex-grow-1 gl-display-flex gl-align-items-center"> + <span>{{ $options.I18N_EDIT_AGENT }}: {{ latestAgentVersion.name }}</span> + <gl-experiment-badge /> + </div> + </template> + </title-area> + + <agent-form + :project-path="projectPath" + :agent-id="latestAgentVersion.id" + :agent-name-value="latestAgentVersion.name" + :agent-prompt-value="latestAgentVersion.latestVersion.prompt" + :button-label="$options.I18N_UPDATE_AGENT" + :error-message="errorMessage" + :loading="loading" + @submit="updateAgent" + /> + </div> + </div> +</template> diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index fe38901c98e76e7e0b6ca2252588799bb53e7ea8..42cd65e60f8f8c7efd6716ead0f5eb1366a1a59d 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -152,6 +152,7 @@ module MutationType mount_mutation ::Mutations::AuditEvents::Streaming::HTTP::NamespaceFilters::Create 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::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/update.rb b/ee/app/graphql/mutations/ai/agents/update.rb new file mode 100644 index 0000000000000000000000000000000000000000..001e4d3bc79d4dd0c19b268acacd758e0a8dc796 --- /dev/null +++ b/ee/app/graphql/mutations/ai/agents/update.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Ai + module Agents + class Update < Base + graphql_name 'AiAgentUpdate' + + include FindsProject + + argument :agent_id, Types::GlobalIDType[::Ai::Agent], + required: true, + description: 'ID of the agent.' + + argument :name, GraphQL::Types::String, + required: false, + description: 'Name of the agent.' + + argument :prompt, GraphQL::Types::String, + required: false, + description: 'Prompt for the agent.' + + def resolve(**args) + authorized_find!(args[:project_path]) + agent = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(args[:agent_id])) + + updated_agent = ::Ai::Agents::UpdateAgentService.new(agent, args[:name], args[:prompt]).execute + + { + agent: updated_agent.errors.any? ? nil : updated_agent, + errors: errors_on_object(updated_agent) + } + end + end + end + end +end diff --git a/ee/app/models/ai/agent.rb b/ee/app/models/ai/agent.rb index d675ee1eab7d36397020846c4ff977428bbb0c2e..950567a8dd9a0156007c4c68bbcdaeff4b2f19fa 100644 --- a/ee/app/models/ai/agent.rb +++ b/ee/app/models/ai/agent.rb @@ -15,7 +15,7 @@ class Agent < ApplicationRecord belongs_to :project has_many :versions, class_name: 'Ai::AgentVersion' - has_one :latest_version, -> { latest_by_agent }, class_name: 'Ai::AgentVersion', inverse_of: :agent + has_one :latest_version, -> { latest_by_agent }, class_name: 'Ai::AgentVersion', inverse_of: :agent, validate: true scope :including_project, -> { includes(:project) } scope :for_project, ->(project) { where(project_id: project.id) } diff --git a/ee/app/services/ai/agents/update_agent_service.rb b/ee/app/services/ai/agents/update_agent_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..fdd777890e1305fa815e30520efc0bc7879f3612 --- /dev/null +++ b/ee/app/services/ai/agents/update_agent_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ai + module Agents + class UpdateAgentService < BaseService + def initialize(agent, name, prompt) + @agent = agent + @name = name + @prompt = prompt + end + + def execute + Ai::Agent.transaction do + @agent.name = @name unless @name.nil? + @agent.latest_version.prompt = @prompt unless @prompt.nil? + @agent.save # this method doesn't raise if it fails so that we can show vailidation errors to the user + end + + @agent + end + end + end +end diff --git a/ee/spec/frontend/ml/ai_agents/components/agent_form_spec.js b/ee/spec/frontend/ml/ai_agents/components/agent_form_spec.js index e36e437ed8d532a17dad2801804fdc0331616042..86c9a4aac954aabcdd3b4a54aee6beecfd6b2175 100644 --- a/ee/spec/frontend/ml/ai_agents/components/agent_form_spec.js +++ b/ee/spec/frontend/ml/ai_agents/components/agent_form_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlFormInput, GlFormTextarea, GlAlert } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlFormTextarea, GlAlert, GlFormFields } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import AgentForm from 'ee/ml/ai_agents/components/agent_form.vue'; @@ -21,6 +21,7 @@ describe('AI Agents Form', () => { }); }; + const findFormFields = () => wrapper.findComponent(GlFormFields); const findTextInput = () => wrapper.findComponent(GlFormInput); const findTextareaInput = () => wrapper.findComponent(GlFormTextarea); const findSubmitButton = () => wrapper.findComponent(GlButton); @@ -60,6 +61,16 @@ describe('AI Agents Form', () => { expect(findSubmitButton().props('loading')).toBe(true); }); + it('displays the input values when the props are supplied', () => { + createComponent({ + agentNameValue: 'agent_1', + agentPromptValue: 'Do something', + }); + + expect(findFormFields().props('values').name).toEqual('agent_1'); + expect(findFormFields().props('values').prompt).toEqual('Do something'); + }); + it('emits an event with the form data when the form is submitted', async () => { createComponent(); @@ -70,6 +81,7 @@ describe('AI Agents Form', () => { expect(wrapper.emitted('submit')[0][0]).toEqual({ projectPath: 'path/to/project', + agentId: '', name: 'agent_1', prompt: 'Do something', }); diff --git a/ee/spec/frontend/ml/ai_agents/graphql/mocks.js b/ee/spec/frontend/ml/ai_agents/graphql/mocks.js index c0b3b2ed04bfb17e517fbe2ba2d3ff8909439b8c..e88da9bb3e081ab23846b1b48fe36a0f9089ec94 100644 --- a/ee/spec/frontend/ml/ai_agents/graphql/mocks.js +++ b/ee/spec/frontend/ml/ai_agents/graphql/mocks.js @@ -20,6 +20,34 @@ export const createAiAgentsResponses = { }, }; +export const updateAiAgentsResponses = { + success: { + data: { + aiAgentUpdate: { + agent: { + id: 'gid://gitlab/Ai::Agent/1', + routeId: 2, + name: 'New name', + latestVersion: { + id: 'gid://gitlab/Ai::AgentVersion/1', + prompt: 'my prompt', + model: 'default', + }, + }, + errors: [], + }, + }, + }, + validationFailure: { + data: { + aiAgentUpdate: { + agent: null, + errors: ['Name is invalid'], + }, + }, + }, +}; + export const listAiAgentsResponses = { data: { project: { @@ -61,3 +89,36 @@ export const listAiAgentsEmptyResponses = { }, }, }; + +export const getLatestAiAgentResponse = { + data: { + project: { + id: 'gid://gitlab/Project/1', + aiAgent: { + id: 'gid://gitlab/Ai::Agent/1', + routeId: 2, + name: 'agent-1', + versions: [ + { + id: 'gid://gitlab/Ai::AgentVersion/1', + prompt: 'example prompt', + model: 'default', + }, + ], + latestVersion: { + id: 'gid://gitlab/Ai::AgentVersion/1', + prompt: 'example prompt', + model: 'default', + }, + }, + }, + }, +}; + +export const getLatestAiAgentErrorResponse = { + errors: [ + { + message: 'An error has occurred when loading the agent.', + }, + ], +}; 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 new file mode 100644 index 0000000000000000000000000000000000000000..b2ce89f1fd6398afd4f2b8e8612b1b2cffb31a76 --- /dev/null +++ b/ee/spec/frontend/ml/ai_agents/views/edit_agent_spec.js @@ -0,0 +1,196 @@ +import { + GlButton, + GlFormInput, + GlFormTextarea, + GlForm, + GlExperimentBadge, + GlFormFields, + GlAlert, + GlEmptyState, +} from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +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 createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { + updateAiAgentsResponses, + getLatestAiAgentResponse, + getLatestAiAgentErrorResponse, +} from '../graphql/mocks'; + +Vue.use(VueApollo); + +const push = jest.fn(); +const $router = { + push, +}; + +describe('ee/ml/ai_agents/views/edit_agent', () => { + let wrapper; + let apolloMocks; + const agentId = 1; + + const createComponent = () => { + const apolloProvider = createMockApollo(apolloMocks); + + wrapper = mountExtended(EditAgent, { + apolloProvider, + provide: { projectPath: 'path/to/project' }, + mocks: { + $router, + $route: { + params: { + agentId, + }, + }, + }, + }); + }; + + const findTitleArea = () => wrapper.findComponent(TitleArea); + const findBadge = () => wrapper.findComponent(GlExperimentBadge); + const findButton = () => wrapper.findComponent(GlButton); + const findForm = () => wrapper.findComponent(GlForm); + const findInput = () => wrapper.findComponent(GlFormInput); + const findTextarea = () => wrapper.findComponent(GlFormTextarea); + const findFormFields = () => wrapper.findComponent(GlFormFields); + const findErrorAlert = () => wrapper.findComponent(GlAlert); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const submitForm = async () => { + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await waitForPromises(); + }; + + describe('when the agent data has successfully loaded', () => { + beforeEach(async () => { + apolloMocks = [ + [getLatestAiAgentVersionQuery, jest.fn().mockResolvedValueOnce(getLatestAiAgentResponse)], + ]; + createComponent(); + await waitForPromises(); + }); + + it('renders the page title', () => { + expect(findTitleArea().text()).toContain('Edit Ai Agent'); + }); + + it('displays the experiment badge', () => { + expect(findBadge().exists()).toBe(true); + }); + + it('renders the button', () => { + expect(findButton().text()).toBe('Update agent'); + }); + + it('renders the form and expected inputs', () => { + expect(findForm().exists()).toBe(true); + expect(findInput().exists()).toBe(true); + expect(findTextarea().exists()).toBe(true); + expect(findFormFields().props('values').name).toEqual('agent-1'); + expect(findFormFields().props('values').prompt).toEqual('example prompt'); + }); + }); + + describe('when the agent data fails to load', () => { + beforeEach(async () => { + apolloMocks = [ + [ + getLatestAiAgentVersionQuery, + jest.fn().mockResolvedValueOnce(getLatestAiAgentErrorResponse), + ], + ]; + createComponent(); + await waitForPromises(); + }); + + it('displays an error', () => { + expect(findEmptyState().text()).toBe('The requested agent was not found.'); + }); + }); + + describe('when successfully updating the agent data', () => { + let resolver; + + beforeEach(async () => { + resolver = jest.fn().mockResolvedValueOnce(updateAiAgentsResponses.success); + apolloMocks = [ + [getLatestAiAgentVersionQuery, jest.fn().mockResolvedValueOnce(getLatestAiAgentResponse)], + [updateAiAgentMutation, resolver], + ]; + + createComponent(); + await waitForPromises(); + }); + + it('submits the update with correct parameters', async () => { + await findInput().vm.$emit('input', 'agent_1'); + await findTextarea().vm.$emit('input', 'Do something'); + + await submitForm(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentId: 'gid://gitlab/Ai::Agent/1', + projectPath: 'path/to/project', + name: 'agent_1', + prompt: 'Do something', + }), + ); + }); + + it('navigates to the new page when result is successful', async () => { + await findInput().vm.$emit('input', 'agent_1'); + await findTextarea().vm.$emit('input', 'Do something'); + + await submitForm(); + + expect($router.push).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'show', + params: { agentId: 2 }, + }), + ); + }); + }); + + describe('when updating the agent data fails', () => { + it('shows errors when result is a top level error', async () => { + const error = new Error('Failure!'); + const resolver = jest.fn().mockRejectedValue({ error }); + apolloMocks = [ + [getLatestAiAgentVersionQuery, jest.fn().mockResolvedValueOnce(getLatestAiAgentResponse)], + [updateAiAgentMutation, resolver], + ]; + + createComponent(); + await waitForPromises(); + + await submitForm(); + + expect(findErrorAlert().text()).toBe('An error has occurred when saving the agent.'); + expect($router.push).not.toHaveBeenCalled(); + }); + + it('shows errors when result is a validation error', async () => { + const resolver = jest.fn().mockResolvedValueOnce(updateAiAgentsResponses.validationFailure); + + apolloMocks = [ + [getLatestAiAgentVersionQuery, jest.fn().mockResolvedValueOnce(getLatestAiAgentResponse)], + [updateAiAgentMutation, resolver], + ]; + + createComponent(); + await waitForPromises(); + await submitForm(); + + expect(findErrorAlert().text()).toBe('Name is invalid'); + expect($router.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ee/spec/requests/api/graphql/ai/agents/update_spec.rb b/ee/spec/requests/api/graphql/ai/agents/update_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb5a5b12e81da104229ce31f2129a8b1d66336f4 --- /dev/null +++ b/ee/spec/requests/api/graphql/ai/agents/update_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Update 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, name: name, prompt: prompt } } + let(:name) { 'some_name' } + let(:prompt) { 'A prompt' } + + let(:mutation) { graphql_mutation(:ai_agent_update, input) } + let(:mutation_response) { graphql_mutation_response(:ai_agent_update) } + + 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 'updates an agent' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['agent']).to include( + 'name' => name + ) + end + + context 'when name is invalid' do + err_msg = "Name is invalid" + let(:name) { 'invalid name' } + + it_behaves_like 'a mutation that returns errors in the response', errors: [err_msg] + end + end +end diff --git a/ee/spec/services/ai/ai/agents/create_agent_service_spec.rb b/ee/spec/services/ai/agents/create_agent_service_spec.rb similarity index 100% rename from ee/spec/services/ai/ai/agents/create_agent_service_spec.rb rename to ee/spec/services/ai/agents/create_agent_service_spec.rb diff --git a/ee/spec/services/ai/agents/update_agent_service_spec.rb b/ee/spec/services/ai/agents/update_agent_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..09900c45602f8c078cb1251921f12b81bb3b7b48 --- /dev/null +++ b/ee/spec/services/ai/agents/update_agent_service_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ai::Agents::UpdateAgentService, 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_it_be(:another_project) { create(:project) } + let_it_be(:prompt) { 'prompt' } + let_it_be(:name) { 'name' } + + subject(:updated_agent) { described_class.new(agent, name, prompt).execute } + + describe '#execute' do + context 'when attributes are valid' do + let(:name) { 'new_agent_name' } + let(:prompt) { 'new_prompt' } + let(:project) { agent.project } + + it 'updates an agent', :aggregate_failures do + expect(updated_agent.name).to eq(name) + expect(updated_agent.latest_version.prompt).to eq(prompt) + end + end + + context 'when an invalid name is supplied' do + let(:name) { 'invalid name' } + + it 'returns a model with errors', :aggregate_failures do + expect(updated_agent.errors.full_messages).to eq(["Name is invalid"]) + end + end + + context 'when the agent version can not be saved' do + it 'returns a model with errors', :aggregate_failures do + agent.latest_version.model = nil + + expect(updated_agent.errors.full_messages).to eq(["Latest version is invalid"]) + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3dcaa309142b8878d89475c66b336611a619b14f..2a23e8696fab7208d086b01a41513b558c678c6f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1939,6 +1939,9 @@ msgstr "" msgid "AIAgents|New agent" msgstr "" +msgid "AIAgents|The requested agent was not found." +msgstr "" + msgid "AIAgent|AI Agent: %{agentId}" msgstr "" @@ -1954,12 +1957,18 @@ msgstr "" msgid "AIAgent|Create agent" msgstr "" +msgid "AIAgent|Edit Ai Agent" +msgstr "" + msgid "AIAgent|Prompt (optional)" msgstr "" msgid "AIAgent|Try out your agent" msgstr "" +msgid "AIAgent|Update agent" +msgstr "" + msgid "AIAgent|Your agent's system prompt will be applied to the chat input." msgstr ""