diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx index 9e03ed3dc1117..29e408e6a8a94 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.test.tsx @@ -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; @@ -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', () => ({ @@ -57,13 +55,14 @@ 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) => { @@ -71,15 +70,15 @@ const renderWithProvider = (ui: React.ReactElement) => { }; describe('Sml', () => { - it('renders SML search results as type/title', () => { + it('renders SML autocomplete results as type/title', () => { const { container } = renderWithProvider(); 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, @@ -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, @@ -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, @@ -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(); + + expect(mockUseSmlAutocomplete).toHaveBeenCalledWith('git', { filters: undefined }); + }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx index a896855f0ff1e..9f806969b4b11 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/sml.tsx @@ -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'; @@ -24,7 +24,7 @@ export const Sml = forwardRef( 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( @@ -56,7 +56,6 @@ export const Sml = forwardRef( / - {titlePlain} diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx index 9ff5db5d3d0e9..965563c9456ac 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.test.tsx @@ -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: () => ({ @@ -21,7 +22,7 @@ jest.mock('@kbn/react-query', () => ({ jest.mock('../../../../../../../hooks/use_agent_builder_service', () => ({ useAgentBuilderServices: () => ({ - smlService: { search: mockSearch }, + smlService: { autocomplete: mockAutocomplete }, }), })); @@ -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(() => { @@ -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; void queryFn(); - expect(mockSearch).toHaveBeenCalledWith({ + expect(mockAutocomplete).toHaveBeenCalledWith({ query: '*', size: SML_SEARCH_DEFAULT_SIZE, - skipContent: true, + filters: undefined, }); }); @@ -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; + void queryFn(); + expect(mockAutocomplete).toHaveBeenCalledWith({ + query: '*', + size: SML_SEARCH_DEFAULT_SIZE, + filters, + }); + }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts index 21c8510b23cdc..03a95a99c621d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/components/conversations/conversation_input/message_editor/command_menu/menus/sml/use_prefetch_sml.ts @@ -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, }), }); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.test.tsx b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.test.tsx new file mode 100644 index 0000000000000..e6bcf7f1f7d06 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.test.tsx @@ -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 }) => ( + {children} + ); + 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', + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.ts new file mode 100644 index 0000000000000..7ddd175e34695 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/hooks/sml/use_sml_autocomplete.ts @@ -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, + }; +}; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts b/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts index f63314f261622..2ac9c3ad5d9ef 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/application/query_keys.ts @@ -55,6 +55,8 @@ export const queryKeys = { sml: { search: (query: string, skipContent: boolean, filters?: SmlSearchFilters) => ['sml', 'search', { query, skipContent, filters }] as const, + autocomplete: (query: string, filters?: SmlSearchFilters) => + ['sml', 'autocomplete', { query, filters }] as const, }, plugins: { all: ['plugins', 'list'] as const, diff --git a/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts b/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts index 28010732eb2c8..e83c2b0f5e702 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts +++ b/x-pack/platform/plugins/shared/agent_builder/public/services/sml/sml_service.ts @@ -7,12 +7,17 @@ import type { HttpSetup } from '@kbn/core-http-browser'; import type { + SmlAutocompleteHttpResponse, SmlSearchFilters, SmlSearchHttpResponse, } from '@kbn/agent-context-layer-plugin/public'; -import { smlSearchPath } from '@kbn/agent-context-layer-plugin/public'; +import { smlAutocompletePath, smlSearchPath } from '@kbn/agent-context-layer-plugin/public'; -/** Browser client for SML search (`/internal/agent_context_layer/sml/_search`). */ +/** + * Browser client for SML. + * - `search(...)` → `/internal/agent_context_layer/sml/_search` (full retrieval) + * - `autocomplete(...)` → `/internal/agent_context_layer/sml/_autocomplete` (@ menu / typeahead) + */ export class SmlService { private readonly http: HttpSetup; @@ -35,4 +40,18 @@ export class SmlService { }), }); } + + async autocomplete(params: { + query: string; + size: number; + filters?: SmlSearchFilters; + }): Promise { + return await this.http.post(smlAutocompletePath, { + body: JSON.stringify({ + query: params.query, + size: params.size, + ...(params.filters ? { filters: params.filters } : {}), + }), + }); + } } diff --git a/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts b/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts index c4d915e1c45be..01ed30b83a377 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/common/constants.ts @@ -7,3 +7,4 @@ export const internalApiPath = '/internal/agent_context_layer'; export const smlSearchPath = `${internalApiPath}/sml/_search`; +export const smlAutocompletePath = `${internalApiPath}/sml/_autocomplete`; diff --git a/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts b/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts index ccf19f91b0b2e..9e7415a8f40b0 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/common/http_api/sml.ts @@ -40,3 +40,50 @@ export interface SmlSearchHttpResultItem { score: number; content?: string; } + +/** + * Max length of `query` for POST `/internal/agent_context_layer/sml/_autocomplete`. + * Autocomplete payloads are user-typed prefixes — shorter than full retrieval queries. + */ +export const SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH = 256; + +/** + * Response body for `POST /internal/agent_context_layer/sml/_autocomplete`. + */ +export interface SmlAutocompleteHttpResponse { + total: number; + results: SmlAutocompleteHttpResultItem[]; +} + +/** + * One row in the @ menu / typeahead. Results are returned in score order; + * consumers iterate without re-sorting. + */ +export interface SmlAutocompleteHttpResultItem { + id: string; + type: string; + origin_id: string; + title: string; + /** + * The specific `discovery_labels` entries that matched the typed prefix, + * with their `kind` so the UI can render the matched label in context + * (e.g. for `kind: 'title'` the UI may bold the matched span in the title; + * for `kind: 'tagline'` it may render the value as a chip). + * + * Title and type are reachable as discovery_labels (indexer auto-prepends + * `{value: title, kind: 'title'}` and `{value: type, kind: 'type'}`). + */ + matched_discovery_labels?: SmlMatchedDiscoveryLabel[]; +} + +export interface SmlMatchedDiscoveryLabel { + value: string; + kind: string; + /** + * The matched span within `value`, wrapped in `...` tags. Present + * when ES returned a highlight snippet for this entry. UI renders the tags + * as appropriate (e.g. mapping `` to a bolded span). Example: typed + * prefix `"git"` against value `"github"` produces `"github"`. + */ + highlighted?: string; +} diff --git a/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts b/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts index d6e97bdcd0b90..b16710d9d7e46 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/public/index.ts @@ -7,12 +7,19 @@ import type { PluginInitializer } from '@kbn/core-plugins-browser'; -export { smlSearchPath, internalApiPath } from '../common/constants'; -export { SML_HTTP_SEARCH_QUERY_MAX_LENGTH, SmlSearchFilterType } from '../common/http_api/sml'; +export { smlSearchPath, smlAutocompletePath, internalApiPath } from '../common/constants'; +export { + SML_HTTP_SEARCH_QUERY_MAX_LENGTH, + SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH, + SmlSearchFilterType, +} from '../common/http_api/sml'; export type { SmlSearchFilters, SmlSearchHttpResponse, SmlSearchHttpResultItem, + SmlAutocompleteHttpResponse, + SmlAutocompleteHttpResultItem, + SmlMatchedDiscoveryLabel, } from '../common/http_api/sml'; export const plugin: PluginInitializer<{}, {}> = () => ({ diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts index 16b3b6c8e5867..eed1d5c94a308 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/plugin.ts @@ -16,6 +16,7 @@ import type { import { registerFeatures } from './features'; import { registerUISettings } from './ui_settings'; import { registerSearchRoute } from './routes/search'; +import { registerAutocompleteRoute } from './routes/autocomplete'; import { createSmlService, type SmlServiceInstance } from './services/sml/sml_service'; import { registerSmlCrawlerTaskDefinition, @@ -82,6 +83,12 @@ export class AgentContextLayerPlugin logger: this.logger, getSmlService, }); + registerAutocompleteRoute({ + router, + coreSetup, + logger: this.logger, + getSmlService, + }); return { registerType: smlSetup.registerType, diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.test.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.test.ts new file mode 100644 index 0000000000000..8f10937363c33 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.test.ts @@ -0,0 +1,199 @@ +/* + * 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 { httpServerMock, httpServiceMock } from '@kbn/core-http-server-mocks'; +import { coreMock } from '@kbn/core/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids'; +import type { SmlAutocompleteResult } from '../services/sml/types'; +import { registerAutocompleteRoute } from './autocomplete'; + +const createMockSmlService = () => ({ + search: jest.fn(), + autocomplete: jest.fn(), + checkItemsAccess: jest.fn(), + indexAttachment: jest.fn(), + getDocuments: jest.fn(), + getTypeDefinition: jest.fn(), + listTypeDefinitions: jest.fn(), + getCrawler: jest.fn(), +}); + +const createMockUiSettingsClient = (enabled = true) => ({ + get: jest.fn().mockImplementation(async (key: string) => { + if (key === AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID) return enabled; + return undefined; + }), +}); + +describe('registerAutocompleteRoute', () => { + let router: ReturnType; + let handler: Function; + let mockSmlService: ReturnType; + const logger = loggingSystemMock.create().get(); + + beforeEach(() => { + router = httpServiceMock.createRouter(); + mockSmlService = createMockSmlService(); + + const coreSetup = coreMock.createSetup(); + (coreSetup.getStartServices as jest.Mock).mockResolvedValue([ + {}, + { spaces: { spacesService: { getSpaceId: jest.fn().mockReturnValue('test-space') } } }, + {}, + ]); + + registerAutocompleteRoute({ + router: router as any, + coreSetup: coreSetup as any, + logger, + getSmlService: () => mockSmlService as any, + }); + + const [, registeredHandler] = router.post.mock.calls[0]; + handler = registeredHandler; + }); + + const callHandler = async (body: Record, uiSettingsEnabled = true) => { + const request = httpServerMock.createKibanaRequest({ body }); + const response = httpServerMock.createResponseFactory(); + const mockUiSettings = createMockUiSettingsClient(uiSettingsEnabled); + const ctx = { + core: Promise.resolve({ + uiSettings: { client: mockUiSettings }, + elasticsearch: { client: { asInternalUser: {}, asCurrentUser: {} } }, + }), + }; + await handler(ctx, request, response); + return response; + }; + + it('returns 404 when feature flag is disabled', async () => { + const response = await callHandler({ query: 'git', size: 5 }, false); + expect(response.notFound).toHaveBeenCalled(); + expect(mockSmlService.autocomplete).not.toHaveBeenCalled(); + }); + + it('returns 200 with autocomplete results and per-row provenance when enabled', async () => { + const mockResults: SmlAutocompleteResult[] = [ + { + id: 'chunk-1', + type: 'connector', + title: 'GitHub Connector', + origin_id: 'gh-1', + spaces: ['test-space'], + permissions: [], + matched_discovery_labels: [ + { value: 'GitHub Connector', kind: 'title' }, + { value: 'github', kind: 'tagline' }, + ], + }, + ]; + mockSmlService.autocomplete.mockResolvedValue({ results: mockResults, total: 1 }); + + const response = await callHandler({ query: 'git', size: 10 }); + expect(response.ok).toHaveBeenCalledWith({ + body: { + total: 1, + results: [ + { + id: 'chunk-1', + type: 'connector', + origin_id: 'gh-1', + title: 'GitHub Connector', + matched_discovery_labels: [ + { value: 'GitHub Connector', kind: 'title' }, + { value: 'github', kind: 'tagline' }, + ], + }, + ], + }, + }); + }); + + it('omits matched_discovery_labels from the response when absent on the result', async () => { + const mockResults: SmlAutocompleteResult[] = [ + { + id: 'chunk-2', + type: 'dashboard', + title: 'Sales Q3', + origin_id: 'dash-1', + spaces: ['test-space'], + permissions: [], + }, + ]; + mockSmlService.autocomplete.mockResolvedValue({ results: mockResults, total: 1 }); + + const response = await callHandler({ query: 'sal', size: 5 }); + const body = response.ok.mock.calls[0][0]?.body as Record; + const results = (body as any).results; + expect(results[0]).not.toHaveProperty('matched_discovery_labels'); + }); + + it('does not leak server-only fields (permissions, spaces) into the HTTP response', async () => { + const mockResults: SmlAutocompleteResult[] = [ + { + id: 'chunk-3', + type: 'visualization', + title: 'V', + origin_id: 'v-1', + spaces: ['test-space'], + permissions: ['saved_object:visualization/get'], + }, + ]; + mockSmlService.autocomplete.mockResolvedValue({ results: mockResults, total: 1 }); + + const response = await callHandler({ query: 'v' }); + const body = response.ok.mock.calls[0][0]?.body as Record; + const results = (body as any).results; + expect(results[0]).not.toHaveProperty('permissions'); + expect(results[0]).not.toHaveProperty('spaces'); + }); + + it('passes spaceId from spaces plugin to sml.autocomplete', async () => { + mockSmlService.autocomplete.mockResolvedValue({ results: [], total: 0 }); + await callHandler({ query: 'test' }); + expect(mockSmlService.autocomplete).toHaveBeenCalledWith( + expect.objectContaining({ spaceId: 'test-space' }) + ); + }); + + it('falls back to default space when spaces plugin is unavailable', async () => { + const coreSetup = coreMock.createSetup(); + (coreSetup.getStartServices as jest.Mock).mockResolvedValue([{}, {}, {}]); + + const localRouter = httpServiceMock.createRouter(); + registerAutocompleteRoute({ + router: localRouter as any, + coreSetup: coreSetup as any, + logger, + getSmlService: () => mockSmlService as any, + }); + + const [, localHandler] = localRouter.post.mock.calls[0]; + const request = httpServerMock.createKibanaRequest({ body: { query: 'test' } }); + const response = httpServerMock.createResponseFactory(); + const ctx = { + core: Promise.resolve({ + uiSettings: { client: createMockUiSettingsClient(true) }, + elasticsearch: { client: {} }, + }), + }; + + mockSmlService.autocomplete.mockResolvedValue({ results: [], total: 0 }); + await localHandler(ctx, request, response); + expect(mockSmlService.autocomplete).toHaveBeenCalledWith( + expect.objectContaining({ spaceId: 'default' }) + ); + }); + + it('propagates errors from sml.autocomplete', async () => { + mockSmlService.autocomplete.mockRejectedValue(new Error('ES connection failed')); + await expect(callHandler({ query: 'test' })).rejects.toThrow('ES connection failed'); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('ES connection failed')); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.ts new file mode 100644 index 0000000000000..dce9c5e2d08a5 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/routes/autocomplete.ts @@ -0,0 +1,111 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { CoreSetup, IRouter, Logger } from '@kbn/core/server'; +import type { RouteSecurity } from '@kbn/core-http-server'; +import { AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids'; +import { apiPrivileges } from '../../common/features'; +import type { + SmlAutocompleteHttpResponse, + SmlAutocompleteHttpResultItem, +} from '../../common/http_api/sml'; +import { + SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH, + SmlSearchFilterType, +} from '../../common/http_api/sml'; +import { smlAutocompletePath } from '../../common/constants'; +import type { SmlService } from '../services/sml/types'; +import type { AgentContextLayerStartDependencies, AgentContextLayerPluginStart } from '../types'; + +const SML_AUTOCOMPLETE_SIZE_MAX = 50; + +const AGENT_CONTEXT_LAYER_READ_SECURITY: RouteSecurity = { + authz: { requiredPrivileges: [apiPrivileges.readAgentContextLayer] }, +}; + +export const registerAutocompleteRoute = ({ + router, + coreSetup, + logger, + getSmlService, +}: { + router: IRouter; + coreSetup: CoreSetup; + logger: Logger; + getSmlService: () => SmlService; +}) => { + router.post( + { + path: smlAutocompletePath, + validate: { + body: schema.object({ + query: schema.string({ minLength: 1, maxLength: SML_HTTP_AUTOCOMPLETE_QUERY_MAX_LENGTH }), + size: schema.maybe(schema.number({ min: 1, max: SML_AUTOCOMPLETE_SIZE_MAX })), + // Per-type scoping (e.g. agent-centric connector allow-list). Same + // shape as POST /sml/_search so a single FE filter-builder can feed + // either route. + filters: schema.maybe( + schema.recordOf( + schema.literal(SmlSearchFilterType.connector), + schema.object({ + ids: schema.maybe(schema.arrayOf(schema.string(), { maxSize: 100 })), + }) + ) + ), + }), + }, + options: { access: 'internal' }, + security: AGENT_CONTEXT_LAYER_READ_SECURITY, + }, + async (ctx, request, response) => { + try { + const coreContext = await ctx.core; + const uiSettingsClient = coreContext.uiSettings.client; + + const isEnabled = await uiSettingsClient.get( + AGENT_CONTEXT_LAYER_EXPERIMENTAL_FEATURES_SETTING_ID + ); + if (!isEnabled) { + return response.notFound(); + } + + const sml = getSmlService(); + const { query, size, filters } = request.body; + const esClient = coreContext.elasticsearch.client; + + const [, startDeps] = await coreSetup.getStartServices(); + const spaceId = startDeps.spaces?.spacesService?.getSpaceId(request) ?? 'default'; + + const { results, total } = await sml.autocomplete({ + query, + size, + spaceId, + esClient, + request, + filters, + }); + + const body: SmlAutocompleteHttpResponse = { + total, + results: results.map(({ id, type, origin_id, title, matched_discovery_labels }) => { + const item: SmlAutocompleteHttpResultItem = { id, type, origin_id, title }; + if (matched_discovery_labels && matched_discovery_labels.length > 0) { + item.matched_discovery_labels = matched_discovery_labels; + } + return item; + }), + }; + + return response.ok({ body }); + } catch (error) { + logger.error(`SML autocomplete route error: ${(error as Error).message}`); + throw error; + } + } + ); +}; diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts index 8b2181d05e77d..dabb1c30c6a16 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.test.ts @@ -162,10 +162,14 @@ describe('createSmlIndexer', () => { updated_at: expect.any(String), spaces: ['default', 'space-2'], permissions: ['perm1'], + discovery_labels: [ + { value: 'My Viz', kind: 'title' }, + { value: 'lens', kind: 'type' }, + ], }); }); - it('create action: round-trips all new schema fields (tags, payload, references, description, user_id)', async () => { + it('create action: round-trips all new schema fields (tags, discovery_labels, payload, references, description, user_id)', async () => { const bulkMock = jest.fn().mockResolvedValue({ errors: false, items: [] }); const getClientMock = jest.fn().mockReturnValue({ bulk: bulkMock }); (createSmlStorage as jest.Mock).mockReturnValue({ getClient: getClientMock }); @@ -178,6 +182,10 @@ describe('createSmlIndexer', () => { content: 'sales dashboard for Q3 with revenue and conversion metrics', description: 'Quarterly sales overview, executive audience', tags: ['sales', 'executive', 'quarterly'], + discovery_labels: [ + { value: 'q3 sales', kind: 'tagline' }, + { value: 'sales q3 dashboard', kind: 'nickname' }, + ], payload: { owner_team: 'sales-ops', fields: [{ name: 'revenue', type: 'currency' }], @@ -216,6 +224,12 @@ describe('createSmlIndexer', () => { content: 'sales dashboard for Q3 with revenue and conversion metrics', description: 'Quarterly sales overview, executive audience', tags: ['sales', 'executive', 'quarterly'], + discovery_labels: [ + { value: 'Sales Q3', kind: 'title' }, + { value: 'dashboard', kind: 'type' }, + { value: 'q3 sales', kind: 'tagline' }, + { value: 'sales q3 dashboard', kind: 'nickname' }, + ], payload: { owner_team: 'sales-ops', fields: [{ name: 'revenue', type: 'currency' }], diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts index e3dd199a4bdf6..07c810a59cb16 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_indexer.ts @@ -141,6 +141,14 @@ class SmlIndexerImpl implements SmlIndexer { if (chunk.tags !== undefined) { document.tags = chunk.tags; } + // Auto-prepend `title` and `type` as discovery_labels so the @ menu has a + // single uniform surface to query. Producer-provided labels (taglines, + // nicknames, categories, etc.) come after. + document.discovery_labels = [ + { value: chunk.title, kind: 'title' }, + { value: chunk.type, kind: 'type' }, + ...(chunk.discovery_labels ?? []), + ]; if (chunk.payload !== undefined) { document.payload = chunk.payload; } diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts index 84c2847695402..0d8ed628a2880 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.test.ts @@ -10,6 +10,7 @@ import { loggerMock } from '@kbn/logging-mocks'; import type { ElasticsearchClient, IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import type { KibanaRequest } from '@kbn/core-http-server'; import type { AuthorizationServiceSetup } from '@kbn/security-plugin-types-server'; +import { SmlSearchFilterType } from '../../../common/http_api/sml'; import { createSmlService, isNotFoundError } from './sml_service'; import { smlIndexName } from './sml_storage'; import type { SmlTypeDefinition } from './types'; @@ -111,6 +112,7 @@ describe('createSmlService', () => { const smlService = service.start({ logger }); expect(smlService.search).toBeDefined(); + expect(smlService.autocomplete).toBeDefined(); expect(smlService.checkItemsAccess).toBeDefined(); expect(smlService.getDocuments).toBeDefined(); expect(smlService.indexAttachment).toBeDefined(); @@ -166,15 +168,6 @@ describe('SmlService', () => { }); describe('search', () => { - const saytBoolPrefixFields = [ - 'title_autocomplete', - 'title_autocomplete._2gram', - 'title_autocomplete._3gram', - 'title_autocomplete._index_prefix', - 'type.autocomplete', - 'type.autocomplete._index_prefix', - ]; - it('calls ES search with correct retriever, space filter, and _source fields', async () => { const service = createSmlService(); service.setup({ logger }); @@ -213,24 +206,10 @@ describe('SmlService', () => { { standard: { query: { - bool: { - should: [ - { - multi_match: { - query: 'foo bar', - type: 'bool_prefix', - fields: saytBoolPrefixFields, - }, - }, - { - multi_match: { - query: 'foo bar', - type: 'best_fields', - fields: ['title^2', 'description', 'content'], - }, - }, - ], - minimum_should_match: 1, + multi_match: { + query: 'foo bar', + type: 'best_fields', + fields: ['title^2', 'description', 'content'], }, }, }, @@ -374,7 +353,7 @@ describe('SmlService', () => { expect(result.total).toBe(1); }); - it('surfaces all new schema fields (origin, tags, payload) in search results', async () => { + it('surfaces all new schema fields (origin, tags, discovery_labels, payload) in search results', async () => { const service = createSmlService(); service.setup({ logger }); const smlService = service.start({ logger }); @@ -392,6 +371,7 @@ describe('SmlService', () => { content: 'sales content', description: 'sales summary', tags: ['sales', 'executive'], + discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }], payload: { owner_team: 'sales-ops' }, user_id: 'user-7', references: ['category://sales'], @@ -423,6 +403,7 @@ describe('SmlService', () => { content: 'sales content', description: 'sales summary', tags: ['sales', 'executive'], + discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }], payload: { owner_team: 'sales-ops' }, user_id: 'user-7', references: ['category://sales'], @@ -665,6 +646,320 @@ describe('SmlService', () => { }); }); + describe('autocomplete', () => { + it('builds a single nested discovery_labels query (with inner_hits) and a space filter', async () => { + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger }); + + esClient.search.mockResolvedValue({ + hits: { total: 0, hits: [] }, + } as any); + + await smlService.autocomplete({ + query: 'git', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + }); + + expect(esClient.search).toHaveBeenCalledTimes(1); + const call = esClient.search.mock.calls[0]![0]!; + expect(call.query).toEqual({ + bool: { + must: [ + { + nested: { + path: 'discovery_labels', + query: { + multi_match: { + query: 'git', + type: 'bool_prefix', + operator: 'and', + fields: [ + 'discovery_labels.value', + 'discovery_labels.value._2gram', + 'discovery_labels.value._3gram', + ], + }, + }, + inner_hits: { + _source: ['discovery_labels.value', 'discovery_labels.kind'], + size: 10, + highlight: { + type: 'unified', + number_of_fragments: 0, + pre_tags: [''], + post_tags: [''], + encoder: 'html', + fields: { + 'discovery_labels.value': {}, + }, + }, + }, + }, + }, + ], + filter: [ + { + bool: { + should: [{ term: { spaces: 'default' } }, { term: { spaces: '*' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }); + expect(call._source).toEqual(['id', 'type', 'title', 'origin_id', 'spaces', 'permissions']); + }); + + it('uses match_all for query "*"', async () => { + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger }); + + esClient.search.mockResolvedValue({ hits: { total: 0, hits: [] } } as any); + + await smlService.autocomplete({ + query: '*', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + }); + + const call = esClient.search.mock.calls[0]![0]!; + expect(call.query!.bool!.must).toEqual([{ match_all: {} }]); + }); + + it('threads per-type filters through buildTypeFilters into the ES filter clauses', async () => { + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger }); + + esClient.search.mockResolvedValue({ hits: { total: 0, hits: [] } } as any); + + await smlService.autocomplete({ + query: 'git', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + filters: { [SmlSearchFilterType.connector]: { ids: ['gh-1', 'jira-1'] } }, + }); + + const call = esClient.search.mock.calls[0]![0]!; + const filterClauses = call.query!.bool!.filter as Array>; + // First clause is the space filter; second is the type filter from buildTypeFilters. + expect(filterClauses).toHaveLength(2); + expect(filterClauses[1]).toEqual({ + bool: { + should: [ + { + bool: { + must: [ + { term: { type: 'connector' } }, + { terms: { origin_id: ['gh-1', 'jira-1'] } }, + ], + }, + }, + { bool: { must_not: [{ term: { type: 'connector' } }] } }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('maps inner_hits onto matched_discovery_labels', async () => { + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger }); + + esClient.search.mockResolvedValue({ + hits: { + total: 1, + hits: [ + { + _source: { + id: 'chunk-1', + type: 'connector', + title: 'GitHub Connector', + origin_id: 'gh-1', + spaces: ['default'], + permissions: [], + }, + _score: 5.4, + inner_hits: { + discovery_labels: { + hits: { + total: { value: 2, relation: 'eq' }, + hits: [ + { + _nested: { field: 'discovery_labels', offset: 0 }, + _score: 5.4, + _source: { value: 'GitHub Connector', kind: 'title' }, + highlight: { + 'discovery_labels.value': ['GitHub Connector'], + }, + }, + { + _nested: { field: 'discovery_labels', offset: 2 }, + _score: 4.1, + _source: { value: 'github', kind: 'tagline' }, + highlight: { + 'discovery_labels.value': ['github'], + }, + }, + ], + }, + }, + }, + }, + ], + }, + } as any); + + const result = await smlService.autocomplete({ + query: 'git', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toEqual({ + id: 'chunk-1', + type: 'connector', + title: 'GitHub Connector', + origin_id: 'gh-1', + spaces: ['default'], + permissions: [], + matched_discovery_labels: [ + { + value: 'GitHub Connector', + kind: 'title', + highlighted: 'GitHub Connector', + }, + { value: 'github', kind: 'tagline', highlighted: 'github' }, + ], + }); + expect(result.total).toBe(1); + }); + + it('omits matched_discovery_labels when absent', async () => { + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger }); + + esClient.search.mockResolvedValue({ + hits: { + total: 1, + hits: [ + { + _source: { + id: 'chunk-2', + type: 'dashboard', + title: 'Sales Q3', + origin_id: 'dash-1', + spaces: ['default'], + permissions: [], + }, + _score: 2.0, + }, + ], + }, + } as any); + + const result = await smlService.autocomplete({ + query: 'sal', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + }); + + expect(result.results[0]).toEqual({ + id: 'chunk-2', + type: 'dashboard', + title: 'Sales Q3', + origin_id: 'dash-1', + spaces: ['default'], + permissions: [], + }); + }); + + it('returns empty results when the index does not exist (404)', async () => { + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger }); + + esClient.search.mockRejectedValue(createNotFoundError()); + + const result = await smlService.autocomplete({ + query: 'git', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + }); + + expect(result).toEqual({ results: [], total: 0 }); + }); + + it('applies permission filtering when securityAuthz is present', async () => { + const securityAuthz = createMockSecurityAuthzPartial( + ['saved_object:dashboard/get'], + ['saved_object:connector/get'] + ); + const service = createSmlService(); + service.setup({ logger }); + const smlService = service.start({ logger, securityAuthz }); + + esClient.search.mockResolvedValue({ + hits: { + total: 2, + hits: [ + { + _source: { + id: 'chunk-allowed', + type: 'dashboard', + title: 'Allowed', + origin_id: 'd1', + spaces: ['default'], + permissions: ['saved_object:dashboard/get'], + }, + _score: 3, + }, + { + _source: { + id: 'chunk-denied', + type: 'connector', + title: 'Denied', + origin_id: 'c1', + spaces: ['default'], + permissions: ['saved_object:connector/get'], + }, + _score: 2, + }, + ], + }, + } as any); + + const result = await smlService.autocomplete({ + query: 'a', + size: 10, + spaceId: 'default', + esClient: scopedClient, + request, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0].id).toBe('chunk-allowed'); + }); + }); + describe('checkItemsAccess', () => { it('grants all access when securityAuthz is absent', async () => { const service = createSmlService(); @@ -937,7 +1232,7 @@ describe('SmlService', () => { }); }); - it('round-trips all new schema fields (origin, tags, payload)', async () => { + it('round-trips all new schema fields (origin, tags, discovery_labels, payload)', async () => { const service = createSmlService(); service.setup({ logger }); const smlService = service.start({ logger }); @@ -955,6 +1250,7 @@ describe('SmlService', () => { content: 'sales content', description: 'sales summary', tags: ['sales', 'executive'], + discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }], payload: { owner_team: 'sales-ops' }, user_id: 'user-7', references: ['category://sales'], @@ -982,6 +1278,7 @@ describe('SmlService', () => { content: 'sales content', description: 'sales summary', tags: ['sales', 'executive'], + discovery_labels: [{ value: 'q3 sales', kind: 'tagline' }], payload: { owner_team: 'sales-ops' }, user_id: 'user-7', references: ['category://sales'], diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts index 8287574c7bb27..72b67eab25f1e 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_service.ts @@ -13,9 +13,11 @@ import type { AuthorizationServiceSetup } from '@kbn/security-plugin-types-serve import type { SmlService, SmlSearchResult, + SmlAutocompleteResult, SmlDocument, SmlTypeDefinition, SmlSearchFilters, + MatchedDiscoveryLabel, } from './types'; import { createSmlTypeRegistry, type SmlTypeRegistry } from './sml_type_registry'; import { createSmlIndexer, type SmlIndexer } from './sml_indexer'; @@ -98,6 +100,22 @@ class SmlServiceImpl implements SmlServiceInstance { logger, }); }, + autocomplete: async ({ query, size = 10, spaceId, esClient, request, filters }) => { + const rawResults = await autocompleteSml({ + query, + size, + spaceId, + esClient, + logger, + filters, + }); + return filterResultsByPermissions({ + searchResult: rawResults, + request, + securityAuthz: this.securityAuthz, + logger, + }); + }, checkItemsAccess: async ({ ids, spaceId, esClient, request }) => { return checkItemsAccess({ ids, @@ -171,18 +189,20 @@ const getAuthorizedPermissions = async ({ * 1. Collect all unique permission strings from the results. * 2. Batch-check them with the security plugin. * 3. Remove results whose required permissions are not fully authorized. + * + * Generic over the result type so both `search` and `autocomplete` paths share it. */ -const filterResultsByPermissions = async ({ +const filterResultsByPermissions = async ({ searchResult, request, securityAuthz, logger, }: { - searchResult: { results: SmlSearchResult[]; total: number }; + searchResult: { results: T[]; total: number }; request: KibanaRequest; securityAuthz?: AuthorizationServiceSetup; logger: Logger; -}): Promise<{ results: SmlSearchResult[]; total: number }> => { +}): Promise<{ results: T[]; total: number }> => { // When the security plugin is absent (e.g. development/testing with security // disabled), all results are returned unfiltered. This follows the standard // Kibana convention: no security plugin → open access. @@ -313,15 +333,6 @@ const checkItemsAccess = async ({ return accessMap; }; -const SML_SEARCH_AS_YOU_TYPE_FIELDS = [ - 'title_autocomplete', - 'title_autocomplete._2gram', - 'title_autocomplete._3gram', - 'title_autocomplete._index_prefix', - 'type.autocomplete', - 'type.autocomplete._index_prefix', -] as const; - const SML_SEMANTIC_FIELDS = ['title_semantic', 'description_semantic', 'content_semantic'] as const; const SML_BM25_TEXT_FIELDS = ['title^2', 'description', 'content'] as const; @@ -450,24 +461,10 @@ const searchSml = async ({ { standard: { query: { - bool: { - should: [ - { - multi_match: { - query: trimmed, - type: 'bool_prefix' as const, - fields: [...SML_SEARCH_AS_YOU_TYPE_FIELDS], - }, - }, - { - multi_match: { - query: trimmed, - type: 'best_fields' as const, - fields: [...SML_BM25_TEXT_FIELDS], - }, - }, - ], - minimum_should_match: 1, + multi_match: { + query: trimmed, + type: 'best_fields' as const, + fields: [...SML_BM25_TEXT_FIELDS], }, }, }, @@ -504,6 +501,7 @@ const searchSml = async ({ content: source.content, description: source.description, tags: source.tags, + discovery_labels: source.discovery_labels, payload: source.payload, references: source.references, created_at: source.created_at ?? '', @@ -528,6 +526,211 @@ const searchSml = async ({ } }; +/** + * Pick a highlight snippet from ES's per-subfield highlight object. + * Returns the first non-empty snippet; absent if none. + */ +const pickHighlightSnippet = ( + highlight: Record | undefined +): string | undefined => { + if (!highlight) return undefined; + for (const snippets of Object.values(highlight)) { + if (snippets && snippets.length > 0) { + return snippets[0]; + } + } + return undefined; +}; + +/** + * Build the autocomplete query: a single nested `multi_match bool_prefix` against + * `discovery_labels.value` (SAYT) and its auto-generated `_2gram` / `_3gram` + * subfields, with `inner_hits` to surface which entries matched (with their + * `kind`). Title and type are reachable through this surface because the + * indexer auto-prepends them to `discovery_labels`. + * + * `bool_prefix` is SAYT's native query type: all-but-last analyzed tokens are + * required to match as exact indexed terms (against the bigram/trigram shingle + * subfields), and the last token is required to match as a prefix (against + * `_index_prefix`). With `operator: and` every typed token must contribute — + * including the trailing partial. This yields tight per-token semantics: + * `"github c"` matches `"GitHub Connector"` but not `"Githubster Cup"` + * (because `"github"` is not an indexed token of `"Githubster"`). + * + * Known limitation: ES does not produce useful highlight snippets for + * SAYT + `bool_prefix` + nested + inner_hits (bug + * elastic/elasticsearch#53744, open since 2020). The highlight config below + * is retained so the route is forward-compatible once the bug is fixed; until + * then, `matched_discovery_labels` entries are returned without `highlighted` + * and the UI renders plain `value`. + * + * After trim: empty string or `*` → `match_all`. + */ +const buildSmlAutocompleteQuery = (query: string): Record => { + const trimmed = query.trim(); + if (trimmed === '' || trimmed === '*') { + return { match_all: {} }; + } + return { + nested: { + path: 'discovery_labels', + query: { + multi_match: { + query: trimmed, + type: 'bool_prefix', + operator: 'and', + fields: [ + 'discovery_labels.value', + 'discovery_labels.value._2gram', + 'discovery_labels.value._3gram', + ], + }, + }, + inner_hits: { + _source: ['discovery_labels.value', 'discovery_labels.kind'], + size: 10, + highlight: { + type: 'unified', + number_of_fragments: 0, + pre_tags: [''], + post_tags: [''], + // HTML-encode the source text so literal `<`/`>`/`&` in user content + // don't collide with the `` wrappers when rendered. No-op while + // #53744 keeps SAYT+nested highlight broken; correct once it lands. + encoder: 'html', + fields: { + 'discovery_labels.value': {}, + }, + }, + }, + }, + }; +}; + +/** + * Autocomplete the SML index. Prefix-only, with per-row provenance for the @ menu. + */ +const autocompleteSml = async ({ + query, + size, + spaceId, + esClient, + logger, + filters, +}: { + query: string; + size: number; + spaceId: string; + esClient: IScopedClusterClient; + logger: Logger; + filters?: SmlSearchFilters; +}): Promise<{ results: SmlAutocompleteResult[]; total: number }> => { + logger.debug( + `SML autocomplete: query=${JSON.stringify( + query + )}, size=${size}, spaceId='${spaceId}', index='${smlIndexName}'` + ); + + try { + const smlQuery = buildSmlAutocompleteQuery(query); + + const typeFilter = buildTypeFilters(filters); + const filterClauses: Array> = [ + { + bool: { + should: [{ term: { spaces: spaceId } }, { term: { spaces: '*' } }], + minimum_should_match: 1, + }, + }, + ]; + if (typeFilter) { + filterClauses.push(typeFilter); + } + + const response = await esClient.asInternalUser.search({ + index: smlIndexName, + size, + allow_no_indices: true, + ignore_unavailable: true, + query: { + bool: { + must: [smlQuery], + filter: filterClauses, + }, + }, + _source: ['id', 'type', 'title', 'origin_id', 'spaces', 'permissions'], + }); + + const total = + typeof response.hits.total === 'number' + ? response.hits.total + : response.hits.total?.value ?? 0; + + const results: SmlAutocompleteResult[] = response.hits.hits + .filter((hit) => hit._source != null) + .map((hit) => { + const source = hit._source!; + const result: SmlAutocompleteResult = { + id: source.id ?? '', + type: source.type ?? '', + title: source.title ?? '', + origin_id: source.origin_id ?? '', + spaces: source.spaces ?? [], + permissions: source.permissions ?? [], + }; + // Inner hits from the nested discovery_labels query: the specific entries + // that matched, with their ES-generated highlight snippet wrapping the + // matched span(s) in .... + const innerHits = ( + hit as { + inner_hits?: Record< + string, + { + hits: { + hits: Array<{ + _source: { value?: string; kind?: string }; + highlight?: Record; + }>; + }; + } + >; + } + ).inner_hits; + const labelHits = innerHits?.discovery_labels?.hits?.hits; + if (labelHits && labelHits.length > 0) { + const matched: MatchedDiscoveryLabel[] = labelHits + .filter((h) => h._source?.value != null && h._source?.kind != null) + .map((h) => { + const entry: MatchedDiscoveryLabel = { + value: h._source.value!, + kind: h._source.kind!, + }; + const snippet = pickHighlightSnippet(h.highlight); + if (snippet) { + entry.highlighted = snippet; + } + return entry; + }); + if (matched.length > 0) { + result.matched_discovery_labels = matched; + } + } + return result; + }); + + logger.debug(`SML autocomplete: returned ${results.length} result(s), total=${total}`); + + return { results, total }; + } catch (error) { + if (isNotFoundError(error)) { + logger.debug('SML index does not exist yet — returning empty autocomplete results'); + return { results: [], total: 0 }; + } + logger.warn(`SML autocomplete failed: ${(error as Error).message}`); + throw error; + } +}; + /** * Fetch SML documents by their chunk IDs, scoped to a space. */ @@ -586,6 +789,9 @@ const getDocumentsByIds = async ({ if (source.tags !== undefined) { doc.tags = source.tags; } + if (source.discovery_labels !== undefined) { + doc.discovery_labels = source.discovery_labels; + } if (source.payload !== undefined) { doc.payload = source.payload; } diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts index ff961c79c108b..39717e037c53b 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/sml_storage.ts @@ -21,13 +21,8 @@ export const smlIndexName = chatSystemIndex('sml-data'); */ const smlStorageSchemaProperties = { id: types.keyword({}), - type: types.keyword({ - fields: { - autocomplete: types.search_as_you_type({}), - }, - }), - title: types.text({ copy_to: ['title_autocomplete', 'title_semantic'] }), - title_autocomplete: types.search_as_you_type({}), + type: types.keyword({}), + title: types.text({ copy_to: 'title_semantic' }), title_semantic: types.semantic_text({}), origin_id: types.keyword({}), content: types.text({ copy_to: 'content_semantic' }), @@ -35,6 +30,30 @@ const smlStorageSchemaProperties = { description: types.text({ copy_to: 'description_semantic' }), description_semantic: types.semantic_text({}), tags: types.keyword({}), + /** + * Autocomplete surface. The indexer auto-prepends two entries on every record: + * { value: chunk.title, kind: 'title' } + * { value: chunk.type, kind: 'type' } + * plus any entries the producer provides (taglines, nicknames, categories, etc.). + * The @ menu queries `discovery_labels.value` with `multi_match bool_prefix` + * (SAYT's native query type) and reads `inner_hits` to render which entry + * matched, with `kind`-driven UI badging. + * + * `discovery_labels.value` is `search_as_you_type`. ES auto-generates the + * `_2gram`, `_3gram`, and `_index_prefix` subfields used by `bool_prefix`. + * + * Known limitation: ES does not produce useful highlight snippets for + * SAYT + `bool_prefix` queries in a nested context (bug + * elastic/elasticsearch#53744, open since 2020). `matched_discovery_labels` + * entries are returned without `highlighted`; the UI falls back to rendering + * plain `value` for those entries. + */ + discovery_labels: types.nested({ + properties: { + value: types.search_as_you_type({}), + kind: types.keyword({}), + }, + }), references: types.keyword({}), payload: types.flattened({}), user_id: types.keyword({}), diff --git a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts index 2f9a7a413ead4..3e1b1c475f6ed 100644 --- a/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts +++ b/x-pack/platform/plugins/shared/agent_context_layer/server/services/sml/types.ts @@ -15,6 +15,21 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { AttachmentInput } from '@kbn/agent-builder-common/attachments'; import type { SmlSearchFilters } from '../../../common/http_api/sml'; +/** + * One entry in {@link SmlChunk.discovery_labels}. `value` is what the autocomplete + * matches against; `kind` describes how the UI should render the matched label. + * + * `kind` is open (free-form keyword at the ES level). The indexer auto-prepends + * entries with `kind: 'title'` and `kind: 'type'` derived from the chunk's title + * and type fields. Producers can add additional entries with any kind (e.g. + * 'tagline', 'nickname', 'category', 'synonym') — the UI decides how to render + * each kind. + */ +export interface DiscoveryLabel { + value: string; + kind: string; +} + /** * A single SML chunk to be indexed. */ @@ -29,6 +44,22 @@ export interface SmlChunk { description?: string; /** Free-form labels for filtering and discovery */ tags?: string[]; + /** + * Categorical / nickname terms that make this record discoverable beyond `type` + * and `title`. Each label carries a `kind` so the UI can render it appropriately + * (e.g. as a tagline, nickname, category, or synonym). Indexed as a nested field; + * the autocomplete surface queries `discovery_labels.value` (SAYT) with + * `multi_match bool_prefix` and uses `inner_hits` to surface which entry + * matched. + * + * Example for a GitHub connector: + * [ + * { value: 'github', kind: 'tagline' }, + * { value: 'gh', kind: 'nickname' }, + * { value: 'version control', kind: 'category' }, + * ] + */ + discovery_labels?: DiscoveryLabel[]; /** * Type-specific structured data. Stored as `flattened` so leaves are * keyword-searchable for sub-path filtering. SML treats this opaquely; @@ -137,6 +168,12 @@ export interface SmlDocument { description?: string; /** Free-form labels */ tags?: string[]; + /** + * Categorical / nickname terms beyond `type` and `title`. + * Nested entries `{ value, kind }`; `value.autocomplete` is the SAYT subfield + * that powers the @ menu, and `kind` drives UI badge rendering. + */ + discovery_labels?: DiscoveryLabel[]; /** Type-specific structured data (`flattened` mapping) */ payload?: Record; /** Owner or last-modifier user id */ @@ -162,6 +199,44 @@ export type SmlSearchResult = Omit & { score: number; }; +/** + * One `discovery_labels` nested entry that matched an autocomplete prefix query. + * Surfaced via `inner_hits`. + */ +export interface MatchedDiscoveryLabel { + value: string; + kind: string; + /** + * The matched span within `value`, wrapped in `...` tags. Present + * when ES returned a highlight snippet for this inner hit; absent if not. + * Example: typed prefix `"git"` against value `"github"` produces `"github"`. + */ + highlighted?: string; +} + +/** + * An SML autocomplete result — narrower than {@link SmlSearchResult}, tuned for + * @ menu / typeahead rendering. Drops bulk content (`content`, `description`, + * `payload`, etc.) and surfaces per-row provenance. + */ +export interface SmlAutocompleteResult { + id: string; + type: string; + title: string; + origin_id: string; + /** Used server-side for permission filtering; not exposed in the HTTP response. */ + permissions: string[]; + /** Used server-side for space filtering; not exposed in the HTTP response. */ + spaces: string[]; + /** + * The specific `discovery_labels` entries that matched the typed prefix. + * `kind` lets the UI render each label appropriately — e.g. for a hit on the + * record's title vs. on a producer-supplied tagline, the UI can decide whether + * (and how) to surface the matched span. + */ + matched_discovery_labels?: MatchedDiscoveryLabel[]; +} + /** * Crawler state document stored in the crawler state index. */ @@ -224,6 +299,25 @@ export interface SmlService { filters?: SmlSearchFilters; }) => Promise<{ results: SmlSearchResult[]; total: number }>; + /** + * Autocomplete / typeahead against the SML index. A single nested + * `multi_match bool_prefix operator: and` against `discovery_labels.value` + * (search_as_you_type) and its `_2gram` / `_3gram` subfields. Returns per-row + * provenance for UI badges. Filters by space and permissions the same way + * as `search`, and accepts the same per-type `filters` (e.g. agent-centric + * connector allow-list) so consumers can use one filter builder for both + * routes. + */ + autocomplete: (params: { + query: string; + size?: number; + spaceId: string; + esClient: IScopedClusterClient; + request: KibanaRequest; + /** Per-type filters. See {@link SmlSearchFilters}. */ + filters?: SmlSearchFilters; + }) => Promise<{ results: SmlAutocompleteResult[]; total: number }>; + /** * Check whether the current user has access to specific SML items. * Returns a map of document id → authorized (true/false). diff --git a/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts b/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts index 10b98303fb5ad..dcbaa5a5e7102 100644 --- a/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts +++ b/x-pack/platform/test/agent_builder_api_integration/apis/sml.ts @@ -9,7 +9,10 @@ import expect from '@kbn/expect'; import { v4 as uuidv4 } from 'uuid'; import { smlElasticsearchIndexMappings, smlIndexName } from '@kbn/agent-builder-plugin/server'; import type { SmlAttachHttpResponse } from '@kbn/agent-builder-plugin/common/http_api/sml'; -import type { SmlSearchHttpResponse } from '@kbn/agent-context-layer-plugin/common/http_api/sml'; +import type { + SmlSearchHttpResponse, + SmlAutocompleteHttpResponse, +} from '@kbn/agent-context-layer-plugin/common/http_api/sml'; import type { FtrProviderContext } from '../../api_integration/ftr_provider_context'; import { createLlmProxy, type LlmProxy } from '../utils/llm_proxy'; import { setupAgentDirectAnswer } from '../utils/proxy_scenario'; @@ -30,10 +33,9 @@ export default function ({ getService }: FtrProviderContext) { describe('POST /internal/agent_context_layer/sml/_search', () => { const runId = uuidv4(); - const chunkId = `sml-ftr-autocomplete-${runId}`; + const chunkId = `sml-ftr-search-${runId}`; const originId = `sml-ftr-origin-${runId}`; - /** Distinctive title tokens for prefix search (SAYT + bool_prefix). */ - const indexedTitle = `sml ftr autocomplete pacific bluefin ${runId}`; + const indexedTitle = `sml ftr search pacific bluefin ${runId}`; before(async () => { const exists = await es.indices.exists({ index: smlIndexName }); @@ -75,12 +77,12 @@ export default function ({ getService }: FtrProviderContext) { } }); - it('returns a hit when query matches a partial word in the title (autocomplete)', async () => { + it('returns a hit when a full-term query matches the title (BM25)', async () => { const response = await supertest .post('/internal/agent_context_layer/sml/_search') .set('kbn-xsrf', 'kibana') .set('x-elastic-internal-origin', 'kibana') - .send({ query: 'pacif', size: 20 }) + .send({ query: 'pacific', size: 20 }) .expect(200); const body = response.body as SmlSearchHttpResponse; @@ -144,6 +146,147 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('POST /internal/agent_context_layer/sml/_autocomplete', () => { + const runId = uuidv4(); + const chunkId = `sml-ftr-autocomp-${runId}`; + const originId = `sml-ftr-ac-origin-${runId}`; + const recordType = `ftrtype${runId.replace(/-/g, '').slice(0, 8)}`; + // Distinct tokens chosen so the prefix queries below match only this record. + const titleValue = `unicornsprocket ${runId}`; + const taglineValue = `ferromagnetic-${runId}`; + + before(async () => { + const exists = await es.indices.exists({ index: smlIndexName }); + if (!exists) { + await es.indices.create({ + index: smlIndexName, + mappings: smlElasticsearchIndexMappings, + }); + } + const now = '2024-06-01T12:00:00.000Z'; + await es.index({ + index: smlIndexName, + id: chunkId, + refresh: 'wait_for', + document: { + id: chunkId, + type: recordType, + title: titleValue, + origin_id: originId, + content: `autocomplete content for ${runId}`, + // Indexer auto-prepends title + type into discovery_labels at write + // time. Here we index via raw `es.index` so we mirror that shape + // explicitly to exercise the route end-to-end. + discovery_labels: [ + { value: titleValue, kind: 'title' }, + { value: recordType, kind: 'type' }, + { value: taglineValue, kind: 'tagline' }, + ], + created_at: now, + updated_at: now, + spaces: ['default'], + permissions: [], + }, + }); + }); + + after(async () => { + try { + await es.delete({ index: smlIndexName, id: chunkId, refresh: true }); + } catch { + // ignore cleanup failures + } + }); + + it('matches a short prefix against the auto-prepended title label', async () => { + const response = await supertest + .post('/internal/agent_context_layer/sml/_autocomplete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ query: 'unicorn', size: 10 }) + .expect(200); + + const body = response.body as SmlAutocompleteHttpResponse; + const match = body.results.find((r) => r.id === chunkId); + expect(match).to.be.ok(); + expect(match!.type).to.be(recordType); + expect(match!.title).to.be(titleValue); + expect(match!.origin_id).to.be(originId); + + const titleLabel = match!.matched_discovery_labels?.find((l) => l.kind === 'title'); + expect(titleLabel).to.be.ok(); + expect(titleLabel!.value).to.be(titleValue); + // `highlighted` is omitted here because SAYT + bool_prefix + nested + // inner_hits doesn't return useful highlight snippets in current ES + // (bug elastic/elasticsearch#53744). The route is forward-compatible: + // UI handles `highlighted` when present, plain `value` otherwise. + expect(titleLabel!.highlighted).to.be(undefined); + }); + + it('matches a producer-supplied tagline label and surfaces its kind', async () => { + const response = await supertest + .post('/internal/agent_context_layer/sml/_autocomplete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ query: 'ferro', size: 10 }) + .expect(200); + + const body = response.body as SmlAutocompleteHttpResponse; + const match = body.results.find((r) => r.id === chunkId); + expect(match).to.be.ok(); + const taglineLabel = match!.matched_discovery_labels?.find((l) => l.kind === 'tagline'); + expect(taglineLabel).to.be.ok(); + expect(taglineLabel!.value).to.be(taglineValue); + expect(taglineLabel!.highlighted).to.be(undefined); + }); + + it('matches a prefix of the auto-prepended type label', async () => { + const typePrefix = recordType.slice(0, 5); + const response = await supertest + .post('/internal/agent_context_layer/sml/_autocomplete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ query: typePrefix, size: 10 }) + .expect(200); + + const body = response.body as SmlAutocompleteHttpResponse; + const match = body.results.find((r) => r.id === chunkId); + expect(match).to.be.ok(); + const typeLabel = match!.matched_discovery_labels?.find((l) => l.kind === 'type'); + expect(typeLabel).to.be.ok(); + expect(typeLabel!.value).to.be(recordType); + }); + + it('returns the result with the expected shape (no content, no permissions)', async () => { + const response = await supertest + .post('/internal/agent_context_layer/sml/_autocomplete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ query: 'unicorn', size: 10 }) + .expect(200); + + const body = response.body as SmlAutocompleteHttpResponse; + const match = body.results.find((r) => r.id === chunkId); + expect(match).to.be.ok(); + // Autocomplete responses are deliberately narrow — these belong to + // /sml/_search, not /sml/_autocomplete. + expect(match).not.to.have.property('content'); + expect(match).not.to.have.property('description'); + expect(match).not.to.have.property('permissions'); + expect(match).not.to.have.property('spaces'); + expect(match).not.to.have.property('score'); + }); + + it('rejects an empty query string', async () => { + await supertest + .post('/internal/agent_context_layer/sml/_autocomplete') + .set('kbn-xsrf', 'kibana') + .set('x-elastic-internal-origin', 'kibana') + .send({ query: '' }) + .expect(400); + }); + }); + describe('POST /internal/agent_builder/sml/_attach', () => { let llmProxy: LlmProxy; let connectorId: string;