diff --git a/apps/api/src/app/agents/agent-chat/activity-to-events.spec.ts b/apps/api/src/app/agents/agent-chat/activity-to-events.spec.ts index fa3c72a87c6..d09ea979698 100644 --- a/apps/api/src/app/agents/agent-chat/activity-to-events.spec.ts +++ b/apps/api/src/app/agents/agent-chat/activity-to-events.spec.ts @@ -117,6 +117,37 @@ describe('activity-to-events run lifecycle', () => { ]); }); + it('maps stored Card trees to protocol card content', () => { + const card = { + type: 'card', + title: 'Support Agent', + children: [{ type: 'text', content: 'How can I help?' }], + }; + const envelopes = mapNewestFirstEventActivities( + [ + activity({ + type: ConversationActivityTypeEnum.MESSAGE, + identifier: 'msg_card0000001', + platformMessageId: 'act_card0000001', + sequence: 1, + content: 'How can I help?', + richContent: { card }, + }), + ], + context + ); + + expect(envelopes.map((envelope) => envelope.event)).to.deep.equal([ + { + type: 'message', + role: 'assistant', + messageId: 'act_card0000001', + content: { card }, + files: undefined, + }, + ]); + }); + it('uses immutable activity ids for approval request messages', () => { const envelopes = mapNewestFirstEventActivities( [ diff --git a/apps/api/src/app/agents/agent-chat/activity-to-events.ts b/apps/api/src/app/agents/agent-chat/activity-to-events.ts index 6aa007418d8..1dd90cc5c34 100644 --- a/apps/api/src/app/agents/agent-chat/activity-to-events.ts +++ b/apps/api/src/app/agents/agent-chat/activity-to-events.ts @@ -3,6 +3,7 @@ import { type AgentEvent, type AgentEventEnvelope, type AgentFileRef, + type AgentMessageContent, isDeltaEvent, } from '@novu/agent-event-protocol'; import { @@ -35,6 +36,23 @@ function filesFromRichContent(richContent?: Record) { return files as AgentFileRef[]; } +function isCardTree(value: unknown): value is Record { + return typeof value === 'object' && value !== null && (value as { type?: unknown }).type === 'card'; +} + +/** Prefer the stored Card tree. Fall back to markdown when no Card is present. */ +export function messageContentFromStored(params: { + content?: string; + richContent?: Record; +}): AgentMessageContent { + const card = params.richContent?.card; + if (isCardTree(card)) { + return { card }; + } + + return { markdown: params.content ?? '' }; +} + const MESSAGE_ROLE_BY_SENDER = { [ConversationActivitySenderTypeEnum.AGENT]: 'assistant', [ConversationActivitySenderTypeEnum.SUBSCRIBER]: 'user', @@ -53,7 +71,7 @@ function mapActivityToEvent(activity: ConversationActivityEntity): AgentEvent | role, // Browser-visible id is platformMessageId (aligned with live WS envelopes). messageId: activity.platformMessageId ?? activity.identifier, - content: { markdown: activity.content }, + content: messageContentFromStored({ content: activity.content, richContent: activity.richContent }), files: filesFromRichContent(activity.richContent), }; } @@ -142,7 +160,7 @@ function mapActivityToEvent(activity: ConversationActivityEntity): AgentEvent | return { type: 'channel.edit', messageId: activity.platformMessageId ?? activity.identifier, - content: { markdown: activity.content }, + content: messageContentFromStored({ content: activity.content, richContent: activity.richContent }), }; case ConversationActivityTypeEnum.DELETE: diff --git a/apps/api/src/app/agents/agent-chat/agent-chat-event.factory.ts b/apps/api/src/app/agents/agent-chat/agent-chat-event.factory.ts index ea97d8294fb..dfb01fa9d7c 100644 --- a/apps/api/src/app/agents/agent-chat/agent-chat-event.factory.ts +++ b/apps/api/src/app/agents/agent-chat/agent-chat-event.factory.ts @@ -1,5 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { AGENT_EVENT_PROTOCOL_VERSION, type AgentEvent, type AgentEventEnvelope } from '@novu/agent-event-protocol'; +import { + AGENT_EVENT_PROTOCOL_VERSION, + type AgentEvent, + type AgentEventEnvelope, + type AgentMessageContent, +} from '@novu/agent-event-protocol'; import { shortId } from '@novu/application-generic'; type AgentChatFactoryBaseInput = { @@ -14,12 +19,12 @@ type AgentChatFactoryBaseInput = { export type AgentChatFactoryMessageInput = AgentChatFactoryBaseInput & { platformMessageId: string; - content: { markdown: string }; + content: AgentMessageContent; }; export type AgentChatFactoryEditInput = AgentChatFactoryBaseInput & { platformMessageId: string; - content: { markdown: string }; + content: AgentMessageContent; }; export type AgentChatFactoryDeleteInput = AgentChatFactoryBaseInput & { diff --git a/apps/api/src/app/agents/agent-chat/agent-chat-platform-delivery.service.ts b/apps/api/src/app/agents/agent-chat/agent-chat-platform-delivery.service.ts index 914e6213a2d..b163804e3b8 100644 --- a/apps/api/src/app/agents/agent-chat/agent-chat-platform-delivery.service.ts +++ b/apps/api/src/app/agents/agent-chat/agent-chat-platform-delivery.service.ts @@ -8,15 +8,13 @@ import { type AgentChatEditMessageParams, type AgentChatStartTypingParams, conversationIdFromThreadId, - extractCardPlainText, } from '@novu/chat-adapter-agent-chat'; import { type ConversationEntity, ConversationParticipantTypeEnum, SubscriberRepository } from '@novu/dal'; import { WebSocketEventEnum } from '@novu/shared'; -import type { CardElement } from 'chat'; import type { ResolvedAgentConfig } from '../channels/agent-config-resolver.service'; import { AgentConversationService } from '../conversation-runtime/conversation/agent-conversation.service'; -import { ConversationEventSequenceService } from '../conversation-runtime/conversation/conversation-event-sequence.service'; import { OutboundDeliveryInfo } from '../conversation-runtime/egress/outbound-delivery-info.service'; +import { messageContentFromStored } from './activity-to-events'; import { AgentChatEventFactory } from './agent-chat-event.factory'; export type AgentChatPlatformDeliveryContext = { @@ -36,7 +34,6 @@ export type AgentChatPlatformDeliveryContext = { export class AgentChatPlatformDeliveryService { constructor( private readonly conversationService: AgentConversationService, - private readonly eventSequenceService: ConversationEventSequenceService, private readonly subscriberRepository: SubscriberRepository, private readonly webSocketsQueueService: WebSocketsQueueService, private readonly eventFactory: AgentChatEventFactory, @@ -61,13 +58,12 @@ export class AgentChatPlatformDeliveryService { this.deliveryInfo.report({ messageId: platformMessageId, sequence }); if (conversation && sequence !== undefined) { - const markdown = this.markdownForLiveEnvelope(content, richContent); const envelope = this.eventFactory.createMessageEnvelope({ conversationId: conversation._id, conversationIdentifier: conversation.identifier, agentId: context.config.agentIdentifier, platformMessageId, - content: { markdown }, + content: messageContentFromStored({ content, richContent }), sequence, }); await this.emitBestEffort(context, conversation, envelope); @@ -89,13 +85,12 @@ export class AgentChatPlatformDeliveryService { this.deliveryInfo.report({ sequence }); if (conversation && sequence !== undefined) { - const markdown = this.markdownForLiveEnvelope(content, richContent); const envelope = this.eventFactory.createEditEnvelope({ conversationId: conversation._id, conversationIdentifier: conversation.identifier, agentId: context.config.agentIdentifier, platformMessageId: messageId, - content: { markdown }, + content: messageContentFromStored({ content, richContent }), sequence, }); await this.emitBestEffort(context, conversation, envelope); @@ -208,27 +203,13 @@ export class AgentChatPlatformDeliveryService { return undefined; } - return this.eventSequenceService.mint({ + return this.conversationService.mintEventSequence({ environmentId: context.config.environmentId, organizationId: context.config.organizationId, conversationId: conversation._id, }); } - private markdownForLiveEnvelope(content: string, richContent?: Record): string { - const trimmed = content?.trim() ?? ''; - if (trimmed && trimmed !== '[Card]') { - return trimmed; - } - - const card = richContent?.card; - if (card && typeof card === 'object') { - return extractCardPlainText(card as CardElement); - } - - return trimmed || '[Card]'; - } - private async emitBestEffort( context: AgentChatPlatformDeliveryContext, conversation: ConversationEntity, diff --git a/apps/api/src/app/agents/agent-chat/usecases/list-agent-chat-conversation-events/list-agent-chat-conversation-events.usecase.ts b/apps/api/src/app/agents/agent-chat/usecases/list-agent-chat-conversation-events/list-agent-chat-conversation-events.usecase.ts index 1bc1840de80..5e1c1a949ef 100644 --- a/apps/api/src/app/agents/agent-chat/usecases/list-agent-chat-conversation-events/list-agent-chat-conversation-events.usecase.ts +++ b/apps/api/src/app/agents/agent-chat/usecases/list-agent-chat-conversation-events/list-agent-chat-conversation-events.usecase.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import type { AgentEventEnvelope } from '@novu/agent-event-protocol'; import { AgentRepository, ConversationParticipantTypeEnum, ConversationRepository } from '@novu/dal'; -import { ConversationActivityLedger } from '../../../conversation-runtime/conversation/conversation-activity-ledger'; +import { AgentConversationService } from '../../../conversation-runtime/conversation/agent-conversation.service'; import { AgentPlatformEnum } from '../../../shared/enums/agent-platform.enum'; import { type EventMapContext, mapNewestFirstEventActivities } from '../../activity-to-events'; import { withAgentChatContextFilter } from '../../agent-chat-context-query.util'; @@ -17,7 +17,7 @@ interface EventPageResult { export class ListAgentChatConversationEvents { constructor( private readonly conversationRepository: ConversationRepository, - private readonly activityLedger: ConversationActivityLedger, + private readonly conversationService: AgentConversationService, private readonly agentRepository: AgentRepository ) {} @@ -71,7 +71,7 @@ export class ListAgentChatConversationEvents { agentIdentifier: agent.identifier, }; - const page = await this.activityLedger.listForView({ + const page = await this.conversationService.listForView({ view: 'client_events', environmentId: command.environmentId, organizationId: command.organizationId, diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts index 37c71e3320f..35c39a18667 100644 --- a/apps/api/src/app/agents/agents.module.ts +++ b/apps/api/src/app/agents/agents.module.ts @@ -202,13 +202,6 @@ import { USE_CASES } from './usecases'; AgentConversationEnabledGuard, AgentChatEnabledGuard, ], - exports: [ - ...USE_CASES, - ChatInstanceRegistry, - InboundDispatcher, - OutboundGateway, - ConfirmLinkedAuthCards, - ConversationActivityLedger, - ], + exports: [...USE_CASES, ChatInstanceRegistry, InboundDispatcher, OutboundGateway, ConfirmLinkedAuthCards], }) export class AgentsModule {} diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.helpers.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.helpers.ts new file mode 100644 index 00000000000..cb96efb125f --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.helpers.ts @@ -0,0 +1,35 @@ +export const INBOUND_ATTACHMENT_ONLY_PREVIEW = '[Attachment]'; +export const DEFAULT_CONVERSATION_TITLE = 'Untitled conversation'; + +/** Default number of recent activities loaded as conversation history for every runtime. */ +export const AGENT_HISTORY_LIMIT = 50; + +export function getConversationTitle(firstMessageText: string): string { + const trimmed = firstMessageText.trim(); + + if (trimmed.length === 0) { + return DEFAULT_CONVERSATION_TITLE; + } + + return trimmed.slice(0, 200); +} + +export function getInboundActivityPreview( + content: string | undefined, + options: { richContent?: Record; hasPlatformAttachments?: boolean } = {} +): string { + const trimmed = content?.trim() ?? ''; + + if (trimmed.length > 0) { + return trimmed; + } + + const attachments = options.richContent?.attachments; + const hasStoredAttachments = Array.isArray(attachments) && attachments.length > 0; + + if (hasStoredAttachments || options.hasPlatformAttachments) { + return INBOUND_ATTACHMENT_ONLY_PREVIEW; + } + + return trimmed; +} diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.spec.ts index 66c3eb60c8f..4e754c0b951 100644 --- a/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.spec.ts @@ -13,7 +13,7 @@ import { getInboundActivityPreview, INBOUND_ATTACHMENT_ONLY_PREVIEW, } from './agent-conversation.service'; -import { ConversationEventSequenceService } from './conversation-event-sequence.service'; +import { ConversationActivityLedger } from './conversation-activity-ledger'; describe('AgentConversationService', () => { function makeLogger() { @@ -41,308 +41,53 @@ describe('AgentConversationService', () => { }; } - function makeActivityRepository() { + function makeLedger(overrides: Partial> = {}) { return { - createAgentActivity: sinon.stub().resolves({ _id: 'activity-1', identifier: 'act_generated' }), - findOne: sinon.stub().resolves(null), - }; - } - - function basePersistParams() { - return { - conversationId: 'conv-1', - channel: { - platform: 'slack', - _integrationId: 'integration-a', - platformThreadId: 'thread-1', - }, - platformMessageId: 'msg-1', - agentIdentifier: 'agent-a', - content: 'hello', - environmentId: 'env-1', - organizationId: 'org-1', - }; - } - - function makeEventSequenceService(mint = sinon.stub().resolves(undefined)) { - return { mint } as unknown as ConversationEventSequenceService; + persistAgentMessage: overrides.persistAgentMessage ?? sinon.stub().resolves({ activity: {}, created: true }), + persistWorkflowOriginHydration: overrides.persistWorkflowOriginHydration ?? sinon.stub().resolves(undefined), + isWorkflowOriginHydrated: overrides.isWorkflowOriginHydrated ?? sinon.stub().resolves(false), + persistMcpConnectionRequest: overrides.persistMcpConnectionRequest ?? sinon.stub().resolves({}), + persistMcpConnectionResult: overrides.persistMcpConnectionResult ?? sinon.stub().resolves({}), + persistToolResult: overrides.persistToolResult ?? sinon.stub().resolves(undefined), + persistInboundMessage: overrides.persistInboundMessage ?? sinon.stub().resolves({}), + listForView: overrides.listForView ?? sinon.stub().resolves({ data: [], hasMore: false }), + mint: overrides.mint ?? sinon.stub().resolves(1), + } as unknown as ConversationActivityLedger; } function makeService( conversationRepository: ConversationRepository, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - activityRepository: any = makeActivityRepository(), - eventSequenceService = makeEventSequenceService(), - agentChatLiveActivityPublisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) } + ledger: ConversationActivityLedger = makeLedger() ) { - return new AgentConversationService( - conversationRepository, - activityRepository as any, - eventSequenceService, - agentChatLiveActivityPublisher as any, - makeLogger() as any - ); + return new AgentConversationService(conversationRepository, ledger, makeLogger() as any); } - describe('persistAgentMessage', () => { - it('uses the caller-supplied identifier when provided', async () => { - const activityRepository = makeActivityRepository(); - const conversationRepository = { - touchActivity: sinon.stub().resolves(undefined), - } as unknown as ConversationRepository; - const service = makeService(conversationRepository, activityRepository); - - const result = await service.persistAgentMessage({ - ...basePersistParams(), - identifier: 'client-msg-123', - }); - - expect(result.created).to.equal(true); - expect(activityRepository.createAgentActivity.calledOnce).to.equal(true); - expect(activityRepository.createAgentActivity.firstCall.args[0].identifier).to.equal('client-msg-123'); - expect(activityRepository.createAgentActivity.firstCall.args[0].type).to.equal( - ConversationActivityTypeEnum.MESSAGE - ); - }); - - it('mints an act_ identifier when none is supplied', async () => { - const activityRepository = makeActivityRepository(); - const conversationRepository = { - touchActivity: sinon.stub().resolves(undefined), - } as unknown as ConversationRepository; - const service = makeService(conversationRepository, activityRepository); - - await service.persistAgentMessage(basePersistParams()); - - const identifier = activityRepository.createAgentActivity.firstCall.args[0].identifier; - - expect(identifier).to.match(/^act_/); - }); - - it('logs and returns the existing activity on duplicate identifier races', async () => { - const duplicateError = Object.assign(new Error('duplicate key'), { code: 11000 }); - const existingActivity = { _id: 'existing-1', identifier: 'client-msg-123' }; - const activityRepository = { - createAgentActivity: sinon.stub().rejects(duplicateError), - findOne: sinon.stub().resolves(existingActivity), - }; - const conversationRepository = { - touchActivity: sinon.stub().resolves(undefined), - } as unknown as ConversationRepository; - const logger = makeLogger(); - const service = new AgentConversationService( - conversationRepository, - activityRepository as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - logger as any - ); - - const result = await service.persistAgentMessage({ - ...basePersistParams(), - identifier: 'client-msg-123', - }); - - expect(result.activity).to.equal(existingActivity); - expect(result.created).to.equal(false); - expect(logger.warn.calledOnce).to.equal(true); - }); - }); - - describe('persistWorkflowOriginHydration', () => { - function makeHydrationParams() { - return { + describe('delegation', () => { + it('delegates persistAgentMessage to the ledger', async () => { + const ledger = makeLedger(); + const service = makeService({} as unknown as ConversationRepository, ledger); + const params = { conversationId: 'conv-1', - channel: { - platform: 'whatsapp', - _integrationId: 'integration-a', - platformThreadId: 'whatsapp:15551234567', - }, + channel: { platform: 'slack', _integrationId: 'int-1', platformThreadId: 'thread-1' }, agentIdentifier: 'agent-a', + content: 'hello', environmentId: 'env-1', organizationId: 'org-1', - platformMessageId: 'wamid.abc', - platformThreadId: 'whatsapp:15551234567', - messageContent: 'Your order shipped', - signalData: { workflowIdentifier: 'order-alerts' }, - }; - } - - it('swallows duplicate-key errors from the signal write', async () => { - const duplicateError = Object.assign(new Error('duplicate key'), { code: 11000 }); - const activityRepository = { - createAgentActivity: sinon - .stub() - .resolves({ _id: 'activity-1', identifier: 'workflow-dispatch-msg:wamid.abc' }), - createSignalActivity: sinon.stub().rejects(duplicateError), - findOne: sinon.stub().resolves(null), - }; - const conversationRepository = { - touchActivity: sinon.stub().resolves(undefined), - } as unknown as ConversationRepository; - const logger = makeLogger(); - const service = new AgentConversationService( - conversationRepository, - activityRepository as any, - makeEventSequenceService(), - { emit: sinon.stub().resolves(undefined) } as any, - logger as any - ); - - await service.persistWorkflowOriginHydration(makeHydrationParams()); - - expect(activityRepository.createSignalActivity.calledOnce).to.equal(true); - expect(logger.warn.calledOnce).to.equal(true); - expect(logger.warn.firstCall.args[1]).to.equal('Workflow origin already hydrated'); - }); - - it('rethrows non-duplicate errors from the signal write', async () => { - const activityRepository = { - createAgentActivity: sinon - .stub() - .resolves({ _id: 'activity-1', identifier: 'workflow-dispatch-msg:wamid.abc' }), - createSignalActivity: sinon.stub().rejects(new Error('mongo timeout')), - findOne: sinon.stub().resolves(null), - }; - const conversationRepository = { - touchActivity: sinon.stub().resolves(undefined), - } as unknown as ConversationRepository; - const service = makeService(conversationRepository, activityRepository); - - try { - await service.persistWorkflowOriginHydration(makeHydrationParams()); - expect.fail('expected persistWorkflowOriginHydration to throw'); - } catch (err) { - expect((err as Error).message).to.equal('mongo timeout'); - } - }); - }); - - describe('isWorkflowOriginHydrated', () => { - it('matches the signal identifier written by persistWorkflowOriginHydration', async () => { - const activityRepository = { count: sinon.stub().resolves(1) }; - const service = makeService({} as unknown as ConversationRepository, activityRepository); - - const hydrated = await service.isWorkflowOriginHydrated('env-1', 'conv-1', 'wamid.abc'); - - expect(hydrated).to.equal(true); - expect(activityRepository.count.firstCall.args[0]).to.deep.equal({ - _environmentId: 'env-1', - _conversationId: 'conv-1', - identifier: 'workflow-dispatch-origin:wamid.abc', - }); - }); - - it('returns false when the signal is absent', async () => { - const activityRepository = { count: sinon.stub().resolves(0) }; - const service = makeService({} as unknown as ConversationRepository, activityRepository); - - expect(await service.isWorkflowOriginHydrated('env-1', 'conv-1', 'wamid.abc')).to.equal(false); - }); - }); - - describe('MCP connection activities', () => { - it('persists request and result activities and publishes both to agent chat', async () => { - const activityRepository = makeActivityRepository(); - activityRepository.createAgentActivity.callsFake(async (params: Record) => ({ - _id: `activity-${activityRepository.createAgentActivity.callCount}`, - ...params, - })); - const conversationRepository = { - touchActivity: sinon.stub().resolves(undefined), - } as unknown as ConversationRepository; - const publisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) }; - const service = makeService( - conversationRepository, - activityRepository, - makeEventSequenceService(sinon.stub().onFirstCall().resolves(10).onSecondCall().resolves(11)), - publisher - ); - const context = { - ...basePersistParams(), - channel: { - platform: 'agent_chat', - _integrationId: 'integration-a', - platformThreadId: 'thread-1', - }, }; - await service.persistMcpConnectionRequest({ - ...context, - actionId: 'tool-use-1', - mcpId: 'stripe', - displayName: 'Stripe', - authorizeUrl: 'https://example.com/authorize', - }); - await service.persistMcpConnectionResult({ - ...context, - actionId: 'tool-use-1', - mcpId: 'stripe', - status: 'connected', - }); + await service.persistAgentMessage(params); - expect(activityRepository.createAgentActivity.firstCall.args[0]).to.deep.include({ - identifier: 'mcp-connection:tool-use-1:request', - type: ConversationActivityTypeEnum.MCP_CONNECTION_REQUEST, - sequence: 10, - richContent: { - mcpConnection: { - actionId: 'tool-use-1', - mcpId: 'stripe', - displayName: 'Stripe', - authorizeUrl: 'https://example.com/authorize', - authorizeUrlWithAutoApprove: undefined, - }, - }, - }); - expect(activityRepository.createAgentActivity.secondCall.args[0]).to.deep.include({ - identifier: 'mcp-connection:tool-use-1:result', - type: ConversationActivityTypeEnum.MCP_CONNECTION_RESULT, - sequence: 11, - richContent: { - mcpConnection: { - actionId: 'tool-use-1', - mcpId: 'stripe', - status: 'connected', - message: undefined, - }, - }, - }); - expect(publisher.emitPersistedClientEvent.callCount).to.equal(2); + expect(ledger.persistAgentMessage.calledOnceWithExactly(params)).to.equal(true); }); - }); - describe('event sequencing', () => { - it('allocates a sequence for durable tool activities on any channel', async () => { - const conversationRepository = {} as unknown as ConversationRepository; - const activityRepository = { - createToolActivity: sinon.stub().resolves({ _id: 'tool-activity' }), - }; - const mint = sinon.stub().resolves(4); - const service = makeService(conversationRepository, activityRepository, makeEventSequenceService(mint)); + it('delegates mintEventSequence to the ledger', async () => { + const ledger = makeLedger(); + const service = makeService({} as unknown as ConversationRepository, ledger); + const params = { environmentId: 'env-1', organizationId: 'org-1', conversationId: 'conv-1' }; - await service.persistToolResult({ - conversationId: 'conv-1', - channel: { - platform: 'slack', - _integrationId: 'integration-a', - platformThreadId: 'thread-1', - }, - agentIdentifier: 'agent-a', - environmentId: 'env-1', - organizationId: 'org-1', - toolCallId: 'tool-call-1', - output: 'done', - }); + await service.mintEventSequence(params); - expect(activityRepository.createToolActivity.firstCall.args[0].sequence).to.equal(4); - expect( - mint.calledOnceWithExactly({ - environmentId: 'env-1', - organizationId: 'org-1', - conversationId: 'conv-1', - }) - ).to.equal(true); + expect(ledger.mint.calledOnceWithExactly(params)).to.equal(true); }); }); @@ -386,13 +131,7 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); await service.createOrGetConversation({ ...baseCreateParams(), @@ -418,13 +157,7 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); await service.createOrGetConversation({ ...baseCreateParams(), @@ -451,13 +184,7 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); let threw = false; try { @@ -490,13 +217,7 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); let threw = false; try { @@ -528,13 +249,7 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); await service.createOrGetConversation(baseCreateParams()); @@ -558,13 +273,7 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); await service.createOrGetConversation(baseCreateParams()); @@ -587,16 +296,43 @@ describe('AgentConversationService', () => { updateParticipants: sinon.stub(), } as unknown as ConversationRepository; - const service = new AgentConversationService( - conversationRepository, - {} as any, - makeEventSequenceService(), - { emitPersistedClientEvent: sinon.stub().resolves(undefined) } as any, - makeLogger() as any - ); + const service = makeService(conversationRepository); await service.findByPlatformThread('e', 'o', 'agent-x', 'int-x', 'thread-z'); expect(findByPlatformThread.calledOnceWithExactly('e', 'o', 'agent-x', 'int-x', 'thread-z')).to.equal(true); }); + + it('orchestrates resolveConversation across repository and ledger', async () => { + const updateStatus = sinon.stub().resolves(undefined); + const markBillingResolved = sinon.stub().resolves(undefined); + const clearExternalSessionId = sinon.stub().resolves(undefined); + const persistResolveSignal = sinon.stub().resolves(undefined); + const conversationRepository = { + updateStatus, + markBillingResolved, + clearExternalSessionId, + } as unknown as ConversationRepository; + const ledger = makeLedger({ persistResolveSignal }); + const service = makeService(conversationRepository, ledger); + const params = { + conversationId: 'conv-1', + channel: { platform: 'slack', _integrationId: 'int-1', platformThreadId: 'thread-1' }, + agentIdentifier: 'agent-a', + environmentId: 'env-1', + organizationId: 'org-1', + summary: 'done', + }; + + await service.resolveConversation(params); + + expect(updateStatus.calledOnce).to.equal(true); + expect(markBillingResolved.calledOnce).to.equal(true); + expect(clearExternalSessionId.calledOnce).to.equal(true); + expect(persistResolveSignal.calledOnce).to.equal(true); + expect(persistResolveSignal.firstCall.args[0]).to.include({ + content: 'done', + summary: 'done', + }); + }); }); diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts index c3c386bf9ed..65cced8ebef 100644 --- a/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts @@ -1,63 +1,57 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { PinoLogger, shortId } from '@novu/application-generic'; import { + ActivityView, ConversationActivityEntity, - ConversationActivityRepository, - ConversationActivitySenderTypeEnum, - ConversationActivityToolData, - ConversationActivityTypeEnum, ConversationChannel, ConversationEntity, ConversationParticipantTypeEnum, ConversationRepository, ConversationStatusEnum, - isDuplicateKeyError, } from '@novu/dal'; -import type { TriggerRecipientsPayload } from '@novu/shared'; -import { AgentChatLiveActivityPublisher } from '../../agent-chat/agent-chat-live-activity.publisher'; -import { mintApprovalActionIds } from '../../shared/tool-approval/mint-approval-action-ids'; -import { ConversationEventSequenceService } from './conversation-event-sequence.service'; - -export const INBOUND_ATTACHMENT_ONLY_PREVIEW = '[Attachment]'; -export const DEFAULT_CONVERSATION_TITLE = 'Untitled conversation'; - -/** Default number of recent activities loaded as conversation history for every runtime. */ -export const AGENT_HISTORY_LIMIT = 50; - -/** Stable per-origin identifier for the workflow-origin signal — see `persistWorkflowOriginHydration`. */ -function workflowOriginSignalIdentifier(platformMessageId: string): string { - return `workflow-dispatch-origin:${platformMessageId}`; -} - -export function getConversationTitle(firstMessageText: string): string { - const trimmed = firstMessageText.trim(); - - if (trimmed.length === 0) { - return DEFAULT_CONVERSATION_TITLE; - } - - return trimmed.slice(0, 200); -} - -export function getInboundActivityPreview( - content: string | undefined, - options: { richContent?: Record; hasPlatformAttachments?: boolean } = {} -): string { - const trimmed = content?.trim() ?? ''; - - if (trimmed.length > 0) { - return trimmed; - } - - const attachments = options.richContent?.attachments; - const hasStoredAttachments = Array.isArray(attachments) && attachments.length > 0; - - if (hasStoredAttachments || options.hasPlatformAttachments) { - return INBOUND_ATTACHMENT_ONLY_PREVIEW; - } - - return trimmed; -} +import { getConversationTitle } from './agent-conversation.helpers'; +import type { + ConversationActivityContext, + PersistAgentActivityParams, + PersistAgentMessageResult, + PersistInboundMessageParams, + PersistMcpConnectionRequestParams, + PersistMcpConnectionResultParams, + PersistToolApprovalDecisionParams, + PersistToolApprovalRequestParams, + PersistToolResultParams, + PersistTriggerSignalParams, + PersistWorkflowOriginHydrationParams, + ResolveConversationParams, + UpdateMetadataParams, +} from './agent-conversation.types'; +import { ConversationActivityLedger } from './conversation-activity-ledger'; +import type { PersistRunLifecycleParams } from './run-lifecycle-activity'; + +export { + AGENT_HISTORY_LIMIT, + DEFAULT_CONVERSATION_TITLE, + getConversationTitle, + getInboundActivityPreview, + INBOUND_ATTACHMENT_ONLY_PREVIEW, +} from './agent-conversation.helpers'; + +export type { + ConversationActivityContext, + MetadataOp, + PersistAgentActivityParams, + PersistAgentMessageResult, + PersistInboundMessageParams, + PersistMcpConnectionRequestParams, + PersistMcpConnectionResultParams, + PersistToolApprovalDecisionParams, + PersistToolApprovalRequestParams, + PersistToolResultParams, + PersistTriggerSignalParams, + PersistWorkflowOriginHydrationParams, + ResolveConversationParams, + UpdateMetadataParams, +} from './agent-conversation.types'; export interface CreateOrGetConversationParams { environmentId: string; @@ -85,139 +79,18 @@ export interface CreateOrGetConversationParams { contextKeys?: string[]; } -export interface PersistInboundMessageParams { - conversationId: string; - platform: string; - integrationId: string; - platformThreadId: string; - senderType: ConversationActivitySenderTypeEnum; - senderId: string; - senderName?: string; - content: string; - richContent?: Record; - hasPlatformAttachments?: boolean; - platformMessageId?: string; - /** Caller-supplied activity identifier; defaults to a server-minted act_* id */ - identifier?: string; - /** Pre-allocated conversation event sequence; minted at persist time when absent */ - sequence?: number; - environmentId: string; - organizationId: string; -} - -export interface ConversationActivityContext { - conversationId: string; - channel: ConversationChannel; - agentIdentifier: string; - environmentId: string; - organizationId: string; -} - -export interface PersistAgentMessageResult { - activity: ConversationActivityEntity; - /** `false` when the identifier already existed — the caller lost the persist race. */ - created: boolean; -} - -export interface PersistAgentActivityParams extends ConversationActivityContext { - platformMessageId?: string; - /** Overrides channel.platformThreadId when delivery returns a different thread ID */ - platformThreadId?: string; - /** Caller-supplied activity identifier; defaults to a server-minted act_* id */ - identifier?: string; - agentName?: string; - content: string; - richContent?: Record; - /** Pre-allocated conversation event sequence; minted at persist time when absent */ - sequence?: number; -} - -export interface PersistToolApprovalRequestParams extends ConversationActivityContext { - approvalId: string; - toolCallId: string; - toolName: string; - input?: Record; - /** Human-readable preview for the display timeline. */ - preview?: string; - /** When omitted, self-hosted `tool-approval:*` ids are minted. */ - approveActionId?: string; - denyActionId?: string; -} - -export type MetadataOp = - | { action: 'set'; key: string; value: unknown } - | { action: 'delete'; key: string } - | { action: 'clear' }; - -export interface UpdateMetadataParams extends ConversationActivityContext { - currentMetadata: Record; - ops: MetadataOp[]; -} - -export interface ResolveConversationParams extends ConversationActivityContext { - summary?: string; -} - -export interface PersistTriggerSignalParams extends ConversationActivityContext { - workflowId: string; - to: TriggerRecipientsPayload; - transactionId: string; -} - -export interface PersistWorkflowOriginHydrationParams extends ConversationActivityContext { - platformMessageId: string; - platformThreadId: string; - messageContent: string; - signalData: Record; -} - -export interface PersistToolApprovalDecisionParams extends ConversationActivityContext { - approvalId: string; - approved: boolean; - toolName?: string; - actorType: - | ConversationActivitySenderTypeEnum.SUBSCRIBER - | ConversationActivitySenderTypeEnum.PLATFORM_USER - | ConversationActivitySenderTypeEnum.SYSTEM; - actorId: string; -} - -export interface PersistToolResultParams extends ConversationActivityContext { - toolCallId: string; - toolName?: string; - /** The tool's output as returned by the model runtime (JSON-serializable). */ - output: unknown; - /** Human-readable preview for the display timeline; defaults to a generic line. */ - preview?: string; -} - -export interface PersistMcpConnectionRequestParams extends ConversationActivityContext { - actionId: string; - mcpId: string; - displayName: string; - authorizeUrl: string; - authorizeUrlWithAutoApprove?: string; -} - -export interface PersistMcpConnectionResultParams extends ConversationActivityContext { - actionId: string; - mcpId: string; - status: 'connected' | 'failed'; - message?: string; -} - @Injectable() export class AgentConversationService { constructor( private readonly conversationRepository: ConversationRepository, - private readonly activityRepository: ConversationActivityRepository, - private readonly eventSequenceService: ConversationEventSequenceService, - private readonly agentChatLiveActivityPublisher: AgentChatLiveActivityPublisher, + private readonly ledger: ConversationActivityLedger, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } + // --- Thread --- + getPrimaryChannel(conversation: ConversationEntity): ConversationChannel { const channel = conversation.channels?.[0]; if (!channel) { @@ -302,113 +175,6 @@ export class AgentConversationService { return conversation; } - private async ensureParticipant(conversation: ConversationEntity, params: CreateOrGetConversationParams) { - const alreadyPresent = conversation.participants.some( - (p) => p.id === params.participantId && p.type === params.participantType - ); - if (alreadyPresent) return; - - const platformIdentity = `${params.platform}:${params.platformUserId}`; - - if (params.participantType === ConversationParticipantTypeEnum.SUBSCRIBER) { - const platformUserIdx = conversation.participants.findIndex( - (p) => p.type === ConversationParticipantTypeEnum.PLATFORM_USER && p.id === platformIdentity - ); - - if (platformUserIdx !== -1) { - conversation.participants[platformUserIdx] = { type: params.participantType, id: params.participantId }; - - this.logger.debug( - `Upgraded participant ${platformIdentity} → subscriber ${params.participantId} in conversation ${conversation._id}` - ); - } else { - conversation.participants.push({ type: params.participantType, id: params.participantId }); - } - } else { - conversation.participants.push({ type: params.participantType, id: params.participantId }); - } - - await this.conversationRepository.updateParticipants( - params.environmentId, - params.organizationId, - conversation._id, - conversation.participants - ); - } - - async persistInboundMessage(params: PersistInboundMessageParams): Promise { - const content = params.content ?? ''; - const preview = getInboundActivityPreview(content, { - richContent: params.richContent, - hasPlatformAttachments: params.hasPlatformAttachments, - }); - const identifier = params.identifier ?? `act_${shortId(12)}`; - const sequence = await this.resolveEventSequence( - params.conversationId, - params.environmentId, - params.organizationId, - params.sequence - ); - - try { - const [activity] = await Promise.all([ - this.activityRepository.createUserActivity({ - identifier, - conversationId: params.conversationId, - platform: params.platform, - integrationId: params.integrationId, - platformThreadId: params.platformThreadId, - senderType: params.senderType, - senderId: params.senderId, - senderName: params.senderName, - content, - richContent: params.richContent, - platformMessageId: params.platformMessageId, - sequence, - environmentId: params.environmentId, - organizationId: params.organizationId, - }), - this.conversationRepository.touchActivity( - params.environmentId, - params.organizationId, - params.conversationId, - preview - ), - ]); - - return activity; - } catch (err) { - if (params.identifier && isDuplicateKeyError(err)) { - const existing = await this.activityRepository.findOne( - { - _environmentId: params.environmentId, - _conversationId: params.conversationId, - identifier: params.identifier, - }, - '*' - ); - - if (existing) { - return existing; - } - } - - throw err; - } - } - - async findSourceActivity( - environmentId: string, - conversationId: string, - platformMessageId: string - ): Promise { - return this.activityRepository.findByPlatformMessageId(environmentId, conversationId, platformMessageId); - } - - async countAgentMessages(environmentId: string, conversationId: string): Promise { - return this.activityRepository.countAgentMessages(environmentId, conversationId); - } - async getConversation( conversationId: string, environmentId: string, @@ -489,205 +255,6 @@ export class AgentConversationService { ); } - async findAgentMessageByIdentifier( - environmentId: string, - conversationId: string, - identifier: string - ): Promise { - return this.activityRepository.findOne( - { - _environmentId: environmentId, - _conversationId: conversationId, - identifier, - type: ConversationActivityTypeEnum.MESSAGE, - }, - '*' - ); - } - - async persistAgentMessage(params: PersistAgentActivityParams): Promise { - try { - const activity = await this.persistAgentActivity(params, ConversationActivityTypeEnum.MESSAGE, 'activity'); - - return { activity, created: true }; - } catch (err) { - if (params.identifier && isDuplicateKeyError(err)) { - this.logger.warn( - { identifier: params.identifier, conversationId: params.conversationId }, - 'Agent message activity already recorded (duplicate identifier)' - ); - - const existing = await this.activityRepository.findOne( - { - _environmentId: params.environmentId, - _conversationId: params.conversationId, - identifier: params.identifier, - }, - '*' - ); - - if (existing) { - return { activity: existing, created: false }; - } - } - - throw err; - } - } - - /** Records the platform-native message id after a successful post on a persist-first delivery. */ - async setAgentMessagePlatformMessageId(params: { - environmentId: string; - organizationId: string; - conversationId: string; - activityId: string; - platformMessageId: string; - }): Promise { - await this.activityRepository.update( - { - _environmentId: params.environmentId, - _organizationId: params.organizationId, - _conversationId: params.conversationId, - _id: params.activityId, - }, - { $set: { platformMessageId: params.platformMessageId } } - ); - } - - /** Compensating delete when the platform post fails, so a retry can re-claim the identifier. */ - async deleteAgentMessage(params: { - environmentId: string; - organizationId: string; - conversationId: string; - activityId: string; - }): Promise { - await this.activityRepository.findOneAndDelete({ - _environmentId: params.environmentId, - _organizationId: params.organizationId, - _conversationId: params.conversationId, - _id: params.activityId, - }); - } - - async persistToolApprovalRequest(params: PersistToolApprovalRequestParams): Promise { - const toolName = params.toolName; - const sequence = await this.resolveEventSequence( - params.conversationId, - params.environmentId, - params.organizationId - ); - const actionIds = - params.approveActionId && params.denyActionId - ? { approveActionId: params.approveActionId, denyActionId: params.denyActionId } - : mintApprovalActionIds({ approvalId: params.approvalId }); - - const activity = await this.activityRepository.createToolActivity({ - identifier: `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.channel.platformThreadId, - senderType: ConversationActivitySenderTypeEnum.AGENT, - senderId: params.agentIdentifier, - content: params.preview ?? `Approval required: ${toolName}`, - type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST, - toolData: { - approvalId: params.approvalId, - toolCallId: params.toolCallId, - toolName: params.toolName, - input: params.input, - approveActionId: actionIds.approveActionId, - denyActionId: actionIds.denyActionId, - }, - sequence, - environmentId: params.environmentId, - organizationId: params.organizationId, - }); - - await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ - channel: params.channel, - conversationId: params.conversationId, - environmentId: params.environmentId, - organizationId: params.organizationId, - agentIdentifier: params.agentIdentifier, - activity, - }); - - return activity; - } - - /** Links a delivered approval card message to its ledger row (for platform edits). */ - async linkToolApprovalRequestCard(params: { - environmentId: string; - organizationId: string; - conversationId: string; - activityId: string; - platformMessageId: string; - }): Promise { - await this.activityRepository.update( - { - _environmentId: params.environmentId, - _organizationId: params.organizationId, - _conversationId: params.conversationId, - _id: params.activityId, - type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST, - }, - { $set: { platformMessageId: params.platformMessageId } } - ); - } - - async persistAgentEdit(params: PersistAgentActivityParams): Promise { - return this.persistAgentActivity(params, ConversationActivityTypeEnum.EDIT, 'preview'); - } - - async persistAgentDelete(params: PersistAgentActivityParams): Promise { - return this.persistAgentActivity(params, ConversationActivityTypeEnum.DELETE, 'preview'); - } - - private async persistAgentActivity( - params: PersistAgentActivityParams & { - toolData?: ConversationActivityToolData; - }, - type: ConversationActivityTypeEnum, - touch: 'activity' | 'preview' - ): Promise { - const threadId = params.platformThreadId ?? params.channel.platformThreadId; - const sequence = await this.resolveEventSequence( - params.conversationId, - params.environmentId, - params.organizationId, - params.sequence - ); - - const touchFn = - touch === 'activity' - ? this.conversationRepository.touchActivity.bind(this.conversationRepository) - : this.conversationRepository.touchPreview.bind(this.conversationRepository); - - const [activity] = await Promise.all([ - this.activityRepository.createAgentActivity({ - identifier: params.identifier ?? `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: threadId, - platformMessageId: params.platformMessageId, - agentId: params.agentIdentifier, - senderName: params.agentName, - content: params.content, - richContent: params.richContent, - toolData: params.toolData, - type, - sequence, - environmentId: params.environmentId, - organizationId: params.organizationId, - }), - touchFn(params.environmentId, params.organizationId, params.conversationId, params.content), - ]); - - return activity; - } - async updateMetadata(params: UpdateMetadataParams): Promise { let merged: Record = { ...(params.currentMetadata ?? {}) }; const descriptions: string[] = []; @@ -721,17 +288,10 @@ export class AgentConversationService { params.conversationId, merged ), - this.activityRepository.createSignalActivity({ - identifier: `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.channel.platformThreadId, - agentId: params.agentIdentifier, + this.ledger.persistMetadataSignal({ + ...params, content: `Metadata updated: ${descriptions.join(', ')}`, - signalData: { type: 'metadata', payload: merged }, - environmentId: params.environmentId, - organizationId: params.organizationId, + payload: merged, }), ]); } @@ -753,268 +313,218 @@ export class AgentConversationService { new Date().toISOString() ), this.conversationRepository.clearExternalSessionId(params.environmentId, params.conversationId), - this.activityRepository.createSignalActivity({ - identifier: `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.channel.platformThreadId, - agentId: params.agentIdentifier, + this.ledger.persistResolveSignal({ + ...params, content: params.summary ?? 'Conversation resolved', - signalData: { type: 'resolve', payload: params.summary ? { summary: params.summary } : undefined }, - environmentId: params.environmentId, - organizationId: params.organizationId, }), ]); } - /** - * Persist a tool-approval decision as a signal activity so it becomes part of - * the durable transcript. Self-hosted (stateless) agents reconstruct the resume - * message list from history via `toModelMessages`, so the decision must live in - * the transcript — not only in the ephemeral approval card. - */ - async persistToolApprovalDecision(params: PersistToolApprovalDecisionParams): Promise { - const toolName = params.toolName ?? 'tool call'; - const sequence = await this.resolveEventSequence( - params.conversationId, - params.environmentId, - params.organizationId - ); + // --- Messages --- - const activity = await this.activityRepository.createToolActivity({ - identifier: `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.channel.platformThreadId, - senderType: params.actorType, - senderId: params.actorId, - content: params.approved ? `Approved ${toolName}` : `Denied ${toolName}`, - type: ConversationActivityTypeEnum.TOOL_APPROVAL_DECISION, - toolData: { approvalId: params.approvalId, approved: params.approved, toolName: params.toolName }, - sequence, - environmentId: params.environmentId, - organizationId: params.organizationId, - }); + async persistInboundMessage(params: PersistInboundMessageParams): Promise { + return this.ledger.persistInboundMessage(params); + } - await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ - channel: params.channel, - conversationId: params.conversationId, - environmentId: params.environmentId, - organizationId: params.organizationId, - agentIdentifier: params.agentIdentifier, - activity, - }); + async persistAgentMessage(params: PersistAgentActivityParams): Promise { + return this.ledger.persistAgentMessage(params); + } - return activity; + async setAgentMessagePlatformMessageId(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + platformMessageId: string; + }): Promise { + return this.ledger.setAgentMessagePlatformMessageId(params); } - async persistToolResult(params: PersistToolResultParams): Promise { - const sequence = await this.resolveEventSequence( - params.conversationId, - params.environmentId, - params.organizationId - ); + async deleteAgentMessage(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + }): Promise { + return this.ledger.deleteAgentMessage(params); + } - const activity = await this.activityRepository.createToolActivity({ - identifier: `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.channel.platformThreadId, - senderType: ConversationActivitySenderTypeEnum.AGENT, - senderId: params.agentIdentifier, - content: params.preview ?? `Tool result: ${params.toolName ?? params.toolCallId}`, - type: ConversationActivityTypeEnum.TOOL_RESULT, - toolData: { toolCallId: params.toolCallId, toolName: params.toolName, output: params.output }, - sequence, - environmentId: params.environmentId, - organizationId: params.organizationId, - }); + async persistAgentEdit(params: PersistAgentActivityParams): Promise { + return this.ledger.persistAgentEdit(params); + } - await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ - channel: params.channel, - conversationId: params.conversationId, - environmentId: params.environmentId, - organizationId: params.organizationId, - agentIdentifier: params.agentIdentifier, - activity, - }); + async persistAgentDelete(params: PersistAgentActivityParams): Promise { + return this.ledger.persistAgentDelete(params); } - async persistMcpConnectionRequest(params: PersistMcpConnectionRequestParams): Promise { - const activity = await this.persistAgentActivity( - { - ...params, - identifier: `mcp-connection:${params.actionId}:request`, - content: `Connect ${params.displayName}`, - richContent: { - mcpConnection: { - actionId: params.actionId, - mcpId: params.mcpId, - displayName: params.displayName, - authorizeUrl: params.authorizeUrl, - authorizeUrlWithAutoApprove: params.authorizeUrlWithAutoApprove, - }, - }, - }, - ConversationActivityTypeEnum.MCP_CONNECTION_REQUEST, - 'activity' - ); + async persistWorkflowOriginHydration(params: PersistWorkflowOriginHydrationParams): Promise { + return this.ledger.persistWorkflowOriginHydration(params); + } - await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ - channel: params.channel, - conversationId: params.conversationId, - environmentId: params.environmentId, - organizationId: params.organizationId, - agentIdentifier: params.agentIdentifier, - activity, - }); + async isWorkflowOriginHydrated( + environmentId: string, + conversationId: string, + platformMessageId: string + ): Promise { + return this.ledger.isWorkflowOriginHydrated(environmentId, conversationId, platformMessageId); + } + + async listForView(params: { + view: ActivityView; + environmentId: string; + organizationId: string; + conversationId: string; + limit?: number; + before?: string; + }): Promise<{ data: ConversationActivityEntity[]; hasMore: boolean }> { + return this.ledger.listForView(params); + } + + // --- Tools --- - return activity; + async persistToolApprovalRequest(params: PersistToolApprovalRequestParams): Promise { + return this.ledger.persistToolApprovalRequest(params); + } + + async linkToolApprovalRequestCard(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + platformMessageId: string; + }): Promise { + return this.ledger.linkToolApprovalRequestCard(params); + } + + async persistToolApprovalDecision(params: PersistToolApprovalDecisionParams): Promise { + return this.ledger.persistToolApprovalDecision(params); + } + + async persistToolResult(params: PersistToolResultParams): Promise { + return this.ledger.persistToolResult(params); + } + + async persistMcpConnectionRequest(params: PersistMcpConnectionRequestParams): Promise { + return this.ledger.persistMcpConnectionRequest(params); } async persistMcpConnectionResult(params: PersistMcpConnectionResultParams): Promise { - const activity = await this.persistAgentActivity( - { - ...params, - identifier: `mcp-connection:${params.actionId}:result`, - content: params.status === 'connected' ? 'Connection completed' : (params.message ?? 'Connection failed'), - richContent: { - mcpConnection: { - actionId: params.actionId, - mcpId: params.mcpId, - status: params.status, - message: params.message, - }, - }, - }, - ConversationActivityTypeEnum.MCP_CONNECTION_RESULT, - 'activity' - ); + return this.ledger.persistMcpConnectionResult(params); + } - await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ - channel: params.channel, - conversationId: params.conversationId, - environmentId: params.environmentId, - organizationId: params.organizationId, - agentIdentifier: params.agentIdentifier, - activity, - }); + async persistTriggerSignal(params: PersistTriggerSignalParams): Promise { + return this.ledger.persistTriggerSignal(params); + } - return activity; + async persistRunLifecycle(params: PersistRunLifecycleParams): Promise { + return this.ledger.persistRunLifecycle(params); } - /** - * Whoever makes the event real first mints its sequence: live delivery paths - * (web) mint before emitting and pass the value here; everything else gets - * one at persist time. Channel-agnostic — every conversation is sequenced. - */ - private async resolveEventSequence( + // --- Lookups --- + + async findByPlatformMessageId( + environmentId: string, conversationId: string, + platformMessageId: string + ): Promise { + return this.ledger.findByPlatformMessageId(environmentId, conversationId, platformMessageId); + } + + async findSourceActivity( environmentId: string, - organizationId: string, - sequence?: number - ): Promise { - if (sequence !== undefined) { - return sequence; - } + conversationId: string, + platformMessageId: string + ): Promise { + return this.findByPlatformMessageId(environmentId, conversationId, platformMessageId); + } - return this.eventSequenceService.mint({ - environmentId, - organizationId, - conversationId, - }); + async countAgentMessages(environmentId: string, conversationId: string): Promise { + return this.ledger.countAgentMessages(environmentId, conversationId); } - async persistTriggerSignal(params: PersistTriggerSignalParams): Promise { - await this.activityRepository.createSignalActivity({ - identifier: `act_${shortId(12)}`, - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.channel.platformThreadId, - agentId: params.agentIdentifier, - content: `Triggered workflow: ${params.workflowId}`, - signalData: { - type: 'trigger', - payload: { - workflowId: params.workflowId, - to: params.to, - transactionId: params.transactionId, - }, - }, - environmentId: params.environmentId, - organizationId: params.organizationId, - }); + async findAgentMessageByIdentifier( + environmentId: string, + conversationId: string, + identifier: string + ): Promise { + return this.ledger.findAgentMessageByIdentifier(environmentId, conversationId, identifier); } - /** - * Whether this origin is already in the transcript. The signal is the final write of - * `persistWorkflowOriginHydration`, so a partially applied hydration reads as not hydrated. - */ - async isWorkflowOriginHydrated( + async findToolActivitiesByPlanMessageId( environmentId: string, conversationId: string, - platformMessageId: string - ): Promise { - const count = await this.activityRepository.count( - { - _environmentId: environmentId, - _conversationId: conversationId, - identifier: workflowOriginSignalIdentifier(platformMessageId), - }, - 1 - ); + planMessageId: string + ): Promise { + return this.ledger.findToolActivitiesByPlanMessageId(environmentId, conversationId, planMessageId); + } - return count > 0; + async persistToolUseSignal( + params: ConversationActivityContext & { content: string; payload: Record } + ): Promise { + return this.ledger.persistToolUseSignal(params); } - /** - * Persist the workflow-origin message + signal. Stable `workflow-dispatch-*` - * identifiers collide on the unique index under concurrency; both writes are - * duplicate-key tolerant so a retry after a partial success is idempotent. - */ - async persistWorkflowOriginHydration(params: PersistWorkflowOriginHydrationParams): Promise { - await this.persistAgentMessage({ - conversationId: params.conversationId, - channel: params.channel, - agentIdentifier: params.agentIdentifier, - environmentId: params.environmentId, - organizationId: params.organizationId, - platformMessageId: params.platformMessageId, - platformThreadId: params.platformThreadId, - identifier: `workflow-dispatch-msg:${params.platformMessageId}`, - content: params.messageContent, - }); + async enrichToolUseSignal(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + content: string; + payload: Record; + }): Promise { + return this.ledger.enrichToolUseSignal(params); + } - try { - await this.activityRepository.createSignalActivity({ - identifier: workflowOriginSignalIdentifier(params.platformMessageId), - conversationId: params.conversationId, - platform: params.channel.platform, - integrationId: params.channel._integrationId, - platformThreadId: params.platformThreadId, - agentId: params.agentIdentifier, - content: `Workflow origin: ${String(params.signalData.workflowIdentifier ?? 'unknown')}`, - signalData: { - type: 'workflow_origin', - payload: params.signalData, - }, - platformMessageId: params.platformMessageId, - environmentId: params.environmentId, - organizationId: params.organizationId, - }); - } catch (err) { - if (!isDuplicateKeyError(err)) { - throw err; - } + async repointSubscriberSender(params: { + environmentId: string; + organizationId: string; + fromSubscriberId: string; + toSubscriberId: string; + }): Promise { + return this.ledger.repointSubscriberSender(params); + } + + // --- Sequence --- + + async mintEventSequence(params: { + environmentId: string; + organizationId: string; + conversationId: string; + }): Promise { + return this.ledger.mint(params); + } + + private async ensureParticipant(conversation: ConversationEntity, params: CreateOrGetConversationParams) { + const alreadyPresent = conversation.participants.some( + (p) => p.id === params.participantId && p.type === params.participantType + ); + if (alreadyPresent) return; + + const platformIdentity = `${params.platform}:${params.platformUserId}`; - this.logger.warn( - { platformMessageId: params.platformMessageId, conversationId: params.conversationId }, - 'Workflow origin already hydrated' + if (params.participantType === ConversationParticipantTypeEnum.SUBSCRIBER) { + const platformUserIdx = conversation.participants.findIndex( + (p) => p.type === ConversationParticipantTypeEnum.PLATFORM_USER && p.id === platformIdentity ); + + if (platformUserIdx !== -1) { + conversation.participants[platformUserIdx] = { type: params.participantType, id: params.participantId }; + + this.logger.debug( + `Upgraded participant ${platformIdentity} → subscriber ${params.participantId} in conversation ${conversation._id}` + ); + } else { + conversation.participants.push({ type: params.participantType, id: params.participantId }); + } + } else { + conversation.participants.push({ type: params.participantType, id: params.participantId }); } + + await this.conversationRepository.updateParticipants( + params.environmentId, + params.organizationId, + conversation._id, + conversation.participants + ); } } diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.types.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.types.ts new file mode 100644 index 00000000000..81d3353487c --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.types.ts @@ -0,0 +1,123 @@ +import type { ConversationActivityEntity, ConversationActivitySenderTypeEnum, ConversationChannel } from '@novu/dal'; +import type { TriggerRecipientsPayload } from '@novu/shared'; + +export interface PersistInboundMessageParams { + conversationId: string; + platform: string; + integrationId: string; + platformThreadId: string; + senderType: ConversationActivitySenderTypeEnum; + senderId: string; + senderName?: string; + content: string; + richContent?: Record; + hasPlatformAttachments?: boolean; + platformMessageId?: string; + /** Caller-supplied activity identifier; defaults to a server-minted act_* id */ + identifier?: string; + /** Pre-allocated conversation event sequence; minted at persist time when absent */ + sequence?: number; + environmentId: string; + organizationId: string; +} + +export interface ConversationActivityContext { + conversationId: string; + channel: ConversationChannel; + agentIdentifier: string; + environmentId: string; + organizationId: string; +} + +export interface PersistAgentMessageResult { + activity: ConversationActivityEntity; + /** `false` when the identifier already existed — the caller lost the persist race. */ + created: boolean; +} + +export interface PersistAgentActivityParams extends ConversationActivityContext { + platformMessageId?: string; + /** Overrides channel.platformThreadId when delivery returns a different thread ID */ + platformThreadId?: string; + /** Caller-supplied activity identifier; defaults to a server-minted act_* id */ + identifier?: string; + agentName?: string; + content: string; + richContent?: Record; + /** Pre-allocated conversation event sequence; minted at persist time when absent */ + sequence?: number; +} + +export interface PersistToolApprovalRequestParams extends ConversationActivityContext { + approvalId: string; + toolCallId: string; + toolName: string; + input?: Record; + /** Human-readable preview for the display timeline. */ + preview?: string; + /** When omitted, self-hosted `tool-approval:*` ids are minted. */ + approveActionId?: string; + denyActionId?: string; +} + +export type MetadataOp = + | { action: 'set'; key: string; value: unknown } + | { action: 'delete'; key: string } + | { action: 'clear' }; + +export interface UpdateMetadataParams extends ConversationActivityContext { + currentMetadata: Record; + ops: MetadataOp[]; +} + +export interface ResolveConversationParams extends ConversationActivityContext { + summary?: string; +} + +export interface PersistTriggerSignalParams extends ConversationActivityContext { + workflowId: string; + to: TriggerRecipientsPayload; + transactionId: string; +} + +export interface PersistWorkflowOriginHydrationParams extends ConversationActivityContext { + platformMessageId: string; + platformThreadId: string; + messageContent: string; + signalData: Record; +} + +export interface PersistToolApprovalDecisionParams extends ConversationActivityContext { + approvalId: string; + approved: boolean; + toolName?: string; + actorType: + | ConversationActivitySenderTypeEnum.SUBSCRIBER + | ConversationActivitySenderTypeEnum.PLATFORM_USER + | ConversationActivitySenderTypeEnum.SYSTEM; + actorId: string; +} + +export interface PersistToolResultParams extends ConversationActivityContext { + toolCallId: string; + toolName?: string; + /** The tool's output as returned by the model runtime (JSON-serializable). */ + output: unknown; + /** Human-readable preview for the display timeline; defaults to a generic line. */ + preview?: string; +} + +export interface PersistMcpConnectionRequestParams extends ConversationActivityContext { + actionId: string; + mcpId: string; + displayName: string; + authorizeUrl: string; + authorizeUrlWithAutoApprove?: string; +} + +export interface PersistMcpConnectionResultParams extends ConversationActivityContext { + actionId: string; + mcpId: string; + status: 'connected' | 'failed'; + message?: string; +} diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.spec.ts index 17a1b5d9cf6..cfac25cbd0f 100644 --- a/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.spec.ts @@ -17,7 +17,7 @@ describe('AgentSubscriberAdoptionService', () => { const conversationRepository = { repointSubscriberParticipant: overrides.repointParticipant ?? sinon.stub().resolves(2), }; - const conversationActivityRepository = { + const conversationService = { repointSubscriberSender: overrides.repointSender ?? sinon.stub().resolves(5), }; const mcpConnectionRepository = { @@ -40,7 +40,7 @@ describe('AgentSubscriberAdoptionService', () => { const service = new AgentSubscriberAdoptionService( conversationRepository as any, - conversationActivityRepository as any, + conversationService as any, mcpConnectionRepository as any, agentToolTrustRepository as any, subscriberRepository as any, @@ -51,7 +51,7 @@ describe('AgentSubscriberAdoptionService', () => { return { service, conversationRepository, - conversationActivityRepository, + conversationService, mcpConnectionRepository, agentToolTrustRepository, subscriberRepository, @@ -68,7 +68,7 @@ describe('AgentSubscriberAdoptionService', () => { const { service, conversationRepository, - conversationActivityRepository, + conversationService, mcpConnectionRepository, agentToolTrustRepository, subscriberRepository, @@ -83,8 +83,8 @@ describe('AgentSubscriberAdoptionService', () => { fromSubscriberId: 'sub-phantom', toSubscriberId: 'sub-real', }); - expect(conversationActivityRepository.repointSubscriberSender.calledOnce).to.equal(true); - expect(conversationActivityRepository.repointSubscriberSender.firstCall.args[0]).to.include({ + expect(conversationService.repointSubscriberSender.calledOnce).to.equal(true); + expect(conversationService.repointSubscriberSender.firstCall.args[0]).to.include({ environmentId: 'env-1', organizationId: 'org-1', fromSubscriberId: 'sub-phantom', diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.ts index a2468b67cae..87f3c8a5999 100644 --- a/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-adoption.service.ts @@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common'; import { AnalyticsService, PinoLogger } from '@novu/application-generic'; import { AgentToolTrustRepository, - ConversationActivityRepository, ConversationRepository, McpConnectionRepository, SubscriberRepository, } from '@novu/dal'; import { AGENT_PLATFORM_PROVISION_SOURCE, AGENT_PROVISION_DATA_KEYS } from '@novu/shared'; +import { AgentConversationService } from './agent-conversation.service'; /** * Identity pair for a subscriber involved in an adoption merge. The email @@ -35,7 +35,7 @@ export interface AdoptionSubscriberRef { export class AgentSubscriberAdoptionService { constructor( private readonly conversationRepository: ConversationRepository, - private readonly conversationActivityRepository: ConversationActivityRepository, + private readonly conversationService: AgentConversationService, private readonly mcpConnectionRepository: McpConnectionRepository, private readonly agentToolTrustRepository: AgentToolTrustRepository, private readonly subscriberRepository: SubscriberRepository, @@ -81,7 +81,7 @@ export class AgentSubscriberAdoptionService { toSubscriberId: real.subscriberId, }); - const activities = await this.conversationActivityRepository.repointSubscriberSender({ + const activities = await this.conversationService.repointSubscriberSender({ environmentId, organizationId, fromSubscriberId: phantom.subscriberId, diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.spec.ts b/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.spec.ts index 6bcc7eb820e..e95b1e9b7e4 100644 --- a/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.spec.ts @@ -1,7 +1,8 @@ -import { ConversationActivityTypeEnum } from '@novu/dal'; +import { ConversationActivityTypeEnum, ConversationRepository } from '@novu/dal'; import { expect } from 'chai'; import sinon from 'sinon'; import { ConversationActivityLedger } from './conversation-activity-ledger'; +import { ConversationEventSequenceService } from './conversation-event-sequence.service'; describe('ConversationActivityLedger', () => { const lifecycleParams = { @@ -18,39 +19,339 @@ describe('ConversationActivityLedger', () => { event: { type: 'run-start' } as const, }; - function makeLedger(createRunActivity: sinon.SinonStub) { - return new ConversationActivityLedger({ createRunActivity } as any, { mint: sinon.stub().resolves(7) } as any); + function makeLogger() { + return { + setContext: sinon.stub(), + debug: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + }; } - it('stamps the minted sequence so lifecycle rows interleave with messages in order', async () => { - const createRunActivity = sinon.stub().resolves({ - _id: 'run-1', - type: ConversationActivityTypeEnum.RUN_START, - sequence: 7, + function makeActivityRepository(overrides: Record = {}) { + return { + createRunActivity: overrides.createRunActivity ?? sinon.stub(), + createAgentActivity: + overrides.createAgentActivity ?? sinon.stub().resolves({ _id: 'activity-1', identifier: 'act_generated' }), + createToolActivity: overrides.createToolActivity ?? sinon.stub().resolves({ _id: 'tool-activity' }), + createSignalActivity: overrides.createSignalActivity ?? sinon.stub().resolves({}), + findOne: overrides.findOne ?? sinon.stub().resolves(null), + count: overrides.count ?? sinon.stub().resolves(0), + ...overrides, + }; + } + + function makeConversationRepository(overrides: Record = {}) { + return { + touchActivity: overrides.touchActivity ?? sinon.stub().resolves(undefined), + touchPreview: overrides.touchPreview ?? sinon.stub().resolves(undefined), + ...overrides, + }; + } + + function makeLedger( + activityRepository = makeActivityRepository(), + eventSequenceService = { mint: sinon.stub().resolves(7) } as unknown as ConversationEventSequenceService, + publisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) }, + conversationRepository = makeConversationRepository(), + logger = makeLogger() + ) { + return new ConversationActivityLedger( + activityRepository as any, + eventSequenceService, + publisher as any, + conversationRepository as unknown as ConversationRepository, + logger as any + ); + } + + function basePersistParams() { + return { + conversationId: 'conv-1', + channel: { + platform: 'slack', + _integrationId: 'integration-a', + platformThreadId: 'thread-1', + }, + platformMessageId: 'msg-1', + agentIdentifier: 'agent-a', + content: 'hello', + environmentId: 'env-1', + organizationId: 'org-1', + }; + } + + describe('persistRunLifecycle', () => { + it('stamps the minted sequence so lifecycle rows interleave with messages in order', async () => { + const createRunActivity = sinon.stub().resolves({ + _id: 'run-1', + type: ConversationActivityTypeEnum.RUN_START, + sequence: 7, + }); + const publisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) }; + + await makeLedger(makeActivityRepository({ createRunActivity }), undefined, publisher).persistRunLifecycle( + lifecycleParams + ); + + expect(createRunActivity.firstCall.args[0].sequence).to.equal(7); + expect(createRunActivity.firstCall.args[0].identifier).to.equal('run_run-abc_start'); + expect(publisher.emitPersistedClientEvent.calledOnce).to.equal(true); }); - await makeLedger(createRunActivity).persistProtocolEvent(lifecycleParams); + it('returns null when the same run event is ingested twice and does not emit', async () => { + const createRunActivity = sinon.stub().rejects(Object.assign(new Error('dup'), { code: 11000 })); + const publisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) }; - expect(createRunActivity.firstCall.args[0].sequence).to.equal(7); - expect(createRunActivity.firstCall.args[0].identifier).to.equal('run_run-abc_start'); - }); + const activity = await makeLedger( + makeActivityRepository({ createRunActivity }), + undefined, + publisher + ).persistRunLifecycle(lifecycleParams); - it('returns null when the same run event is ingested twice so it is published once', async () => { - const createRunActivity = sinon.stub().rejects(Object.assign(new Error('dup'), { code: 11000 })); + expect(activity).to.equal(null); + expect(publisher.emitPersistedClientEvent.called).to.equal(false); + }); - const activity = await makeLedger(createRunActivity).persistProtocolEvent(lifecycleParams); + it('propagates non-duplicate write failures to the caller', async () => { + const createRunActivity = sinon.stub().rejects(new Error('mongo down')); - expect(activity).to.equal(null); + try { + await makeLedger(makeActivityRepository({ createRunActivity })).persistRunLifecycle(lifecycleParams); + expect.fail('expected persistRunLifecycle to reject'); + } catch (err) { + expect((err as Error).message).to.equal('mongo down'); + } + }); }); - it('propagates non-duplicate write failures to the caller', async () => { - const createRunActivity = sinon.stub().rejects(new Error('mongo down')); + describe('persistAgentMessage', () => { + it('uses the caller-supplied identifier when provided', async () => { + const activityRepository = makeActivityRepository(); + const ledger = makeLedger(activityRepository); + + const result = await ledger.persistAgentMessage({ + ...basePersistParams(), + identifier: 'client-msg-123', + }); - try { - await makeLedger(createRunActivity).persistProtocolEvent(lifecycleParams); - expect.fail('expected persistProtocolEvent to reject'); - } catch (err) { - expect((err as Error).message).to.equal('mongo down'); + expect(result.created).to.equal(true); + expect(activityRepository.createAgentActivity.calledOnce).to.equal(true); + expect(activityRepository.createAgentActivity.firstCall.args[0].identifier).to.equal('client-msg-123'); + expect(activityRepository.createAgentActivity.firstCall.args[0].type).to.equal( + ConversationActivityTypeEnum.MESSAGE + ); + }); + + it('mints an act_ identifier when none is supplied', async () => { + const activityRepository = makeActivityRepository(); + const ledger = makeLedger(activityRepository); + + await ledger.persistAgentMessage(basePersistParams()); + + const identifier = activityRepository.createAgentActivity.firstCall.args[0].identifier; + + expect(identifier).to.match(/^act_/); + }); + + it('logs and returns the existing activity on duplicate identifier races', async () => { + const duplicateError = Object.assign(new Error('duplicate key'), { code: 11000 }); + const existingActivity = { _id: 'existing-1', identifier: 'client-msg-123' }; + const activityRepository = makeActivityRepository({ + createAgentActivity: sinon.stub().rejects(duplicateError), + findOne: sinon.stub().resolves(existingActivity), + }); + const logger = makeLogger(); + const ledger = makeLedger(activityRepository, undefined, undefined, undefined, logger); + + const result = await ledger.persistAgentMessage({ + ...basePersistParams(), + identifier: 'client-msg-123', + }); + + expect(result.activity).to.equal(existingActivity); + expect(result.created).to.equal(false); + expect(logger.warn.calledOnce).to.equal(true); + }); + + it('pairs touchActivity with agent message persist', async () => { + const touchActivity = sinon.stub().resolves(undefined); + const conversationRepository = makeConversationRepository({ touchActivity }); + const ledger = makeLedger(makeActivityRepository(), undefined, undefined, conversationRepository); + + await ledger.persistAgentMessage(basePersistParams()); + + expect(touchActivity.calledOnce).to.equal(true); + }); + }); + + describe('persistWorkflowOriginHydration', () => { + function makeHydrationParams() { + return { + conversationId: 'conv-1', + channel: { + platform: 'whatsapp', + _integrationId: 'integration-a', + platformThreadId: 'whatsapp:15551234567', + }, + agentIdentifier: 'agent-a', + environmentId: 'env-1', + organizationId: 'org-1', + platformMessageId: 'wamid.abc', + platformThreadId: 'whatsapp:15551234567', + messageContent: 'Your order shipped', + signalData: { workflowIdentifier: 'order-alerts' }, + }; } + + it('swallows duplicate-key errors from the signal write', async () => { + const duplicateError = Object.assign(new Error('duplicate key'), { code: 11000 }); + const activityRepository = makeActivityRepository({ + createSignalActivity: sinon.stub().rejects(duplicateError), + }); + const logger = makeLogger(); + const ledger = makeLedger(activityRepository, undefined, undefined, undefined, logger); + + await ledger.persistWorkflowOriginHydration(makeHydrationParams()); + + expect(activityRepository.createSignalActivity.calledOnce).to.equal(true); + expect(logger.warn.calledOnce).to.equal(true); + expect(logger.warn.firstCall.args[1]).to.equal('Workflow origin already hydrated'); + }); + + it('rethrows non-duplicate errors from the signal write', async () => { + const activityRepository = makeActivityRepository({ + createSignalActivity: sinon.stub().rejects(new Error('mongo timeout')), + }); + const ledger = makeLedger(activityRepository); + + try { + await ledger.persistWorkflowOriginHydration(makeHydrationParams()); + expect.fail('expected persistWorkflowOriginHydration to throw'); + } catch (err) { + expect((err as Error).message).to.equal('mongo timeout'); + } + }); + }); + + describe('isWorkflowOriginHydrated', () => { + it('matches the signal identifier written by persistWorkflowOriginHydration', async () => { + const activityRepository = makeActivityRepository({ count: sinon.stub().resolves(1) }); + const ledger = makeLedger(activityRepository); + + const hydrated = await ledger.isWorkflowOriginHydrated('env-1', 'conv-1', 'wamid.abc'); + + expect(hydrated).to.equal(true); + expect(activityRepository.count.firstCall.args[0]).to.deep.equal({ + _environmentId: 'env-1', + _conversationId: 'conv-1', + identifier: 'workflow-dispatch-origin:wamid.abc', + }); + }); + + it('returns false when the signal is absent', async () => { + const activityRepository = makeActivityRepository({ count: sinon.stub().resolves(0) }); + const ledger = makeLedger(activityRepository); + + expect(await ledger.isWorkflowOriginHydrated('env-1', 'conv-1', 'wamid.abc')).to.equal(false); + }); + }); + + describe('MCP connection activities', () => { + it('persists request and result activities and publishes both to agent chat', async () => { + const activityRepository = makeActivityRepository(); + activityRepository.createAgentActivity.callsFake(async (params: Record) => ({ + _id: `activity-${activityRepository.createAgentActivity.callCount}`, + ...params, + })); + const mint = sinon.stub().onFirstCall().resolves(10).onSecondCall().resolves(11); + const publisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) }; + const ledger = makeLedger(activityRepository, { mint } as unknown as ConversationEventSequenceService, publisher); + const context = { + ...basePersistParams(), + channel: { + platform: 'agent_chat', + _integrationId: 'integration-a', + platformThreadId: 'thread-1', + }, + }; + + await ledger.persistMcpConnectionRequest({ + ...context, + actionId: 'tool-use-1', + mcpId: 'stripe', + displayName: 'Stripe', + authorizeUrl: 'https://example.com/authorize', + }); + await ledger.persistMcpConnectionResult({ + ...context, + actionId: 'tool-use-1', + mcpId: 'stripe', + status: 'connected', + }); + + expect(activityRepository.createAgentActivity.firstCall.args[0]).to.deep.include({ + identifier: 'mcp-connection:tool-use-1:request', + type: ConversationActivityTypeEnum.MCP_CONNECTION_REQUEST, + sequence: 10, + richContent: { + mcpConnection: { + actionId: 'tool-use-1', + mcpId: 'stripe', + displayName: 'Stripe', + authorizeUrl: 'https://example.com/authorize', + authorizeUrlWithAutoApprove: undefined, + }, + }, + }); + expect(activityRepository.createAgentActivity.secondCall.args[0]).to.deep.include({ + identifier: 'mcp-connection:tool-use-1:result', + type: ConversationActivityTypeEnum.MCP_CONNECTION_RESULT, + sequence: 11, + richContent: { + mcpConnection: { + actionId: 'tool-use-1', + mcpId: 'stripe', + status: 'connected', + message: undefined, + }, + }, + }); + expect(publisher.emitPersistedClientEvent.callCount).to.equal(2); + }); + }); + + describe('event sequencing', () => { + it('allocates a sequence for durable tool activities on any channel', async () => { + const activityRepository = makeActivityRepository(); + const mint = sinon.stub().resolves(4); + const publisher = { emitPersistedClientEvent: sinon.stub().resolves(undefined) }; + const ledger = makeLedger(activityRepository, { mint } as unknown as ConversationEventSequenceService, publisher); + + await ledger.persistToolResult({ + conversationId: 'conv-1', + channel: { + platform: 'slack', + _integrationId: 'integration-a', + platformThreadId: 'thread-1', + }, + agentIdentifier: 'agent-a', + environmentId: 'env-1', + organizationId: 'org-1', + toolCallId: 'tool-call-1', + output: 'done', + }); + + expect(activityRepository.createToolActivity.firstCall.args[0].sequence).to.equal(4); + expect( + mint.calledOnceWithExactly({ + environmentId: 'env-1', + organizationId: 'org-1', + conversationId: 'conv-1', + }) + ).to.equal(true); + expect(publisher.emitPersistedClientEvent.calledOnce).to.equal(true); + }); }); }); diff --git a/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.ts b/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.ts index e03f4ee5127..e763e7244cb 100644 --- a/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.ts @@ -1,11 +1,32 @@ import { Injectable } from '@nestjs/common'; +import { PinoLogger, shortId } from '@novu/application-generic'; import { ActivityView, ConversationActivityEntity, ConversationActivityRepository, + ConversationActivitySenderTypeEnum, + ConversationActivitySignalData, + ConversationActivityToolData, + ConversationActivityTypeEnum, + ConversationRepository, isDuplicateKeyError, } from '@novu/dal'; -import { AGENT_HISTORY_LIMIT } from './agent-conversation.service'; +import { AgentChatLiveActivityPublisher } from '../../agent-chat/agent-chat-live-activity.publisher'; +import { mintApprovalActionIds } from '../../shared/tool-approval/mint-approval-action-ids'; +import { AGENT_HISTORY_LIMIT, getInboundActivityPreview } from './agent-conversation.helpers'; +import type { + ConversationActivityContext, + PersistAgentActivityParams, + PersistAgentMessageResult, + PersistInboundMessageParams, + PersistMcpConnectionRequestParams, + PersistMcpConnectionResultParams, + PersistToolApprovalDecisionParams, + PersistToolApprovalRequestParams, + PersistToolResultParams, + PersistTriggerSignalParams, + PersistWorkflowOriginHydrationParams, +} from './agent-conversation.types'; import { ConversationEventSequenceService } from './conversation-event-sequence.service'; import { describeRunLifecycleFromEvent, @@ -22,12 +43,22 @@ export interface ListActivityViewParams { before?: string; } +/** Stable per-origin identifier for the workflow-origin signal — see `persistWorkflowOriginHydration`. */ +function workflowOriginSignalIdentifier(platformMessageId: string): string { + return `workflow-dispatch-origin:${platformMessageId}`; +} + @Injectable() export class ConversationActivityLedger { constructor( private readonly activityRepository: ConversationActivityRepository, - private readonly eventSequenceService: ConversationEventSequenceService - ) {} + private readonly eventSequenceService: ConversationEventSequenceService, + private readonly agentChatLiveActivityPublisher: AgentChatLiveActivityPublisher, + private readonly conversationRepository: ConversationRepository, + private readonly logger: PinoLogger + ) { + this.logger.setContext(this.constructor.name); + } async listForView(params: ListActivityViewParams): Promise<{ data: ConversationActivityEntity[]; hasMore: boolean }> { return this.activityRepository.listForView({ @@ -40,11 +71,15 @@ export class ConversationActivityLedger { }); } + async mint(params: { environmentId: string; organizationId: string; conversationId: string }): Promise { + return this.eventSequenceService.mint(params); + } + /** * Persist a protocol operational event (run lifecycle today). Returns `null` when the same - * event was already persisted, so callers can publish exactly once per event. + * event was already persisted. Emits a client event only when the row is newly created. */ - async persistProtocolEvent(params: PersistRunLifecycleParams): Promise { + async persistRunLifecycle(params: PersistRunLifecycleParams): Promise { const { type, content, richContent, identifierSuffix } = describeRunLifecycleFromEvent(params.event); const identifier = runLifecycleIdentifier(params.runId, identifierSuffix); const sequence = await this.eventSequenceService.mint({ @@ -54,7 +89,7 @@ export class ConversationActivityLedger { }); try { - return await this.activityRepository.createRunActivity({ + const activity = await this.activityRepository.createRunActivity({ identifier, conversationId: params.conversationId, platform: params.channel.platform, @@ -68,6 +103,10 @@ export class ConversationActivityLedger { environmentId: params.environmentId, organizationId: params.organizationId, }); + + await this.emitPersistedClientEvent(params, activity); + + return activity; } catch (err) { if (isDuplicateKeyError(err)) { return null; @@ -76,4 +115,567 @@ export class ConversationActivityLedger { throw err; } } + + async persistInboundMessage(params: PersistInboundMessageParams): Promise { + const content = params.content ?? ''; + const preview = getInboundActivityPreview(content, { + richContent: params.richContent, + hasPlatformAttachments: params.hasPlatformAttachments, + }); + const identifier = params.identifier ?? `act_${shortId(12)}`; + const sequence = await this.resolveEventSequence( + params.conversationId, + params.environmentId, + params.organizationId, + params.sequence + ); + + try { + const [activity] = await Promise.all([ + this.activityRepository.createUserActivity({ + identifier, + conversationId: params.conversationId, + platform: params.platform, + integrationId: params.integrationId, + platformThreadId: params.platformThreadId, + senderType: params.senderType, + senderId: params.senderId, + senderName: params.senderName, + content, + richContent: params.richContent, + platformMessageId: params.platformMessageId, + sequence, + environmentId: params.environmentId, + organizationId: params.organizationId, + }), + this.conversationRepository.touchActivity( + params.environmentId, + params.organizationId, + params.conversationId, + preview + ), + ]); + + return activity; + } catch (err) { + if (params.identifier && isDuplicateKeyError(err)) { + const existing = await this.activityRepository.findOne( + { + _environmentId: params.environmentId, + _conversationId: params.conversationId, + identifier: params.identifier, + }, + '*' + ); + + if (existing) { + return existing; + } + } + + throw err; + } + } + + async persistAgentMessage(params: PersistAgentActivityParams): Promise { + try { + const activity = await this.persistAgentActivity(params, ConversationActivityTypeEnum.MESSAGE, 'activity'); + + return { activity, created: true }; + } catch (err) { + if (params.identifier && isDuplicateKeyError(err)) { + this.logger.warn( + { identifier: params.identifier, conversationId: params.conversationId }, + 'Agent message activity already recorded (duplicate identifier)' + ); + + const existing = await this.activityRepository.findOne( + { + _environmentId: params.environmentId, + _conversationId: params.conversationId, + identifier: params.identifier, + }, + '*' + ); + + if (existing) { + return { activity: existing, created: false }; + } + } + + throw err; + } + } + + async persistAgentEdit(params: PersistAgentActivityParams): Promise { + return this.persistAgentActivity(params, ConversationActivityTypeEnum.EDIT, 'preview'); + } + + async persistAgentDelete(params: PersistAgentActivityParams): Promise { + return this.persistAgentActivity(params, ConversationActivityTypeEnum.DELETE, 'preview'); + } + + async setAgentMessagePlatformMessageId(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + platformMessageId: string; + }): Promise { + await this.activityRepository.update( + { + _environmentId: params.environmentId, + _organizationId: params.organizationId, + _conversationId: params.conversationId, + _id: params.activityId, + }, + { $set: { platformMessageId: params.platformMessageId } } + ); + } + + async deleteAgentMessage(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + }): Promise { + await this.activityRepository.findOneAndDelete({ + _environmentId: params.environmentId, + _organizationId: params.organizationId, + _conversationId: params.conversationId, + _id: params.activityId, + }); + } + + async persistToolApprovalRequest(params: PersistToolApprovalRequestParams): Promise { + const toolName = params.toolName; + const sequence = await this.resolveEventSequence( + params.conversationId, + params.environmentId, + params.organizationId + ); + const actionIds = + params.approveActionId && params.denyActionId + ? { approveActionId: params.approveActionId, denyActionId: params.denyActionId } + : mintApprovalActionIds({ approvalId: params.approvalId }); + + const activity = await this.activityRepository.createToolActivity({ + identifier: `act_${shortId(12)}`, + conversationId: params.conversationId, + platform: params.channel.platform, + integrationId: params.channel._integrationId, + platformThreadId: params.channel.platformThreadId, + senderType: ConversationActivitySenderTypeEnum.AGENT, + senderId: params.agentIdentifier, + content: params.preview ?? `Approval required: ${toolName}`, + type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST, + toolData: { + approvalId: params.approvalId, + toolCallId: params.toolCallId, + toolName: params.toolName, + input: params.input, + approveActionId: actionIds.approveActionId, + denyActionId: actionIds.denyActionId, + }, + sequence, + environmentId: params.environmentId, + organizationId: params.organizationId, + }); + + await this.emitPersistedClientEvent(params, activity); + + return activity; + } + + async linkToolApprovalRequestCard(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + platformMessageId: string; + }): Promise { + await this.activityRepository.update( + { + _environmentId: params.environmentId, + _organizationId: params.organizationId, + _conversationId: params.conversationId, + _id: params.activityId, + type: ConversationActivityTypeEnum.TOOL_APPROVAL_REQUEST, + }, + { $set: { platformMessageId: params.platformMessageId } } + ); + } + + async persistToolApprovalDecision(params: PersistToolApprovalDecisionParams): Promise { + const toolName = params.toolName ?? 'tool call'; + const sequence = await this.resolveEventSequence( + params.conversationId, + params.environmentId, + params.organizationId + ); + + const activity = await this.activityRepository.createToolActivity({ + identifier: `act_${shortId(12)}`, + conversationId: params.conversationId, + platform: params.channel.platform, + integrationId: params.channel._integrationId, + platformThreadId: params.channel.platformThreadId, + senderType: params.actorType, + senderId: params.actorId, + content: params.approved ? `Approved ${toolName}` : `Denied ${toolName}`, + type: ConversationActivityTypeEnum.TOOL_APPROVAL_DECISION, + toolData: { approvalId: params.approvalId, approved: params.approved, toolName: params.toolName }, + sequence, + environmentId: params.environmentId, + organizationId: params.organizationId, + }); + + await this.emitPersistedClientEvent(params, activity); + + return activity; + } + + async persistToolResult(params: PersistToolResultParams): Promise { + const sequence = await this.resolveEventSequence( + params.conversationId, + params.environmentId, + params.organizationId + ); + + const activity = await this.activityRepository.createToolActivity({ + identifier: `act_${shortId(12)}`, + conversationId: params.conversationId, + platform: params.channel.platform, + integrationId: params.channel._integrationId, + platformThreadId: params.channel.platformThreadId, + senderType: ConversationActivitySenderTypeEnum.AGENT, + senderId: params.agentIdentifier, + content: params.preview ?? `Tool result: ${params.toolName ?? params.toolCallId}`, + type: ConversationActivityTypeEnum.TOOL_RESULT, + toolData: { toolCallId: params.toolCallId, toolName: params.toolName, output: params.output }, + sequence, + environmentId: params.environmentId, + organizationId: params.organizationId, + }); + + await this.emitPersistedClientEvent(params, activity); + } + + async persistMcpConnectionRequest(params: PersistMcpConnectionRequestParams): Promise { + const activity = await this.persistAgentActivity( + { + ...params, + identifier: `mcp-connection:${params.actionId}:request`, + content: `Connect ${params.displayName}`, + richContent: { + mcpConnection: { + actionId: params.actionId, + mcpId: params.mcpId, + displayName: params.displayName, + authorizeUrl: params.authorizeUrl, + authorizeUrlWithAutoApprove: params.authorizeUrlWithAutoApprove, + }, + }, + }, + ConversationActivityTypeEnum.MCP_CONNECTION_REQUEST, + 'activity' + ); + + await this.emitPersistedClientEvent(params, activity); + + return activity; + } + + async persistMcpConnectionResult(params: PersistMcpConnectionResultParams): Promise { + const activity = await this.persistAgentActivity( + { + ...params, + identifier: `mcp-connection:${params.actionId}:result`, + content: params.status === 'connected' ? 'Connection completed' : (params.message ?? 'Connection failed'), + richContent: { + mcpConnection: { + actionId: params.actionId, + mcpId: params.mcpId, + status: params.status, + message: params.message, + }, + }, + }, + ConversationActivityTypeEnum.MCP_CONNECTION_RESULT, + 'activity' + ); + + await this.emitPersistedClientEvent(params, activity); + + return activity; + } + + async persistMetadataSignal( + params: ConversationActivityContext & { content: string; payload: Record } + ) { + await this.persistSignal({ + ...params, + signalData: { type: 'metadata', payload: params.payload }, + }); + } + + async persistResolveSignal(params: ConversationActivityContext & { content: string; summary?: string }) { + await this.persistSignal({ + ...params, + signalData: { type: 'resolve', payload: params.summary ? { summary: params.summary } : undefined }, + }); + } + + async persistTriggerSignal(params: PersistTriggerSignalParams): Promise { + await this.persistSignal({ + ...params, + content: `Triggered workflow: ${params.workflowId}`, + signalData: { + type: 'trigger', + payload: { + workflowId: params.workflowId, + to: params.to, + transactionId: params.transactionId, + }, + }, + }); + } + + async isWorkflowOriginHydrated( + environmentId: string, + conversationId: string, + platformMessageId: string + ): Promise { + const count = await this.activityRepository.count( + { + _environmentId: environmentId, + _conversationId: conversationId, + identifier: workflowOriginSignalIdentifier(platformMessageId), + }, + 1 + ); + + return count > 0; + } + + async persistWorkflowOriginHydration(params: PersistWorkflowOriginHydrationParams): Promise { + await this.persistAgentMessage({ + conversationId: params.conversationId, + channel: params.channel, + agentIdentifier: params.agentIdentifier, + environmentId: params.environmentId, + organizationId: params.organizationId, + platformMessageId: params.platformMessageId, + platformThreadId: params.platformThreadId, + identifier: `workflow-dispatch-msg:${params.platformMessageId}`, + content: params.messageContent, + }); + + try { + await this.persistSignal({ + conversationId: params.conversationId, + channel: params.channel, + agentIdentifier: params.agentIdentifier, + environmentId: params.environmentId, + organizationId: params.organizationId, + identifier: workflowOriginSignalIdentifier(params.platformMessageId), + platformThreadId: params.platformThreadId, + platformMessageId: params.platformMessageId, + content: `Workflow origin: ${String(params.signalData.workflowIdentifier ?? 'unknown')}`, + signalData: { + type: 'workflow_origin', + payload: params.signalData, + }, + }); + } catch (err) { + if (!isDuplicateKeyError(err)) { + throw err; + } + + this.logger.warn( + { platformMessageId: params.platformMessageId, conversationId: params.conversationId }, + 'Workflow origin already hydrated' + ); + } + } + + async findByPlatformMessageId( + environmentId: string, + conversationId: string, + platformMessageId: string + ): Promise { + return this.activityRepository.findByPlatformMessageId(environmentId, conversationId, platformMessageId); + } + + async findSourceActivity( + environmentId: string, + conversationId: string, + platformMessageId: string + ): Promise { + return this.findByPlatformMessageId(environmentId, conversationId, platformMessageId); + } + + async countAgentMessages(environmentId: string, conversationId: string): Promise { + return this.activityRepository.countAgentMessages(environmentId, conversationId); + } + + async findAgentMessageByIdentifier( + environmentId: string, + conversationId: string, + identifier: string + ): Promise { + return this.activityRepository.findOne( + { + _environmentId: environmentId, + _conversationId: conversationId, + identifier, + type: ConversationActivityTypeEnum.MESSAGE, + }, + '*' + ); + } + + async findToolActivitiesByPlanMessageId( + environmentId: string, + conversationId: string, + planMessageId: string + ): Promise { + return this.activityRepository.findToolActivitiesByPlanMessageId(environmentId, conversationId, planMessageId); + } + + async persistToolUseSignal( + params: ConversationActivityContext & { content: string; payload: Record } + ): Promise { + await this.persistSignal({ + ...params, + signalData: { type: 'tool-use', payload: params.payload }, + }); + } + + async enrichToolUseSignal(params: { + environmentId: string; + organizationId: string; + conversationId: string; + activityId: string; + content: string; + payload: Record; + }): Promise { + await this.activityRepository.update( + { + _environmentId: params.environmentId, + _organizationId: params.organizationId, + _conversationId: params.conversationId, + _id: params.activityId, + }, + { $set: { content: params.content, 'signalData.payload': params.payload } } + ); + } + + async repointSubscriberSender(params: { + environmentId: string; + organizationId: string; + fromSubscriberId: string; + toSubscriberId: string; + }): Promise { + return this.activityRepository.repointSubscriberSender(params); + } + + private async persistAgentActivity( + params: PersistAgentActivityParams & { + toolData?: ConversationActivityToolData; + }, + type: ConversationActivityTypeEnum, + touch: 'activity' | 'preview' + ): Promise { + const threadId = params.platformThreadId ?? params.channel.platformThreadId; + const sequence = await this.resolveEventSequence( + params.conversationId, + params.environmentId, + params.organizationId, + params.sequence + ); + + const touchFn = + touch === 'activity' + ? this.conversationRepository.touchActivity.bind(this.conversationRepository) + : this.conversationRepository.touchPreview.bind(this.conversationRepository); + + const [activity] = await Promise.all([ + this.activityRepository.createAgentActivity({ + identifier: params.identifier ?? `act_${shortId(12)}`, + conversationId: params.conversationId, + platform: params.channel.platform, + integrationId: params.channel._integrationId, + platformThreadId: threadId, + platformMessageId: params.platformMessageId, + agentId: params.agentIdentifier, + senderName: params.agentName, + content: params.content, + richContent: params.richContent, + toolData: params.toolData, + type, + sequence, + environmentId: params.environmentId, + organizationId: params.organizationId, + }), + touchFn(params.environmentId, params.organizationId, params.conversationId, params.content), + ]); + + return activity; + } + + private async resolveEventSequence( + conversationId: string, + environmentId: string, + organizationId: string, + sequence?: number + ): Promise { + if (sequence !== undefined) { + return sequence; + } + + return this.eventSequenceService.mint({ + environmentId, + organizationId, + conversationId, + }); + } + + private async persistSignal( + params: ConversationActivityContext & { + content: string; + signalData: ConversationActivitySignalData; + identifier?: string; + platformMessageId?: string; + platformThreadId?: string; + } + ): Promise { + await this.activityRepository.createSignalActivity({ + identifier: params.identifier ?? `act_${shortId(12)}`, + conversationId: params.conversationId, + platform: params.channel.platform, + integrationId: params.channel._integrationId, + platformThreadId: params.platformThreadId ?? params.channel.platformThreadId, + agentId: params.agentIdentifier, + content: params.content, + signalData: params.signalData, + environmentId: params.environmentId, + organizationId: params.organizationId, + platformMessageId: params.platformMessageId, + }); + } + + private async emitPersistedClientEvent( + params: ConversationActivityContext, + activity: ConversationActivityEntity + ): Promise { + await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ + channel: params.channel, + conversationId: params.conversationId, + environmentId: params.environmentId, + organizationId: params.organizationId, + agentIdentifier: params.agentIdentifier, + activity, + }); + } } diff --git a/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.spec.ts index 36c685ef95f..7e72de19f5f 100644 --- a/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.spec.ts @@ -25,8 +25,6 @@ describe('ReplyApprovalInterceptor', () => { const conversationService = { getPrimaryChannel: sinon.stub().returns(channel), persistToolApprovalDecision: sinon.stub().resolves(undefined), - }; - const activityLedger = { listForView: sinon.stub().resolves({ data: history, hasMore: false }), }; const outboundGateway = { @@ -37,14 +35,9 @@ describe('ReplyApprovalInterceptor', () => { warn: sinon.stub(), setContext: sinon.stub(), }; - const interceptor = new ReplyApprovalInterceptor( - conversationService as any, - activityLedger as any, - outboundGateway as any, - logger as any - ); - - return { interceptor, conversationService, activityLedger, outboundGateway }; + const interceptor = new ReplyApprovalInterceptor(conversationService as any, outboundGateway as any, logger as any); + + return { interceptor, conversationService, outboundGateway }; } function makeTurn(overrides: Record = {}) { @@ -295,14 +288,14 @@ describe('ReplyApprovalInterceptor', () => { }); it('should fall through when the platform has interactive buttons', async () => { - const { interceptor, activityLedger } = makeDeps(); + const { interceptor, conversationService } = makeDeps(); const runtime = { dispatch: sinon.stub().resolves(undefined) }; const turn = makeTurn({ config: { ...makeTurn().config, platform: 'slack' } }); const consumed = await interceptor.tryHandleAsApprovalReply(turn, runtime as any); expect(consumed).to.equal(false); - expect(activityLedger.listForView.called).to.equal(false); + expect(conversationService.listForView.called).to.equal(false); expect(runtime.dispatch.called).to.equal(false); }); @@ -566,38 +559,38 @@ describe('ReplyApprovalInterceptor', () => { }); it('should fall through when the reaction was removed rather than added', async () => { - const { interceptor, activityLedger } = makeDeps(); + const { interceptor, conversationService } = makeDeps(); const runtime = { dispatch: sinon.stub().resolves(undefined) }; const turn = makeReactionTurn({ reaction: { emoji: 'thumbs_up', added: false, messageId: 'msg-approval' } }); const consumed = await interceptor.tryHandleAsApprovalReaction(turn, runtime as any); expect(consumed).to.equal(false); - expect(activityLedger.listForView.called).to.equal(false); + expect(conversationService.listForView.called).to.equal(false); expect(runtime.dispatch.called).to.equal(false); }); it('should fall through for an emoji that is not a verdict', async () => { - const { interceptor, activityLedger } = makeDeps(); + const { interceptor, conversationService } = makeDeps(); const runtime = { dispatch: sinon.stub().resolves(undefined) }; const turn = makeReactionTurn({ reaction: { emoji: 'heart', added: true, messageId: 'msg-approval' } }); const consumed = await interceptor.tryHandleAsApprovalReaction(turn, runtime as any); expect(consumed).to.equal(false); - expect(activityLedger.listForView.called).to.equal(false); + expect(conversationService.listForView.called).to.equal(false); expect(runtime.dispatch.called).to.equal(false); }); it('should fall through when the platform has interactive buttons', async () => { - const { interceptor, activityLedger } = makeDeps(); + const { interceptor, conversationService } = makeDeps(); const runtime = { dispatch: sinon.stub().resolves(undefined) }; const turn = makeReactionTurn({ config: { ...makeTurn().config, platform: 'slack' } }); const consumed = await interceptor.tryHandleAsApprovalReaction(turn, runtime as any); expect(consumed).to.equal(false); - expect(activityLedger.listForView.called).to.equal(false); + expect(conversationService.listForView.called).to.equal(false); expect(runtime.dispatch.called).to.equal(false); }); diff --git a/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.ts b/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.ts index 5a7ffa0539f..9069c53661f 100644 --- a/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/ingress/reply-approval-interceptor.service.ts @@ -16,7 +16,6 @@ import { resolveApprovalRequesterId, } from '../../shared/tool-approval/unresolved-approvals'; import { AgentConversationService } from '../conversation/agent-conversation.service'; -import { ConversationActivityLedger } from '../conversation/conversation-activity-ledger'; import { OutboundGateway } from '../egress/outbound.gateway'; import type { AgentRuntime } from '../runtime/agent-runtime.port'; import type { ConversationTurn } from '../runtime/conversation-turn'; @@ -62,7 +61,6 @@ function quoteContainsToolName(normalizedQuote: string, toolName: string): boole export class ReplyApprovalInterceptor { constructor( private readonly conversationService: AgentConversationService, - private readonly activityLedger: ConversationActivityLedger, private readonly outboundGateway: OutboundGateway, private readonly logger: PinoLogger ) { @@ -360,7 +358,7 @@ export class ReplyApprovalInterceptor { const { config, conversation } = turn; try { - const page = await this.activityLedger.listForView({ + const page = await this.conversationService.listForView({ view: 'approval_activities', environmentId: config.environmentId, organizationId: config.organizationId, diff --git a/apps/api/src/app/agents/conversation-runtime/link/confirm-linked-auth-cards.usecase.ts b/apps/api/src/app/agents/conversation-runtime/link/confirm-linked-auth-cards.usecase.ts index e42b0ff0e9a..04174864e60 100644 --- a/apps/api/src/app/agents/conversation-runtime/link/confirm-linked-auth-cards.usecase.ts +++ b/apps/api/src/app/agents/conversation-runtime/link/confirm-linked-auth-cards.usecase.ts @@ -1,11 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InstrumentUsecase, PinoLogger } from '@novu/application-generic'; -import { - ConversationActivityRepository, - ConversationChannel, - ConversationEntity, - ConversationRepository, -} from '@novu/dal'; +import { ConversationChannel, ConversationEntity, ConversationRepository } from '@novu/dal'; import { AGENT_AUTH_METADATA_KEYS } from '@novu/shared'; import type { CardElement } from 'chat'; import { AgentConfigResolver } from '../../channels/agent-config-resolver.service'; @@ -28,7 +23,6 @@ import { ConfirmLinkedAuthCardsCommand } from './confirm-linked-auth-cards.comma export class ConfirmLinkedAuthCards { constructor( private readonly conversationRepository: ConversationRepository, - private readonly conversationActivityRepository: ConversationActivityRepository, private readonly outboundGateway: OutboundGateway, private readonly conversationService: AgentConversationService, private readonly agentConfigResolver: AgentConfigResolver, @@ -143,9 +137,10 @@ export class ConfirmLinkedAuthCards { conversationId: string, storedMessageId: string ): Promise { - const activity = await this.conversationActivityRepository.findOne( - { _environmentId: environmentId, _conversationId: conversationId, identifier: storedMessageId }, - ['platformMessageId'] + const activity = await this.conversationService.findAgentMessageByIdentifier( + environmentId, + conversationId, + storedMessageId ); if (activity?.platformMessageId) { diff --git a/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts b/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts index f3b9fb1d51a..0fdd4fbd285 100644 --- a/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts @@ -1,9 +1,8 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; -import { PinoLogger, shortId } from '@novu/application-generic'; +import { PinoLogger } from '@novu/application-generic'; import { AgentRepository, ConversationActivityEntity, - ConversationActivityRepository, type ConversationChannel, ConversationEntity, ConversationRepository, @@ -27,7 +26,6 @@ interface ToolTask { @Injectable() export class HandlePlanProgress { constructor( - private readonly activityRepository: ConversationActivityRepository, private readonly agentRepository: AgentRepository, private readonly conversationRepository: ConversationRepository, private readonly conversationService: AgentConversationService, @@ -52,7 +50,7 @@ export class HandlePlanProgress { const channel = this.conversationService.getPrimaryChannel(conversation); const activePlanMessageId = conversation.activePlanMessageId; const existingActivities = activePlanMessageId - ? await this.activityRepository.findToolActivitiesByPlanMessageId( + ? await this.conversationService.findToolActivitiesByPlanMessageId( command.environmentId, command.conversationId, activePlanMessageId @@ -205,32 +203,25 @@ export class HandlePlanProgress { if (isEnrichingInProgress) { const activity = this.findLatestInProgressToolActivity(existingActivities, taskInput.id); if (activity) { - await this.activityRepository.update( - { - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _conversationId: command.conversationId, - _id: activity._id, - }, - { $set: { content, 'signalData.payload': payload } } - ); + await this.conversationService.enrichToolUseSignal({ + environmentId: command.environmentId, + organizationId: command.organizationId, + conversationId: command.conversationId, + activityId: activity._id, + content, + payload, + }); return; } } - await this.activityRepository.createSignalActivity({ - identifier: `act_${shortId(12)}`, + await this.conversationService.persistToolUseSignal({ conversationId: command.conversationId, - platform: channel.platform, - integrationId: channel._integrationId, - platformThreadId: channel.platformThreadId, - agentId: command.agentIdentifier, + channel, + agentIdentifier: command.agentIdentifier, content, - signalData: { - type: 'tool-use', - payload, - }, + payload, environmentId: command.environmentId, organizationId: command.organizationId, }); diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.spec.ts index bcbea8d0b87..090ef1749ae 100644 --- a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.spec.ts @@ -33,18 +33,18 @@ describe('BridgeExecutorService', () => { ) { const logger = makeLogger(); const attachmentStorage = overrides.attachmentStorage ?? { signRead: sinon.stub().resolves('https://signed/read') }; - const activityLedger = { listForView: sinon.stub().resolves({ data: [], hasMore: false }) }; + const conversationService = { listForView: sinon.stub().resolves({ data: [], hasMore: false }) }; const featureFlagsService = makeFeatureFlagsService(overrides.isEventProtocolEnabled); const service = new BridgeExecutorService( {} as any, logger as any, attachmentStorage as any, - activityLedger as any, + conversationService as any, featureFlagsService as unknown as FeatureFlagsService ); - return { service, logger, attachmentStorage, activityLedger, featureFlagsService }; + return { service, logger, attachmentStorage, conversationService, featureFlagsService }; } function makeExecutionParams() { diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts index 41b71f2fee8..a18734d0032 100644 --- a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts @@ -35,7 +35,7 @@ import { ResolvedAgentConfig } from '../../channels/agent-config-resolver.servic import { captureAgentException, captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; import { buildAgentApiRootUrl } from '../../shared/util/agent-api-root-url'; import { AgentAttachmentStorage, type StoredAttachment } from '../conversation/agent-attachment-storage.service'; -import { ConversationActivityLedger } from '../conversation/conversation-activity-ledger'; +import { AgentConversationService } from '../conversation/agent-conversation.service'; const MAX_RETRIES = 2; @@ -171,7 +171,7 @@ export class BridgeExecutorService { private readonly getDecryptedSecretKey: GetDecryptedSecretKey, private readonly logger: PinoLogger, private readonly attachmentStorage: AgentAttachmentStorage, - private readonly activityLedger: ConversationActivityLedger, + private readonly conversationService: AgentConversationService, private readonly featureFlagsService: FeatureFlagsService ) { this.logger.setContext(this.constructor.name); @@ -421,7 +421,7 @@ export class BridgeExecutorService { organizationId: string ): Promise { try { - const page = await this.activityLedger.listForView({ + const page = await this.conversationService.listForView({ view: 'agent_handoff', environmentId, organizationId, diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.spec.ts index 21c1a81db41..eeeaa164c85 100644 --- a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.spec.ts @@ -36,8 +36,6 @@ describe('BridgeExpireSupersededApprovalsService', () => { const conversationService = { getPrimaryChannel: sinon.stub().returns(channel), persistToolApprovalDecision: sinon.stub().resolves(undefined), - }; - const activityLedger = { listForView: sinon.stub().resolves({ data: [pendingRequest], hasMore: false }), }; const outboundGateway = { @@ -46,7 +44,6 @@ describe('BridgeExpireSupersededApprovalsService', () => { }; const service = new BridgeExpireSupersededApprovalsService( conversationService as any, - activityLedger as any, outboundGateway as any, makeLogger() as any ); @@ -93,8 +90,6 @@ describe('BridgeExpireSupersededApprovalsService', () => { const conversationService = { getPrimaryChannel: sinon.stub().returns({ platform: 'slack', platformThreadId: 'thread-1' }), persistToolApprovalDecision: sinon.stub().resolves(undefined), - }; - const activityLedger = { listForView: sinon.stub().resolves({ data: [approvedDecision, request], hasMore: false }), }; const outboundGateway = { @@ -102,7 +97,6 @@ describe('BridgeExpireSupersededApprovalsService', () => { }; const service = new BridgeExpireSupersededApprovalsService( conversationService as any, - activityLedger as any, outboundGateway as any, makeLogger() as any ); @@ -125,8 +119,6 @@ describe('BridgeExpireSupersededApprovalsService', () => { const conversationService = { getPrimaryChannel: sinon.stub().returns({ platform: 'slack', platformThreadId: 'thread-1' }), persistToolApprovalDecision: sinon.stub().resolves(undefined), - }; - const activityLedger = { listForView: sinon.stub().resolves({ data: [pendingRequest], hasMore: false }), }; const outboundGateway = { @@ -134,7 +126,6 @@ describe('BridgeExpireSupersededApprovalsService', () => { }; const service = new BridgeExpireSupersededApprovalsService( conversationService as any, - activityLedger as any, outboundGateway as any, makeLogger() as any ); diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.ts index e0cd9c30408..332a8baa64b 100644 --- a/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-expire-superseded-approvals.service.ts @@ -12,7 +12,6 @@ import { findUnresolvedToolApprovalRequests, } from '../../shared/tool-approval/unresolved-approvals'; import { AgentConversationService } from '../conversation/agent-conversation.service'; -import { ConversationActivityLedger } from '../conversation/conversation-activity-ledger'; import { OutboundGateway } from '../egress/outbound.gateway'; import type { ConversationTurn } from './conversation-turn'; @@ -28,7 +27,6 @@ import type { ConversationTurn } from './conversation-turn'; export class BridgeExpireSupersededApprovalsService { constructor( private readonly conversationService: AgentConversationService, - private readonly activityLedger: ConversationActivityLedger, private readonly outboundGateway: OutboundGateway, private readonly logger: PinoLogger ) { @@ -37,7 +35,7 @@ export class BridgeExpireSupersededApprovalsService { async expireOnNewMessage(turn: ConversationTurn): Promise { const { config, conversation } = turn; - const page = await this.activityLedger.listForView({ + const page = await this.conversationService.listForView({ view: 'approval_activities', environmentId: config.environmentId, organizationId: config.organizationId, diff --git a/apps/api/src/app/agents/managed-runtime/managed-agent.service.ts b/apps/api/src/app/agents/managed-runtime/managed-agent.service.ts index a997638bd0b..3520bafb918 100644 --- a/apps/api/src/app/agents/managed-runtime/managed-agent.service.ts +++ b/apps/api/src/app/agents/managed-runtime/managed-agent.service.ts @@ -3,7 +3,6 @@ import { type IAgentRuntimeProvider, PinoLogger } from '@novu/application-generi import { type AgentEntity, AgentRepository, - ConversationActivityRepository, ConversationActivitySenderTypeEnum, ConversationActivityTypeEnum, ConversationEntity, @@ -17,7 +16,6 @@ import type { Request, Response } from 'express'; import type { ResolvedAgentConfig } from '../channels/agent-config-resolver.service'; import { InboundAckService } from '../conversation-runtime/ack/inbound-ack.service'; import { AgentConversationService } from '../conversation-runtime/conversation/agent-conversation.service'; -import { ConversationActivityLedger } from '../conversation-runtime/conversation/conversation-activity-ledger'; import { AgentMcpSessionService } from '../mcp/runtime/agent-mcp-session.service'; import { AgentPlatformEnum } from '../shared/enums/agent-platform.enum'; import { AgentRuntimeDefinitionService } from './agent-runtime-definition.service'; @@ -70,9 +68,7 @@ export class ManagedAgentService implements OnModuleInit { private readonly providerFactory: ManagedAgentProviderFactory, private readonly eventHandler: ManagedAgentEventHandler, private readonly conversationRepository: ConversationRepository, - private readonly conversationActivityRepository: ConversationActivityRepository, private readonly conversationService: AgentConversationService, - private readonly activityLedger: ConversationActivityLedger, private readonly subscriberRepository: SubscriberRepository, private readonly agentMcpSessionService: AgentMcpSessionService, private readonly demoQuota: DemoClaudeQuotaPolicy, @@ -119,9 +115,7 @@ export class ManagedAgentService implements OnModuleInit { subscriberMongoId: context.subscriber?._id, }); - const messages = sessionId - ? buildLiveSessionMessages(context) - : await this.buildMessagesWithHistory(context); + const messages = sessionId ? buildLiveSessionMessages(context) : await this.buildMessagesWithHistory(context); const sendResult = await provider.send({ messages, @@ -166,13 +160,10 @@ export class ManagedAgentService implements OnModuleInit { pendingPlatformMessageId: string; agent: Pick; }): Promise { - const activity = await this.conversationActivityRepository.findOne( - { - _conversationId: params.conversation._id, - _environmentId: params.config.environmentId, - platformMessageId: params.pendingPlatformMessageId, - }, - '*' + const activity = await this.conversationService.findByPlatformMessageId( + params.config.environmentId, + String(params.conversation._id), + params.pendingPlatformMessageId ); if (!activity) { @@ -412,7 +403,7 @@ export class ManagedAgentService implements OnModuleInit { } private async buildMessagesWithHistory(context: ManagedAgentContext): Promise { - const page = await this.activityLedger.listForView({ + const page = await this.conversationService.listForView({ view: 'llm_transcript', environmentId: context.config.environmentId, organizationId: context.config.organizationId, diff --git a/apps/api/src/app/agents/shared/agent-event-sink.service.ts b/apps/api/src/app/agents/shared/agent-event-sink.service.ts index 6b47eec69d9..4199c20654d 100644 --- a/apps/api/src/app/agents/shared/agent-event-sink.service.ts +++ b/apps/api/src/app/agents/shared/agent-event-sink.service.ts @@ -1,18 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { type AgentEvent, type AgentEventEnvelope, isDeltaEvent } from '@novu/agent-event-protocol'; import { PinoLogger } from '@novu/application-generic'; -import { - ConversationActivityEntity, - ConversationActivityRepository, - type ConversationChannel, - ConversationRepository, -} from '@novu/dal'; +import { ConversationActivityEntity, type ConversationChannel, ConversationRepository } from '@novu/dal'; import { isNovuInternalToolName } from '@novu/shared'; import type { Response as ThalamusResponse } from '@novu/thalamus'; -import { AgentChatLiveActivityPublisher } from '../agent-chat/agent-chat-live-activity.publisher'; import { InboundAckService } from '../conversation-runtime/ack/inbound-ack.service'; import { AgentConversationService } from '../conversation-runtime/conversation/agent-conversation.service'; -import { ConversationActivityLedger } from '../conversation-runtime/conversation/conversation-activity-ledger'; import { type RunLifecycleEvent } from '../conversation-runtime/conversation/run-lifecycle-activity'; import { OutboundGateway } from '../conversation-runtime/egress/outbound.gateway'; import { HandleAgentReplyCommand } from '../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; @@ -77,12 +70,9 @@ export class AgentEventSink { private readonly inboundAck: InboundAckService, private readonly demoQuota: DemoClaudeQuotaPolicy, private readonly conversationRepository: ConversationRepository, - private readonly activityRepository: ConversationActivityRepository, - private readonly activityLedger: ConversationActivityLedger, private readonly outboundGateway: OutboundGateway, private readonly conversationService: AgentConversationService, private readonly mcpConnectionErrorHandler: McpConnectionErrorHandler, - private readonly agentChatLiveActivityPublisher: AgentChatLiveActivityPublisher, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -763,7 +753,7 @@ export class AgentEventSink { } try { - const activity = await this.activityLedger.persistProtocolEvent({ + await this.conversationService.persistRunLifecycle({ conversationId: context.conversationId, channel, agentIdentifier: context.agentIdentifier, @@ -772,17 +762,6 @@ export class AgentEventSink { runId, event, }); - - if (activity) { - await this.agentChatLiveActivityPublisher.emitPersistedClientEvent({ - channel, - conversationId: context.conversationId, - environmentId: context.environmentId, - organizationId: context.organizationId, - agentIdentifier: context.agentIdentifier, - activity, - }); - } } catch (err) { this.logger.error(err, `run lifecycle persist failed: run=${runId}`); captureAgentException(err, { @@ -802,9 +781,10 @@ export class AgentEventSink { return false; } - const existing = await this.activityRepository.findOne( - { _environmentId: environmentId, _conversationId: conversationId, identifier: messageId }, - '*' + const existing = await this.conversationService.findAgentMessageByIdentifier( + environmentId, + conversationId, + messageId ); return existing !== null; @@ -830,9 +810,10 @@ export class AgentEventSink { } for (let attempt = 0; attempt < ACTIVITY_RESOLVE_MAX_ATTEMPTS; attempt += 1) { - const activity = await this.activityRepository.findOne( - { _environmentId: environmentId, _conversationId: conversationId, identifier: messageId }, - '*' + const activity = await this.conversationService.findAgentMessageByIdentifier( + environmentId, + conversationId, + messageId ); if (activity) { @@ -844,7 +825,7 @@ export class AgentEventSink { } } - return this.activityRepository.findByPlatformMessageId(environmentId, conversationId, messageId); + return this.conversationService.findByPlatformMessageId(environmentId, conversationId, messageId); } private async resolveConversationAgentId(context: AgentEventContext): Promise { diff --git a/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-panel.tsx b/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-panel.tsx index a208744fa34..3a13319e5b6 100644 --- a/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-panel.tsx +++ b/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-panel.tsx @@ -87,9 +87,10 @@ function AgentChatSurface({ const [draft, setDraft] = useState(''); const textareaRef = useRef(null); const scrollRef = useRef(null); - const { messages, pendingActions, sendMessage, respondToAction, error, isRunning, isLoading, typing } = useAgentChat({ - agentId, - }); + const { messages, pendingActions, sendMessage, sendAction, respondToAction, error, isRunning, isLoading, typing } = + useAgentChat({ + agentId, + }); const composerDisabled = isRunning || isLoading; const canSend = !composerDisabled && Boolean(draft.trim()); @@ -146,6 +147,8 @@ function AgentChatSurface({ key={message.id} message={message} showAvatar={message.role !== 'user' && messages[index - 1]?.role !== message.role} + cardActionsDisabled={composerDisabled} + onCardAction={(action) => void sendAction(action)} /> ))} {showTypingRow ? : null} diff --git a/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-parts.tsx b/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-parts.tsx index 8c50e92526f..3f9bcf06b5d 100644 --- a/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-parts.tsx +++ b/apps/dashboard/src/components/agents/agent-chat-panel/agent-chat-parts.tsx @@ -27,6 +27,9 @@ function stripPoweredByWatermark(text: string): string { type AgentMessagePart = AgentMessage['parts'][number]; type ToolPart = Extract; type TextPart = Extract; +type CardPart = Extract; + +export type CardActionHandler = (args: { actionId: string; sourceMessageId: string; value?: string }) => void; function formatMessageTime(createdAt: string): string | null { const date = new Date(createdAt); @@ -81,12 +84,23 @@ export function ChatEmptyState({ onPickStarter }: { onPickStarter: (text: string ); } -export function ChatMessageRow({ message, showAvatar }: { message: AgentMessage; showAvatar: boolean }) { +export function ChatMessageRow({ + message, + showAvatar, + onCardAction, + cardActionsDisabled, +}: { + message: AgentMessage; + showAvatar: boolean; + onCardAction?: CardActionHandler; + cardActionsDisabled?: boolean; +}) { const isUser = message.role === 'user'; const textParts = message.parts.filter((part): part is TextPart => part.type === 'text'); const text = stripPoweredByWatermark(textParts.map((part) => part.text).join('')); const isStreaming = textParts.some((part) => part.state === 'streaming'); const tools = message.parts.filter((part): part is ToolPart => part.type === 'tool'); + const cards = message.parts.filter((part): part is CardPart => part.type === 'card'); const time = formatMessageTime(message.createdAt); const failed = message.status === 'failed'; @@ -118,7 +132,7 @@ export function ChatMessageRow({ message, showAvatar }: { message: AgentMessage; ); } - const hasContent = Boolean(text) || tools.length > 0; + const hasContent = Boolean(text) || tools.length > 0 || cards.length > 0; if (!hasContent) return null; return ( @@ -133,6 +147,14 @@ export function ChatMessageRow({ message, showAvatar }: { message: AgentMessage; ) : null} ) : null} + {cards.map((part, index) => ( + onCardAction({ ...action, sourceMessageId: message.id }) : undefined} + /> + ))} {tools.length > 0 ? (
{tools.map((tool) => ( @@ -150,6 +172,188 @@ export function ChatMessageRow({ message, showAvatar }: { message: AgentMessage; ); } +type CardButtonView = { id: string; label: string; value?: string; style?: string }; + +type CardChildView = + | { type: 'text'; content: string } + | { type: 'divider' } + | { type: 'image'; url: string; alt: string } + | { type: 'link'; url: string; label: string } + | { type: 'actions'; buttons: CardButtonView[] }; + +type CardView = { + title?: string; + subtitle?: string; + imageUrl?: string; + children: CardChildView[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function cardButtonsFromNode(node: unknown): CardButtonView[] { + if (!isRecord(node)) { + return []; + } + + if (node.type === 'button') { + const id = readString(node.id); + const label = readString(node.label); + if (!id || !label) { + return []; + } + + return [{ id, label, value: readString(node.value), style: readString(node.style) }]; + } + + if (node.type === 'actions' && Array.isArray(node.children)) { + return node.children.flatMap((child) => cardButtonsFromNode(child)); + } + + return []; +} + +function cardChildFromNode(node: unknown): CardChildView | null { + if (!isRecord(node)) { + return null; + } + + if (node.type === 'text') { + const content = readString(node.content); + + return content ? { type: 'text', content } : null; + } + + if (node.type === 'divider') { + return { type: 'divider' }; + } + + if (node.type === 'image') { + const url = toSafeExternalUrl(readString(node.url)); + + return url ? { type: 'image', url, alt: readString(node.alt) ?? '' } : null; + } + + if (node.type === 'link') { + const url = toSafeExternalUrl(readString(node.url)); + const label = readString(node.label); + + return url && label ? { type: 'link', url, label } : null; + } + + const buttons = cardButtonsFromNode(node); + + return buttons.length > 0 ? { type: 'actions', buttons } : null; +} + +function cardViewFromRecord(card: Record): CardView { + const children = Array.isArray(card.children) ? card.children : []; + + return { + title: readString(card.title), + subtitle: readString(card.subtitle), + imageUrl: toSafeExternalUrl(readString(card.imageUrl)), + children: children.flatMap((child) => { + const view = cardChildFromNode(child); + + return view ? [view] : []; + }), + }; +} + +function cardButtonAppearance(style?: string): { + variant: 'error' | 'primary' | 'secondary'; + mode: 'filled' | 'outline'; +} { + if (style === 'danger') { + return { variant: 'error', mode: 'outline' }; + } + + if (style === 'primary') { + return { variant: 'primary', mode: 'filled' }; + } + + return { variant: 'secondary', mode: 'outline' }; +} + +function ChatCardPart({ + card, + disabled, + onAction, +}: { + card: Record; + disabled?: boolean; + onAction?: (args: { actionId: string; value?: string }) => void; +}) { + const view = cardViewFromRecord(card); + + return ( +
+ {view.imageUrl ? ( + {view.title + ) : null} + {view.title ?

{view.title}

: null} + {view.subtitle ?

{view.subtitle}

: null} + {view.children.map((child, index) => { + switch (child.type) { + case 'text': + return ( + + {child.content} + + ); + case 'divider': + return
; + case 'image': + return ( + {child.alt} + ); + case 'link': + return ( + + {child.label} + + + ); + case 'actions': + return ( +
+ {child.buttons.map((button) => { + const appearance = cardButtonAppearance(button.style); + + return ( + + ); + })} +
+ ); + } + })} +
+ ); +} + function ToolChip({ tool }: { tool: ToolPart }) { const isRunning = tool.state === 'input-streaming' || tool.state === 'input-available'; const isFailed = tool.state === 'output-error'; diff --git a/apps/dashboard/src/components/ai-elements/prompt-input.tsx b/apps/dashboard/src/components/ai-elements/prompt-input.tsx index 7b4ae53d6a5..93bd10fbdf3 100644 --- a/apps/dashboard/src/components/ai-elements/prompt-input.tsx +++ b/apps/dashboard/src/components/ai-elements/prompt-input.tsx @@ -73,7 +73,7 @@ export interface PromptInputControllerProps { const PromptInputController = createContext(null); const ProviderAttachmentsContext = createContext(null); -export const usePromptInputController = () => { +const usePromptInputController = () => { const ctx = useContext(PromptInputController); if (!ctx) { throw new Error('Wrap your component inside to use usePromptInputController().'); @@ -84,7 +84,7 @@ export const usePromptInputController = () => { // Optional variants (do NOT throw). Useful for dual-mode components. const useOptionalPromptInputController = () => useContext(PromptInputController); -export const useProviderAttachments = () => { +const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); if (!ctx) { throw new Error('Wrap your component inside to use useProviderAttachments().'); @@ -94,7 +94,7 @@ export const useProviderAttachments = () => { const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); -export type PromptInputProviderProps = PropsWithChildren<{ +type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; }>; @@ -102,7 +102,7 @@ export type PromptInputProviderProps = PropsWithChildren<{ * Optional global provider that lifts PromptInput state outside of PromptInput. * If you don't use it, PromptInput stays fully self-managed. */ -export function PromptInputProvider({ initialInput: initialTextInput = '', children }: PromptInputProviderProps) { +function PromptInputProvider({ initialInput: initialTextInput = '', children }: PromptInputProviderProps) { // ----- textInput state const [textInput, setTextInput] = useState(initialTextInput); const clearInput = useCallback(() => setTextInput(''), []); @@ -215,7 +215,7 @@ export function PromptInputProvider({ initialInput: initialTextInput = '', child const LocalAttachmentsContext = createContext(null); -export const usePromptInputAttachments = () => { +const usePromptInputAttachments = () => { // Prefer local context (inside PromptInput) as it has validation, fall back to provider const provider = useOptionalProviderAttachments(); const local = useContext(LocalAttachmentsContext); @@ -230,16 +230,16 @@ export const usePromptInputAttachments = () => { // Referenced Sources (Local to PromptInput) // ============================================================================ -export interface ReferencedSourcesContext { +interface ReferencedSourcesContext { sources: (SourceDocumentUIPart & { id: string })[]; add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; remove: (id: string) => void; clear: () => void; } -export const LocalReferencedSourcesContext = createContext(null); +const LocalReferencedSourcesContext = createContext(null); -export const usePromptInputReferencedSources = () => { +const usePromptInputReferencedSources = () => { const ctx = useContext(LocalReferencedSourcesContext); if (!ctx) { throw new Error('usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider'); @@ -247,11 +247,11 @@ export const usePromptInputReferencedSources = () => { return ctx; }; -export type PromptInputActionAddAttachmentsProps = ComponentProps & { +type PromptInputActionAddAttachmentsProps = ComponentProps & { label?: string; }; -export const PromptInputActionAddAttachments = ({ +const PromptInputActionAddAttachments = ({ label = 'Add photos or files', ...props }: PromptInputActionAddAttachmentsProps) => { @@ -812,9 +812,9 @@ export const PromptInputTextarea = ({ ); }; -export type PromptInputHeaderProps = Omit, 'align'>; +type PromptInputHeaderProps = Omit, 'align'>; -export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => ( +const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => ( ); @@ -824,15 +824,15 @@ export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProp ); -export type PromptInputToolsProps = HTMLAttributes; +type PromptInputToolsProps = HTMLAttributes; -export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => ( +const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
); -export type PromptInputButtonProps = ComponentProps; +type PromptInputButtonProps = ComponentProps; -export const PromptInputButton = ({ +const PromptInputButton = ({ variant = 'primary', mode = 'ghost', className, @@ -846,12 +846,12 @@ export const PromptInputButton = ({ ); }; -export type PromptInputActionMenuProps = ComponentProps; -export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ; +type PromptInputActionMenuProps = ComponentProps; +const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ; -export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; +type PromptInputActionMenuTriggerProps = PromptInputButtonProps; -export const PromptInputActionMenuTrigger = ({ className, children, ...props }: PromptInputActionMenuTriggerProps) => ( +const PromptInputActionMenuTrigger = ({ className, children, ...props }: PromptInputActionMenuTriggerProps) => ( {children ?? } @@ -859,13 +859,13 @@ export const PromptInputActionMenuTrigger = ({ className, children, ...props }: ); -export type PromptInputActionMenuContentProps = ComponentProps; -export const PromptInputActionMenuContent = ({ className, ...props }: PromptInputActionMenuContentProps) => ( +type PromptInputActionMenuContentProps = ComponentProps; +const PromptInputActionMenuContent = ({ className, ...props }: PromptInputActionMenuContentProps) => ( ); -export type PromptInputActionMenuItemProps = ComponentProps; -export const PromptInputActionMenuItem = ({ className, ...props }: PromptInputActionMenuItemProps) => ( +type PromptInputActionMenuItemProps = ComponentProps; +const PromptInputActionMenuItem = ({ className, ...props }: PromptInputActionMenuItemProps) => ( ); @@ -925,13 +925,13 @@ export const PromptInputSubmit = ({ ); }; -export type PromptInputSelectProps = ComponentProps; +type PromptInputSelectProps = ComponentProps; -export const PromptInputSelect = (props: PromptInputSelectProps) => ; -export type PromptInputSelectTriggerProps = ComponentProps; +type PromptInputSelectTriggerProps = ComponentProps; -export const PromptInputSelectTrigger = ({ className, ...props }: PromptInputSelectTriggerProps) => ( +const PromptInputSelectTrigger = ({ className, ...props }: PromptInputSelectTriggerProps) => ( ); -export type PromptInputSelectContentProps = ComponentProps; +type PromptInputSelectContentProps = ComponentProps; -export const PromptInputSelectContent = ({ className, ...props }: PromptInputSelectContentProps) => ( +const PromptInputSelectContent = ({ className, ...props }: PromptInputSelectContentProps) => ( ); -export type PromptInputSelectItemProps = ComponentProps; +type PromptInputSelectItemProps = ComponentProps; -export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => ( +const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => ( ); -export type PromptInputSelectValueProps = ComponentProps; +type PromptInputSelectValueProps = ComponentProps; -export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => ( +const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => ( ); -export type PromptInputHoverCardProps = ComponentProps; +type PromptInputHoverCardProps = ComponentProps; -export const PromptInputHoverCard = ({ openDelay = 0, closeDelay = 0, ...props }: PromptInputHoverCardProps) => ( +const PromptInputHoverCard = ({ openDelay = 0, closeDelay = 0, ...props }: PromptInputHoverCardProps) => ( ); -export type PromptInputHoverCardTriggerProps = ComponentProps; +type PromptInputHoverCardTriggerProps = ComponentProps; -export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => ; +const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => ; -export type PromptInputHoverCardContentProps = ComponentProps; +type PromptInputHoverCardContentProps = ComponentProps; -export const PromptInputHoverCardContent = ({ align = 'start', ...props }: PromptInputHoverCardContentProps) => ( +const PromptInputHoverCardContent = ({ align = 'start', ...props }: PromptInputHoverCardContentProps) => ( ); -export type PromptInputTabsListProps = HTMLAttributes; +type PromptInputTabsListProps = HTMLAttributes; -export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => ( +const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
); -export type PromptInputTabProps = HTMLAttributes; +type PromptInputTabProps = HTMLAttributes; -export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => ( +const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
); -export type PromptInputTabLabelProps = HTMLAttributes; +type PromptInputTabLabelProps = HTMLAttributes; -export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => ( +const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (

); -export type PromptInputTabBodyProps = HTMLAttributes; +type PromptInputTabBodyProps = HTMLAttributes; -export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => ( +const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
); -export type PromptInputTabItemProps = HTMLAttributes; +type PromptInputTabItemProps = HTMLAttributes; -export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => ( +const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
); -export type PromptInputCommandProps = ComponentProps; +type PromptInputCommandProps = ComponentProps; -export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => ( +const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => ( ); -export type PromptInputCommandInputProps = ComponentProps; +type PromptInputCommandInputProps = ComponentProps; -export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => ( +const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => ( ); -export type PromptInputCommandListProps = ComponentProps; +type PromptInputCommandListProps = ComponentProps; -export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => ( +const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => ( ); -export type PromptInputCommandEmptyProps = ComponentProps; +type PromptInputCommandEmptyProps = ComponentProps; -export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => ( +const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => ( ); -export type PromptInputCommandGroupProps = ComponentProps; +type PromptInputCommandGroupProps = ComponentProps; -export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => ( +const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => ( ); -export type PromptInputCommandItemProps = ComponentProps; +type PromptInputCommandItemProps = ComponentProps; -export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => ( +const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => ( ); -export type PromptInputCommandSeparatorProps = ComponentProps; +type PromptInputCommandSeparatorProps = ComponentProps; -export const PromptInputCommandSeparator = ({ className, ...props }: PromptInputCommandSeparatorProps) => ( +const PromptInputCommandSeparator = ({ className, ...props }: PromptInputCommandSeparatorProps) => ( ); diff --git a/apps/dashboard/src/components/maily/blocks/footers.tsx b/apps/dashboard/src/components/maily/blocks/footers.tsx index 24c69f38a84..28dd13980c8 100644 --- a/apps/dashboard/src/components/maily/blocks/footers.tsx +++ b/apps/dashboard/src/components/maily/blocks/footers.tsx @@ -6,7 +6,7 @@ import { EmailHeaderLogoWithCoverImage } from '@/components/icons/email-header-l import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; -export const createFooterPlainText: (props: { track: ReturnType }) => BlockItem = (props) => { +const createFooterPlainText: (props: { track: ReturnType }) => BlockItem = (props) => { const { track } = props; return { @@ -49,7 +49,7 @@ export const createFooterPlainText: (props: { track: ReturnType }) => BlockItem = ( +const createFooterLogoWithTextStacked: (props: { track: ReturnType }) => BlockItem = ( props ) => { const { track } = props; @@ -113,7 +113,7 @@ export const createFooterLogoWithTextStacked: (props: { track: ReturnType }) => BlockItem = ( +const createFooterLogoTextAndSocials: (props: { track: ReturnType }) => BlockItem = ( props ) => { const { track } = props; @@ -290,7 +290,7 @@ export const createFooterLogoTextAndSocials: (props: { track: ReturnType }) => BlockItem = ( +const createFooterLogoWithSimpleText: (props: { track: ReturnType }) => BlockItem = ( props ) => { const { track } = props; diff --git a/apps/dashboard/src/components/maily/blocks/headers.tsx b/apps/dashboard/src/components/maily/blocks/headers.tsx index 652b268e4f7..76eb1152613 100644 --- a/apps/dashboard/src/components/maily/blocks/headers.tsx +++ b/apps/dashboard/src/components/maily/blocks/headers.tsx @@ -6,7 +6,7 @@ import { EmailHeaderLogoWithText } from '@/components/icons/email-header-logo-wi import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; -export const createHeaderCenteredLogoWithBorder: (props: { track: ReturnType }) => BlockItem = ( +const createHeaderCenteredLogoWithBorder: (props: { track: ReturnType }) => BlockItem = ( props ) => { const { track } = props; @@ -62,7 +62,7 @@ export const createHeaderCenteredLogoWithBorder: (props: { track: ReturnType }) => BlockItem = (props) => { +const createHeaderLogoWithText: (props: { track: ReturnType }) => BlockItem = (props) => { const { track } = props; return { @@ -146,7 +146,7 @@ export const createHeaderLogoWithText: (props: { track: ReturnType }) => BlockItem = ( +const createHeaderLogoWithCoverImage: (props: { track: ReturnType }) => BlockItem = ( props ) => { const { track } = props; diff --git a/apps/dashboard/src/context/region/region-config.ts b/apps/dashboard/src/context/region/region-config.ts index ca994888fb9..f7eea8686e2 100644 --- a/apps/dashboard/src/context/region/region-config.ts +++ b/apps/dashboard/src/context/region/region-config.ts @@ -97,13 +97,13 @@ export const REGIONS: RegionConfig[] = parseRegionsFromEnv(); /** * Map of region code to region config */ -export const REGION_MAP = new Map(REGIONS.map((region) => [region.code, region])); +const REGION_MAP = new Map(REGIONS.map((region) => [region.code, region])); /** * Map of AWS region to region code * Used for detecting region from organization metadata */ -export const AWS_REGION_TO_CODE_MAP = new Map(REGIONS.map((region) => [region.awsRegion, region.code])); +const AWS_REGION_TO_CODE_MAP = new Map(REGIONS.map((region) => [region.awsRegion, region.code])); /** * Default region (first region in the list) diff --git a/apps/dashboard/src/pages/agent-details.tsx b/apps/dashboard/src/pages/agent-details.tsx index e25108815db..0173df1bda7 100644 --- a/apps/dashboard/src/pages/agent-details.tsx +++ b/apps/dashboard/src/pages/agent-details.tsx @@ -366,7 +366,9 @@ export function AgentDetailsPage() { - + + + {currentTab === 'integrations' ? ( diff --git a/apps/dashboard/src/utils/connect/onboarding-session.ts b/apps/dashboard/src/utils/connect/onboarding-session.ts index 34621ec66c7..3e8b10805d9 100644 --- a/apps/dashboard/src/utils/connect/onboarding-session.ts +++ b/apps/dashboard/src/utils/connect/onboarding-session.ts @@ -5,10 +5,10 @@ import { type OnboardingLoaderVariant, } from '@/components/onboarding/onboarding-loader'; -export const ONBOARDING_PROVISIONING_KEY = 'novu.onboarding.provisioning'; +const ONBOARDING_PROVISIONING_KEY = 'novu.onboarding.provisioning'; /** Legacy Connect-only flag — still read for in-flight sessions. */ -export const CONNECT_PROVISIONING_KEY = 'novu.connect.provisioning'; -export const CONNECT_PROVISION_QUERY = 'provision'; +const CONNECT_PROVISIONING_KEY = 'novu.connect.provisioning'; +const CONNECT_PROVISION_QUERY = 'provision'; const PROVISIONING_CHANGE_EVENT = 'novu.onboarding.provisioning-change'; @@ -17,7 +17,7 @@ type ProvisioningPayload = { startedAt: number; }; -export function notifyOnboardingProvisioningChange(): void { +function notifyOnboardingProvisioningChange(): void { if (typeof window === 'undefined') return; window.dispatchEvent(new Event(PROVISIONING_CHANGE_EVENT)); @@ -34,10 +34,10 @@ export function subscribeOnboardingProvisioningChange(listener: () => void): () } /** @deprecated Use `subscribeOnboardingProvisioningChange`. */ -export const subscribeConnectProvisioningChange = subscribeOnboardingProvisioningChange; +const subscribeConnectProvisioningChange = subscribeOnboardingProvisioningChange; /** @deprecated Use `notifyOnboardingProvisioningChange`. */ -export const notifyConnectProvisioningChange = notifyOnboardingProvisioningChange; +const notifyConnectProvisioningChange = notifyOnboardingProvisioningChange; // `connect` is the legacy name for the agents-flavored loader; map it forward to `agents`. function normalizeVariant(variant: string | undefined): OnboardingLoaderVariant | null { @@ -93,11 +93,11 @@ export function beginOnboardingProvisioning(variant: OnboardingLoaderVariant): v } } -export function beginAgentsProvisioning(): void { +function beginAgentsProvisioning(): void { beginOnboardingProvisioning('agents'); } -export function beginPlatformProvisioning(): void { +function beginPlatformProvisioning(): void { beginOnboardingProvisioning('platform'); } @@ -113,7 +113,7 @@ export function isOnboardingProvisioningActive(): boolean { return getOnboardingProvisioningVariant() !== null; } -export function isAgentsProvisioningActive(): boolean { +function isAgentsProvisioningActive(): boolean { return getOnboardingProvisioningVariant() === 'agents'; } @@ -139,14 +139,14 @@ export function getMinLoaderDurationMs(variant: OnboardingLoaderVariant): number return stepCount * ONBOARDING_STEP_DELAY_MS; } -export function buildConnectProvisionOrgListPath(orgListPath: string): string { +function buildConnectProvisionOrgListPath(orgListPath: string): string { const url = new URL(orgListPath, 'http://local'); url.searchParams.set(CONNECT_PROVISION_QUERY, '1'); return `${url.pathname}${url.search}`; } -export function withConnectProvisioningIntent(href: string): string { +function withConnectProvisioningIntent(href: string): string { if (!href) return href; try { @@ -165,7 +165,7 @@ export function withConnectProvisioningIntent(href: string): string { } } -export function consumeConnectProvisionIntentFromLocation(): boolean { +function consumeConnectProvisionIntentFromLocation(): boolean { if (typeof window === 'undefined') { return false; } @@ -185,7 +185,7 @@ export function consumeConnectProvisionIntentFromLocation(): boolean { return true; } -export function hasConnectProvisionIntent(): boolean { +function hasConnectProvisionIntent(): boolean { if (typeof window === 'undefined') { return false; } diff --git a/biome.json b/biome.json index 9b241e7657c..d777af2ab57 100644 --- a/biome.json +++ b/biome.json @@ -268,6 +268,96 @@ } } }, + { + "includes": [ + "apps/api/src/app/agents/**/*.{ts,tsx,js}", + "!apps/api/src/app/agents/agents.module.ts", + "!apps/api/src/app/agents/conversation-runtime/conversation/conversation-activity-ledger.ts", + "!apps/api/src/app/agents/conversation-runtime/conversation/conversation-event-sequence.service.ts", + "!apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts", + "!apps/api/src/app/agents/**/*.spec.ts", + "!apps/api/src/app/agents/**/*.test.ts", + "!apps/api/src/app/agents/**/*.e2e.ts", + "!apps/api/src/app/agents/e2e/**/*.{ts,tsx,js}" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@novu/*/**/*": "Please import only from the root package entry point. For example, use 'import { Client } from '@novu/api';' instead of 'import { Client } from '@novu/api/src';'", + "@nestjs/common": { + "importNames": ["Logger"], + "message": "Please use the PinoLogger from @novu/application-generic instead" + }, + "@novu/application-generic": { + "importNames": ["Logger", "validateUrlSsrf"], + "message": "Logger: use PinoLogger from @novu/application-generic. validateUrlSsrf: use safeOutboundRequest / safeOutboundJsonRequest (or HttpClientService with enforceSsrfProtection: true) — see https://novu.atlassian.net/browse/NV-7560" + }, + "@novu/shared/utils/ssrf-url-validation": { + "importNames": ["validateUrlSsrf"], + "message": "validateUrlSsrf is a one-shot pre-flight check vulnerable to redirects and DNS rebinding. Use safeOutboundRequest / safeOutboundJsonRequest from '@novu/shared/utils/safe-outbound-http' (or HttpClientService with enforceSsrfProtection: true)." + }, + "@novu/dal": { + "importNames": ["ConversationActivityRepository"], + "message": "Import ConversationActivityRepository only from agents.module.ts, conversation-activity-ledger.ts, conversation-event-sequence.service.ts, or test files — see https://novu.atlassian.net/browse/NV-8576" + }, + "svix": { + "importNames": ["Svix"], + "message": "Please use the SvixClient from @novu/application-generic instead" + }, + "@nestjs/swagger": { + "importNames": [ + "ApiOkResponse", + "ApiCreatedResponse", + "ApiAcceptedResponse", + "ApiNoContentResponse", + "ApiMovedPermanentlyResponse", + "ApiFoundResponse", + "ApiBadRequestResponse", + "ApiUnauthorizedResponse", + "ApiTooManyRequestsResponse", + "ApiNotFoundResponse", + "ApiInternalServerErrorResponse", + "ApiBadGatewayResponse", + "ApiConflictResponse", + "ApiForbiddenResponse", + "ApiGatewayTimeoutResponse", + "ApiGoneResponse", + "ApiMethodNotAllowedResponse", + "ApiNotAcceptableResponse", + "ApiNotImplementedResponse", + "ApiPreconditionFailedResponse", + "ApiPayloadTooLargeResponse", + "ApiRequestTimeoutResponse", + "ApiServiceUnavailableResponse", + "ApiUnprocessableEntityResponse", + "ApiUnsupportedMediaTypeResponse", + "ApiDefaultResponse" + ], + "message": "Use 'ApiResponse' from '/shared/framework/response.decorator' instead." + } + }, + "patterns": [ + { + "group": ["**/conversation-activity-ledger", "**/conversation-activity-ledger.ts"], + "importNamePattern": "ConversationActivityLedger", + "message": "Import ConversationActivityLedger only from agents.module.ts, agent-conversation.service.ts, or test files — see https://novu.atlassian.net/browse/NV-8576" + }, + { + "group": ["**/conversation-event-sequence.service", "**/conversation-event-sequence.service.ts"], + "importNamePattern": "ConversationEventSequenceService", + "message": "Import ConversationEventSequenceService only from agents.module.ts, conversation-activity-ledger.ts, or test files — see https://novu.atlassian.net/browse/NV-8576" + } + ] + } + } + } + } + } + }, { "includes": ["libs/application-generic/**/*.{ts,tsx,js}"], "linter": { diff --git a/docs/agents/channels/agent-chat/quickstart.mdx b/docs/agents/channels/agent-chat/quickstart.mdx index c690d182971..d775642a3a0 100644 --- a/docs/agents/channels/agent-chat/quickstart.mdx +++ b/docs/agents/channels/agent-chat/quickstart.mdx @@ -226,7 +226,7 @@ Start with `type === 'text'`. Then handle the other part types as you need them. | `tool` | Tool call and result. | | `approval` | Gated tool. Use `pendingActions` and `respondToAction`. | | `mcp-connection` | MCP connect card. Open `authorizeUrl`. | -| `card` | Structured card payload. | +| `card` | Structured Card tree. Draw `part.card`. Button clicks call `sendAction`. See [Cards](#cards). | | `file` | File attachment metadata. | | `source` | Citation (`url` or `document`). | @@ -265,6 +265,104 @@ const { messages } = useAgentChat({ agentId: 'YOUR_AGENT_IDENTIFIER' }); Full field tables: [`useAgentChat`](/platform/sdks/react/hooks/use-agent-chat). +## Cards + +A Card is a structured reply. It can include a title, text, images, links, and buttons. + +When the agent sends a Card, the message includes a part with `type: 'card'`. The data is on `part.card`. + +Agents create Cards with JSX or `Card({…})`. That authoring API is shared with Slack and the other ACI channels: [Interactive cards](/agents/custom-code-agent/building-blocks/reply#interactive-cards). You can also post the JSON with [Send an agent reply](/api-reference/agents/send-an-agent-reply). + +The SDK does not type `part.card`. Read the fields that you support. Skip child types that you do not use. + +An Order card arrives as this object on `part.card`: + +```json +{ + "type": "card", + "title": "Order #1234", + "children": [ + { "type": "text", "content": "Your order is ready for pickup." }, + { + "type": "actions", + "children": [ + { "type": "button", "id": "ack", "label": "Acknowledge" }, + { "type": "button", "id": "escalate", "label": "Escalate", "style": "danger" } + ] + } + ] +} +``` + +| Field | What it is | +| --- | --- | +| `title` | Optional heading. | +| `subtitle` | Optional secondary heading. | +| `imageUrl` | Optional header image. | +| `children` | Ordered nodes. See the table below. | + +| `child.type` | Fields | +| --- | --- | +| `text` | `content` | +| `divider` | None | +| `image` | `url`, `alt` | +| `link` | `url`, `label` | +| `actions` | `children` of `button` | +| `button` | `id`, `label`, optional `value` and `style` | + +Draw `title` and the `children` that you support. If the subscriber clicks a button, call `sendAction`. The agent receives the click in `onAction`. + +```tsx +const { messages, sendAction } = useAgentChat({ + agentId: 'YOUR_AGENT_IDENTIFIER', +}); + +{messages.map((message) => + message.parts.map((part, i) => { + if (part.type !== 'card') { + return null; + } + + const title = typeof part.card.title === 'string' ? part.card.title : null; + const children = Array.isArray(part.card.children) ? part.card.children : []; + + return ( +
+ {title ?

{title}

: null} + {children.map((child, j) => { + if (child?.type === 'text') { + return

{child.content}

; + } + + if (child?.type !== 'actions') { + return null; + } + + return child.children?.map((button) => ( + + )); + })} +
+ ); + }) +)} +``` + +Do not send the button label with `sendMessage`. That call creates a new chat message. +Do not call `respondToAction`. That function is for tool approval only. + ## Thinking and running While the agent turn is in progress, `isRunning` is true. `typing` is present when the agent is typing. Assistant messages can include `thinking` parts before text arrives. diff --git a/docs/agents/custom-code-agent/building-blocks/reply.mdx b/docs/agents/custom-code-agent/building-blocks/reply.mdx index 989af561c4b..d6e88b452bc 100644 --- a/docs/agents/custom-code-agent/building-blocks/reply.mdx +++ b/docs/agents/custom-code-agent/building-blocks/reply.mdx @@ -164,11 +164,11 @@ await ctx.reply( ); ``` -When a user clicks a button or selects a dropdown value, `onAction` fires with `action.id` and `action.value`. See [Handlers and context](/agents/custom-code-agent/building-blocks/handle-events#onaction). - +When a user clicks a button or selects a dropdown value, `onAction` fires with `action.id` and `action.value`. See [Handlers and context](/agents/custom-code-agent/building-blocks/handle-events#onaction). On [Agent Chat](/agents/channels/agent-chat/quickstart#cards), the same tree arrives as a `card` part. The web UI draws it and calls `sendAction`. + ### Available card components The following table lists the card components you can use in replies: @@ -199,4 +199,7 @@ The following table lists the card components you can use in replies: Send replies from your backend without a bridge handler. + + Draw `part.type === 'card'` in your product UI and call `sendAction`. + diff --git a/docs/platform/integrations/push/apns.mdx b/docs/platform/integrations/push/apns.mdx index ee32b4e9b0c..eb3f5e4aea7 100644 --- a/docs/platform/integrations/push/apns.mdx +++ b/docs/platform/integrations/push/apns.mdx @@ -348,14 +348,15 @@ You do not need to hand-build the `aps` dictionary or set raw headers yourself. | `badge` | `aps.badge` | `number` | App icon badge count | | `category` | `aps.category` | `string` | Actionable notification category | | `threadId` | `aps.thread-id` | `string` | Groups related notifications | -| `priority` | `apns-priority` header | `10` (default) or `5` | `10` delivers immediately, `5` conserves device power | -| `topic` | `apns-topic` header | `string` | Defaults to your integration Bundle ID | +| `priority` | `apns-priority` header | `10` (default) or `5` | `10` delivers immediately, `5` conserves device power. Use `10` for VoIP | +| `topic` | `apns-topic` header | `string` | Defaults to your integration Bundle ID. For VoIP, use `.voip` | | `collapseId` | `apns-collapse-id` header | `string` | Collapses notifications that share the same id | -| `pushType` | `apns-push-type` header | `"alert"` or `"background"` | Must match the payload contents | +| `pushType` | `apns-push-type` header | `"alert"`, `"background"`, or `"voip"` | Must match the payload contents. Use `"voip"` for CallKit / PushKit | | `expiry` | `apns-expiration` header | `number` (UNIX timestamp) | `0` tells APNS not to retry | +| `rawPayload` | Full notification body | `object` | Sent as-is. Skips the usual `aps` / title / body mapping. Use this for CallKit payloads | - Anything you send in the trigger `payload` is delivered alongside the `aps` dictionary as the notification's custom data, so you usually do not need to override the payload to pass app-specific values. + Anything you send in the trigger `payload` is delivered alongside the `aps` dictionary as the notification's custom data, so you usually do not need to override the payload to pass app-specific values. If you set `rawPayload`, that object becomes the entire body instead. Here is an example that sets the sound and badge, and adjusts the `apns-priority` and `apns-topic` headers. The `topic` field is optional since it defaults to your integration Bundle ID: @@ -580,4 +581,372 @@ delivers the following notification to the device: Because Novu generates the `aps` dictionary from the fields in the table above, an `aps` object nested inside a `payload` override is not applied. Use the fields above to shape `aps`, and the trigger `payload` to pass custom data. + + +## VoIP pushes for CallKit and PushKit + +iOS incoming-call flows (CallKit / PushKit) need a true VoIP push. Apple requires: + +- `apns-push-type: voip` +- `apns-topic` set to your app's VoIP topic (`.voip`) +- A [PushKit](https://developer.apple.com/documentation/pushkit) VoIP device token (not the regular remote-notification token) + +Novu supports this with APNS overrides (`pushType`, `topic`, `rawPayload`). Unlike email or SMS, a Push step does **not** let you pin the send to one integration with `integrationIdentifier`. It delivers through every active push integration that has device tokens for that subscriber, and `overrides.providers.apns` is applied to each APNS integration the same way. + +Because of that, use a dedicated VoIP APNS integration and keep token types on the matching integration: + +1. Create an APNS integration used only for VoIP (for example identifier `apns-voip`). Reuse the same `.p8` key, Key ID, and Team ID if you already have an alert integration. Set **Bundle ID** to your VoIP topic (`com.example.app.voip`), or keep the app Bundle ID and override `topic` at trigger time. +2. Register the PushKit VoIP token on that integration with `integrationIdentifier`. Store regular remote-notification tokens only on your alert APNS integration, not on the VoIP one. +3. Trigger a workflow that includes a Push step, and set `pushType`, `topic`, and `rawPayload` under `overrides.providers.apns`. + +If a subscriber has tokens on both APNS integrations, an incoming-call trigger still attempts delivery on the alert integration with the VoIP overrides. APNS rejects that attempt; the VoIP send can still succeed, and the rejected attempt appears in activity. The same cross-talk happens in reverse on alert workflows. There is no push equivalent of email/SMS `integrationIdentifier` override to send through only one integration. + +Store the VoIP token on the VoIP integration: + + + +```typescript +import { Novu } from '@novu/api'; +import { ChatOrPushProviderEnum } from "@novu/api/models/components"; + +const novu = new Novu({ secretKey: "" }); + +await novu.subscribers.credentials.update( + { + providerId: ChatOrPushProviderEnum.Apns, + integrationIdentifier: "apns-voip", + credentials: { deviceTokens: [voipToken] }, + }, + "subscriberId" +); +``` + + +```python +import os +import novu_py +from novu_py import Novu + +with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu: + novu.subscribers.credentials.update( + subscriber_id="subscriberId", + update_subscriber_channel_request_dto={ + "provider_id": novu_py.ChatOrPushProviderEnum.APNS, + "integration_identifier": "apns-voip", + "credentials": {"deviceTokens": [voip_token]}, + }, + ) +``` + + +```go +import ( + "context" + "os" + + novugo "github.com/novuhq/novu-go" + "github.com/novuhq/novu-go/models/components" +) + +s := novugo.New(novugo.WithSecurity(os.Getenv("NOVU_SECRET_KEY"))) + +res, err := s.Subscribers.Credentials.Update(context.Background(), "subscriberId", components.UpdateSubscriberChannelRequestDto{ + ProviderID: components.ChatOrPushProviderEnumApns, + IntegrationIdentifier: novugo.String("apns-voip"), + Credentials: components.ChannelCredentials{ + DeviceTokens: []string{voipToken}, + }, +}, nil) +``` + + +```php +use novu; +use novu\Models\Components; + +$sdk = novu\Novu::builder()->setSecurity('')->build(); + +$sdk->subscribersCredentials->update( + subscriberId: 'subscriberId', + updateSubscriberChannelRequestDto: new Components\UpdateSubscriberChannelRequestDto( + providerId: Components\ChatOrPushProviderEnum::Apns, + integrationIdentifier: 'apns-voip', + credentials: new Components\ChannelCredentials( + deviceTokens: [$voipToken], + ), + ), +); +``` + + +```csharp +using Novu; +using Novu.Models.Components; +using System.Collections.Generic; + +var sdk = new NovuSDK(secretKey: ""); + +await sdk.Subscribers.Credentials.UpdateAsync( + subscriberId: "subscriberId", + updateSubscriberChannelRequestDto: new UpdateSubscriberChannelRequestDto() { + ProviderId = ChatOrPushProviderEnum.Apns, + IntegrationIdentifier = "apns-voip", + Credentials = new ChannelCredentials() { + DeviceTokens = new List { voipToken }, + }, + }); +``` + + +```java +import co.novu.Novu; +import co.novu.models.components.*; +import java.util.List; + +Novu novu = Novu.builder().secretKey("").build(); + +novu.subscribers().credentials().update() + .subscriberId("subscriberId") + .body(UpdateSubscriberChannelRequestDto.builder() + .providerId(ChatOrPushProviderEnum.APNS) + .integrationIdentifier("apns-voip") + .credentials(ChannelCredentials.builder() + .deviceTokens(List.of(voipToken)) + .build()) + .build()) + .call(); +``` + + +```bash +curl -L -X PUT 'https://api.novu.co/v1/subscribers//credentials' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: ApiKey ' \ +-d '{ + "providerId": "apns", + "integrationIdentifier": "apns-voip", + "credentials": { + "deviceTokens": ["VOIP_DEVICE_TOKEN"] + } +}' +``` + + + +Then trigger with VoIP overrides. `rawPayload` is the CallKit payload your app expects. When it is set, Novu sends that object as the full notification body (no generated `aps.alert` from the Push step Subject / Body): + + + +```typescript +import { Novu } from '@novu/api'; + +const novu = new Novu({ secretKey: "" }); + +await novu.trigger({ + workflowId: "incoming-call", + to: { subscriberId: "subscriberId" }, + payload: {}, + overrides: { + providers: { + apns: { + pushType: "voip", + topic: "com.example.app.voip", + priority: 10, + rawPayload: { + uuid: "019fef37-c728-709c-aac4-3a4740139a11", + nameCaller: "Jane Doe", + handle: "Handler", + isVideo: true, + }, + }, + }, + }, +}); +``` + + +```python +import os +import novu_py +from novu_py import Novu + +with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu: + novu.trigger(trigger_event_request_dto=novu_py.TriggerEventRequestDto( + workflow_id="incoming-call", + to={"subscriber_id": "subscriberId"}, + payload={}, + overrides={ + "providers": { + "apns": { + "pushType": "voip", + "topic": "com.example.app.voip", + "priority": 10, + "rawPayload": { + "uuid": "019fef37-c728-709c-aac4-3a4740139a11", + "nameCaller": "Jane Doe", + "handle": "Handler", + "isVideo": True, + }, + }, + }, + }, + )) +``` + + +```go +import ( + "context" + "os" + + novugo "github.com/novuhq/novu-go" + "github.com/novuhq/novu-go/models/components" +) + +s := novugo.New(novugo.WithSecurity(os.Getenv("NOVU_SECRET_KEY"))) + +res, err := s.Trigger(context.Background(), components.TriggerEventRequestDto{ + WorkflowID: "incoming-call", + To: components.CreateToSubscriberPayloadDto(components.SubscriberPayloadDto{ + SubscriberID: "subscriberId", + }), + Payload: map[string]any{}, + Overrides: map[string]map[string]any{ + "providers": { + "apns": map[string]any{ + "pushType": "voip", + "topic": "com.example.app.voip", + "priority": 10, + "rawPayload": map[string]any{ + "uuid": "019fef37-c728-709c-aac4-3a4740139a11", + "nameCaller": "Jane Doe", + "handle": "Handler", + "isVideo": true, + }, + }, + }, + }, +}, nil) +``` + + +```php +use novu; +use novu\Models\Components; + +$sdk = novu\Novu::builder()->setSecurity('')->build(); + +$sdk->trigger( + triggerEventRequestDto: new Components\TriggerEventRequestDto( + workflowId: 'incoming-call', + to: new Components\SubscriberPayloadDto(subscriberId: 'subscriberId'), + payload: [], + overrides: [ + 'providers' => [ + 'apns' => [ + 'pushType' => 'voip', + 'topic' => 'com.example.app.voip', + 'priority' => 10, + 'rawPayload' => [ + 'uuid' => '019fef37-c728-709c-aac4-3a4740139a11', + 'nameCaller' => 'Jane Doe', + 'handle' => 'Handler', + 'isVideo' => true, + ], + ], + ], + ], + ), +); +``` + + +```csharp +using Novu; +using Novu.Models.Components; +using System.Collections.Generic; + +var sdk = new NovuSDK(secretKey: ""); + +await sdk.TriggerAsync(triggerEventRequestDto: new TriggerEventRequestDto() { + WorkflowId = "incoming-call", + To = To.CreateSubscriberPayloadDto(new SubscriberPayloadDto() { SubscriberId = "subscriberId" }), + Payload = new Dictionary(), + Overrides = new Overrides() { + Providers = new Dictionary>() { + { "apns", new Dictionary() { + { "pushType", "voip" }, + { "topic", "com.example.app.voip" }, + { "priority", 10 }, + { "rawPayload", new Dictionary() { + { "uuid", "019fef37-c728-709c-aac4-3a4740139a11" }, + { "nameCaller", "Jane Doe" }, + { "handle", "Handler" }, + { "isVideo", true }, + } }, + } }, + }, + }, +}); +``` + + +```java +import co.novu.Novu; +import co.novu.models.components.*; +import java.util.Map; + +Novu novu = Novu.builder().secretKey("").build(); + +novu.trigger() + .body(TriggerEventRequestDto.builder() + .workflowId("incoming-call") + .to(To2.of(SubscriberPayloadDto.builder().subscriberId("subscriberId").build())) + .payload(Map.of()) + .overrides(TriggerEventRequestDtoOverrides.builder() + .additionalProperties(Map.of("providers", Map.of("apns", Map.ofEntries( + Map.entry("pushType", "voip"), + Map.entry("topic", "com.example.app.voip"), + Map.entry("priority", 10), + Map.entry("rawPayload", Map.of( + "uuid", "019fef37-c728-709c-aac4-3a4740139a11", + "nameCaller", "Jane Doe", + "handle", "Handler", + "isVideo", true)))))) + .build()) + .build()) + .call(); +``` + + +```bash +curl --location 'https://api.novu.co/v1/events/trigger' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: ApiKey ' \ +-d '{ + "name": "incoming-call", + "to": ["subscriberId"], + "payload": {}, + "overrides": { + "providers": { + "apns": { + "pushType": "voip", + "topic": "com.example.app.voip", + "priority": 10, + "rawPayload": { + "uuid": "019fef37-c728-709c-aac4-3a4740139a11", + "nameCaller": "Jane Doe", + "handle": "Handler", + "isVideo": true + } + } + } + } +}' +``` + + + + + Passing raw APNS HTTP headers under `_passthrough.headers` (for example `apns-push-type`) is not supported for the native APNS provider. Use `pushType`, `topic`, and `priority` instead. Those map to the correct headers through `@parse/node-apn`. \ No newline at end of file diff --git a/docs/platform/sdks/react/hooks/use-agent-chat.mdx b/docs/platform/sdks/react/hooks/use-agent-chat.mdx index 8d08b059525..076b282c492 100644 --- a/docs/platform/sdks/react/hooks/use-agent-chat.mdx +++ b/docs/platform/sdks/react/hooks/use-agent-chat.mdx @@ -31,7 +31,7 @@ Use it inside [`NovuProvider`](/platform/sdks/react/hooks/novu-provider). Produc | `messages` | `AgentMessage[]` | Folded timeline for the current conversation. | | `pendingActions` | `AgentPendingAction[]` | Tool approvals and MCP connect items that are still pending. Derived from `messages`. | | `conversationId` | `string \| undefined` | Server conversation id after create or resume. Undefined until the first successful send on a new chat. | -| `error` | `NovuError \| AgentChatPlanLimitError` | Last error from load, send, or `respondToAction`. | +| `error` | `NovuError \| AgentChatPlanLimitError` | Last error from load, send, `respondToAction`, or `sendAction`. | | `isLoading` | `boolean` | True until the first history fetch completes. False when there is no `conversationId` prop. | | `isFetching` | `boolean` | True while a history request is in flight. | | `isRunning` | `boolean` | True while the agent turn is in progress. | @@ -42,6 +42,7 @@ Use it inside [`NovuProvider`](/platform/sdks/react/hooks/novu-provider). Produc | `fetchMore` | `() => Promise<{ data?: { messages: AgentMessage[]; hasMore: boolean }; error?: NovuError }>` | Load an older history page. | | `sendMessage` | `(text: string) => Promise<{ data?: SendMessageResult; error?: NovuError \| AgentChatPlanLimitError }>` | Send a user message. Creates a conversation when `conversationId` is omitted. | | `respondToAction` | `(args: { actionId: string; decision: 'approved' \| 'denied' }) => Promise<{ data?: RespondToActionResult; error?: NovuError \| AgentChatPlanLimitError }>` | Approve or deny a pending `tool-approval`. Pass `action.id` from `pendingActions`. | +| `sendAction` | `(args: { actionId: string; sourceMessageId: string; value?: string }) => Promise<{ data?: SendActionResult; error?: NovuError \| AgentChatPlanLimitError }>` | Click a Card button. Pass `id` / `value` from the button and `message.id` as `sourceMessageId`. Do not use this for tool approval. Render walk: [Cards](/agents/channels/agent-chat/quickstart#cards). | ## Message type @@ -62,7 +63,7 @@ Use it inside [`NovuProvider`](/platform/sdks/react/hooks/novu-provider). Produc | `tool` | Tool call and result. | | `approval` | Gated tool waiting on the subscriber. Use `pendingActions` + `respondToAction`. | | `mcp-connection` | MCP server connect card. Open `authorizeUrl` in the browser. | -| `card` | Structured card payload (`card` object). | +| `card` | Structured Card tree (`card` object). Draw it in your UI. Button clicks call `sendAction`. Agents send Cards with [Interactive cards](/agents/custom-code-agent/building-blocks/reply#interactive-cards). Client walk: [Cards](/agents/channels/agent-chat/quickstart#cards). | | `file` | File attachment metadata. | | `source` | Citation (`url` or `document`). | @@ -87,4 +88,4 @@ Use it inside [`NovuProvider`](/platform/sdks/react/hooks/novu-provider). Produc ## JavaScript SDK -The same client lives on `novu.agentChat` in [`@novu/js`](/platform/sdks/javascript#agent-chat): `sendMessage`, `loadConversation`, `fetchMore`, `respondToAction`, `subscribe`, `unsubscribe`. +The same client lives on `novu.agentChat` in [`@novu/js`](/platform/sdks/javascript#agent-chat): `sendMessage`, `loadConversation`, `fetchMore`, `respondToAction`, `sendAction`, `subscribe`, `unsubscribe`. diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index 33d17828cf7..bc0a6acc00d 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -15,8 +15,8 @@ const modules = [ { name: 'UMD minified', filePath: umdPath, - // Raised for headless agentChat on Novu (NV-8445). Split to ./agent-chat later if needed. - limitInBytes: 224_000, + // Raised for headless agentChat on Novu. Split to ./agent-chat later if needed. + limitInBytes: 225_000, }, { name: 'UMD gzip', diff --git a/packages/js/src/agent-chat/agent-chat.test.ts b/packages/js/src/agent-chat/agent-chat.test.ts index 39657d56341..85268b0bf8f 100644 --- a/packages/js/src/agent-chat/agent-chat.test.ts +++ b/packages/js/src/agent-chat/agent-chat.test.ts @@ -10,6 +10,7 @@ describe('AgentChat', () => { let emitter: NovuEventEmitter; let sendMessage: jest.Mock; let respondToAction: jest.Mock; + let sendAction: jest.Mock; let getEvents: jest.Mock; let connect: jest.Mock; let agentChat: AgentChat; @@ -18,9 +19,10 @@ describe('AgentChat', () => { emitter = new NovuEventEmitter(); sendMessage = jest.fn(); respondToAction = jest.fn(); + sendAction = jest.fn(); getEvents = jest.fn(); connect = jest.fn().mockResolvedValue({ data: undefined }); - const agentChatService = { sendMessage, respondToAction, getEvents } as unknown as AgentChatService; + const agentChatService = { sendMessage, respondToAction, sendAction, getEvents } as unknown as AgentChatService; agentChat = new AgentChat({ inboxServiceInstance, eventEmitterInstance: emitter, @@ -1558,6 +1560,44 @@ describe('AgentChat', () => { expect(respondToAction).not.toHaveBeenCalled(); }); + it('sendAction POSTs actionId, value, and sourceMessageId', async () => { + sendMessage.mockResolvedValue({ identifier: 'conv_abcdefghijkl', messageId: 'msg_abcdefghijkl' }); + sendAction.mockResolvedValue({ identifier: 'conv_abcdefghijkl' }); + + await agentChat.sendMessage({ agentId: 'agent_1', text: 'hi', key: 'local_card' }); + const result = await agentChat.sendAction({ + agentId: 'agent_1', + key: 'local_card', + actionId: 'topic-billing', + sourceMessageId: 'act_card0000001', + value: 'billing', + }); + + expect(result).toEqual({ data: { conversationId: 'conv_abcdefghijkl' } }); + expect(sendAction).toHaveBeenCalledWith({ + agentId: 'agent_1', + conversationId: 'conv_abcdefghijkl', + actionId: 'topic-billing', + sourceMessageId: 'act_card0000001', + value: 'billing', + }); + }); + + it('sendAction returns error without POST when sourceMessageId is empty', async () => { + sendMessage.mockResolvedValue({ identifier: 'conv_abcdefghijkl', messageId: 'msg_abcdefghijkl' }); + + await agentChat.sendMessage({ agentId: 'agent_1', text: 'hi', key: 'local_card_empty' }); + const result = await agentChat.sendAction({ + agentId: 'agent_1', + key: 'local_card_empty', + actionId: 'topic-billing', + sourceMessageId: ' ', + }); + + expect(result.error).toBeDefined(); + expect(sendAction).not.toHaveBeenCalled(); + }); + function recordChanges(key: string) { const changes: AgentChatChange[] = []; emitter.on('agent_chat.messages.updated', ({ data }) => { diff --git a/packages/js/src/agent-chat/agent-chat.ts b/packages/js/src/agent-chat/agent-chat.ts index 8da240cc2bb..1cb843fb7d9 100644 --- a/packages/js/src/agent-chat/agent-chat.ts +++ b/packages/js/src/agent-chat/agent-chat.ts @@ -14,6 +14,8 @@ import type { LoadConversationResult, RespondToActionArgs, RespondToActionResult, + SendActionArgs, + SendActionResult, SendMessageArgs, SendMessageResult, } from './types'; @@ -128,53 +130,61 @@ export class AgentChat extends BaseModule { } async respondToAction(args: RespondToActionArgs): Result { - return this.callWithSession(async () => { - const entry = this.#resolveFetchEntry(args); - if (!entry?.conversationId) { - return { - error: new NovuError( - 'Cannot respond to action without a conversation id', - new Error('missing conversation id') - ), - }; - } - - const pending = derivePendingActions(entry.messages).find((action) => action.id === args.actionId); - if (!pending || pending.type !== 'tool-approval') { - return { error: new NovuError('Pending action not found', new Error('pending action not found')) }; - } + return this.#withConversationAction( + args, + 'Cannot respond to action without a conversation id', + 'Failed to respond to action', + async (entry, conversationId) => { + const pending = derivePendingActions(entry.messages).find((action) => action.id === args.actionId); + if (!pending || pending.type !== 'tool-approval') { + return { error: new NovuError('Pending action not found', new Error('pending action not found')) }; + } - const actionId = args.decision === 'approved' ? pending.approveActionId : pending.denyActionId; - if (!actionId) { - return { - error: new NovuError( - 'Pending approval is missing action id', - new Error('pending approval missing action id') - ), - }; - } + const actionId = args.decision === 'approved' ? pending.approveActionId : pending.denyActionId; + if (!actionId) { + return { + error: new NovuError( + 'Pending approval is missing action id', + new Error('pending approval missing action id') + ), + }; + } - try { - const data = await this.#agentChatService.respondToAction({ + return this.#agentChatService.respondToAction({ agentId: args.agentId, - conversationId: entry.conversationId, + conversationId, actionId, agentHash: args.agentHash, }); + } + ); + } - return { - data: { - conversationId: data.identifier, - }, - }; - } catch (error) { - if (error instanceof AgentChatPlanLimitError) { - return { error }; + async sendAction(args: SendActionArgs): Result { + return this.#withConversationAction( + args, + 'Cannot send action without a conversation id', + 'Failed to send action', + async (_entry, conversationId) => { + const actionId = args.actionId.trim(); + const sourceMessageId = args.sourceMessageId.trim(); + if (!actionId) { + return { error: new NovuError('actionId is required', new Error('missing action id')) }; + } + if (!sourceMessageId) { + return { error: new NovuError('sourceMessageId is required', new Error('missing source message id')) }; } - return { error: new NovuError('Failed to respond to action', error) }; + return this.#agentChatService.sendAction({ + agentId: args.agentId, + conversationId, + actionId, + sourceMessageId, + value: args.value, + agentHash: args.agentHash, + }); } - }); + ); } /** @@ -292,6 +302,42 @@ export class AgentChat extends BaseModule { }); } + /** + * Shared session + conversation + plan-limit wrapper for `respondToAction` and `sendAction`. + * The two public methods stay separate: they take different ids and extra fields. + */ + async #withConversationAction( + args: { agentId: string; conversationId?: string; key?: string }, + missingConversationMessage: string, + failureMessage: string, + run: (entry: ConversationEntry, conversationId: string) => Promise<{ identifier: string } | { error: NovuError }> + ): Result<{ conversationId: string }, NovuError | AgentChatPlanLimitError> { + return this.callWithSession<{ conversationId: string }, NovuError | AgentChatPlanLimitError>(async () => { + const entry = this.#resolveFetchEntry(args); + const conversationId = entry?.conversationId; + if (!entry || !conversationId) { + return { + error: new NovuError(missingConversationMessage, new Error('missing conversation id')), + }; + } + + try { + const result = await run(entry, conversationId); + if ('error' in result) { + return { error: result.error }; + } + + return { data: { conversationId: result.identifier } }; + } catch (error) { + if (error instanceof AgentChatPlanLimitError) { + return { error }; + } + + return { error: new NovuError(failureMessage, error) }; + } + }); + } + #resolveFetchEntry(args: FetchMoreArgs): ConversationEntry | undefined { if (args.key) { const byKey = this.#store.get(args.key); diff --git a/packages/js/src/agent-chat/apply-envelope.test.ts b/packages/js/src/agent-chat/apply-envelope.test.ts index 62094f4ec69..e53377852d2 100644 --- a/packages/js/src/agent-chat/apply-envelope.test.ts +++ b/packages/js/src/agent-chat/apply-envelope.test.ts @@ -1,4 +1,9 @@ -import { AGENT_EVENT_PROTOCOL_VERSION, type AgentEvent, type AgentEventEnvelope } from '@novu/agent-event-protocol'; +import { + AGENT_EVENT_PROTOCOL_VERSION, + type AgentEvent, + type AgentEventEnvelope, + type AgentMessageContent, +} from '@novu/agent-event-protocol'; import { type AgentMessage, createInitialAgentConversationState } from './agent-message.types'; import { appendUserMessage, applyEnvelope, applyEnvelopes } from './apply-envelope'; @@ -378,4 +383,38 @@ describe('applyEnvelope', () => { expect(next.typing).toBeUndefined(); }); + + it('folds card content into a card part', () => { + const card = { + type: 'card', + title: 'Support Agent', + children: [{ type: 'text', content: 'How can I help?' }], + }; + const next = applyEnvelope( + createInitialAgentConversationState(), + envelope(1, { + type: 'message', + role: 'assistant', + messageId: 'm-card', + content: { card }, + }) + ); + + expect(assistantMessages(next.messages)[0]?.parts).toEqual([{ type: 'card', card }]); + }); + + it('folds exclusive durable content as markdown or card, not both', () => { + const card = { type: 'card', title: 'Support' }; + const next = applyEnvelope( + createInitialAgentConversationState(), + envelope(1, { + type: 'message', + role: 'assistant', + messageId: 'm-both', + content: { markdown: 'Hello', card } as AgentMessageContent, + }) + ); + + expect(assistantMessages(next.messages)[0]?.parts).toEqual([{ type: 'text', text: 'Hello', state: 'done' }]); + }); }); diff --git a/packages/js/src/agent-chat/apply-envelope.ts b/packages/js/src/agent-chat/apply-envelope.ts index 8c7618ce71e..6936a8c60ec 100644 --- a/packages/js/src/agent-chat/apply-envelope.ts +++ b/packages/js/src/agent-chat/apply-envelope.ts @@ -351,9 +351,7 @@ function applyDurableMessageParts( } else { next = [...next, { type: 'text', text: content.markdown, state: 'done' }]; } - } - - if ('card' in content) { + } else { next = [...next, { type: 'card', card: content.card }]; } diff --git a/packages/js/src/agent-chat/index.ts b/packages/js/src/agent-chat/index.ts index c18ae09ef0d..72cac8a0acf 100644 --- a/packages/js/src/agent-chat/index.ts +++ b/packages/js/src/agent-chat/index.ts @@ -18,6 +18,8 @@ export type { LoadConversationResult, RespondToActionArgs, RespondToActionResult, + SendActionArgs, + SendActionResult, SendMessageArgs, SendMessageResult, } from './types'; diff --git a/packages/js/src/agent-chat/types.ts b/packages/js/src/agent-chat/types.ts index cfc0d143ad5..a720cc0a0c9 100644 --- a/packages/js/src/agent-chat/types.ts +++ b/packages/js/src/agent-chat/types.ts @@ -83,6 +83,22 @@ export type RespondToActionResult = { conversationId: string; }; +export type SendActionArgs = AgentHashFields & { + agentId: string; + /** `id` of the clicked Card button. */ + actionId: string; + /** Platform message id of the message that carries the Card. */ + sourceMessageId: string; + /** `value` of the clicked Card button, if set. */ + value?: string; + conversationId?: string; + key?: string; +}; + +export type SendActionResult = { + conversationId: string; +}; + /** What caused a fold. A live fold carries the envelope that caused it. Internal to the store seam. */ export type AgentChatChangeSource = | { kind: 'live'; envelope: AgentEventEnvelope } diff --git a/packages/js/src/api/agent-chat-service.test.ts b/packages/js/src/api/agent-chat-service.test.ts index d29cb2b1d46..811e389e201 100644 --- a/packages/js/src/api/agent-chat-service.test.ts +++ b/packages/js/src/api/agent-chat-service.test.ts @@ -166,6 +166,42 @@ describe('AgentChatService', () => { ); }); + it('POSTs a Card button action with sourceMessageId and value', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { identifier: 'conv_abcdefghijkl' } }), + } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + const httpClient = new HttpClient({ apiUrl: 'https://test.novu.co' }); + httpClient.setAuthorizationToken('test-token'); + const service = new AgentChatService({ httpClient }); + + const result = await service.sendAction({ + agentId: 'agent_1', + conversationId: 'conv_abcdefghijkl', + actionId: 'topic-billing', + sourceMessageId: 'act_card0000001', + value: 'billing', + }); + + expect(result).toEqual({ identifier: 'conv_abcdefghijkl' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://test.novu.co/v1/agent-chat/conversations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agentId: 'agent_1', + conversationIdentifier: 'conv_abcdefghijkl', + actionId: 'topic-billing', + sourceMessageId: 'act_card0000001', + value: 'billing', + }), + }) + ); + }); + it('GETs older conversation events with before cursor', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, diff --git a/packages/js/src/api/agent-chat-service.ts b/packages/js/src/api/agent-chat-service.ts index 76a4cbb87b2..e077916626b 100644 --- a/packages/js/src/api/agent-chat-service.ts +++ b/packages/js/src/api/agent-chat-service.ts @@ -48,6 +48,17 @@ export type AgentChatRespondToActionArgs = AgentHashFields & { actionId: string; }; +export type AgentChatSendActionArgs = AgentHashFields & { + agentId: string; + conversationId: string; + /** `id` of the clicked Card button. */ + actionId: string; + /** Platform message id of the message that carries the Card. */ + sourceMessageId: string; + /** `value` of the clicked Card button, if set. */ + value?: string; +}; + export type AgentChatRespondToActionResponse = { identifier: string; }; @@ -77,6 +88,17 @@ export class AgentChatService { }); } + async sendAction(args: AgentChatSendActionArgs): Promise { + return this.#postAccept({ + agentId: args.agentId, + conversationIdentifier: args.conversationId, + actionId: args.actionId, + sourceMessageId: args.sourceMessageId, + ...(args.value !== undefined ? { value: args.value } : {}), + ...(args.agentHash ? { agentHash: args.agentHash } : {}), + }); + } + async #postAccept( body: Record ): Promise { diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 36bfdf0e2cf..85ff672d702 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -16,6 +16,8 @@ export type { LoadConversationResult, RespondToActionArgs, RespondToActionResult, + SendActionArgs, + SendActionResult, SendMessageArgs, SendMessageResult, } from './agent-chat'; diff --git a/packages/react/src/hooks/useAgentChat.ts b/packages/react/src/hooks/useAgentChat.ts index f88ead2371d..b000a7a3471 100644 --- a/packages/react/src/hooks/useAgentChat.ts +++ b/packages/react/src/hooks/useAgentChat.ts @@ -9,6 +9,7 @@ import type { LoadConversationResult, NovuError, RespondToActionResult, + SendActionResult, SendMessageResult, } from '@novu/js'; import { derivePendingActions } from '@novu/js'; @@ -74,6 +75,10 @@ export type UseAgentChatResult = { data?: RespondToActionResult; error?: NovuError | AgentChatPlanLimitError; }>; + sendAction: (args: { actionId: string; sourceMessageId: string; value?: string }) => Promise<{ + data?: SendActionResult; + error?: NovuError | AgentChatPlanLimitError; + }>; }; function createLocalSessionKey(): string { @@ -376,11 +381,36 @@ export const useAgentChat = (props: UseAgentChatProps): UseAgentChatResult => { [novu, agentId, agentHash, sessionKeyRef, conversationIdRef, propsRef] ); + const sendAction = useCallback( + async (args: { actionId: string; sourceMessageId: string; value?: string }) => { + setError(undefined); + + const response = await novu.agentChat.sendAction({ + agentId, + agentHash, + key: sessionKeyRef.current, + conversationId: conversationIdRef.current, + actionId: args.actionId, + sourceMessageId: args.sourceMessageId, + value: args.value, + }); + + if (response.error) { + setError(response.error); + propsRef.current.onError?.(response.error); + } + + return response; + }, + [novu, agentId, agentHash, sessionKeyRef, conversationIdRef, propsRef] + ); + return { messages, pendingActions, sendMessage, respondToAction, + sendAction, conversationId, error, isLoading, diff --git a/packages/react/src/server/index.tsx b/packages/react/src/server/index.tsx index 02bde2440ef..9e103dfcc54 100644 --- a/packages/react/src/server/index.tsx +++ b/packages/react/src/server/index.tsx @@ -91,6 +91,7 @@ export function useAgentChat(_: UseAgentChatProps): UseAgentChatResult { fetchMore: () => Promise.resolve({ data: undefined, error: undefined }), sendMessage: () => Promise.resolve({ data: undefined, error: undefined }), respondToAction: () => Promise.resolve({ data: undefined, error: undefined }), + sendAction: () => Promise.resolve({ data: undefined, error: undefined }), }; }