Skip to content
Merged
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
@@ -0,0 +1,56 @@
/*
* 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 {
RECOMMENDED_CONNECTOR_IDS,
isRecommendedConnector,
getFirstRecommendedConnectorId,
} from './recommended_connectors';

describe('recommended_connectors', () => {
describe('RECOMMENDED_CONNECTOR_IDS', () => {
it('includes expected SOTA per provider and open-weight connector IDs', () => {
expect(RECOMMENDED_CONNECTOR_IDS).toContain('Anthropic-Claude-Sonnet-4-5');
expect(RECOMMENDED_CONNECTOR_IDS).toContain('OpenAI-GPT-5-2');
expect(RECOMMENDED_CONNECTOR_IDS).toContain('Google-Gemini-2-5-Pro');
expect(RECOMMENDED_CONNECTOR_IDS).toContain('OpenAI-GPT-OSS-120B');
});
});

describe('isRecommendedConnector', () => {
it('returns true for IDs in the recommended list', () => {
expect(isRecommendedConnector('Anthropic-Claude-Sonnet-4-5')).toBe(true);
expect(isRecommendedConnector('OpenAI-GPT-OSS-120B')).toBe(true);
});

it('returns false for IDs not in the recommended list', () => {
expect(isRecommendedConnector('custom-connector-id')).toBe(false);
expect(isRecommendedConnector('Elastic-Managed-LLM')).toBe(false);
});
});

describe('getFirstRecommendedConnectorId', () => {
it('returns the first recommended ID present in the list (order by RECOMMENDED_CONNECTOR_IDS)', () => {
const connectorIds = ['Google-Gemini-2-5-Pro', 'OpenAI-GPT-5-2', 'custom'];
expect(getFirstRecommendedConnectorId(connectorIds)).toBe('OpenAI-GPT-5-2');
});

it('returns the only recommended ID when one is present', () => {
expect(getFirstRecommendedConnectorId(['other', 'OpenAI-GPT-OSS-120B', 'foo'])).toBe(
'OpenAI-GPT-OSS-120B'
);
});

it('returns undefined when no recommended connector is in the list', () => {
expect(getFirstRecommendedConnectorId(['custom-1', 'custom-2'])).toBeUndefined();
});

it('returns undefined for empty list', () => {
expect(getFirstRecommendedConnectorId([])).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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.
*/

export const RECOMMENDED_CONNECTOR_IDS: readonly string[] = [
'Anthropic-Claude-Sonnet-4-5',
'OpenAI-GPT-5-2',
'Google-Gemini-2-5-Pro',
'OpenAI-GPT-OSS-120B',
] as const;

const RECOMMENDED_SET = new Set<string>(RECOMMENDED_CONNECTOR_IDS);

export function isRecommendedConnector(connectorId: string): boolean {
return RECOMMENDED_SET.has(connectorId);
}

export function getFirstRecommendedConnectorId(connectorIds: string[]): string | undefined {
const idSet = new Set(connectorIds);
for (const id of RECOMMENDED_CONNECTOR_IDS) {
if (idSet.has(id)) return id;
}
return undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useNavigation } from '../../../../../hooks/use_navigation';
import { useSendMessage } from '../../../../../context/send_message/send_message_context';
import { useDefaultConnector } from '../../../../../hooks/chat/use_default_connector';
import { useKibana } from '../../../../../hooks/use_kibana';
import { isRecommendedConnector } from '../../../../../../../common/recommended_connectors';
import {
getMaxListHeight,
selectorPopoverPanelStyles,
Expand All @@ -46,6 +47,21 @@ const defaultConnectorLabel = i18n.translate(
}
);

const recommendedSectionLabel = i18n.translate(
'xpack.agentBuilder.conversationInput.connectorSelector.recommendedSectionLabel',
{ defaultMessage: 'Recommended' }
);

const otherSectionLabel = i18n.translate(
'xpack.agentBuilder.conversationInput.connectorSelector.otherSectionLabel',
{ defaultMessage: 'Other' }
);

const customSectionLabel = i18n.translate(
'xpack.agentBuilder.conversationInput.connectorSelector.customSectionLabel',
{ defaultMessage: 'Custom' }
);

