Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,16 @@ const defaultMockResults = [
origin_id: 'att-1',
type: 'visualization',
title: 'Pacific Sales',
content: 'content',
score: 1,
},
{
id: 'chunk-2',
origin_id: 'att-2',
type: 'visualization',
title: 'Atlantic Metrics',
content: 'content',
score: 0.9,
},
];

let mockUseSmlSearchReturn: {
let mockUseSmlAutocompleteReturn: {
results: typeof defaultMockResults;
total: number;
isLoading: boolean;
Expand All @@ -44,8 +40,10 @@ let mockUseSmlSearchReturn: {
error: null,
};

jest.mock('../../../../../../../hooks/sml/use_sml_search', () => ({
useSmlSearch: () => mockUseSmlSearchReturn,
const mockUseSmlAutocomplete = jest.fn(() => mockUseSmlAutocompleteReturn);

jest.mock('../../../../../../../hooks/sml/use_sml_autocomplete', () => ({
useSmlAutocomplete: (...args: unknown[]) => mockUseSmlAutocomplete(...(args as [])),
}));

jest.mock('../../../../../../../hooks/use_conversation', () => ({
Expand All @@ -57,29 +55,30 @@ jest.mock('../../../../../../../hooks/agents/use_agent_by_id', () => ({
}));

beforeEach(() => {
mockUseSmlSearchReturn = {
mockUseSmlAutocompleteReturn = {
results: defaultMockResults,
total: defaultMockResults.length,
isLoading: false,
isError: false,
error: null,
};
mockUseSmlAutocomplete.mockClear();
});

const renderWithProvider = (ui: React.ReactElement) => {
return render(<EuiProvider>{ui}</EuiProvider>);
};

describe('Sml', () => {
it('renders SML search results as type/title', () => {
it('renders SML autocomplete results as type/title', () => {
const { container } = renderWithProvider(<Sml query="" onSelect={jest.fn()} />);

expect(container.textContent).toContain('visualization/Pacific Sales');
expect(container.textContent).toContain('visualization/Atlantic Metrics');
});

it('shows loading state when search is loading', () => {
mockUseSmlSearchReturn = {
it('shows loading state when autocomplete is loading', () => {
mockUseSmlAutocompleteReturn = {
results: [],
total: 0,
isLoading: true,
Expand Down Expand Up @@ -107,8 +106,8 @@ describe('Sml', () => {
});
});

it('shows default empty list when search errors with no results (errors surface via toast from useSmlSearch)', () => {
mockUseSmlSearchReturn = {
it('shows default empty list when autocomplete errors with no results', () => {
mockUseSmlAutocompleteReturn = {
results: [],
total: 0,
isLoading: false,
Expand All @@ -123,8 +122,8 @@ describe('Sml', () => {
expect(screen.getByText('No matching results')).toBeInTheDocument();
});

it('still lists cached results when useSmlSearch reports error', () => {
mockUseSmlSearchReturn = {
it('still lists cached results when useSmlAutocomplete reports error', () => {
mockUseSmlAutocompleteReturn = {
results: defaultMockResults,
total: defaultMockResults.length,
isLoading: false,
Expand All @@ -138,4 +137,10 @@ describe('Sml', () => {
expect(screen.queryByTestId('smlMenu-loading')).not.toBeInTheDocument();
expect(screen.queryByTestId('smlMenuError')).not.toBeInTheDocument();
});

it('passes undefined filters to useSmlAutocomplete when the agent has no connector scoping', () => {
renderWithProvider(<Sml query="git" onSelect={jest.fn()} />);

expect(mockUseSmlAutocomplete).toHaveBeenCalledWith('git', { filters: undefined });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import React, { forwardRef, useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import { EuiHighlight, useEuiTheme } from '@elastic/eui';
import { useSmlSearch } from '../../../../../../../hooks/sml/use_sml_search';
import { useSmlAutocomplete } from '../../../../../../../hooks/sml/use_sml_autocomplete';
import { useAgentId } from '../../../../../../../hooks/use_conversation';
import { useAgentBuilderAgentById } from '../../../../../../../hooks/agents/use_agent_by_id';
import type { CommandMenuComponentProps, CommandMenuHandle } from '../../types';
Expand All @@ -24,7 +24,7 @@ export const Sml = forwardRef<CommandMenuHandle, CommandMenuComponentProps>(
const { agent } = useAgentBuilderAgentById(agentId);
const filters = useMemo(() => buildSmlFiltersFromAgent(agent), [agent]);
const { euiTheme } = useEuiTheme();
const { results, isLoading } = useSmlSearch(query, { skipContent: true, filters });
const { results, isLoading } = useSmlAutocomplete(query, { filters });
const { type, title } = useMemo(() => getSmlMenuHighlightSearchStrings(query), [query]);

const smlMenuLabelStyles = useMemo(
Expand Down Expand Up @@ -56,7 +56,6 @@ export const Sml = forwardRef<CommandMenuHandle, CommandMenuComponentProps>(
</EuiHighlight>
</span>
<span>/</span>

<EuiHighlight strict={false} search={title}>
{titlePlain}
</EuiHighlight>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
*/

import { act, renderHook } from '@testing-library/react';
import { SmlSearchFilterType } from '@kbn/agent-context-layer-plugin/public';
import { SML_SEARCH_DEFAULT_SIZE } from '../../../../../../../../services/sml/constants';
import { queryKeys } from '../../../../../../../query_keys';
import { usePrefetchSml } from './use_prefetch_sml';

const mockPrefetchQuery = jest.fn();
const mockSearch = jest.fn();
const mockAutocomplete = jest.fn();

jest.mock('@kbn/react-query', () => ({
useQueryClient: () => ({
Expand All @@ -21,7 +22,7 @@ jest.mock('@kbn/react-query', () => ({

jest.mock('../../../../../../../hooks/use_agent_builder_service', () => ({
useAgentBuilderServices: () => ({
smlService: { search: mockSearch },
smlService: { autocomplete: mockAutocomplete },
}),
}));

Expand All @@ -36,7 +37,7 @@ describe('usePrefetchSml', () => {
mockExperimentalEnabled = true;
});

it('prefetches wildcard SML search when experimental features are enabled', () => {
it('prefetches wildcard SML autocomplete when experimental features are enabled', () => {
const { result } = renderHook(() => usePrefetchSml());

act(() => {
Expand All @@ -45,15 +46,15 @@ describe('usePrefetchSml', () => {

expect(mockPrefetchQuery).toHaveBeenCalledTimes(1);
expect(mockPrefetchQuery).toHaveBeenCalledWith({
queryKey: queryKeys.sml.search('*', true),
queryKey: queryKeys.sml.autocomplete('*'),
queryFn: expect.any(Function),
});
const queryFn = mockPrefetchQuery.mock.calls[0][0].queryFn as () => Promise<unknown>;
void queryFn();
expect(mockSearch).toHaveBeenCalledWith({
expect(mockAutocomplete).toHaveBeenCalledWith({
query: '*',
size: SML_SEARCH_DEFAULT_SIZE,
skipContent: true,
filters: undefined,
});
});

Expand All @@ -67,4 +68,25 @@ describe('usePrefetchSml', () => {

expect(mockPrefetchQuery).not.toHaveBeenCalled();
});

it('threads agent-derived filters into the prefetch call and query key', () => {
const filters = { [SmlSearchFilterType.connector]: { ids: ['gh-1'] } };
const { result } = renderHook(() => usePrefetchSml(filters));

act(() => {
result.current();
});

expect(mockPrefetchQuery).toHaveBeenCalledWith({
queryKey: queryKeys.sml.autocomplete('*', filters),
queryFn: expect.any(Function),
});
const queryFn = mockPrefetchQuery.mock.calls[0][0].queryFn as () => Promise<unknown>;
void queryFn();
expect(mockAutocomplete).toHaveBeenCalledWith({
query: '*',
size: SML_SEARCH_DEFAULT_SIZE,
filters,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ export const usePrefetchSml = (filters?: SmlSearchFilters) => {
return;
}
queryClient.prefetchQuery({
queryKey: queryKeys.sml.search('*', true, filters),
queryKey: queryKeys.sml.autocomplete('*', filters),
queryFn: () =>
smlService.search({
smlService.autocomplete({
query: '*',
size: SML_SEARCH_DEFAULT_SIZE,
skipContent: true,
filters,
}),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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 { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@kbn/react-query';
import { SmlSearchFilterType } from '@kbn/agent-context-layer-plugin/public';
import { SML_SEARCH_DEFAULT_SIZE } from '../../../services/sml/constants';
import { useSmlAutocomplete } from './use_sml_autocomplete';

const mockAddError = jest.fn();
const mockAutocomplete = jest.fn();

jest.mock('../use_kibana', () => ({
useKibana: () => ({
services: {
notifications: {
toasts: {
addError: mockAddError,
},
},
},
}),
}));

jest.mock('../use_agent_builder_service', () => ({
useAgentBuilderServices: () => ({
smlService: { autocomplete: mockAutocomplete },
}),
}));

jest.mock('@kbn/react-hooks', () => ({
useDebouncedValue: (value: string) => value,
}));

const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
Wrapper.displayName = 'UseSmlAutocompleteTestWrapper';
return Wrapper;
};

describe('useSmlAutocomplete', () => {
beforeEach(() => {
mockAddError.mockClear();
mockAutocomplete.mockReset();
});

it('forwards the normalized query and filters into the autocomplete call', async () => {
mockAutocomplete.mockResolvedValue({ total: 0, results: [] });
const filters = { [SmlSearchFilterType.connector]: { ids: ['gh-1'] } };

renderHook(() => useSmlAutocomplete('git', { filters }), { wrapper: createWrapper() });

await waitFor(() => {
expect(mockAutocomplete).toHaveBeenCalledTimes(1);
});

expect(mockAutocomplete).toHaveBeenCalledWith({
query: 'git',
size: SML_SEARCH_DEFAULT_SIZE,
filters,
});
});

it('surfaces failures via notifications.toasts.addError', async () => {
const networkError = new Error('network');
mockAutocomplete.mockRejectedValue(networkError);

renderHook(() => useSmlAutocomplete('git'), { wrapper: createWrapper() });

await waitFor(() => {
expect(mockAddError).toHaveBeenCalledTimes(1);
});

const [errorArg, optionsArg] = mockAddError.mock.calls[0];
expect(errorArg).toBe(networkError);
expect(optionsArg).toEqual({
title: 'Unable to load autocomplete suggestions',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* 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 { useEffect, useMemo } from 'react';
import { useDebouncedValue } from '@kbn/react-hooks';
import { useQuery } from '@kbn/react-query';
import { formatAgentBuilderErrorMessage } from '@kbn/agent-builder-browser';
import { i18n } from '@kbn/i18n';
import type { SmlSearchFilters } from '@kbn/agent-context-layer-plugin/public';
import { SML_SEARCH_DEFAULT_SIZE } from '../../../services/sml/constants';
import { queryKeys } from '../../query_keys';
import { useAgentBuilderServices } from '../use_agent_builder_service';
import { useKibana } from '../use_kibana';
import { normalizeSmlSearchQuery } from './normalize_sml_search_query';

const SML_AUTOCOMPLETE_DEBOUNCE_MS = 250;
const SML_AUTOCOMPLETE_STALE_TIME_MS = 60_000;
const SML_AUTOCOMPLETE_CACHE_TIME_MS = 300_000;

const smlAutocompleteErrorToastTitle = i18n.translate(
'xpack.agentBuilder.conversationInput.commandMenu.smlAutocompleteErrorTitle',
{ defaultMessage: 'Unable to load autocomplete suggestions' }
);

export interface UseSmlAutocompleteOptions {
/** Per-type filters for SML autocomplete (e.g. agent-centric connector allow-list). */
readonly filters?: SmlSearchFilters;
}

/**
* Typeahead hook for the @ menu. Hits POST `/sml/_autocomplete`, which returns
* per-row `matched_discovery_labels` (with `kind` for UI badging, and
* `highlighted` when ES is able to produce a snippet).
*
* For full retrieval (LLM tool, content search), see `useSmlSearch`.
*/
export const useSmlAutocomplete = (query: string, options?: UseSmlAutocompleteOptions) => {
const { services } = useKibana();
const { smlService } = useAgentBuilderServices();
const debouncedQuery = useDebouncedValue(query, SML_AUTOCOMPLETE_DEBOUNCE_MS);
const filters = options?.filters;

const normalized = useMemo(() => normalizeSmlSearchQuery(debouncedQuery), [debouncedQuery]);

const { isError, isLoading, error, data } = useQuery({
queryKey: queryKeys.sml.autocomplete(normalized, filters),
queryFn: () =>
smlService.autocomplete({
query: normalized,
size: SML_SEARCH_DEFAULT_SIZE,
filters,
}),
staleTime: SML_AUTOCOMPLETE_STALE_TIME_MS,
cacheTime: SML_AUTOCOMPLETE_CACHE_TIME_MS,
});

useEffect(() => {
if (!isError || isLoading) {
return;
}
services.notifications.toasts.addError(
error instanceof Error ? error : new Error(formatAgentBuilderErrorMessage(error)),
{ title: smlAutocompleteErrorToastTitle }
);
}, [isError, isLoading, error, services.notifications.toasts]);

return {
results: data?.results ?? [],
total: data?.total ?? 0,
isLoading,
isError,
error,
};
};
Loading