diff --git a/config/serverless.es.yml b/config/serverless.es.yml index 970c70e8e11de..ebc0b15047006 100644 --- a/config/serverless.es.yml +++ b/config/serverless.es.yml @@ -107,6 +107,7 @@ xpack.searchAssistant.enabled: true xpack.searchAssistant.ui.enabled: true xpack.observabilityAIAssistant.scope: "search" aiAssistantManagementSelection.preferredAIAssistantType: "observability" +aiAssistantManagementSelection.preferredChatExperience: "agent" # Query Rules UI xpack.searchQueryRules.enabled: true diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/public/hooks/use_is_nav_control_visible.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/hooks/use_is_nav_control_visible.tsx index 4e82d1831e1fb..7e553e07699b6 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/hooks/use_is_nav_control_visible.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/hooks/use_is_nav_control_visible.tsx @@ -62,8 +62,7 @@ export function useIsNavControlVisible(coreStart: CoreStart, spaces?: SpacesPlug ); const chatExperience$ = coreStart.settings.client.get$( - PREFERRED_CHAT_EXPERIENCE_SETTING_KEY, - AIChatExperience.Classic + PREFERRED_CHAT_EXPERIENCE_SETTING_KEY ); const activeSpace$ = useMemo( diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx index 6cbab51bec90e..0ce287c31baeb 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx @@ -183,10 +183,7 @@ export class AIAssistantManagementPlugin if (!this.isServerless && licensing) { this.managementAppVisibilitySubscription = combineLatest([ licensing.license$, - coreStart.settings.client.get$( - PREFERRED_CHAT_EXPERIENCE_SETTING_KEY, - AIChatExperience.Classic - ), + coreStart.settings.client.get$(PREFERRED_CHAT_EXPERIENCE_SETTING_KEY), ]).subscribe(([license, chatExperience]) => { const isEnterprise = license?.hasAtLeast('enterprise'); diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.test.ts b/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.test.ts index 7818b230ec37f..fd46a90186226 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.test.ts +++ b/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.test.ts @@ -7,8 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { PluginInitializerContext, CoreSetup } from '@kbn/core/server'; -import type { AIAssistantManagementSelectionPluginServerDependenciesSetup } from './types'; +import type { PluginInitializerContext } from '@kbn/core/server'; +import { coreMock } from '@kbn/core/server/mocks'; +import { spacesMock } from '@kbn/spaces-plugin/server/mocks'; +import type { Space } from '@kbn/spaces-plugin/common'; +import type { AIAssistantManagementSelectionConfig } from './config'; import { AIChatExperience } from '@kbn/ai-assistant-common'; import { AIAssistantType } from '../common/ai_assistant_type'; import { @@ -24,43 +27,69 @@ describe('plugin', () => { jest.clearAllMocks(); }); - describe('stateful', () => { - it('uses the correct setting keys to register both AI assistant type and chat experience settings', async () => { - const initializerContext = { - env: { - packageInfo: { - buildFlavor: 'classic', - }, - }, - config: { - get: jest.fn().mockReturnValue({ - preferredAIAssistantType: AIAssistantType.Observability, - preferredChatExperience: AIChatExperience.Classic, - }), + const createPlugin = (config: Partial = {}) => { + const initializerContext = { + env: { + packageInfo: { + buildFlavor: 'classic', }, - } as unknown as PluginInitializerContext; - const aiAssistantManagementSelectionPlugin = new AIAssistantManagementSelectionPlugin( - initializerContext - ); + }, + config: { + get: jest.fn().mockReturnValue(config), + }, + logger: { + get: jest.fn().mockReturnValue({ + error: jest.fn(), + }), + }, + } as unknown as PluginInitializerContext; + return new AIAssistantManagementSelectionPlugin(initializerContext); + }; - const coreSetup = { - uiSettings: { - register: jest.fn(), + const setupPlugin = ( + plugin: AIAssistantManagementSelectionPlugin, + options: { + spaces?: ReturnType; + coreStart?: Partial<{ security: { authc: { getCurrentUser: jest.Mock } } }>; + } = {} + ) => { + const coreSetup = coreMock.createSetup(); + const spaces = options.spaces ?? spacesMock.createStart(); + const coreStart = options.coreStart ?? { + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue({ username: 'test-user' }), }, - capabilities: { - registerProvider: jest.fn(), - }, - } as unknown as CoreSetup; + }, + }; + coreSetup.getStartServices.mockResolvedValue([coreStart as any, { spaces }, {} as any]); - const setupDeps = { - management: { - sections: { - getSection: jest.fn(), - }, + const setupDeps = { + management: { + sections: { + getSection: jest.fn(), }, - } as unknown as AIAssistantManagementSelectionPluginServerDependenciesSetup; + }, + } as any; + + plugin.setup(coreSetup, setupDeps); + return { coreSetup, spaces }; + }; - aiAssistantManagementSelectionPlugin.setup(coreSetup, setupDeps); + const getChatExperienceGetValue = (coreSetup: ReturnType) => { + const registeredSettings = coreSetup.uiSettings.register.mock.calls.find( + (call) => call[0][PREFERRED_CHAT_EXPERIENCE_SETTING_KEY] + ); + return registeredSettings![0][PREFERRED_CHAT_EXPERIENCE_SETTING_KEY].getValue; + }; + + describe('stateful', () => { + it('registers both AI assistant type and chat experience settings', async () => { + const plugin = createPlugin({ + preferredAIAssistantType: AIAssistantType.Observability, + preferredChatExperience: AIChatExperience.Classic, + }); + const { coreSetup } = setupPlugin(plugin); expect(coreSetup.uiSettings.register).toHaveBeenCalledTimes(2); @@ -72,12 +101,71 @@ describe('plugin', () => { }, }); - // Second call: Chat Experience setting - expect(coreSetup.uiSettings.register).toHaveBeenNthCalledWith(2, { - [PREFERRED_CHAT_EXPERIENCE_SETTING_KEY]: { - ...chatExperienceSetting, - value: AIChatExperience.Classic, - }, + // Second call: Chat Experience setting - should have getValue function + const chatExperienceSettingDef = + coreSetup.uiSettings.register.mock.calls[1][0][PREFERRED_CHAT_EXPERIENCE_SETTING_KEY]; + expect(chatExperienceSettingDef).toMatchObject({ + name: chatExperienceSetting.name, + options: chatExperienceSetting.options, + type: chatExperienceSetting.type, + }); + expect(chatExperienceSettingDef.getValue).toBeDefined(); + expect(typeof chatExperienceSettingDef.getValue).toBe('function'); + expect(chatExperienceSettingDef).not.toHaveProperty('value'); + }); + + describe('chat experience getValue()', () => { + it('should return config value when provided', async () => { + const plugin = createPlugin({ preferredChatExperience: AIChatExperience.Agent }); + const { coreSetup } = setupPlugin(plugin); + const getValue = getChatExperienceGetValue(coreSetup); + + await expect(getValue!({ request: {} as any })).resolves.toBe(AIChatExperience.Agent); + }); + + it('should return Classic when no request is provided', async () => { + const plugin = createPlugin(); + const { coreSetup } = setupPlugin(plugin); + const getValue = getChatExperienceGetValue(coreSetup); + + await expect(getValue!()).resolves.toBe(AIChatExperience.Classic); + }); + + it.each([ + { solution: 'es', expected: AIChatExperience.Agent }, + { solution: 'oblt', expected: AIChatExperience.Classic }, + { solution: 'security', expected: AIChatExperience.Classic }, + { solution: 'classic', expected: AIChatExperience.Classic }, + ])( + 'should return $expected when active space solution is "$solution"', + async ({ solution, expected }) => { + const plugin = createPlugin(); + const spaces = spacesMock.createStart(); + const mockSpace: Pick = { solution: solution as any }; + spaces.spacesService.getActiveSpace.mockResolvedValue(mockSpace as Space); + const { coreSetup } = setupPlugin(plugin, { spaces }); + const getValue = getChatExperienceGetValue(coreSetup); + + const requestMock = { + auth: { isAuthenticated: true }, + }; + await expect(getValue!({ request: requestMock as any })).resolves.toBe(expected); + } + ); + + it('should handle error and fallback to Classic', async () => { + const plugin = createPlugin(); + const spaces = spacesMock.createStart(); + spaces.spacesService.getActiveSpace.mockRejectedValue(new Error('something went wrong')); + const { coreSetup } = setupPlugin(plugin, { spaces }); + const getValue = getChatExperienceGetValue(coreSetup); + + const requestMock = { + auth: { isAuthenticated: true }, + }; + await expect(getValue!({ request: requestMock as any })).resolves.toBe( + AIChatExperience.Classic + ); }); }); }); diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts b/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts index 99c251b6c1052..a477e03f3e2d5 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts +++ b/src/platform/plugins/shared/ai_assistant_management/selection/server/plugin.ts @@ -7,7 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + KibanaRequest, + Logger, +} from '@kbn/core/server'; import { AIChatExperience } from '@kbn/ai-assistant-common'; import type { AIAssistantManagementSelectionConfig } from './config'; import type { @@ -34,13 +41,18 @@ export class AIAssistantManagementSelectionPlugin > { private readonly config: AIAssistantManagementSelectionConfig; + private readonly logger: Logger; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.logger = initializerContext.logger.get(); } public setup( - core: CoreSetup, + core: CoreSetup< + AIAssistantManagementSelectionPluginServerDependenciesStart, + AIAssistantManagementSelectionPluginServerStart + >, plugins: AIAssistantManagementSelectionPluginServerDependenciesSetup ) { this.registerUiSettings(core, plugins); @@ -49,7 +61,10 @@ export class AIAssistantManagementSelectionPlugin } private registerUiSettings( - core: CoreSetup, + core: CoreSetup< + AIAssistantManagementSelectionPluginServerDependenciesStart, + AIAssistantManagementSelectionPluginServerStart + >, plugins: AIAssistantManagementSelectionPluginServerDependenciesSetup ) { const { cloud } = plugins; @@ -67,10 +82,36 @@ export class AIAssistantManagementSelectionPlugin // Register chat experience setting for both stateful and serverless (except workplaceai) if (serverlessProjectType !== 'workplaceai') { + // Default Agent for Elasticsearch solution view, Classic for all other cases core.uiSettings.register({ [PREFERRED_CHAT_EXPERIENCE_SETTING_KEY]: { ...chatExperienceSetting, - value: this.config.preferredChatExperience ?? AIChatExperience.Classic, + getValue: async ({ request }: { request?: KibanaRequest } = {}) => { + if (request) { + try { + const [coreStart, startServices] = await core.getStartServices(); + // Avoid security exceptions before login + const user = coreStart.security.authc.getCurrentUser(request); + if (startServices.spaces && user) { + const activeSpace = await startServices.spaces.spacesService.getActiveSpace( + request + ); + if (activeSpace?.solution === 'es') { + return AIChatExperience.Agent; + } + } + } catch (e) { + this.logger.error('Error getting active space:'); + this.logger.error(e); + } + } + + if (this.config.preferredChatExperience) { + return this.config.preferredChatExperience; + } + + return AIChatExperience.Classic; + }, }, }); } diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/server/types.ts b/src/platform/plugins/shared/ai_assistant_management/selection/server/types.ts index 2fb374dfb929e..da4c35e6401c7 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/server/types.ts +++ b/src/platform/plugins/shared/ai_assistant_management/selection/server/types.ts @@ -9,9 +9,11 @@ import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface AIAssistantManagementSelectionPluginServerDependenciesStart {} +export interface AIAssistantManagementSelectionPluginServerDependenciesStart { + spaces?: SpacesPluginStart; +} export interface AIAssistantManagementSelectionPluginServerDependenciesSetup { features?: FeaturesPluginSetup; diff --git a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx index 2848732ed8dbe..9052b5a815068 100644 --- a/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx +++ b/x-pack/platform/plugins/private/gen_ai_settings/public/components/gen_ai_settings_app.tsx @@ -61,6 +61,7 @@ export const GenAiSettingsApp: React.FC = ({ setBreadcrum const currentChatExperience = unsavedChanges[AI_CHAT_EXPERIENCE_TYPE]?.unsavedValue ?? chatExperienceField?.savedValue ?? + chatExperienceField?.defaultValue ?? AIChatExperience.Classic; const isAgentExperience = currentChatExperience === AIChatExperience.Agent; diff --git a/x-pack/platform/plugins/shared/onechat/public/components/nav_control/lazy_onechat_nav_control.tsx b/x-pack/platform/plugins/shared/onechat/public/components/nav_control/lazy_onechat_nav_control.tsx index 56082acc920b9..cdd6449510df8 100644 --- a/x-pack/platform/plugins/shared/onechat/public/components/nav_control/lazy_onechat_nav_control.tsx +++ b/x-pack/platform/plugins/shared/onechat/public/components/nav_control/lazy_onechat_nav_control.tsx @@ -34,7 +34,7 @@ export const OnechatNavControlInitiator = ({ useEffect(() => { const sub = coreStart.settings.client - .get$(AI_CHAT_EXPERIENCE_TYPE, AIChatExperience.Classic) + .get$(AI_CHAT_EXPERIENCE_TYPE) .subscribe((chatExperience) => { setIsAgentsExperience(chatExperience === AIChatExperience.Agent); }); diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/is_nav_control_visible.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/is_nav_control_visible.tsx index f908542f8bd89..94eb9d4c67c11 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/is_nav_control_visible.tsx +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/is_nav_control_visible.tsx @@ -69,10 +69,8 @@ export function useIsNavControlVisible({ const space$ = spaces.getActiveSpace$(); useEffect(() => { - const chatExperience$ = coreStart.settings.client.get$( - AI_CHAT_EXPERIENCE_TYPE, - AIChatExperience.Classic - ); + const chatExperience$ = + coreStart.settings.client.get$(AI_CHAT_EXPERIENCE_TYPE); const appSubscription = combineLatest([ currentAppId$, diff --git a/x-pack/solutions/search/plugins/search_assistant/public/components/nav_control/lazy_nav_control.tsx b/x-pack/solutions/search/plugins/search_assistant/public/components/nav_control/lazy_nav_control.tsx index 83dacda482b7f..892c3d99440a2 100644 --- a/x-pack/solutions/search/plugins/search_assistant/public/components/nav_control/lazy_nav_control.tsx +++ b/x-pack/solutions/search/plugins/search_assistant/public/components/nav_control/lazy_nav_control.tsx @@ -28,7 +28,7 @@ export const NavControlInitiator = ({ coreStart, pluginsStart }: NavControlIniti useEffect(() => { const sub = coreStart.settings.client - .get$(AI_CHAT_EXPERIENCE_TYPE, AIChatExperience.Classic) + .get$(AI_CHAT_EXPERIENCE_TYPE) .subscribe((chatExperience) => { setIsClassicExperience(chatExperience === AIChatExperience.Classic); }); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts index 8c2a4a3cd79e6..2d9087bead5c7 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/src/hooks/is_nav_control_visible/use_is_nav_control_visible.ts @@ -60,10 +60,7 @@ export function useIsNavControlVisible(isServerless?: boolean) { const space$ = spaces.getActiveSpace$(); useEffect(() => { - const chatExperience$ = settings.client.get$( - AI_CHAT_EXPERIENCE_TYPE, - AIChatExperience.Classic - ); + const chatExperience$ = settings.client.get$(AI_CHAT_EXPERIENCE_TYPE); const appSubscription = combineLatest([ currentAppId$,