diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts index 2687df38131a9..b8995269f9c0d 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/chat/conversation.ts @@ -345,6 +345,13 @@ export interface Conversation { * Keeps track of which prompts have been answered and the response. */ state?: ConversationInternalState; + /** + * Whether the conversation has been marked as read. + * Any new or updated conversation has `read` set to `false` by default + */ + read?: boolean; + /** current status of the conversation */ + status?: ConversationRoundStatus; } export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; diff --git a/x-pack/platform/plugins/shared/agent_builder/common/http_api/conversations.ts b/x-pack/platform/plugins/shared/agent_builder/common/http_api/conversations.ts index dff59118c29bc..0979ebe2f6dcb 100644 --- a/x-pack/platform/plugins/shared/agent_builder/common/http_api/conversations.ts +++ b/x-pack/platform/plugins/shared/agent_builder/common/http_api/conversations.ts @@ -19,3 +19,8 @@ export interface RenameConversationResponse { id: string; title: string; } + +export interface MarkReadConversationResponse { + id: string; + read: boolean; +} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.test.ts new file mode 100644 index 0000000000000..fb330b674fcd3 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { IRouter } from '@kbn/core/server'; +import { kibanaResponseFactory } from '@kbn/core/server'; +import { httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { registerInternalConversationRoutes } from './conversations'; +import type { RouteDependencies } from '../types'; +import { internalApiPath } from '../../../common/constants'; + +const MARK_READ_PATH = `${internalApiPath}/conversations/{conversation_id}/_mark_read`; + +describe('registerInternalConversationRoutes - _mark_read', () => { + let routeHandler: (ctx: any, req: any, res: any) => Promise; + let update: jest.Mock; + + const createMockContext = () => ({ + core: Promise.resolve({}), + licensing: Promise.resolve({ + license: { status: 'active', hasAtLeast: jest.fn().mockReturnValue(true) }, + }), + }); + + const createRequest = (overrides: { params?: object; body?: object } = {}) => + httpServerMock.createKibanaRequest({ + method: 'post', + path: MARK_READ_PATH, + params: { conversation_id: 'conv-1' }, + body: { read: true }, + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + + update = jest.fn().mockResolvedValue({ id: 'conv-1', read: true }); + + const getInternalServices = jest.fn().mockReturnValue({ + conversations: { + getScopedClient: jest.fn().mockResolvedValue({ update }), + }, + }); + + const routeHandlers: Record Promise> = {}; + + const router = { + post: jest + .fn() + .mockImplementation( + (config: { path: string }, handler: (ctx: any, req: any, res: any) => Promise) => { + routeHandlers[config.path] = handler; + } + ), + } as unknown as IRouter; + + registerInternalConversationRoutes({ + router, + getInternalServices, + logger: loggingSystemMock.createLogger(), + } as unknown as RouteDependencies); + + routeHandler = routeHandlers[MARK_READ_PATH]; + }); + + it('calls client.update and returns id and read on success', async () => { + const response = await routeHandler( + createMockContext() as any, + createRequest(), + kibanaResponseFactory + ); + + expect(update).toHaveBeenCalledWith({ id: 'conv-1', read: true }); + expect(response.status).toBe(200); + expect(response.payload).toMatchObject({ id: 'conv-1', read: true }); + }); + + it('returns 500 when the persisted read value does not match the requested value', async () => { + // Simulates ES returning a stale/legacy doc where `read` is undefined + update.mockResolvedValue({ id: 'conv-1', read: undefined }); + + const response = await routeHandler( + createMockContext() as any, + createRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(500); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.ts index eff67edc90094..f227184b56c7d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/internal/conversations.ts @@ -6,9 +6,13 @@ */ import { schema } from '@kbn/config-schema'; +import { createInternalError } from '@kbn/agent-builder-common'; import type { RouteDependencies } from '../types'; import { getHandlerWrapper } from '../wrap_handler'; -import type { RenameConversationResponse } from '../../../common/http_api/conversations'; +import type { + MarkReadConversationResponse, + RenameConversationResponse, +} from '../../../common/http_api/conversations'; import { apiPrivileges } from '../../../common/features'; import { internalApiPath } from '../../../common/constants'; @@ -55,4 +59,48 @@ export function registerInternalConversationRoutes({ }); }) ); + + router.post( + { + path: `${internalApiPath}/conversations/{conversation_id}/_mark_read`, + validate: { + params: schema.object({ + conversation_id: schema.string({ maxLength: 256 }), + }), + body: schema.object({ + read: schema.boolean(), + }), + }, + options: { access: 'internal' }, + security: { + authz: { requiredPrivileges: [apiPrivileges.readAgentBuilder] }, + }, + }, + wrapHandler(async (ctx, request, response) => { + const { conversations: conversationsService } = getInternalServices(); + const { conversation_id: conversationId } = request.params; + const { read } = request.body; + + const client = await conversationsService.getScopedClient({ request }); + const updatedConversation = await client.update({ + id: conversationId, + read, + }); + + // Enables `read` to be mandatory in the response and not `read?`. + if (read !== updatedConversation.read) { + throw createInternalError( + `Failed to persist read state for conversation ${conversationId}: expected ${read}, got ${updatedConversation.read}`, + { conversationId, expected: read, actual: updatedConversation.read } + ); + } + + return response.ok({ + body: { + id: updatedConversation.id, + read: updatedConversation.read, + }, + }); + }) + ); } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/client.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/client.ts index 2084b60d08176..1e028594cdddc 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/client.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/client.ts @@ -79,7 +79,16 @@ class ConversationClientImpl implements ConversationClient { const response = await this.storage.getClient().search({ track_total_hits: false, size: 1000, - _source: ['agent_id', 'user_id', 'user_name', 'title', 'created_at', 'updated_at'], + _source: [ + 'agent_id', + 'user_id', + 'user_name', + 'title', + 'created_at', + 'updated_at', + 'status', + 'read', + ], query: { bool: { filter: [createSpaceDslFilter(this.space)], diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts index ff872edf039ab..625d5f1e2a784 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/converters.ts @@ -59,6 +59,8 @@ const convertBaseFromEs = (document: Document) => { title: document._source.title, created_at: document._source.created_at, updated_at: document._source.updated_at, + status: document._source.status, + read: document._source.read, }; }; @@ -231,6 +233,8 @@ export const toEs = (conversation: Conversation, space: string): ConversationPro conversation_rounds: serializeStepResults(conversation.rounds), attachments: conversation.attachments ?? [], state: conversation.state, + status: conversation.status, + read: conversation.read, }; }; @@ -277,5 +281,7 @@ export const createRequestToEs = ({ conversation_rounds: serializeStepResults(conversation.rounds), attachments: conversation.attachments ?? [], state: conversation.state, + status: conversation.status, + read: false, }; }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/storage.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/storage.ts index 5ff7107f0a66a..e4558768bd7b8 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/storage.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/storage.ts @@ -10,7 +10,10 @@ import type { IndexStorageSettings } from '@kbn/storage-adapter'; import { StorageIndexAdapter, types } from '@kbn/storage-adapter'; import { chatSystemIndex } from '@kbn/agent-builder-server'; import type { VersionedAttachment } from '@kbn/agent-builder-common/attachments'; -import type { ConversationInternalState } from '@kbn/agent-builder-common/chat'; +import type { + ConversationInternalState, + ConversationRoundStatus, +} from '@kbn/agent-builder-common/chat'; import type { PersistentConversationRound } from './types'; export const conversationIndexName = chatSystemIndex('conversations'); @@ -29,6 +32,8 @@ const storageSettings = { conversation_rounds: types.object({ dynamic: false, properties: {} }), attachments: types.object({ dynamic: false, properties: {} }), state: types.object({ dynamic: false, properties: {} }), + status: types.keyword({}), + read: types.boolean({}), }, }, } satisfies IndexStorageSettings; @@ -44,6 +49,8 @@ export interface ConversationProperties { conversation_rounds: PersistentConversationRound[]; attachments?: VersionedAttachment[]; state?: ConversationInternalState; + status?: ConversationRoundStatus; + read?: boolean; // legacy field rounds?: PersistentConversationRound[]; } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/types.ts index 0117aa41f89cb..7279b2c69fed4 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/conversation/client/types.ts @@ -27,7 +27,7 @@ export type ConversationCreateRequest = Omit< }; export type ConversationUpdateRequest = Pick & - Partial>; + Partial>; export interface ConversationListOptions { agentId?: string; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.test.ts index a4d57d841c81e..dbb5a03c908f1 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.test.ts @@ -111,6 +111,8 @@ describe('conversations utils', () => { expect(conversationClient.update).toHaveBeenCalledWith( expect.objectContaining({ rounds: [newRound], + read: false, + status: newRound.status, }) ); }); @@ -147,6 +149,8 @@ describe('conversations utils', () => { expect(conversationClient.update).toHaveBeenCalledWith( expect.objectContaining({ rounds: [existingRound, newRound], + read: false, + status: newRound.status, }) ); }); @@ -184,6 +188,8 @@ describe('conversations utils', () => { expect(conversationClient.update).toHaveBeenCalledWith( expect.objectContaining({ rounds: [newRound], + read: false, + status: newRound.status, }) ); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.ts index 3271bb5b0ca85..af5a886dbc834 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/utils/conversations.ts @@ -42,6 +42,8 @@ export const createConversation$ = ({ title, agent_id: agentId, state: roundCompletedEvent.data.conversation_state, + status: roundCompletedEvent.data.round.status, + read: false, rounds: [roundCompletedEvent.data.round], ...(roundCompletedEvent.data.attachments ? { attachments: roundCompletedEvent.data.attachments } @@ -87,6 +89,8 @@ export const updateConversation$ = ({ title, rounds: updatedRound, state: conversation_state, + status: round.status, + read: false, ...(roundCompletedEvent.data.attachments !== undefined ? { attachments: roundCompletedEvent.data.attachments } : {}),