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
1 change: 1 addition & 0 deletions config/serverless.es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ export function useIsNavControlVisible(coreStart: CoreStart, spaces?: SpacesPlug
);

const chatExperience$ = coreStart.settings.client.get$<AIChatExperience>(
PREFERRED_CHAT_EXPERIENCE_SETTING_KEY,
AIChatExperience.Classic
PREFERRED_CHAT_EXPERIENCE_SETTING_KEY
);

const activeSpace$ = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,7 @@ export class AIAssistantManagementPlugin
if (!this.isServerless && licensing) {
this.managementAppVisibilitySubscription = combineLatest([
licensing.license$,
coreStart.settings.client.get$<AIChatExperience>(
PREFERRED_CHAT_EXPERIENCE_SETTING_KEY,
AIChatExperience.Classic
),
coreStart.settings.client.get$<AIChatExperience>(PREFERRED_CHAT_EXPERIENCE_SETTING_KEY),
]).subscribe(([license, chatExperience]) => {
const isEnterprise = license?.hasAtLeast('enterprise');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<AIAssistantManagementSelectionConfig> = {}) => {
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<typeof spacesMock.createStart>;
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<typeof coreMock.createSetup>) => {
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);

Expand All @@ -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<Space, 'solution'> = { 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
);
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -49,7 +61,10 @@ export class AIAssistantManagementSelectionPlugin
}

private registerUiSettings(
core: CoreSetup,
core: CoreSetup<
AIAssistantManagementSelectionPluginServerDependenciesStart,
AIAssistantManagementSelectionPluginServerStart
>,
plugins: AIAssistantManagementSelectionPluginServerDependenciesSetup
) {
const { cloud } = plugins;
Expand All @@ -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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also use request.auth.isAuthenticated rather than fetching the user.
I don't know which approach is preferred.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's good catch! I will address that in following PR

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;
},
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const GenAiSettingsApp: React.FC<GenAiSettingsAppProps> = ({ setBreadcrum
const currentChatExperience =
unsavedChanges[AI_CHAT_EXPERIENCE_TYPE]?.unsavedValue ??
chatExperienceField?.savedValue ??
chatExperienceField?.defaultValue ??
AIChatExperience.Classic;
const isAgentExperience = currentChatExperience === AIChatExperience.Agent;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const OnechatNavControlInitiator = ({

useEffect(() => {
const sub = coreStart.settings.client
.get$<AIChatExperience>(AI_CHAT_EXPERIENCE_TYPE, AIChatExperience.Classic)
.get$<AIChatExperience>(AI_CHAT_EXPERIENCE_TYPE)
.subscribe((chatExperience) => {
setIsAgentsExperience(chatExperience === AIChatExperience.Agent);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,8 @@ export function useIsNavControlVisible({
const space$ = spaces.getActiveSpace$();

useEffect(() => {
const chatExperience$ = coreStart.settings.client.get$<AIChatExperience>(
AI_CHAT_EXPERIENCE_TYPE,
AIChatExperience.Classic
);
const chatExperience$ =
coreStart.settings.client.get$<AIChatExperience>(AI_CHAT_EXPERIENCE_TYPE);

const appSubscription = combineLatest([
currentAppId$,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const NavControlInitiator = ({ coreStart, pluginsStart }: NavControlIniti

useEffect(() => {
const sub = coreStart.settings.client
.get$<AIChatExperience>(AI_CHAT_EXPERIENCE_TYPE, AIChatExperience.Classic)
.get$<AIChatExperience>(AI_CHAT_EXPERIENCE_TYPE)
.subscribe((chatExperience) => {
setIsClassicExperience(chatExperience === AIChatExperience.Classic);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,7 @@ export function useIsNavControlVisible(isServerless?: boolean) {
const space$ = spaces.getActiveSpace$();

useEffect(() => {
const chatExperience$ = settings.client.get$<AIChatExperience>(
AI_CHAT_EXPERIENCE_TYPE,
AIChatExperience.Classic
);
const chatExperience$ = settings.client.get$<AIChatExperience>(AI_CHAT_EXPERIENCE_TYPE);

const appSubscription = combineLatest([
currentAppId$,
Expand Down