diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/kibana.jsonc b/x-pack/solutions/observability/plugins/observability_agent_builder/kibana.jsonc index 5a8c057e91f18..1ce55fe7b5b5f 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/kibana.jsonc +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/kibana.jsonc @@ -22,7 +22,7 @@ "licensing" ], "optionalPlugins": ["ml", "spaces"], - "requiredBundles": ["kibanaReact"], + "requiredBundles": ["kibanaReact", "kibanaUtils"], "extraPublicDirs": [] } } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml b/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml index 5b40b982ecdbc..2f1e522f4ba4c 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/moon.yml @@ -55,8 +55,10 @@ dependsOn: - '@kbn/licensing-types' - '@kbn/licensing-plugin' - '@kbn/observability-nav-icons' - - '@kbn/server-route-repository-client' - '@kbn/agent-builder-browser' + - '@kbn/sse-utils-server' + - '@kbn/kibana-utils-plugin' + - '@kbn/server-route-repository-client' tags: - plugin - prod diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx new file mode 100644 index 0000000000000..d276bfcb3add8 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { EuiThemeProvider } from '@elastic/eui'; +import { AIChatExperience } from '@kbn/ai-assistant-common'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { AiInsight } from './ai_insight'; +import { useKibana } from '../../hooks/use_kibana'; +import { useLicense } from '../../hooks/use_license'; +import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; +import { useStreamingAiInsight } from '../../hooks/use_streaming_ai_insight'; +import { OBSERVABILITY_AGENT_ID } from '../../../common/constants'; + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useUiSetting$: jest.fn(), +})); + +jest.mock('../../hooks/use_kibana'); +jest.mock('../../hooks/use_license'); +jest.mock('../../hooks/use_genai_connectors'); +jest.mock('../../hooks/use_streaming_ai_insight'); + +const mockUseUiSetting$ = useUiSetting$ as jest.Mock; +const mockUseKibana = useKibana as jest.Mock; +const mockUseLicense = useLicense as jest.Mock; +const mockUseGenAIConnectors = useGenAIConnectors as jest.Mock; +const mockUseStreamingAiInsight = useStreamingAiInsight as jest.Mock; +const mockCreateStream = jest.fn(); +const AiInsightTest = AiInsight as React.ComponentType; + +const renderComponent = () => + render( + + + + ); + +const mockOpenConversationFlyout = jest.fn(); + +const baseStreamingState = () => ({ + isLoading: false, + error: undefined as string | undefined, + summary: '', + context: '', + wasStopped: false, + fetch: jest.fn(), + stop: jest.fn(), + regenerate: jest.fn(), +}); + +const createStreamingState = (overrides: Partial> = {}) => ({ + ...baseStreamingState(), + ...overrides, +}); + +describe('AiInsight', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseUiSetting$.mockReturnValue([AIChatExperience.Agent]); + mockUseKibana.mockReturnValue({ + services: { + agentBuilder: { + openConversationFlyout: mockOpenConversationFlyout, + }, + application: { + capabilities: { + agentBuilder: { show: true }, + }, + }, + }, + }); + mockUseLicense.mockReturnValue({ + getLicense: () => ({ + hasAtLeast: () => true, + }), + }); + mockUseGenAIConnectors.mockReturnValue({ + hasConnectors: true, + }); + mockUseStreamingAiInsight.mockReturnValue(createStreamingState()); + }); + + it('fetches insights when the accordion is opened', () => { + const fetch = jest.fn(); + mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ fetch })); + + const { container, unmount } = renderComponent(); + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + + expect(toggle).toBeTruthy(); + fireEvent.click(toggle!); + + expect(fetch).toHaveBeenCalledTimes(1); + unmount(); + }); + + describe('when an error occurs', () => { + it('displays an error banner with error message', () => { + mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ error: 'Boom' })); + + const { container, getByText, unmount } = renderComponent(); + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + fireEvent.click(toggle!); + + const errorBanner = container.querySelector('[data-test-subj="AiInsightErrorBanner"]'); + expect(errorBanner).toBeTruthy(); + + expect(getByText('Failed to generate AI insight')).toBeTruthy(); + expect(getByText('The AI insight could not be generated: Boom')).toBeTruthy(); + + const retryButton = container.querySelector( + '[data-test-subj="AiInsightErrorBannerRetryButton"]' + ); + expect(retryButton).toBeTruthy(); + + unmount(); + }); + + it('refetches insights when retry button is clicked', () => { + const fetch = jest.fn(); + mockUseStreamingAiInsight.mockReturnValue(createStreamingState({ error: 'Boom', fetch })); + + const { container, unmount } = renderComponent(); + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + fireEvent.click(toggle!); + + const retryButton = container.querySelector( + '[data-test-subj="AiInsightErrorBannerRetryButton"]' + ); + fireEvent.click(retryButton!); + + expect(fetch).toHaveBeenCalledTimes(1); + + unmount(); + }); + }); + + describe('when a summary has been generated', () => { + it('displays start conversation button', () => { + mockUseStreamingAiInsight.mockReturnValue( + createStreamingState({ summary: 'Hello world', context: 'context' }) + ); + + const { container, unmount } = renderComponent(); + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + fireEvent.click(toggle!); + + const startConversationButton = container.querySelector( + '[data-test-subj="aiAgentStartConversationButton"]' + ); + + expect(startConversationButton).toBeTruthy(); + + unmount(); + }); + + it('opens the conversation flyout with correct attachments when start conversation is clicked', () => { + const buildAttachments = jest.fn().mockReturnValue([{ type: 'test', data: {} }]); + mockUseStreamingAiInsight.mockReturnValue( + createStreamingState({ summary: 'Hello world', context: 'context' }) + ); + + const { container, unmount } = render( + + + + ); + + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + fireEvent.click(toggle!); + + const startConversationButton = container.querySelector( + '[data-test-subj="aiAgentStartConversationButton"]' + ); + fireEvent.click(startConversationButton!); + + expect(buildAttachments).toHaveBeenCalledWith('Hello world', 'context'); + expect(mockOpenConversationFlyout).toHaveBeenCalledWith({ + newConversation: true, + attachments: [{ type: 'test', data: {} }], + agentId: OBSERVABILITY_AGENT_ID, + }); + + unmount(); + }); + }); + + it('shows regenerate button after stream is stopped', () => { + const regenerate = jest.fn(); + mockUseStreamingAiInsight.mockReturnValue( + createStreamingState({ summary: 'Partial response', wasStopped: true, regenerate }) + ); + + const { container, unmount } = renderComponent(); + + const toggle = container.querySelector('[data-test-subj="agentBuilderAiInsight"]'); + fireEvent.click(toggle!); + + const regenerateButton = container.querySelector( + '[data-test-subj="observabilityAgentBuilderRegenerateButton"]' + ); + expect(regenerateButton).toBeTruthy(); + + fireEvent.click(regenerateButton!); + expect(regenerate).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx index 19a1498a9418f..c1a9f150e092b 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/ai_insight.tsx @@ -14,19 +14,26 @@ import { EuiPanel, EuiSpacer, EuiText, - EuiSkeletonText, - EuiMarkdownFormat, + EuiButtonEmpty, + EuiHorizontalRule, useEuiTheme, + EuiMarkdownFormat, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { AIChatExperience } from '@kbn/ai-assistant-common'; import { AI_CHAT_EXPERIENCE_TYPE } from '@kbn/management-settings-ids'; +import type { Observable } from 'rxjs'; import { useKibana } from '../../hooks/use_kibana'; import { useLicense } from '../../hooks/use_license'; +import { + useStreamingAiInsight, + type InsightStreamEvent, +} from '../../hooks/use_streaming_ai_insight'; import { useGenAIConnectors } from '../../hooks/use_genai_connectors'; import { StartConversationButton } from './start_conversation_button'; import { AiInsightErrorBanner } from './ai_insight_error_banner'; +import { LoadingCursor } from './loading_cursor'; import { OBSERVABILITY_AGENT_ID } from '../../../common/constants'; export interface AiInsightResponse { @@ -42,17 +49,13 @@ export interface AiInsightAttachment { export interface AiInsightProps { title: string; - fetchInsight: () => Promise; + createStream: (signal: AbortSignal) => Observable; buildAttachments: (summary: string, context: string) => AiInsightAttachment[]; } -export function AiInsight({ title, fetchInsight, buildAttachments }: AiInsightProps) { +export function AiInsight({ title, createStream, buildAttachments }: AiInsightProps) { const { euiTheme } = useEuiTheme(); const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(undefined); - const [summary, setSummary] = useState(''); - const [context, setContext] = useState(''); const { services: { agentBuilder, application }, @@ -69,19 +72,8 @@ export function AiInsight({ title, fetchInsight, buildAttachments }: AiInsightPr const hasEnterpriseLicense = license?.hasAtLeast('enterprise'); const hasAgentBuilderAccess = application?.capabilities.agentBuilder?.show === true; - const handleFetchInsight = useCallback(async () => { - setIsLoading(true); - setError(undefined); - try { - const response = await fetchInsight(); - setSummary(response.summary); - setContext(response.context); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load AI insight'); - } finally { - setIsLoading(false); - } - }, [fetchInsight]); + const { isLoading, error, summary, context, wasStopped, fetch, stop, regenerate } = + useStreamingAiInsight(createStream); const handleStartConversation = useCallback(() => { if (!agentBuilder?.openConversationFlyout) return; @@ -144,31 +136,83 @@ export function AiInsight({ title, fetchInsight, buildAttachments }: AiInsightPr onToggle={(open) => { setIsOpen(open); if (open && !error && !summary && !isLoading) { - handleFetchInsight(); + fetch(); } }} > - {isLoading ? ( - - ) : error ? ( - + {error ? ( + ) : ( - {summary} + + {summary} + {isLoading && } + )} - - {!isLoading && Boolean(summary && summary.trim()) ? ( - <> - - - - - - - - ) : null} + {isLoading ? ( + <> + + + + + + + {i18n.translate( + 'xpack.observabilityAgentBuilder.aiInsight.stopGeneratingButton', + { + defaultMessage: 'Stop generating', + } + )} + + + + + ) : wasStopped ? ( + <> + + + + + + + {i18n.translate('xpack.observabilityAgentBuilder.aiInsight.regenerateButton', { + defaultMessage: 'Regenerate', + })} + + + {Boolean(summary && summary.trim()) && ( + + + + )} + + + ) : Boolean(summary && summary.trim()) ? ( + <> + + + + + + + + + + ) : null} + ); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/index.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/index.ts index a88897d637cb4..a2fe169e941b2 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/index.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/index.ts @@ -5,11 +5,6 @@ * 2.0. */ -export { - AiInsight, - type AiInsightProps, - type AiInsightResponse, - type AiInsightAttachment, -} from './ai_insight'; +export { AiInsight, type AiInsightProps, type AiInsightAttachment } from './ai_insight'; export { AiInsightErrorBanner, type AiInsightErrorBannerProps } from './ai_insight_error_banner'; export { StartConversationButton } from './start_conversation_button'; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/loading_cursor.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/loading_cursor.tsx new file mode 100644 index 0000000000000..c67043d50f232 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/ai_insight/loading_cursor.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { css } from '@emotion/react'; +import React from 'react'; + +export const LoadingCursor = () => { + return ( + + ); +}; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/alert_ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/alert_ai_insight.tsx index 65eb519432485..9fa0b59e289d8 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/alert_ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/alert_ai_insight.tsx @@ -5,19 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { - createRepositoryClient, - type DefaultClientOptions, -} from '@kbn/server-route-repository-client'; -import type { ObservabilityAgentBuilderServerRouteRepository } from '../../../server'; import { AiInsight, type AiInsightAttachment } from '../ai_insight'; import { OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID, OBSERVABILITY_ALERT_ATTACHMENT_TYPE_ID, } from '../../../common'; -import { useKibana } from '../../hooks/use_kibana'; +import { useApiClient } from '../../hooks/use_api_client'; export interface AlertAiInsightProps { alertId: string; @@ -25,32 +20,20 @@ export interface AlertAiInsightProps { } export function AlertAiInsight({ alertId, alertTitle }: AlertAiInsightProps) { - const { - services: { http }, - } = useKibana(); - - const apiClient = createRepositoryClient< - ObservabilityAgentBuilderServerRouteRepository, - DefaultClientOptions - >({ http }); + const apiClient = useApiClient(); - const fetchInsight = async () => { - const response = await apiClient.fetch( - 'POST /internal/observability_agent_builder/ai_insights/alert', - { - signal: null, + const createStream = useCallback( + (signal: AbortSignal) => + apiClient.stream('POST /internal/observability_agent_builder/ai_insights/alert', { + signal, params: { body: { alertId, }, }, - } - ); - return { - summary: response.summary, - context: response.context, - }; - }; + }), + [apiClient, alertId] + ); const buildAttachments = (summary: string, context: string): AiInsightAttachment[] => [ { @@ -83,7 +66,7 @@ export function AlertAiInsight({ alertId, alertTitle }: AlertAiInsightProps) { title={i18n.translate('xpack.observabilityAgentBuilder.alertAiInsight.titleLabel', { defaultMessage: 'Help me understand this alert', })} - fetchInsight={fetchInsight} + createStream={createStream} buildAttachments={buildAttachments} /> ); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx index 1aa6a88841727..c89d7475589cd 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/error_sample_ai_insight.tsx @@ -5,19 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { - createRepositoryClient, - type DefaultClientOptions, -} from '@kbn/server-route-repository-client'; -import type { ObservabilityAgentBuilderServerRouteRepository } from '../../../server'; import { AiInsight, type AiInsightAttachment } from '../ai_insight'; import { OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID, OBSERVABILITY_ERROR_ATTACHMENT_TYPE_ID, } from '../../../common'; -import { useKibana } from '../../hooks/use_kibana'; +import { useApiClient } from '../../hooks/use_api_client'; export interface ErrorSampleAiInsightProps { errorId: string; @@ -34,20 +29,12 @@ export function ErrorSampleAiInsight({ end, environment, }: ErrorSampleAiInsightProps) { - const { - services: { http }, - } = useKibana(); - - const apiClient = createRepositoryClient< - ObservabilityAgentBuilderServerRouteRepository, - DefaultClientOptions - >({ http }); + const apiClient = useApiClient(); - const fetchInsight = async () => { - const response = await apiClient.fetch( - 'POST /internal/observability_agent_builder/ai_insights/error', - { - signal: null, + const createStream = useCallback( + (signal: AbortSignal) => + apiClient.stream('POST /internal/observability_agent_builder/ai_insights/error', { + signal, params: { body: { errorId, @@ -57,13 +44,9 @@ export function ErrorSampleAiInsight({ environment, }, }, - } - ); - return { - summary: response.summary ?? '', - context: response.context ?? '', - }; - }; + }), + [apiClient, errorId, serviceName, start, end, environment] + ); const buildAttachments = (summary: string, context: string): AiInsightAttachment[] => [ { @@ -103,7 +86,7 @@ export function ErrorSampleAiInsight({ title={i18n.translate('xpack.observabilityAgentBuilder.errorAiInsight.titleLabel', { defaultMessage: "What's this error?", })} - fetchInsight={fetchInsight} + createStream={createStream} buildAttachments={buildAttachments} /> ); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx index 71010a18153b0..e853fa952d297 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/components/insights/log_ai_insight.tsx @@ -4,20 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - createRepositoryClient, - type DefaultClientOptions, -} from '@kbn/server-route-repository-client'; -import type { ObservabilityAgentBuilderServerRouteRepository } from '../../../server'; -import { useKibana } from '../../hooks/use_kibana'; import { OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID, OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID, } from '../../../common'; import { AiInsight, type AiInsightAttachment } from '../ai_insight'; +import { useApiClient } from '../../hooks/use_api_client'; export interface LogAiInsightDocument { fields: { @@ -38,14 +33,7 @@ const explainLogMessageButtonLabel = i18n.translate( ); export function LogAiInsight({ doc }: LogAiInsightProps) { - const { - services: { http }, - } = useKibana(); - - const apiClient = createRepositoryClient< - ObservabilityAgentBuilderServerRouteRepository, - DefaultClientOptions - >({ http }); + const apiClient = useApiClient(); const { index, id } = useMemo(() => { return { @@ -54,25 +42,24 @@ export function LogAiInsight({ doc }: LogAiInsightProps) { }; }, [doc]); - if (typeof index !== 'string' || typeof id !== 'string') { - return null; - } - - const fetchInsight = async () => { - const response = await apiClient.fetch( - 'POST /internal/observability_agent_builder/ai_insights/log', - { - signal: null, + const createStream = useCallback( + (signal: AbortSignal) => { + return apiClient.stream('POST /internal/observability_agent_builder/ai_insights/log', { + signal, params: { body: { - index, - id, + index: index as string, + id: id as string, }, }, - } - ); - return response; - }; + }); + }, + [apiClient, index, id] + ); + + if (typeof index !== 'string' || typeof id !== 'string') { + return null; + } const buildAttachments = (summary: string, context: string): AiInsightAttachment[] => [ { @@ -107,7 +94,7 @@ export function LogAiInsight({ doc }: LogAiInsightProps) { <> diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_api_client.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_api_client.ts new file mode 100644 index 0000000000000..0448b40543b30 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_api_client.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { + createRepositoryClient, + type DefaultClientOptions, +} from '@kbn/server-route-repository-client'; +import type { RouteRepositoryClient } from '@kbn/server-route-repository-utils'; +import type { ObservabilityAgentBuilderServerRouteRepository } from '../../server'; +import { useKibana } from './use_kibana'; + +export function useApiClient(): RouteRepositoryClient< + ObservabilityAgentBuilderServerRouteRepository, + DefaultClientOptions +> { + const { + services: { http }, + } = useKibana(); + + return useMemo( + () => + createRepositoryClient({ + http, + }), + [http] + ); +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.test.ts new file mode 100644 index 0000000000000..35e4c905a05dc --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { of, Observable, Subject } from 'rxjs'; +import { useStreamingAiInsight, type InsightStreamEvent } from './use_streaming_ai_insight'; + +describe('useStreamingAiInsight', () => { + it('builds summary and context from stream events', async () => { + const createStream = jest.fn(() => + of( + { type: 'context', context: 'ctx' } as InsightStreamEvent, + { type: 'chatCompletionChunk', content: 'Hello ' } as InsightStreamEvent, + { type: 'chatCompletionChunk', content: 'world' } as InsightStreamEvent + ) + ); + + const { result, unmount } = renderHook(() => useStreamingAiInsight(createStream)); + + act(() => { + result.current.fetch(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.summary).toBe('Hello world'); + expect(result.current.context).toBe('ctx'); + unmount(); + }); + + it('uses the final chat completion message as summary', async () => { + const createStream = jest.fn(() => + of({ type: 'chatCompletionMessage', content: 'Final message' }) + ); + + const { result, unmount } = renderHook(() => useStreamingAiInsight(createStream)); + + act(() => { + result.current.fetch(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.summary).toBe('Final message'); + unmount(); + }); + + it('captures stream errors', async () => { + const createStream = jest.fn( + () => + new Observable((subscriber) => { + subscriber.error(new Error('Boom')); + }) + ); + + const { result, unmount } = renderHook(() => useStreamingAiInsight(createStream)); + + act(() => { + result.current.fetch(); + }); + + await waitFor(() => { + expect(result.current.error).toBe('Boom'); + }); + unmount(); + }); + + it('calling stop() aborts the stream', async () => { + const subject = new Subject(); + let capturedSignal: AbortSignal | undefined; + + const createStream = jest.fn((signal: AbortSignal) => { + capturedSignal = signal; + return subject.asObservable(); + }); + + const { result, unmount } = renderHook(() => useStreamingAiInsight(createStream)); + + act(() => { + result.current.fetch(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.wasStopped).toBe(false); + expect(capturedSignal?.aborted).toBe(false); + + // Emit a chunk before stopping + act(() => { + subject.next({ type: 'chatCompletionChunk', content: 'Hello' }); + }); + + expect(result.current.summary).toBe('Hello'); + + // Stop the stream + act(() => { + result.current.stop(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.wasStopped).toBe(true); + expect(capturedSignal?.aborted).toBe(true); + // Summary should retain what was received before stopping + expect(result.current.summary).toBe('Hello'); + + unmount(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts new file mode 100644 index 0000000000000..012aae5cf0b03 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/public/hooks/use_streaming_ai_insight.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { scan, takeUntil, finalize, Observable } from 'rxjs'; +import { AbortError } from '@kbn/kibana-utils-plugin/common'; + +interface ContextEvent { + type: 'context'; + context: string; +} + +interface ChatCompletionChunkEvent { + type: 'chatCompletionChunk'; + content: string; +} + +interface ChatCompletionMessageEvent { + type: 'chatCompletionMessage'; + content: string; +} + +export type InsightStreamEvent = + | ContextEvent + | ChatCompletionChunkEvent + | ChatCompletionMessageEvent; + +export interface InsightResponse { + summary: string; + context: string; +} + +const handleStreamError = (err: unknown, setError: (error: string | undefined) => void): void => { + if (err instanceof AbortError) { + return; + } + setError(err instanceof Error ? err.message : 'Failed to load AI insight'); +}; + +export function useStreamingAiInsight( + createStream: (signal: AbortSignal) => Observable +) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [summary, setSummary] = useState(''); + const [context, setContext] = useState(''); + const [wasStopped, setWasStopped] = useState(false); + const abortControllerRef = useRef(null); + const cleanupRef = useRef<() => void>(); + + const stop = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + setWasStopped(true); + } + }, []); + + const fetch = useCallback(() => { + cleanupRef.current?.(); + + setIsLoading(true); + setError(undefined); + setWasStopped(false); + setSummary(''); + setContext(''); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + const abort$ = new Observable((subscriber) => { + if (abortController.signal.aborted) { + subscriber.next(); + subscriber.complete(); + return; + } + const handler = () => { + subscriber.next(); + subscriber.complete(); + }; + abortController.signal.addEventListener('abort', handler); + return () => abortController.signal.removeEventListener('abort', handler); + }); + + try { + const observable$ = createStream(abortController.signal).pipe( + scan( + (acc, event) => { + if (event.type === 'context') { + return { ...acc, context: event.context }; + } + if (event.type === 'chatCompletionChunk') { + return { ...acc, summary: acc.summary + event.content }; + } + if (event.type === 'chatCompletionMessage') { + return { ...acc, summary: event.content }; + } + return acc; + }, + { summary: '', context: '' } + ), + takeUntil(abort$), + finalize(() => { + setIsLoading(false); + }) + ); + + const subscription = observable$.subscribe({ + next: (state: InsightResponse) => { + setSummary(state.summary); + setContext(state.context); + }, + error: (err: unknown) => handleStreamError(err, setError), + }); + + cleanupRef.current = () => { + abortController.abort(); + subscription.unsubscribe(); + }; + } catch (e) { + handleStreamError(e, setError); + setIsLoading(false); + } + }, [createStream]); + + useEffect(() => () => cleanupRef.current?.(), []); + + return { + isLoading, + error, + summary, + context, + wasStopped, + fetch, + stop, + regenerate: fetch, + }; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts index c15ef3bdad324..210803256541d 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/apm_error/generate_error_ai_insight.ts @@ -10,12 +10,14 @@ import type { Logger } from '@kbn/logging'; import { MessageRole } from '@kbn/inference-common'; import type { BoundInferenceClient } from '@kbn/inference-common'; import dedent from 'dedent'; +import { concat, of } from 'rxjs'; import type { ObservabilityAgentBuilderDataRegistry } from '../../../data_registry/data_registry'; import type { ObservabilityAgentBuilderCoreSetup, ObservabilityAgentBuilderPluginSetupDependencies, } from '../../../types'; import { fetchApmErrorContext } from './fetch_apm_error_context'; +import type { AiInsightResult, ContextEvent } from '../types'; const ERROR_AI_INSIGHT_SYSTEM_PROMPT = dedent(` You are an expert SRE Assistant within Elastic Observability. Your job is to analyze an APM error using ONLY the provided context (APM trace items, related errors, downstream dependencies, and log categories). @@ -84,7 +86,7 @@ export async function generateErrorAiInsight({ request, inferenceClient, dataRegistry, -}: GenerateErrorAiInsightParams): Promise<{ summary: string; context: string }> { +}: GenerateErrorAiInsightParams): Promise { const errorContext = await fetchApmErrorContext({ core, plugins, @@ -100,7 +102,7 @@ export async function generateErrorAiInsight({ const userPrompt = buildUserPrompt(errorContext); - const response = await inferenceClient.chatComplete({ + const events$ = inferenceClient.chatComplete({ system: ERROR_AI_INSIGHT_SYSTEM_PROMPT, messages: [ { @@ -108,7 +110,16 @@ export async function generateErrorAiInsight({ content: userPrompt, }, ], + stream: true, }); - return { summary: response.content, context: errorContext }; + const streamWithContext$ = concat( + of({ type: 'context', context: errorContext }), + events$ + ); + + return { + events$: streamWithContext$, + context: errorContext, + }; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts index 495e9c1351dcf..cdf12ecf3a15e 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_alert_ai_insights.ts @@ -6,12 +6,15 @@ */ import type { KibanaRequest, Logger } from '@kbn/core/server'; -import type { InferenceClient } from '@kbn/inference-common'; +import type { InferenceClient, ChatCompletionEvent } from '@kbn/inference-common'; import { MessageRole } from '@kbn/inference-common'; import dedent from 'dedent'; import { compact, isEmpty } from 'lodash'; import moment from 'moment'; +import type { Observable } from 'rxjs'; +import { concat, of } from 'rxjs'; import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; +import type { AiInsightResult, ContextEvent } from './types'; import type { ObservabilityAgentBuilderCoreSetup, ObservabilityAgentBuilderPluginSetupDependencies, @@ -49,11 +52,6 @@ interface GetAlertAiInsightParams { logger: Logger; } -interface AlertAiInsightResult { - summary: string; - context: string; -} - export async function getAlertAiInsight({ core, plugins, @@ -63,7 +61,7 @@ export async function getAlertAiInsight({ dataRegistry, request, logger, -}: GetAlertAiInsightParams): Promise { +}: GetAlertAiInsightParams): Promise { const relatedContext = await fetchAlertContext({ core, plugins, @@ -72,14 +70,19 @@ export async function getAlertAiInsight({ request, logger, }); - const summary = await generateAlertSummary({ + const events$: Observable = generateAlertSummary({ inferenceClient, connectorId, alertDoc, context: relatedContext, }); - return { summary, context: relatedContext }; + const streamWithContext$ = concat( + of({ type: 'context', context: relatedContext }), + events$ + ); + + return { events$: streamWithContext$, context: relatedContext }; } // Time window offsets in minutes before alert start @@ -203,7 +206,7 @@ async function fetchAlertContext({ return contextParts.length > 0 ? contextParts.join('\n\n') : 'No related signals available.'; } -async function generateAlertSummary({ +function generateAlertSummary({ inferenceClient, connectorId, alertDoc, @@ -213,7 +216,7 @@ async function generateAlertSummary({ connectorId: string; alertDoc: AlertDocForInsight; context: string; -}): Promise { +}): Observable { const systemPrompt = dedent(` You are an SRE assistant. Help an SRE quickly understand likely cause, impact, and next actions for this alert using the provided context. @@ -254,11 +257,10 @@ async function generateAlertSummary({ Summarize likely cause, impact, and immediate next checks for this alert using the format above. Tie related signals to the alert scope; ignore unrelated noise. If signals are weak or conflicting, mark Assessment "Inconclusive" and propose the safest next diagnostic step. `); - const completion = await inferenceClient.chatComplete({ + return inferenceClient.chatComplete({ connectorId, system: systemPrompt, messages: [{ role: MessageRole.User, content: userPrompt }], + stream: true, }); - - return completion.content; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts index be8b7611f792e..1b8b8de19ace5 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/get_log_ai_insights.ts @@ -9,8 +9,10 @@ import { MessageRole, type InferenceClient } from '@kbn/inference-common'; import type { IScopedClusterClient, KibanaRequest } from '@kbn/core/server'; import { safeJsonStringify } from '@kbn/std'; import dedent from 'dedent'; +import { concat, of } from 'rxjs'; import type { ObservabilityAgentBuilderDataRegistry } from '../../data_registry/data_registry'; import { getLogDocumentById } from './get_log_document_by_id'; +import type { AiInsightResult, ContextEvent } from './types'; export interface GetLogAiInsightsParams { index: string; @@ -30,7 +32,7 @@ export async function getLogAiInsights({ dataRegistry, inferenceClient, connectorId, -}: GetLogAiInsightsParams): Promise<{ summary: string; context: string }> { +}: GetLogAiInsightsParams): Promise { const systemPrompt = dedent(` You are assisting an SRE who is viewing a log entry in the Kibana Logs UI. Using the provided data produce a concise, action-oriented response.`); @@ -76,7 +78,7 @@ export async function getLogAiInsights({ `); } - const response = await inferenceClient.chatComplete({ + const events$ = inferenceClient.chatComplete({ connectorId, system: systemPrompt, messages: [ @@ -88,7 +90,13 @@ export async function getLogAiInsights({ `), }, ], + stream: true, }); - return { summary: response.content, context }; + const streamWithContext$ = concat(of({ type: 'context', context }), events$); + + return { + events$: streamWithContext$, + context, + }; } diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts index 1babef032babd..a376c25844a99 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/route.ts @@ -6,14 +6,17 @@ */ import * as t from 'io-ts'; +import type { ServerRouteRepository } from '@kbn/server-route-repository-utils'; import { apiPrivileges } from '@kbn/agent-builder-plugin/common/features'; +import { observableIntoEventSourceStream } from '@kbn/sse-utils-server'; +import { getRequestAbortedSignal } from '@kbn/inference-plugin/server/routes/get_request_aborted_signal'; import { generateErrorAiInsight } from './apm_error/generate_error_ai_insight'; import { createObservabilityAgentBuilderServerRoute } from '../create_observability_agent_builder_server_route'; import { getLogAiInsights } from './get_log_ai_insights'; import { getAlertAiInsight, type AlertDocForInsight } from './get_alert_ai_insights'; import { getDefaultConnectorId } from '../../utils/get_default_connector_id'; -export function getObservabilityAgentBuilderAiInsightsRouteRepository() { +export function getObservabilityAgentBuilderAiInsightsRouteRepository(): ServerRouteRepository { const getAlertAiInsightRoute = createObservabilityAgentBuilderServerRoute({ endpoint: 'POST /internal/observability_agent_builder/ai_insights/alert', options: { @@ -29,14 +32,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { alertId: t.string, }), }), - handler: async ({ - core, - plugins, - dataRegistry, - logger, - request, - params, - }): Promise<{ summary: string; context: string }> => { + handler: async ({ core, plugins, dataRegistry, logger, request, params, response }) => { const { alertId } = params.body; const [coreStart, startDeps] = await core.getStartServices(); @@ -48,7 +44,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { const alertsClient = await ruleRegistry.getRacClientWithRequest(request); const alertDoc = (await alertsClient.get({ id: alertId })) as AlertDocForInsight; - const { summary, context } = await getAlertAiInsight({ + const result = await getAlertAiInsight({ core, plugins, alertDoc, @@ -59,10 +55,12 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { logger, }); - return { - summary, - context, - }; + return response.ok({ + body: observableIntoEventSourceStream(result.events$, { + logger, + signal: getRequestAbortedSignal(request), + }), + }); }, }); @@ -85,7 +83,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { environment: t.union([t.string, t.undefined]), }), }), - handler: async ({ request, core, plugins, dataRegistry, params, logger }) => { + handler: async ({ request, core, plugins, dataRegistry, params, response, logger }) => { const { errorId, serviceName, start, end, environment = '' } = params.body; const [coreStart, startDeps] = await core.getStartServices(); @@ -94,7 +92,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { const connectorId = await getDefaultConnectorId({ coreStart, inference, request, logger }); const inferenceClient = inference.getClient({ request, bindTo: { connectorId } }); - const { summary, context } = await generateErrorAiInsight({ + const result = await generateErrorAiInsight({ core, plugins, errorId, @@ -108,10 +106,12 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { logger, }); - return { - context, - summary, - }; + return response.ok({ + body: observableIntoEventSourceStream(result.events$, { + logger, + signal: getRequestAbortedSignal(request), + }), + }); }, }); @@ -131,7 +131,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { id: t.string, }), }), - handler: async ({ request, core, dataRegistry, params }) => { + handler: async ({ request, core, dataRegistry, params, response, logger }) => { const { index, id } = params.body; const [coreStart, startDeps] = await core.getStartServices(); @@ -141,7 +141,7 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { const inferenceClient = inference.getClient({ request }); const esClient = coreStart.elasticsearch.client.asScoped(request); - const { summary, context } = await getLogAiInsights({ + const result = await getLogAiInsights({ index, id, inferenceClient, @@ -151,7 +151,12 @@ export function getObservabilityAgentBuilderAiInsightsRouteRepository() { dataRegistry, }); - return { summary, context }; + return response.ok({ + body: observableIntoEventSourceStream(result.events$, { + logger, + signal: getRequestAbortedSignal(request), + }), + }); }, }); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts new file mode 100644 index 0000000000000..1c5e75008bb0a --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/ai_insights/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Observable } from 'rxjs'; +import type { ChatCompletionEvent } from '@kbn/inference-common'; + +export interface ContextEvent { + type: 'context'; + context: string; + [key: string]: unknown; +} + +export interface AiInsightResult { + events$: Observable; + context: string; +} diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/get_global_observability_agent_builder_route_repository.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/get_global_observability_agent_builder_route_repository.ts index 66f9bfea04190..d761e5c454975 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/get_global_observability_agent_builder_route_repository.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/routes/get_global_observability_agent_builder_route_repository.ts @@ -5,9 +5,10 @@ * 2.0. */ +import type { ServerRouteRepository } from '@kbn/server-route-repository-utils'; import { getObservabilityAgentBuilderAiInsightsRouteRepository } from './ai_insights/route'; -export function getGlobalObservabilityAgentBuilderServerRouteRepository() { +export function getGlobalObservabilityAgentBuilderServerRouteRepository(): ServerRouteRepository { return { ...getObservabilityAgentBuilderAiInsightsRouteRepository(), }; diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json b/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json index 58b050c444e63..ba383af190931 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/tsconfig.json @@ -42,8 +42,10 @@ "@kbn/licensing-types", "@kbn/licensing-plugin", "@kbn/observability-nav-icons", - "@kbn/server-route-repository-client", - "@kbn/agent-builder-browser" + "@kbn/agent-builder-browser", + "@kbn/sse-utils-server", + "@kbn/kibana-utils-plugin", + "@kbn/server-route-repository-client" ], "exclude": ["target/**/*"] } diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/sse.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/sse.ts new file mode 100644 index 0000000000000..d84d72bb6d177 --- /dev/null +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/observability_agent_builder/utils/sse.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +interface SseEvent { + type: string; + data: Record; +} + +interface AiInsightResponse { + summary: string; + context: string; +} + +const EVENT_PREFIX = 'event: '; +const DATA_PREFIX = 'data: '; + +/** + * Decodes SSE (Server-Sent Events) response into an array of events. + */ +export function decodeSseEvents(body: Buffer | string): SseEvent[] { + return ( + String(body) + // Split by double newline (SSE events are separated by blank lines) + .split(/\n\n/) + .map((block) => { + const lines = block.split('\n').map((line) => line.trim()); + const eventLine = lines.find((line) => line.startsWith(EVENT_PREFIX)); + const dataLine = lines.find((line) => line.startsWith(DATA_PREFIX)); + + if (!eventLine || !dataLine) return null; + + try { + return { + type: eventLine.slice(EVENT_PREFIX.length).trim(), + data: JSON.parse(dataLine.slice(DATA_PREFIX.length)), + }; + } catch { + return null; + } + }) + .filter((event): event is SseEvent => event !== null) + ); +} + +/** + * Parses SSE response into AiInsightResponse with summary and context. + */ +export function parseSseResponse(body: Buffer | string): AiInsightResponse { + const events = decodeSseEvents(body); + + const contextEvent = events.find((e) => e.type === 'context'); + const messageEvent = events.find((e) => e.type === 'chatCompletionMessage'); + + return { + context: (contextEvent?.data?.context as string) || '', + summary: (messageEvent?.data?.content as string) || '', + }; +} diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/observability_agent_builder_api.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/observability_agent_builder_api.ts index c17f8aa54b5d5..193d4110d0ac4 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/observability_agent_builder_api.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/observability_agent_builder_api.ts @@ -11,6 +11,7 @@ import type { ClientRequestParamsOf, ReturnOf } from '@kbn/server-route-reposito import { formatRequest } from '@kbn/server-route-repository'; import type { ObservabilityAgentBuilderServerRouteRepository } from '@kbn/observability-agent-builder-plugin/server'; import type { DeploymentAgnosticFtrProviderContext } from '../ftr_provider_context'; +import { parseSseResponse } from '../apis/observability_agent_builder/utils/sse'; type APIEndpoint = keyof ObservabilityAgentBuilderServerRouteRepository; @@ -49,7 +50,10 @@ function createObservabilityAgentBuilderApiClient({ const params = 'params' in options ? (options.params as Record) : {}; - const { method, pathname, version } = formatRequest(endpoint, params.path); + const { method, pathname, version } = formatRequest( + endpoint as string, + params.path as Record | undefined + ); const pathnameWithSpaceId = options.spaceId ? `/s/${options.spaceId}${pathname}` : pathname; const url = format({ pathname: pathnameWithSpaceId, query: params?.query }); @@ -67,6 +71,10 @@ function createObservabilityAgentBuilderApiClient({ res = await supertestWithoutAuth[method](url).set(headers); } + if (endpoint.includes('ai_insights') && Buffer.isBuffer(res.body)) { + res.body = parseSseResponse(res.body); + } + return res; }