Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/api/src/app/agents/agent-chat/activity-to-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
22 changes: 20 additions & 2 deletions apps/api/src/app/agents/agent-chat/activity-to-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type AgentEvent,
type AgentEventEnvelope,
type AgentFileRef,
type AgentMessageContent,
isDeltaEvent,
} from '@novu/agent-event-protocol';
import {
Expand Down Expand Up @@ -35,6 +36,23 @@ function filesFromRichContent(richContent?: Record<string, unknown>) {
return files as AgentFileRef[];
}

function isCardTree(value: unknown): value is Record<string, unknown> {
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<string, unknown>;
}): 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',
Expand All @@ -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),
};
}
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions apps/api/src/app/agents/agent-chat/agent-chat-event.factory.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 & {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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, unknown>): 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
) {}

Expand Down Expand Up @@ -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,
Expand Down
9 changes: 1 addition & 8 deletions apps/api/src/app/agents/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; 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;
}
Loading
Loading