diff --git a/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue b/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue index c2ec668cdd85b7904f5f46f5d1fafeca07170c12..d7da37abb4507014473ea2f7b0c6907afafc30b5 100644 --- a/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue +++ b/ee/app/assets/javascripts/ai/components/ai_genie_chat_message.vue @@ -5,8 +5,17 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { GENIE_CHAT_MODEL_ROLES } from '../constants'; +const concatIndicesUntilEmpty = (arr) => { + const start = arr.findIndex((el) => el); + if (start === -1 || start !== 1) return ''; // If there are no non-empty elements + + const end = arr.slice(start).findIndex((el) => !el); + return end > 0 ? arr.slice(start, end).join('') : arr.slice(start).join(''); +}; + export default { name: 'AiGenieChatMessage', + messageChunks: [], directives: { SafeHtml, }, @@ -26,16 +35,34 @@ export default { messageContent: renderMarkdown(this.message.content || this.message.errors[0]), }; }, - mounted() { - this.hydrateContentWithGFM(); - }, - methods: { - isAssistantMessage(message) { - return message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.assistant; + computed: { + isAssistantMessage() { + return this.message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.assistant; }, - isUserMessage(message) { - return message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.user; + isUserMessage() { + return this.message.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.user; }, + }, + watch: { + message: { + handler() { + const { chunkId, content, errors } = this.message; + if (!chunkId) { + this.$options.messageChunks = []; + this.messageContent = renderMarkdown(content || errors[0]); + this.hydrateContentWithGFM(); + } else { + this.$options.messageChunks[chunkId] = content; + this.messageContent = renderMarkdown( + concatIndicesUntilEmpty(this.$options.messageChunks), + ); + } + }, + immediate: true, + deep: true, + }, + }, + methods: { async hydrateContentWithGFM() { const textToConvert = this.message.content || this.message.errors[0]; if (textToConvert) { @@ -51,17 +78,13 @@ export default { <div class="gl-py-3 gl-mb-4 gl-px-4 gl-rounded-lg gl-line-height-20 gl-word-break-word ai-genie-chat-message" :class="{ - 'gl-ml-auto gl-bg-blue-100 gl-text-blue-900 gl-rounded-bottom-right-none': isUserMessage( - message, - ), - 'gl-rounded-bottom-left-none gl-text-gray-900 gl-bg-white gl-border-1 gl-border-solid gl-border-gray-50': isAssistantMessage( - message, - ), + 'gl-ml-auto gl-bg-blue-100 gl-text-blue-900 gl-rounded-bottom-right-none': isUserMessage, + 'gl-rounded-bottom-left-none gl-text-gray-900 gl-bg-white gl-border-1 gl-border-solid gl-border-gray-50': isAssistantMessage, }" > <div ref="content" v-safe-html="messageContent"></div> <slot - v-if="isAssistantMessage(message)" + v-if="isAssistantMessage" name="feedback" :prompt-location="promptLocation" :message="message" diff --git a/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql b/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql index c26d8270de374eb739b8d6cd706e301060e6ee21..6ae83926f55f2f726e309b9d5ab7645b0c4a4b11 100644 --- a/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql +++ b/ee/app/assets/javascripts/ai/graphql/chat.mutation.graphql @@ -1,5 +1,10 @@ -mutation chat($question: String!, $resourceId: AiModelID!) { - aiAction(input: { chat: { resourceId: $resourceId, content: $question } }) { +mutation chat($question: String!, $resourceId: AiModelID!, $clientSubscriptionId: String) { + aiAction( + input: { + chat: { resourceId: $resourceId, content: $question } + clientSubscriptionId: $clientSubscriptionId + } + ) { requestId errors } diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue index 5dea45dc3129d11b725cde4d1bfbb9e7b502cc75..3313956bff8b3dd2576d0c90147ac4f5e5aa3e30 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue +++ b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue @@ -2,6 +2,7 @@ import { GlIcon, GlLink } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; +import { v4 as uuidv4 } from 'uuid'; import { __, s__, n__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { helpCenterState } from '~/super_sidebar/constants'; @@ -75,6 +76,24 @@ export default { }); }, }, + aiCompletionResponseStream: { + query: aiResponseSubscription, + variables() { + return { + userId: this.userId, + resourceId: this.resourceId || this.userId, + clientSubscriptionId: this.clientSubscriptionId, + }; + }, + result({ data }) { + this.addDuoChatMessage(data?.aiCompletionResponse); + }, + error(err) { + this.addDuoChatMessage({ + errors: [err], + }); + }, + }, }, aiMessages: { query: getAiMessages, @@ -93,6 +112,7 @@ export default { data() { return { helpCenterState, + clientSubscriptionId: uuidv4(), }; }, computed: { @@ -111,6 +131,7 @@ export default { variables: { question, resourceId: this.resourceId || this.userId, + clientSubscriptionId: this.clientSubscriptionId, }, }) .then(({ data: { aiAction = {} } = {} }) => { diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js b/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js index 0ca11f6cae4d79ea78ff1cfa143f9321a5b79599..22febc767f58c51efffa7bc9eb5f5411a3f480fb 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js +++ b/ee/app/assets/javascripts/ai/tanuki_bot/store/actions.js @@ -20,6 +20,17 @@ export const addDuoChatMessage = async ({ commit }, messageData = { content: '' let parsedResponse; try { parsedResponse = JSON.parse(msgContent); + + // JSON.parse does not throw when parsing Strings that are coerced to primary data types, like Number or Boolean. + // JSON.parse("1") would return 1 (Number), same as JSON.parse("false"), which returns false (Boolean). + // We therefore check if the parsed response is really an Object. + // This will be solved with https://gitlab.com/gitlab-org/gitlab/-/issues/423315 when we no longer receive + // potential JSON as a string. + if (typeof parsedResponse !== 'object') { + parsedResponse = { + content: msgContent, + }; + } } catch { parsedResponse = { content: msgContent }; } diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/store/mutations.js b/ee/app/assets/javascripts/ai/tanuki_bot/store/mutations.js index 5067667532b10c9e60ffc0f2ab7f7800450ebed9..b96cbf1919b5f2c77777d1bf3f2e9fda085eb2dd 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/store/mutations.js +++ b/ee/app/assets/javascripts/ai/tanuki_bot/store/mutations.js @@ -8,23 +8,39 @@ export default { if (newMessageData.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.system) { return; } - const index = state.messages.findIndex((msg) => msg.requestId === newMessageData.requestId); - const hasMsgWithRequestId = index > -1; - const msgWithRequestId = hasMsgWithRequestId && state.messages[index]; let isLastMessage = false; - if (hasMsgWithRequestId) { - if (msgWithRequestId.role.toLowerCase() === newMessageData.role.toLowerCase()) { - // We update the existing message object instead of pushing a new one - state.messages[index] = { - ...msgWithRequestId, - ...newMessageData, - }; - } else { - // We add the new ASSISTANT message - isLastMessage = index === state.messages.length - 1; - state.messages.splice(index + 1, 0, newMessageData); - } + const getExistingMesagesIndex = (role) => + state.messages.findIndex( + (msg) => msg.requestId === newMessageData.requestId && msg.role.toLowerCase() === role, + ); + const userMessageWithRequestIdIndex = getExistingMesagesIndex(GENIE_CHAT_MODEL_ROLES.user); + const assistantMessageWithRequestIdIndex = getExistingMesagesIndex( + GENIE_CHAT_MODEL_ROLES.assistant, + ); + const assistantMessageExists = assistantMessageWithRequestIdIndex > -1; + const userMessageExists = userMessageWithRequestIdIndex > -1; + + const isUserMesasge = newMessageData.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.user; + const isAssistantMessage = + newMessageData.role.toLowerCase() === GENIE_CHAT_MODEL_ROLES.assistant; + + if (assistantMessageExists && isAssistantMessage) { + // We update the existing ASSISTANT message object instead of pushing a new one + state.messages.splice(assistantMessageWithRequestIdIndex, 1, { + ...state.messages[assistantMessageWithRequestIdIndex], + ...newMessageData, + }); + } else if (userMessageExists && isUserMesasge) { + // We update the existing USER message object instead of pushing a new one + state.messages.splice(userMessageWithRequestIdIndex, 1, { + ...state.messages[userMessageWithRequestIdIndex], + ...newMessageData, + }); + } else if (userMessageExists && isAssistantMessage) { + // We add the new ASSISTANT message + isLastMessage = userMessageWithRequestIdIndex === state.messages.length - 1; + state.messages.splice(userMessageWithRequestIdIndex + 1, 0, newMessageData); } else { // It's the new message, so just push it to the end of the Array state.messages.push(newMessageData); diff --git a/ee/app/assets/javascripts/graphql_shared/subscriptions/ai_completion_response.subscription.graphql b/ee/app/assets/javascripts/graphql_shared/subscriptions/ai_completion_response.subscription.graphql index c4603081e01c45bc1db60f27663d1b0ca7e9530d..02c8b0cbe81642056c427309ba8f1cc8866240d6 100644 --- a/ee/app/assets/javascripts/graphql_shared/subscriptions/ai_completion_response.subscription.graphql +++ b/ee/app/assets/javascripts/graphql_shared/subscriptions/ai_completion_response.subscription.graphql @@ -1,10 +1,19 @@ -subscription aiCompletionResponse($userId: UserID, $resourceId: AiModelID!) { - aiCompletionResponse(userId: $userId, resourceId: $resourceId) { +subscription aiCompletionResponse( + $userId: UserID + $resourceId: AiModelID! + $clientSubscriptionId: String +) { + aiCompletionResponse( + userId: $userId + resourceId: $resourceId + clientSubscriptionId: $clientSubscriptionId + ) { requestId responseBody errors role timestamp type + chunkId } } diff --git a/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js b/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js index 39fa7bcda54eb8c053985bf0645e5c44f9792ffa..e45c26b30003bdab116d1c3e88dfc66d43f5a621 100644 --- a/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js +++ b/ee/spec/frontend/ai/components/ai_genie_chat_message_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import AiGenieChatMessage from 'ee/ai/components/ai_genie_chat_message.vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -10,6 +11,8 @@ jest.mock('~/rest_api'); describe('AiGenieChatMessage', () => { let wrapper; + const findContent = () => wrapper.findComponent({ ref: 'content' }); + const createComponent = ({ propsData = { message: MOCK_USER_MESSAGE }, scopedSlots = {}, @@ -95,4 +98,155 @@ describe('AiGenieChatMessage', () => { }); }); }); + + describe('message output', () => { + it('hydrates the message with GLFM when mounting the component', () => { + createComponent(); + expect(getMarkdown).toHaveBeenCalledWith({ text: MOCK_USER_MESSAGE.content, gfm: true }); + }); + + it('listens to the message changes', async () => { + const newContent = 'new foo content'; + createComponent(); + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + message: { + ...MOCK_USER_MESSAGE, + content: newContent, + }, + }); + await nextTick(); + expect(findContent().text()).not.toContain(MOCK_USER_MESSAGE.content); + expect(findContent().text()).toContain(newContent); + }); + }); + + describe('updates to the message', () => { + const content1 = 'chunk #1'; + const content2 = ' chunk #2'; + const content3 = ' chunk #3'; + const chunk1 = { + ...MOCK_USER_MESSAGE, + content: content1, + chunkId: 1, + }; + const chunk2 = { + ...MOCK_USER_MESSAGE, + content: content2, + chunkId: 2, + }; + const chunk3 = { + ...MOCK_USER_MESSAGE, + content: content3, + chunkId: 3, + }; + + beforeEach(() => { + createComponent(); + }); + + it('does not fail if the message has no chunkId', async () => { + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + message: { + ...MOCK_USER_MESSAGE, + content: content1, + }, + }); + await nextTick(); + expect(findContent().text()).toBe(content1); + }); + + it('renders chunks correctly when the chunks arrive out of order', async () => { + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + message: chunk2, + }); + await nextTick(); + expect(findContent().text()).toBe(''); + + wrapper.setProps({ + message: chunk1, + }); + await nextTick(); + expect(findContent().text()).toBe(content1 + content2); + + wrapper.setProps({ + message: chunk3, + }); + await nextTick(); + expect(findContent().text()).toBe(content1 + content2 + content3); + }); + + it('renders the chunks as they arrive', async () => { + const consolidatedContent = content1 + content2; + + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + message: chunk1, + }); + await nextTick(); + expect(findContent().text()).toBe(content1); + + wrapper.setProps({ + message: chunk2, + }); + await nextTick(); + expect(findContent().text()).toBe(consolidatedContent); + }); + + it('treats the initial message content as chunk if message has chunkId', async () => { + createComponent({ + propsData: { + message: chunk1, + }, + }); + expect(findContent().text()).toBe(content1); + + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + message: chunk2, + }); + await nextTick(); + expect(findContent().text()).toBe(content1 + content2); + }); + + it('hydrates the message with GLFM when the updated message is not a chunk', async () => { + createComponent({ + propsData: { + message: chunk1, + }, + }); + getMarkdown.mockClear(); + expect(getMarkdown).not.toHaveBeenCalled(); + + // setProps is justified here because we are testing the component's + // reactive behavior which consistutes an exception + // See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state + wrapper.setProps({ + message: chunk2, + }); + await nextTick(); + expect(getMarkdown).not.toHaveBeenCalled(); + + wrapper.setProps({ + message: { + ...chunk3, + chunkId: null, + }, + }); + await nextTick(); + expect(getMarkdown).toHaveBeenCalledWith({ text: content3, gfm: true }); + }); + }); }); diff --git a/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js b/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js index 69ce0d0159f65335eb8ad62b08e61ea7c605098c..d38bd4c94087150f1ba16c88b0282993ca72baf5 100644 --- a/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js +++ b/ee/spec/frontend/ai/components/generate_test_file_drawer_spec.js @@ -36,6 +36,7 @@ const subscriptionResponsePartial = { role: GENIE_CHAT_MODEL_ROLES.assistant, timestamp: '2021-05-26T14:00:00.000Z', type: null, + chunkId: null, }; describe('Generate test file drawer component', () => { diff --git a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js index 8f0081b6a464f8ba12177873965422f56d5f926a..fa725e463a42c626ba30280fb5f2bb1e87424e29 100644 --- a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js @@ -1,5 +1,6 @@ import { GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; +import { v4 as uuidv4 } from 'uuid'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; @@ -34,6 +35,7 @@ Vue.use(Vuex); Vue.use(VueApollo); jest.mock('~/rest_api'); +jest.mock('uuid'); describe('GitLab Duo Chat', () => { let wrapper; @@ -90,9 +92,16 @@ describe('GitLab Duo Chat', () => { const findAllUserFeedback = () => wrapper.findAllComponents(UserFeedback); beforeEach(() => { + uuidv4.mockImplementation(() => '123'); getMarkdown.mockImplementation(({ text }) => Promise.resolve({ data: { html: text } })); }); + it('generates unique `clientSubscriptionId` using v4', () => { + createComponent(); + expect(uuidv4).toHaveBeenCalled(); + expect(wrapper.vm.clientSubscriptionId).toBe('123'); + }); + describe('rendering', () => { beforeEach(() => { createComponent(); @@ -192,6 +201,7 @@ describe('GitLab Duo Chat', () => { expect(expectedMutation).toHaveBeenCalledWith({ resourceId: expectedResourceId, question: MOCK_USER_MESSAGE.msg, + clientSubscriptionId: '123', }); }, ); diff --git a/ee/spec/frontend/ai/tanuki_bot/mock_data.js b/ee/spec/frontend/ai/tanuki_bot/mock_data.js index 02721baaea3746231f5cc255b3baffdc4fda1f9d..07c54970cf8f51f115e254ee694a8a64ae35e682 100644 --- a/ee/spec/frontend/ai/tanuki_bot/mock_data.js +++ b/ee/spec/frontend/ai/tanuki_bot/mock_data.js @@ -50,6 +50,7 @@ export const GENERATE_MOCK_TANUKI_RES = (body = JSON.stringify(MOCK_TANUKI_MESSA role: MOCK_TANUKI_MESSAGE.role, timestamp: '2021-04-21T12:00:00.000Z', type: null, + chunkId: null, }, }, }; diff --git a/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js b/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js index b7d906c9d47702e0787f3cbf9e5477c3cc0814bd..d2f593182d2820f041a814cce2f6b97ed98747ca 100644 --- a/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/store/actions_spec.js @@ -67,6 +67,34 @@ describe('TanukiBot Store Actions', () => { expectedMutations: [], }); }); + + it('should commit the correct mutation for a number as content', () => { + return testAction({ + action: actions.addDuoChatMessage, + payload: { responseBody: '1', role: GENIE_CHAT_MODEL_ROLES.assistant }, + state, + expectedMutations: [ + { + type: types.ADD_MESSAGE, + payload: { content: '1', role: GENIE_CHAT_MODEL_ROLES.assistant }, + }, + ], + }); + }); + + it('should commit the correct mutation for a boolean as content', () => { + return testAction({ + action: actions.addDuoChatMessage, + payload: { responseBody: 'true', role: GENIE_CHAT_MODEL_ROLES.assistant }, + state, + expectedMutations: [ + { + type: types.ADD_MESSAGE, + payload: { content: 'true', role: GENIE_CHAT_MODEL_ROLES.assistant }, + }, + ], + }); + }); }); describe('with error', () => { diff --git a/ee/spec/frontend/ai/tanuki_bot/store/mutations_spec.js b/ee/spec/frontend/ai/tanuki_bot/store/mutations_spec.js index 4ce5a00d8f6d1e4e792db6ba71a36252c6c5d767..b147bc7a3f95d4d57ab377ca5f7231f0d66578bc 100644 --- a/ee/spec/frontend/ai/tanuki_bot/store/mutations_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/store/mutations_spec.js @@ -43,8 +43,37 @@ describe('GitLab Duo Chat Store Mutations', () => { }); describe('when there is a message with the same requestId', () => { + const updatedContent = 'Updated content'; + it('updates the correct message based on the role', () => { + state.messages.push( + { + ...MOCK_USER_MESSAGE, + requestId, + }, + { + ...MOCK_TANUKI_MESSAGE, + requestId, + }, + ); + mutations[types.ADD_MESSAGE](state, { + requestId, + role: MOCK_TANUKI_MESSAGE.role, + content: updatedContent, + }); + expect(state.messages.length).toBe(2); + expect(state.messages).toStrictEqual([ + { + ...MOCK_USER_MESSAGE, + requestId, + }, + { + ...MOCK_TANUKI_MESSAGE, + requestId, + content: updatedContent, + }, + ]); + }); describe('when the message is of the same role', () => { - const updatedContent = 'Updated content'; it('updates the message object if it is of exactly the same role', () => { state.messages.push({ ...MOCK_USER_MESSAGE, requestId }); mutations[types.ADD_MESSAGE](state, { diff --git a/ee/spec/frontend/batch_comments/components/summarize_my_review_spec.js b/ee/spec/frontend/batch_comments/components/summarize_my_review_spec.js index 34720039495395c7273cc208973cde307225715d..c0bc9219dba4f09723844c60ac5be56bbbd36f4b 100644 --- a/ee/spec/frontend/batch_comments/components/summarize_my_review_spec.js +++ b/ee/spec/frontend/batch_comments/components/summarize_my_review_spec.js @@ -36,6 +36,7 @@ const subscriptionResponsePartial = { role: GENIE_CHAT_MODEL_ROLES.assistant, timestamp: '2021-05-26T14:00:00.000Z', type: null, + chunkId: null, }; const findButton = () => wrapper.findByTestId('mutation-trigger'); diff --git a/ee/spec/frontend/vue_merge_request_widget/components/ai_commit_message_spec.js b/ee/spec/frontend/vue_merge_request_widget/components/ai_commit_message_spec.js index 16b93373a409580f962c78303d701ea2cf33419e..de5b7dff5d3686266a96b3d5903a3aa3013e34c9 100644 --- a/ee/spec/frontend/vue_merge_request_widget/components/ai_commit_message_spec.js +++ b/ee/spec/frontend/vue_merge_request_widget/components/ai_commit_message_spec.js @@ -23,6 +23,7 @@ describe('Ai Commit Message component', () => { role: GENIE_CHAT_MODEL_ROLES.assistant, timestamp: '2021-05-26T14:00:00.000Z', type: null, + chunkId: null, }; const createComponent = () => { diff --git a/ee/spec/frontend/vulnerabilities/explain_vulnerability/explain_vulnerability_drawer_spec.js b/ee/spec/frontend/vulnerabilities/explain_vulnerability/explain_vulnerability_drawer_spec.js index 3dde2d44f1b672ce7683d7724638db5f15bf9064..ab06a55f4abdb960964e9ade98b93aba4150417c 100644 --- a/ee/spec/frontend/vulnerabilities/explain_vulnerability/explain_vulnerability_drawer_spec.js +++ b/ee/spec/frontend/vulnerabilities/explain_vulnerability/explain_vulnerability_drawer_spec.js @@ -31,6 +31,7 @@ const SUBSCRIPTION_RESPONSE = { role: 'assistant', timestamp: '2021-05-26T14:00:00.000Z', type: null, + chunkId: null, }; const SUBSCRIPTION_ERROR_RESPONSE = { ...SUBSCRIPTION_RESPONSE, errors: ['subscription error'] };