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 e98c62eadc45c0654086d0bf1a3734ce7a6848a7..0f5bf4c984096f542b50281d890be29a2c19f7a9 100644 --- a/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue +++ b/ee/app/assets/javascripts/ai/tanuki_bot/components/app.vue @@ -8,8 +8,6 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { helpPagePath } from '~/helpers/help_page_helper'; import { duoChatGlobalState } from '~/super_sidebar/constants'; import { clearDuoChatCommands } from 'ee/ai/utils'; -import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; -import aiResponseStreamSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response_stream.subscription.graphql'; import DuoChatCallout from 'ee/ai/components/global_callout/duo_chat_callout.vue'; import getAiMessages from 'ee/ai/graphql/get_ai_messages.query.graphql'; import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; @@ -21,7 +19,8 @@ import { GENIE_CHAT_CLEAN_MESSAGE, GENIE_CHAT_CLEAR_MESSAGE, } from 'ee/ai/constants'; -import { TANUKI_BOT_TRACKING_EVENT_NAME, MESSAGE_TYPES, SLASH_COMMANDS } from '../constants'; +import { TANUKI_BOT_TRACKING_EVENT_NAME, SLASH_COMMANDS, MESSAGE_TYPES } from '../constants'; +import TanukiBotSubscriptions from './tanuki_bot_subscriptions.vue'; export default { name: 'TanukiBotChatApp', @@ -46,6 +45,7 @@ export default { components: { GlDuoChat, DuoChatCallout, + TanukiBotSubscriptions, }, mixins: [Tracking.mixin()], provide() { @@ -65,72 +65,6 @@ export default { }, }, apollo: { - $subscribe: { - // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties - aiCompletionResponse: { - query: aiResponseSubscription, - variables() { - return { - userId: this.userId, - aiAction: 'CHAT', - }; - }, - result({ data }) { - const requestId = data?.aiCompletionResponse?.requestId; - if (requestId && !this.cancelledRequestIds.includes(requestId)) { - this.addDuoChatMessage(data.aiCompletionResponse); - if (data.aiCompletionResponse.role.toLowerCase() === MESSAGE_TYPES.TANUKI) { - this.responseCompleted = requestId; - clearDuoChatCommands(); - } - } - }, - error(err) { - this.addDuoChatMessage({ errors: [err.toString()] }); - }, - skip() { - return !this.duoChatGlobalState.isShown; - }, - }, - // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties - aiCompletionResponseStream: { - query: aiResponseStreamSubscription, - variables() { - return { - userId: this.userId, - clientSubscriptionId: this.clientSubscriptionId, - }; - }, - result({ data }) { - const requestId = data?.aiCompletionResponse?.requestId; - if ( - requestId && - requestId !== this.responseCompleted && - !this.cancelledRequestIds.includes(requestId) - ) { - this.addDuoChatMessage(data.aiCompletionResponse); - } - if (data?.aiCompletionResponse?.chunkId && !this.isResponseTracked) { - performance.mark('response-received'); - performance.measure('prompt-to-response', 'prompt-sent', 'response-received'); - const [{ duration }] = performance.getEntriesByName('prompt-to-response'); - this.track('ai_response_time', { - property: requestId, - value: duration, - }); - performance.clearMarks(); - performance.clearMeasures(); - this.isResponseTracked = true; - } - }, - error(err) { - this.addDuoChatMessage({ errors: [err.toString()] }); - }, - skip() { - return !this.duoChatGlobalState.isShown; - }, - }, - }, // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties aiMessages: { query: getAiMessages, @@ -140,7 +74,7 @@ export default { } }, error(err) { - this.addDuoChatMessage({ errors: [err.toString()] }); + this.onError(err); }, }, }, @@ -150,9 +84,9 @@ export default { clientSubscriptionId: uuidv4(), toolName: i18n.GITLAB_DUO, error: '', - responseCompleted: undefined, isResponseTracked: false, cancelledRequestIds: [], + completedRequestId: null, }; }, computed: { @@ -192,9 +126,39 @@ export default { this.cancelledRequestIds.push(this.messages[this.messages.length - 1].requestId); this.setLoading(false); }, + onMessageReceived(aiCompletionResponse) { + this.addDuoChatMessage(aiCompletionResponse); + if (aiCompletionResponse.role.toLowerCase() === MESSAGE_TYPES.TANUKI) { + this.completedRequestId = aiCompletionResponse.requestId; + clearDuoChatCommands(); + } + }, + onMessageStreamReceived(aiCompletionResponse) { + if (aiCompletionResponse.requestId !== this.completedRequestId) { + this.addDuoChatMessage(aiCompletionResponse); + } + }, + onResponseReceived(requestId) { + if (this.isResponseTracked) { + return; + } + + performance.mark('response-received'); + performance.measure('prompt-to-response', 'prompt-sent', 'response-received'); + const [{ duration }] = performance.getEntriesByName('prompt-to-response'); + + this.track('ai_response_time', { + property: requestId, + value: duration, + }); + + performance.clearMarks(); + performance.clearMeasures(); + this.isResponseTracked = true; + }, onSendChatPrompt(question, variables = {}) { - this.responseCompleted = undefined; performance.mark('prompt-sent'); + this.completedRequestId = null; this.isResponseTracked = false; if (!this.isClearOrResetMessage(question)) { @@ -229,7 +193,7 @@ export default { this.addDuoChatMessage({ content: question, }); - this.addDuoChatMessage({ errors: [err.toString()] }); + this.onError(err); this.setLoading(false); }); }, @@ -274,30 +238,45 @@ export default { }); } }, + onError(err) { + this.addDuoChatMessage({ errors: [err.toString()] }); + }, }, }; </script> <template> <div> - <gl-duo-chat - v-if="duoChatGlobalState.isShown" - id="duo-chat" - :slash-commands="$options.SLASH_COMMANDS" - :title="$options.i18n.gitlabChat" - :messages="messages" - :error="error" - :is-loading="loading" - :predefined-prompts="$options.i18n.predefinedPrompts" - :badge-type="null" - :tool-name="toolName" - :canceled-request-ids="cancelledRequestIds" - class="duo-chat-container" - @chat-cancel="onChatCancel" - @send-chat-prompt="onSendChatPrompt" - @chat-hidden="onChatClose" - @track-feedback="onTrackFeedback" - /> + <div v-if="duoChatGlobalState.isShown"> + <!-- Renderless component for subscriptions --> + <tanuki-bot-subscriptions + :user-id="userId" + :client-subscription-id="clientSubscriptionId" + :cancelled-request-ids="cancelledRequestIds" + @message="onMessageReceived" + @message-stream="onMessageStreamReceived" + @response-received="onResponseReceived" + @error="onError" + /> + + <gl-duo-chat + id="duo-chat" + :slash-commands="$options.SLASH_COMMANDS" + :title="$options.i18n.gitlabChat" + :messages="messages" + :error="error" + :is-loading="loading" + :predefined-prompts="$options.i18n.predefinedPrompts" + :badge-type="null" + :tool-name="toolName" + :canceled-request-ids="cancelledRequestIds" + class="duo-chat-container" + @chat-cancel="onChatCancel" + @send-chat-prompt="onSendChatPrompt" + @chat-hidden="onChatClose" + @track-feedback="onTrackFeedback" + /> + </div> <duo-chat-callout @callout-dismissed="onCalloutDismissed" /> </div> </template> diff --git a/ee/app/assets/javascripts/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue b/ee/app/assets/javascripts/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue new file mode 100644 index 0000000000000000000000000000000000000000..22bed07eb8a114aaad361013aa726c69985c1165 --- /dev/null +++ b/ee/app/assets/javascripts/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue @@ -0,0 +1,73 @@ +<script> +import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; +import aiResponseStreamSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response_stream.subscription.graphql'; + +export default { + props: { + userId: { + type: String, + required: true, + }, + clientSubscriptionId: { + type: String, + required: true, + }, + cancelledRequestIds: { + type: Array, + default: () => [], + required: false, + }, + }, + render() { + return null; + }, + apollo: { + $subscribe: { + // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties + aiCompletionResponse: { + query: aiResponseSubscription, + variables() { + return { + userId: this.userId, + aiAction: 'CHAT', + }; + }, + result({ data }) { + const requestId = data?.aiCompletionResponse?.requestId; + + if (requestId && !this.cancelledRequestIds.includes(requestId)) { + this.$emit('message', data.aiCompletionResponse); + } + }, + error(err) { + this.$emit('error', err); + }, + }, + // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties + aiCompletionResponseStream: { + query: aiResponseStreamSubscription, + variables() { + return { + userId: this.userId, + clientSubscriptionId: this.clientSubscriptionId, + }; + }, + result({ data }) { + const requestId = data?.aiCompletionResponse?.requestId; + + if (requestId && !this.cancelledRequestIds.includes(requestId)) { + this.$emit('message-stream', data.aiCompletionResponse); + } + + if (data?.aiCompletionResponse?.chunkId) { + this.$emit('response-received', requestId); + } + }, + error(err) { + this.$emit('error', err); + }, + }, + }, + }, +}; +</script> 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 134d74bf9b829dc5820e94b51814aa8d0157f00a..e364290a7d294ded122ecc9c0b9f92186a74463d 100644 --- a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js +++ b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js @@ -4,18 +4,16 @@ import { v4 as uuidv4 } from 'uuid'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; -import { createMockSubscription } from 'mock-apollo-client'; import { sendDuoChatCommand } from 'ee/ai/utils'; import TanukiBotChatApp from 'ee/ai/tanuki_bot/components/app.vue'; import DuoChatCallout from 'ee/ai/components/global_callout/duo_chat_callout.vue'; +import TanukiBotSubscriptions from 'ee/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue'; import { GENIE_CHAT_RESET_MESSAGE, GENIE_CHAT_CLEAN_MESSAGE, GENIE_CHAT_CLEAR_MESSAGE, } from 'ee/ai/constants'; import { TANUKI_BOT_TRACKING_EVENT_NAME } from 'ee/ai/tanuki_bot/constants'; -import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; -import aiResponseStreamSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response_stream.subscription.graphql'; import chatMutation from 'ee/ai/graphql/chat.mutation.graphql'; import duoUserFeedbackMutation from 'ee/ai/graphql/duo_user_feedback.mutation.graphql'; import getAiMessages from 'ee/ai/graphql/get_ai_messages.query.graphql'; @@ -31,11 +29,10 @@ import { MOCK_USER_MESSAGE, MOCK_USER_ID, MOCK_RESOURCE_ID, - MOCK_TANUKI_SUCCESS_RES, + MOCK_CHUNK_MESSAGE, MOCK_TANUKI_BOT_MUTATATION_RES, - MOCK_CHAT_CACHED_MESSAGES_RES, GENERATE_MOCK_TANUKI_RES, - MOCK_CHUNK_MESSAGE, + MOCK_CHAT_CACHED_MESSAGES_RES, MOCK_SLASH_COMMANDS, } from '../mock_data'; @@ -54,14 +51,14 @@ const skipReason = new SkipReason({ describeSkipVue3(skipReason, () => { let wrapper; + const UUIDMOCK = '123'; + const actionSpies = { addDuoChatMessage: jest.fn(), setMessages: jest.fn(), setLoading: jest.fn(), }; - let aiResponseSubscriptionHandler = jest.fn(); - let aiResponseStreamSubscriptionHandler = jest.fn(); const chatMutationHandlerMock = jest.fn().mockResolvedValue(MOCK_TANUKI_BOT_MUTATATION_RES); const duoUserFeedbackMutationHandlerMock = jest.fn().mockResolvedValue({}); const queryHandlerMock = jest.fn().mockResolvedValue(MOCK_CHAT_CACHED_MESSAGES_RES); @@ -82,6 +79,7 @@ describeSkipVue3(skipReason, () => { }; const findCallout = () => wrapper.findComponent(DuoChatCallout); + const findSubscriptions = () => wrapper.findComponent(TanukiBotSubscriptions); const createComponent = ({ initialState = {}, @@ -100,16 +98,6 @@ describeSkipVue3(skipReason, () => { [getAiMessages, queryHandlerMock], ]); - apolloProvider.defaultClient.setRequestHandler( - aiResponseSubscription, - aiResponseSubscriptionHandler, - ); - - apolloProvider.defaultClient.setRequestHandler( - aiResponseStreamSubscription, - aiResponseStreamSubscriptionHandler, - ); - wrapper = shallowMountExtended(TanukiBotChatApp, { store, apolloProvider, @@ -118,16 +106,10 @@ describeSkipVue3(skipReason, () => { }; const findGlDuoChat = () => wrapper.findComponent(GlDuoChat); - let perfTrackingSpy; + beforeEach(() => { - uuidv4.mockImplementation(() => '123'); + uuidv4.mockImplementation(() => UUIDMOCK); getMarkdown.mockImplementation(({ text }) => Promise.resolve({ data: { html: text } })); - - performance.mark = jest.fn(); - performance.measure = jest.fn(); - performance.getEntriesByName = jest.fn(() => [{ duration: 123 }]); - performance.clearMarks = jest.fn(); - performance.clearMeasures = jest.fn(); }); afterEach(() => { @@ -196,7 +178,7 @@ describeSkipVue3(skipReason, () => { describe('when new commands are added to the global state', () => { beforeEach(() => { createComponent(); - perfTrackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + mockTracking(undefined, wrapper.element, jest.spyOn); performance.mark = jest.fn(); }); @@ -241,7 +223,7 @@ describeSkipVue3(skipReason, () => { describe('@send-chat-prompt', () => { beforeEach(() => { - perfTrackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + mockTracking(undefined, wrapper.element, jest.spyOn); performance.mark = jest.fn(); }); @@ -338,6 +320,41 @@ describeSkipVue3(skipReason, () => { }); }); + describe('@response-received', () => { + let trackingSpy; + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + performance.mark = jest.fn(); + performance.measure = jest.fn(); + performance.getEntriesByName = jest.fn(() => [{ duration: 123 }]); + performance.clearMarks = jest.fn(); + performance.clearMeasures = jest.fn(); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks time to response on first response-received', () => { + findSubscriptions().vm.$emit('response-received', 'request-id-123'); + + expect(performance.mark).toHaveBeenCalledWith('response-received'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'ai_response_time', { + property: 'request-id-123', + value: 123, + }); + }); + + it('does not track time to response after first chunk was tracked', () => { + findSubscriptions().vm.$emit('response-received', 'request-id-123'); + findSubscriptions().vm.$emit('response-received', 'request-id-123'); + + expect(performance.mark).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('@track-feedback', () => { it('calls the feedback GraphQL mutation when message is passed', async () => { createComponent(); @@ -421,17 +438,7 @@ describeSkipVue3(skipReason, () => { }); }); - describe('Subscriptions', () => { - let mockSubscriptionComplete; - let mockSubscriptionStream; - - beforeEach(() => { - mockSubscriptionComplete = createMockSubscription(); - mockSubscriptionStream = createMockSubscription(); - aiResponseSubscriptionHandler = () => mockSubscriptionComplete; - aiResponseStreamSubscriptionHandler = () => mockSubscriptionStream; - }); - + describe('Subscription Component', () => { afterEach(() => { duoChatGlobalState.isShown = false; if (wrapper) { @@ -440,203 +447,112 @@ describeSkipVue3(skipReason, () => { jest.clearAllMocks(); }); - it('activates subscriptions when isShown is true', async () => { + it('renders AiResponseSubscription component with correct props when isShown is true', async () => { duoChatGlobalState.isShown = true; createComponent(); await waitForPromises(); - expect(mockSubscriptionComplete.closed).toBe(false); - expect(mockSubscriptionStream.closed).toBe(false); + expect(findSubscriptions().exists()).toBe(true); + expect(findSubscriptions().props('userId')).toBe(MOCK_USER_ID); + expect(findSubscriptions().props('clientSubscriptionId')).toBe(UUIDMOCK); + expect(findSubscriptions().props('cancelledRequestIds')).toHaveLength(0); }); - it('does not activate subscriptions when isShown is false', async () => { + it('does not render AiResponseSubscription component when isShown is false', async () => { duoChatGlobalState.isShown = false; createComponent(); await waitForPromises(); - expect(mockSubscriptionComplete.closed).toBe(true); - expect(mockSubscriptionStream.closed).toBe(true); - }); - - it('stops adding new messages when more chunks with the same request ID come in after the full message has already been received', async () => { - const requestId = '123'; - const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1, requestId); - const secondChunk = MOCK_CHUNK_MESSAGE('second chunk', 2, requestId); - const successResponse = GENERATE_MOCK_TANUKI_RES('', requestId); - - duoChatGlobalState.isShown = true; - - createComponent(); - await waitForPromises(); - - // message chunk streaming in - mockSubscriptionStream.next(firstChunk); - await waitForPromises(); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(1); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( - expect.any(Object), - firstChunk.data.aiCompletionResponse, - ); - - // full message being sent - mockSubscriptionComplete.next(successResponse); - await waitForPromises(); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(2); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( - expect.any(Object), - successResponse.data.aiCompletionResponse, - ); - - // another chunk with the same request ID - mockSubscriptionStream.next(secondChunk); - await waitForPromises(); - // checking that addDuoChatMessage was not called again since full message was already being sent - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(2); - }); - - it('clears the commands when streaming is done', async () => { - const requestId = '123'; - const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1, requestId); - const successResponse = GENERATE_MOCK_TANUKI_RES('', requestId); - - duoChatGlobalState.isShown = true; - - expect(duoChatGlobalState.commands).toHaveLength(0); - sendDuoChatCommand({ question: '/troubleshoot', resourceId: '1' }); - expect(duoChatGlobalState.commands).toHaveLength(1); - - createComponent(); - await waitForPromises(); - - // message chunk streaming in - mockSubscriptionStream.next(firstChunk); - await waitForPromises(); - // No changes to commands - expect(duoChatGlobalState.commands).toHaveLength(1); - - // full message being sent - mockSubscriptionComplete.next(successResponse); - await waitForPromises(); - await waitForPromises(); - - // commands have been cleared out - expect(duoChatGlobalState.commands).toHaveLength(0); + expect(findSubscriptions().exists()).toBe(false); }); - it('continues to invoke addDuoChatMessage when a new message chunk arrives with a distinct request ID, even after a complete message has been received', async () => { - const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1); - const firstChunkNewRequest = MOCK_CHUNK_MESSAGE('first chunk', 2, 2); - + it('calls addDuoChatMessage when @message is fired', () => { duoChatGlobalState.isShown = true; createComponent(); - await waitForPromises(); - - // message chunk streaming in - mockSubscriptionStream.next(firstChunk); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( - expect.any(Object), - firstChunk.data.aiCompletionResponse, - ); - - // full message being sent - mockSubscriptionComplete.next(MOCK_TANUKI_SUCCESS_RES); - await waitForPromises(); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(2); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( - expect.any(Object), - MOCK_TANUKI_SUCCESS_RES.data.aiCompletionResponse, - ); + const mockMessage = { + content: 'test message content', + role: 'user', + }; - // another chunk with a new request ID - mockSubscriptionStream.next(firstChunkNewRequest); - await waitForPromises(); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(3); + findSubscriptions().vm.$emit('message', mockMessage); + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith(expect.anything(), mockMessage); }); - it('stops streaming in new chunks when requestId was canceled', async () => { - const requestId = '123'; - const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1, requestId); - const secondChunk = MOCK_CHUNK_MESSAGE('second chunk', 2, requestId); - - duoChatGlobalState.isShown = true; - - createComponent({ - initialState: { - messages: [ - { - requestId, - }, - ], - }, + describe('Subscription Component', () => { + beforeEach(() => { + duoChatGlobalState.isShown = true; + createComponent(); + mockTracking(undefined, wrapper.element, jest.spyOn); + performance.mark = jest.fn(); }); - await waitForPromises(); - perfTrackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - // message chunk streaming in - mockSubscriptionStream.next(firstChunk); - await waitForPromises(); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(1); - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( - expect.any(Object), - firstChunk.data.aiCompletionResponse, - ); - findGlDuoChat().vm.$emit('chat-cancel'); + it('stops adding new messages when more chunks with the same request ID come in after the full message has already been received', () => { + const requestId = '123'; + const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1, requestId); + const secondChunk = MOCK_CHUNK_MESSAGE('second chunk', 2, requestId); + const successResponse = GENERATE_MOCK_TANUKI_RES('', requestId); - // another chunk with the same request ID - mockSubscriptionStream.next(secondChunk); - await waitForPromises(); - // checking that addDuoChatMessage was not called again since request id was canceled - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(1); - }); + // message chunk streaming in + findSubscriptions().vm.$emit('message-stream', firstChunk); + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith(expect.anything(), firstChunk); - it('stops adding new message when requestId was canceled', async () => { - const requestId = '123'; - - duoChatGlobalState.isShown = true; - createComponent({ - initialState: { - messages: [ - { - requestId, - }, - ], - }, + // full message being sent + findSubscriptions().vm.$emit('message', successResponse); + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( + expect.anything(), + successResponse, + ); + // another chunk with the same request ID + findSubscriptions().vm.$emit('message-stream', secondChunk); + // addDuoChatMessage should not be called since the full message was already sent + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(2); }); - await waitForPromises(); - findGlDuoChat().vm.$emit('chat-cancel'); + it('continues to invoke addDuoChatMessage when a new message chunk arrives with a distinct request ID, even after a complete message has been received', () => { + const firstRequestId = '123'; + const secondRequestId = '124'; + const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1, firstRequestId); + const secondChunk = MOCK_CHUNK_MESSAGE('second chunk', 2, firstRequestId); + const successResponse = GENERATE_MOCK_TANUKI_RES('', secondRequestId); - // full message being sent - mockSubscriptionComplete.next(GENERATE_MOCK_TANUKI_RES('', requestId)); - await waitForPromises(); - // checking that addDuoChatMessage was not called since request id was canceled - expect(actionSpies.addDuoChatMessage).toHaveBeenCalledTimes(0); - }); + // message chunk streaming in + findSubscriptions().vm.$emit('message-stream', firstChunk); + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith(expect.anything(), firstChunk); - it('tracks performance metrics correctly when a chunk is received', async () => { - const chunkMessage = MOCK_CHUNK_MESSAGE('chunk content', 1, 'requestId-123'); - - duoChatGlobalState.isShown = true; - createComponent(); - perfTrackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + // full message being sent + findSubscriptions().vm.$emit('message', successResponse); + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( + expect.anything(), + successResponse, + ); + // another chunk with a new request ID + findSubscriptions().vm.$emit('message-stream', secondChunk); + // addDuoChatMessage should be called since the second chunk has a new requestId + expect(actionSpies.addDuoChatMessage).toHaveBeenCalledWith( + expect.anything(), + successResponse, + ); + }); - await waitForPromises(); + it('clears the commands when streaming is done', () => { + const requestId = '123'; + const firstChunk = MOCK_CHUNK_MESSAGE('first chunk', 1, requestId); + const successResponse = GENERATE_MOCK_TANUKI_RES('', requestId); - mockSubscriptionStream.next(chunkMessage); + expect(duoChatGlobalState.commands).toHaveLength(0); + sendDuoChatCommand({ question: '/troubleshoot', resourceId: '1' }); + expect(duoChatGlobalState.commands).toHaveLength(1); - expect(performance.mark).toHaveBeenCalledWith('response-received'); - expect(performance.measure).toHaveBeenCalledWith( - 'prompt-to-response', - 'prompt-sent', - 'response-received', - ); - expect(performance.getEntriesByName).toHaveBeenCalledWith('prompt-to-response'); - expect(performance.clearMarks).toHaveBeenCalled(); - expect(performance.clearMeasures).toHaveBeenCalled(); + createComponent(); - expect(perfTrackingSpy).toHaveBeenCalledWith(undefined, 'ai_response_time', { - property: chunkMessage.data.aiCompletionResponse.requestId, - value: 123, + // message chunk streaming in + findSubscriptions().vm.$emit('message-stream', firstChunk); + // No changes to commands + expect(duoChatGlobalState.commands).toHaveLength(1); + // full message being sent + findSubscriptions().vm.$emit('message', successResponse); + // commands have been cleared out + expect(duoChatGlobalState.commands).toHaveLength(0); }); }); }); diff --git a/ee/spec/frontend/ai/tanuki_bot/components/tanuki_bot_subscriptions_spec.js b/ee/spec/frontend/ai/tanuki_bot/components/tanuki_bot_subscriptions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ef85e79c767e4f713fed7e5f9335909c1fd66164 --- /dev/null +++ b/ee/spec/frontend/ai/tanuki_bot/components/tanuki_bot_subscriptions_spec.js @@ -0,0 +1,145 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { createMockSubscription } from 'mock-apollo-client'; +import AiResponseSubscription from 'ee/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue'; +import aiResponseSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response.subscription.graphql'; +import aiResponseStreamSubscription from 'ee/graphql_shared/subscriptions/ai_completion_response_stream.subscription.graphql'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { duoChatGlobalState } from '~/super_sidebar/constants'; + +import { + MOCK_USER_ID, + GENERATE_MOCK_TANUKI_RES, + MOCK_CHUNK_MESSAGE, + MOCK_CLIENT_SUBSCRIPTION_ID, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('Ai Response Subscriptions', () => { + let wrapper; + + let mockSubscriptionComplete; + let mockSubscriptionStream; + let aiResponseSubscriptionHandler; + let aiResponseStreamSubscriptionHandler; + + const createComponent = ({ propsData = {} } = {}) => { + const apolloProvider = createMockApollo(); + + apolloProvider.defaultClient.setRequestHandler( + aiResponseSubscription, + aiResponseSubscriptionHandler, + ); + + apolloProvider.defaultClient.setRequestHandler( + aiResponseStreamSubscription, + aiResponseStreamSubscriptionHandler, + ); + + wrapper = shallowMountExtended(AiResponseSubscription, { + apolloProvider, + propsData: { + userId: MOCK_USER_ID, + clientSubscriptionId: MOCK_CLIENT_SUBSCRIPTION_ID, + ...propsData, + }, + }); + }; + + beforeEach(() => { + mockSubscriptionComplete = createMockSubscription(); + mockSubscriptionStream = createMockSubscription(); + aiResponseSubscriptionHandler = jest.fn(() => mockSubscriptionComplete); + aiResponseStreamSubscriptionHandler = jest.fn(() => mockSubscriptionStream); + }); + + afterEach(() => { + jest.clearAllMocks(); + duoChatGlobalState.commands = []; + + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('Subscriptions', () => { + it('passes the correct variables to the subscription queries', async () => { + createComponent(); + await waitForPromises(); + + expect(aiResponseSubscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + userId: MOCK_USER_ID, + aiAction: 'CHAT', + }), + ); + + expect(aiResponseStreamSubscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + userId: MOCK_USER_ID, + clientSubscriptionId: MOCK_CLIENT_SUBSCRIPTION_ID, + }), + ); + }); + + describe('aiCompletionResponseStream', () => { + it('emits message stream event', async () => { + const requestId = '123'; + const firstChunk = { + data: { aiCompletionResponse: MOCK_CHUNK_MESSAGE('first chunk', 1, requestId) }, + }; + + createComponent(); + await waitForPromises(); + + // message chunk streaming in + mockSubscriptionStream.next(firstChunk); + await waitForPromises(); + + const emittedEvents = wrapper.emitted('message-stream'); + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]).toEqual([firstChunk.data.aiCompletionResponse]); + }); + + it('emits response-received event', async () => { + const requestId = '123'; + const firstChunk = { + data: { aiCompletionResponse: MOCK_CHUNK_MESSAGE('first chunk', 1, requestId) }, + }; + + createComponent(); + await waitForPromises(); + + // message chunk streaming in + mockSubscriptionStream.next(firstChunk); + await waitForPromises(); + + const emittedEvents = wrapper.emitted('response-received'); + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]).toEqual([requestId]); + }); + }); + + describe('aiCompletionResponse', () => { + it('emits message event', async () => { + const requestId = '123'; + const successResponse = { + data: { aiCompletionResponse: GENERATE_MOCK_TANUKI_RES('', requestId) }, + }; + createComponent(); + await waitForPromises(); + + // message chunk streaming in + mockSubscriptionComplete.next(successResponse); + await waitForPromises(); + + const emittedEvents = wrapper.emitted('message'); + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]).toEqual([successResponse.data.aiCompletionResponse]); + }); + }); + }); +}); diff --git a/ee/spec/frontend/ai/tanuki_bot/mock_data.js b/ee/spec/frontend/ai/tanuki_bot/mock_data.js index 82a01b89074a8b0ae05993ce5451927d9f765476..80bdb6d71a5fc06f746d7b644d447d671942ee59 100644 --- a/ee/spec/frontend/ai/tanuki_bot/mock_data.js +++ b/ee/spec/frontend/ai/tanuki_bot/mock_data.js @@ -70,20 +70,16 @@ export const MOCK_FAILING_USER_MESSAGE = { export const MOCK_CHUNK_MESSAGE = (content = '', chunkId = 0, requestId = 1) => { return { - data: { - aiCompletionResponse: { - id: '611363bc-c75a-44e2-80cd-f22ab5e665be', - requestId, - content, - errors: [], - role: 'ASSISTANT', - timestamp: '2024-05-29T17:17:06Z', - type: null, - chunkId, - extras: { - sources: null, - }, - }, + id: '611363bc-c75a-44e2-80cd-f22ab5e665be', + requestId, + content, + errors: [], + role: 'ASSISTANT', + timestamp: '2024-05-29T17:17:06Z', + type: null, + chunkId, + extras: { + sources: null, }, }; }; @@ -93,20 +89,16 @@ export const GENERATE_MOCK_TANUKI_RES = ( requestId = '987', ) => { return { - data: { - aiCompletionResponse: { - id: '123', - content: body, - contentHtml: `<p>${body}</p>`, - errors: [], - requestId, - role: MOCK_TANUKI_MESSAGE.role, - timestamp: '2021-04-21T12:00:00.000Z', - type: null, - chunkId: null, - extras: null, - }, - }, + id: '123', + content: body, + contentHtml: `<p>${body}</p>`, + errors: [], + requestId, + role: MOCK_TANUKI_MESSAGE.role, + timestamp: '2021-04-21T12:00:00.000Z', + type: null, + chunkId: null, + extras: null, }; }; @@ -138,4 +130,5 @@ export const MOCK_TANUKI_BOT_MUTATATION_RES = { }; export const MOCK_USER_ID = 'gid://gitlab/User/1'; +export const MOCK_CLIENT_SUBSCRIPTION_ID = '123'; export const MOCK_RESOURCE_ID = 'gid://gitlab/Issue/1';