const connectorSelectId = 'agentBuilderConnectorSelect';
const connectorListId = `${connectorSelectId}_listbox`;
const CONNECTOR_OPTION_ROW_HEIGHT = 32;
Expand Down Expand Up @@ -150,26 +166,65 @@ export const ConnectorSelector: React.FC<{}> = () => {

const connectors = useMemo(() => aiConnectors ?? [], [aiConnectors]);

const { recommendedConnectors, otherConnectors, customConnectors } = useMemo(() => {
const recommended = connectors.filter((c) => isRecommendedConnector(c.id));
const notRecommended = connectors.filter((c) => !isRecommendedConnector(c.id));
return {
recommendedConnectors: recommended,
otherConnectors: notRecommended.filter((c) => c.isPreconfigured),
customConnectors: notRecommended.filter((c) => !c.isPreconfigured),
};
}, [connectors]);

const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
const closePopover = () => setIsPopoverOpen(false);

const connectorOptions = useMemo(() => {
const options = connectors.map((connector) => {
let checked: 'on' | undefined;
if (connector.id === selectedConnectorId) {
checked = 'on';
}
const option: ConnectorOptionData = {
key: connector.id,
label: connector.name,
checked,
prepend: <ConnectorIcon connectorName={connector.name} />,
append: connector.id === defaultConnectorId ? <DefaultConnectorBadge /> : undefined,
};
return option;
const toOption = (connector: (typeof connectors)[0]): ConnectorOptionData => ({
key: connector.id,
label: connector.name,
checked: connector.id === selectedConnectorId ? 'on' : undefined,
prepend: <ConnectorIcon connectorName={connector.name} />,
append: connector.id === defaultConnectorId ? <DefaultConnectorBadge /> : undefined,
});
return options;
}, [connectors, selectedConnectorId, defaultConnectorId]);
const groupLabel = (label: string, dataTestSubj: string): ConnectorOptionData =>
({
label,
isGroupLabel: true as const,
'data-test-subj': dataTestSubj,
} as ConnectorOptionData);

const recommendedOptions = recommendedConnectors.map(toOption);
const otherOptions = otherConnectors.map(toOption);
const customOptions = customConnectors.map(toOption);

const sections: ConnectorOptionData[] = [];
if (recommendedConnectors.length > 0) {
sections.push(
groupLabel(recommendedSectionLabel, 'connectorSelectorSectionHeader-recommended'),
...recommendedOptions
);
}
if (otherConnectors.length > 0) {
sections.push(
groupLabel(otherSectionLabel, 'connectorSelectorSectionHeader-other'),
...otherOptions
);
}
if (customConnectors.length > 0) {
sections.push(
groupLabel(customSectionLabel, 'connectorSelectorSectionHeader-custom'),
...customOptions
);
}
return sections;
}, [
recommendedConnectors,
otherConnectors,
customConnectors,
selectedConnectorId,
defaultConnectorId,
]);

const initialConnectorId = useDefaultConnector({
connectors,
Expand Down Expand Up @@ -221,13 +276,15 @@ export const ConnectorSelector: React.FC<{}> = () => {
options={connectorOptions}
onChange={(_options, _event, changedOption) => {
const { checked, key: connectorId } = changedOption;
const isChecked = checked === 'on';
if (isChecked && connectorId) {
if (checked === 'on' && connectorId) {
onSelectConnector(connectorId);
closePopover();
}
}}
renderOption={(option) => {
if (option.isGroupLabel) {
return <OptionText key={option.label}>{option.label}</OptionText>;
}
const { key: connectorId, label: connectorName } = option;
return (
<ConnectorOption
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export const useSelectorListStyles = ({ listId }: { listId: string }) => {
text-decoration: none;
}
}
&#${listId} .euiSelectableList__groupLabel {
min-height: 32px;
cursor: default;
:hover {
background-color: unset;
}
}
`;
const selectedItemStyles = css`
&#${listId} .euiSelectableListItem-isFocused {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 { AIConnector } from '@kbn/elastic-assistant';
import { renderHook } from '@testing-library/react';
import { useDefaultConnector } from './use_default_connector';

const createConnector = (id: string, isPreconfigured = false): AIConnector =>
({
id,
name: id,
isPreconfigured,
isMissingSecrets: false,
actionTypeId: '.gen-ai',
secrets: {},
isDeprecated: false,
isSystemAction: false,
config: {},
isConnectorTypeDeprecated: false,
} as AIConnector);

describe('useDefaultConnector', () => {
it('returns GenAI default when set and available', () => {
const connectors = [
createConnector('Anthropic-Claude-Sonnet-4-5', true),
createConnector('custom'),
];
const { result } = renderHook(() =>
useDefaultConnector({
connectors,
defaultConnectorId: 'custom',
})
);
expect(result.current).toBe('custom');
});

it('returns first recommended connector when no GenAI default and recommended available', () => {
const connectors = [
createConnector('Google-Gemini-2-5-Pro'),
createConnector('Anthropic-Claude-Sonnet-4-5'),
];
const { result } = renderHook(() =>
useDefaultConnector({ connectors, defaultConnectorId: undefined })
);
expect(result.current).toBe('Anthropic-Claude-Sonnet-4-5');
});

it('returns first preconfigured connector when no GenAI default and no recommended', () => {
const connectors = [
createConnector('custom-connector'),
createConnector('Google-Gemini-2-5-Pro', true),
];
const { result } = renderHook(() =>
useDefaultConnector({ connectors, defaultConnectorId: undefined })
);
expect(result.current).toBe('Google-Gemini-2-5-Pro');
});

it('returns first connector when no preconfigured and no default', () => {
const connectors = [createConnector('custom-1'), createConnector('custom-2')];
const { result } = renderHook(() =>
useDefaultConnector({ connectors, defaultConnectorId: undefined })
);
expect(result.current).toBe('custom-1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { useMemo } from 'react';
import type { AIConnector } from '@kbn/elastic-assistant';
import { PREFERRED_DEFAULT_CONNECTOR_ID } from '../../../../common/constants';
import { getFirstRecommendedConnectorId } from '../../../../common/recommended_connectors';

interface UseDefaultConnectorParams {
connectors: AIConnector[];
Expand All @@ -28,19 +29,25 @@ export function useDefaultConnector({
return defaultConnectorId;
}

// 2. If no default, prefer the preconfigured Claude Sonnet 4.5 connector when available
// 2. Prefer the first recommended connector when available (SOTA per provider + open-weight)
const recommendedId = getFirstRecommendedConnectorId(connectors.map((c) => c.id));
if (recommendedId) {
return recommendedId;
}

// 3. If not recommended, prefer the preconfigured Claude Sonnet 4.5 connector when available
const preferredConnector = connectors.find((c) => c.id === PREFERRED_DEFAULT_CONNECTOR_ID);
if (preferredConnector) {
return preferredConnector.id;
}

// 3. Otherwise use the first preconfigured connector (Elastic-managed LLM)
// 4. Otherwise use the first preconfigured connector (Elastic-managed LLM)
const preconfiguredConnector = connectors.find((c) => c.isPreconfigured);
if (preconfiguredConnector) {
return preconfiguredConnector.id;
}

// 4. If no preconfigured connector, use the first custom connector
// 5. If no preconfigured connector, use the first custom connector
return connectors[0]?.id;
}, [connectors, defaultConnectorId]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,31 @@ describe('resolveSelectedConnectorId', () => {
expect(inference.getConnectorList).not.toHaveBeenCalled();
});

it('prefers first recommended connector over inference when falling back to connector list', async () => {
const { savedObjects, uiSettings, request } = setupCoreMocks({
[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]: 'NO_DEFAULT_CONNECTOR',
[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY]: false,
});
const inference = inferenceMock.createStartContract();
(inference.getDefaultConnector as jest.Mock).mockRejectedValue(new Error('no default'));
(inference.getConnectorList as jest.Mock).mockResolvedValue([
{ connectorId: 'inference-id', type: InferenceConnectorType.Inference } as InferenceConnector,
{
connectorId: 'Google-Gemini-2-5-Pro',
type: InferenceConnectorType.Gemini,
} as InferenceConnector,
]);

const result = await resolveSelectedConnectorId({
uiSettings,
savedObjects,
request,
inference,
});

expect(result).toBe('Google-Gemini-2-5-Pro');
});

it('prefers Anthropic-Claude-Sonnet-4-5 when available in connector list', async () => {
const { savedObjects, uiSettings, request } = setupCoreMocks({
[GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR]: 'NO_DEFAULT_CONNECTOR',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ import {
GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY,
} from '@kbn/management-settings-ids';
import { PREFERRED_DEFAULT_CONNECTOR_ID } from '../../common/constants';
import { getFirstRecommendedConnectorId } from '../../common/recommended_connectors';

// TODO: Import from gen-ai-settings-plugin (package) once available
const NO_DEFAULT_CONNECTOR = 'NO_DEFAULT_CONNECTOR';

const selectDefaultConnector = ({ connectors }: { connectors: InferenceConnector[] }) => {
const recommendedId = getFirstRecommendedConnectorId(connectors.map((c) => c.connectorId));
if (recommendedId) {
const recommendedConnector = connectors.find((c) => c.connectorId === recommendedId);
if (recommendedConnector) return recommendedConnector;
}

const preferredConnector = connectors.find(
(connector) => connector.connectorId === PREFERRED_DEFAULT_CONNECTOR_ID
);
Expand Down
Loading