diff --git a/apps/api/src/app/agents/dtos/agent-event.enum.ts b/apps/api/src/app/agents/dtos/agent-event.enum.ts index 635d6bce6aa..12280082d34 100644 --- a/apps/api/src/app/agents/dtos/agent-event.enum.ts +++ b/apps/api/src/app/agents/dtos/agent-event.enum.ts @@ -1,4 +1,5 @@ export enum AgentEventEnum { ON_MESSAGE = 'onMessage', + ON_ACTION = 'onAction', ON_RESOLVE = 'onResolve', } diff --git a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts index 025d7b9a719..11e085269f5 100644 --- a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts @@ -2,24 +2,72 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, - IsDefined, IsIn, IsNotEmpty, IsObject, IsOptional, IsString, MaxLength, + Validate, ValidateNested, + ValidatorConstraint, + ValidatorConstraintInterface, } from 'class-validator'; const SIGNAL_TYPES = ['metadata', 'trigger'] as const; -export class TextContentDto { - @ApiProperty() +export interface FileRef { + filename: string; + mimeType?: string; + data?: string; + url?: string; +} + +@ValidatorConstraint({ name: 'isValidReplyContent', async: false }) +export class IsValidReplyContent implements ValidatorConstraintInterface { + validate(content: ReplyContentDto): boolean { + if (!content) return true; + + const fields = [content.text, content.markdown, content.card].filter((v) => v !== undefined); + if (fields.length !== 1) return false; + + if (content.files?.length && !content.markdown) return false; + + for (const file of content.files ?? []) { + const sources = [file.data, file.url].filter(Boolean); + if (sources.length !== 1) return false; + } + + return true; + } + + defaultMessage(): string { + return 'Content must have exactly one of text, markdown, or card. Files only allowed with markdown. Each file needs exactly one of data or url.'; + } +} + +export class ReplyContentDto { + @ApiPropertyOptional() + @IsOptional() @IsString() @IsNotEmpty() @MaxLength(40_000) - text: string; + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + markdown?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + card?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsArray() + files?: FileRef[]; } export class ResolveDto { @@ -71,19 +119,21 @@ export class AgentReplyPayloadDto { @IsNotEmpty() integrationIdentifier: string; - @ApiPropertyOptional({ type: TextContentDto }) + @ApiPropertyOptional({ type: ReplyContentDto }) @IsOptional() @IsObject() @ValidateNested() - @Type(() => TextContentDto) - reply?: TextContentDto; + @Validate(IsValidReplyContent) + @Type(() => ReplyContentDto) + reply?: ReplyContentDto; - @ApiPropertyOptional({ type: TextContentDto }) + @ApiPropertyOptional({ type: ReplyContentDto }) @IsOptional() @IsObject() @ValidateNested() - @Type(() => TextContentDto) - update?: TextContentDto; + @Validate(IsValidReplyContent) + @Type(() => ReplyContentDto) + update?: ReplyContentDto; @ApiPropertyOptional({ type: ResolveDto }) @IsOptional() diff --git a/apps/api/src/app/agents/e2e/mock-agent-handler.ts b/apps/api/src/app/agents/e2e/mock-agent-handler.ts index 368182dc54e..ece600d3601 100644 --- a/apps/api/src/app/agents/e2e/mock-agent-handler.ts +++ b/apps/api/src/app/agents/e2e/mock-agent-handler.ts @@ -15,7 +15,19 @@ * 5. @mention the bot in Slack — watch the round-trip in the logs */ -import { agent, Client, serve } from '@novu/framework/express'; +import { + agent, + serve, + Client, + Actions, + Button, + Card, + CardLink, + CardText, + Divider, + Select, + SelectOption, +} from '@novu/framework/express'; import express from 'express'; const NOVU_SECRET_KEY = process.env.NOVU_SECRET_KEY; @@ -47,9 +59,109 @@ const echoBot = agent('novu-agent', { return; } + if (userText.toLowerCase().includes('card')) { + await ctx.reply( + Card({ + title: `Order #${Math.floor(Math.random() * 9000) + 1000}`, + children: [ + CardText('Your order is ready for pickup.'), + Actions([ + Button({ id: 'confirm', label: 'Confirm Pickup', style: 'primary' }), + Button({ id: 'cancel', label: 'Cancel Order', style: 'danger' }), + ]), + ], + }) + ); + + return; + } + + if (userText.toLowerCase().includes('incident')) { + await ctx.reply( + Card({ + title: `Incident #${Math.floor(Math.random() * 9000) + 1000} — DB Latency Spike`, + children: [ + CardText('*P1 — Production database latency spike*'), + CardText('Detected at 14:32 UTC. Response times exceeded 2s threshold for 3 minutes.'), + Divider(), + CardText('*Status:* Investigating | *Service:* payments-api | *Region:* us-east-1'), + Divider(), + Select({ + id: 'assign', + label: 'Assign to on-call', + options: [ + SelectOption({ value: 'alice', label: 'Alice Chen' }), + SelectOption({ value: 'bob', label: 'Bob Martinez' }), + SelectOption({ value: 'carol', label: 'Carol Wu' }), + ], + }), + Actions([ + Button({ id: 'ack', label: 'Acknowledge', style: 'primary' }), + Button({ id: 'escalate', label: 'Escalate', style: 'danger' }), + ]), + CardLink({ url: 'https://grafana.example.com/d/abc', label: 'View Grafana Dashboard' }), + ], + }) + ); + + return; + } + + if (userText.toLowerCase().includes('markdown')) { + await ctx.reply({ + markdown: [ + `**Echo:** ${userText}`, + '', + '| Metric | Value |', + '|--------|-------|', + '| Latency | 142ms |', + '| Throughput | 1.2k rps |', + '| Error rate | 0.02% |', + '', + '> Sent from _Novu Agent Framework_', + ].join('\n'), + }); + + return; + } + await ctx.reply(`Echo: ${userText}`); }, + onAction: async (ctx) => { + console.log('\n─────────────────────────────────────────'); + console.log(`[${ctx.event}] action: ${ctx.action?.actionId} = ${ctx.action?.value ?? '(no value)'}`); + console.log('─────────────────────────────────────────'); + + const actionId = ctx.action?.actionId ?? 'unknown'; + const value = ctx.action?.value; + + if (actionId === 'ack') { + await ctx.reply( + Card({ + title: 'Incident Acknowledged', + children: [ + CardText( + `Acknowledged by *${ctx.subscriber?.firstName ?? 'unknown'}* at ${new Date().toLocaleTimeString()}.` + ), + Actions([Button({ id: 'resolve', label: 'Resolve Incident', style: 'primary' })]), + ], + }) + ); + } else if (actionId === 'resolve') { + ctx.resolve('Incident resolved via action'); + await ctx.reply(`Incident resolved by *${ctx.subscriber?.firstName ?? 'unknown'}*.`); + } else if (actionId === 'assign') { + await ctx.reply(`On-call assignment updated to *${value}*.`); + } else if (actionId === 'escalate') { + await ctx.reply({ + markdown: `**Escalated** — paging the secondary on-call team.\n\n_Triggered by ${ctx.subscriber?.firstName ?? 'unknown'}_`, + }); + } else { + await ctx.reply(`Got action: *${actionId}*${value ? ` = ${value}` : ''}`); + } + }, + onResolve: async (ctx) => { console.log(`\n[onResolve] Conversation ${ctx.conversation.identifier} closed.`); ctx.metadata.set('resolvedAt', new Date().toISOString()); @@ -70,7 +182,12 @@ app.use( }) ); -app.listen(PORT, () => { +const server = app.listen(PORT, () => { console.log(`\nAgent Handler (using @novu/framework) running on http://localhost:${PORT}/api/novu`); console.log('\nWaiting for bridge calls...\n'); }); + +server.on('error', (err) => console.error('Server error:', err)); +server.on('close', () => console.log('Server closed')); +process.on('uncaughtException', (err) => console.error('Uncaught:', err)); +process.on('unhandledRejection', (err) => console.error('Unhandled rejection:', err)); diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts index 5bbbb5a1a9f..fc86a195e56 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts @@ -3,10 +3,10 @@ import { PinoLogger } from '@novu/application-generic'; import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, SubscriberRepository } from '@novu/dal'; import type { Message, Thread } from 'chat'; import { AgentEventEnum } from '../dtos/agent-event.enum'; -import { ResolvedPlatformConfig } from './agent-credential.service'; import { AgentConversationService } from './agent-conversation.service'; +import { ResolvedPlatformConfig } from './agent-credential.service'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; -import { BridgeExecutorService } from './bridge-executor.service'; +import { type BridgeAction, BridgeExecutorService } from './bridge-executor.service'; @Injectable() export class AgentInboundHandler { @@ -107,4 +107,78 @@ export class AgentInboundHandler { }, }); } + + async handleAction( + agentId: string, + config: ResolvedPlatformConfig, + thread: Thread, + action: BridgeAction, + userId: string + ): Promise { + const subscriberId = await this.subscriberResolver + .resolve({ + environmentId: config.environmentId, + organizationId: config.organizationId, + platform: config.platform, + platformUserId: userId, + integrationIdentifier: config.integrationIdentifier, + }) + .catch((err) => { + this.logger.warn( + err, + `[agent:${agentId}] Subscriber resolution failed for action, continuing without subscriber` + ); + + return null; + }); + + const participantId = subscriberId ?? `${config.platform}:${userId}`; + const participantType = subscriberId + ? ConversationParticipantTypeEnum.SUBSCRIBER + : ConversationParticipantTypeEnum.PLATFORM_USER; + + const conversation = await this.conversationService.createOrGetConversation({ + environmentId: config.environmentId, + organizationId: config.organizationId, + agentId, + platform: config.platform, + integrationId: config.integrationId, + platformThreadId: thread.id, + participantId, + participantType, + platformUserId: userId, + firstMessageText: `[action:${action.actionId}]`, + }); + + const serializedThread = thread.toJSON() as unknown as Record; + await this.conversationService.updateChannelThread( + config.environmentId, + config.organizationId, + conversation._id, + thread.id, + serializedThread + ); + + const [subscriber, history] = await Promise.all([ + subscriberId + ? this.subscriberRepository.findBySubscriberId(config.environmentId, subscriberId) + : Promise.resolve(null), + this.conversationService.getHistory(config.environmentId, conversation._id), + ]); + + await this.bridgeExecutor.execute({ + event: AgentEventEnum.ON_ACTION, + config, + conversation, + subscriber, + history, + message: null, + platformContext: { + threadId: thread.id, + channelId: thread.channelId, + isDM: thread.isDM, + }, + action, + }); + } } diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/services/bridge-executor.service.ts index 33fbb863d0f..362ea6bcfbf 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -34,6 +34,7 @@ export interface BridgeExecutorParams { history: ConversationActivityEntity[]; message: Message | null; platformContext: BridgePlatformContext; + action?: BridgeAction; } interface BridgeMessageAuthor { @@ -50,6 +51,11 @@ interface BridgeMessage { timestamp: string; } +export interface BridgeAction { + actionId: string; + value?: string; +} + interface BridgeConversation { identifier: string; status: string; @@ -94,6 +100,7 @@ export interface AgentBridgeRequest { history: BridgeHistoryEntry[]; platform: string; platformContext: BridgePlatformContext; + action: BridgeAction | null; } @Injectable() @@ -202,19 +209,26 @@ export class BridgeExecutorService { } private buildPayload(params: BridgeExecutorParams): AgentBridgeRequest { - const { event, config, conversation, subscriber, history, message, platformContext } = params; + const { event, config, conversation, subscriber, history, message, platformContext, action } = params; const agentIdentifier = config.agentIdentifier; const apiRootUrl = process.env.API_ROOT_URL || 'http://localhost:3000'; const replyUrl = `${apiRootUrl}/v1/agents/${agentIdentifier}/reply`; - const deliveryId = message?.id - ? `${conversation._id}:${message.id}` - : `${conversation._id}:${event}`; + const timestamp = new Date().toISOString(); + + let deliveryId: string; + if (message?.id) { + deliveryId = `${conversation._id}:${message.id}`; + } else if (action) { + deliveryId = `${conversation._id}:${event}:${action.actionId}:${timestamp}`; + } else { + deliveryId = `${conversation._id}:${event}`; + } return { version: 1, - timestamp: new Date().toISOString(), + timestamp, deliveryId, event, agentId: agentIdentifier, @@ -227,6 +241,7 @@ export class BridgeExecutorService { history: this.mapHistory(history), platform: config.platform, platformContext, + action: action ?? null, }; } diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index e40159c31ec..7fa5f275325 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -5,6 +5,7 @@ import { Request as ExpressRequest, Response as ExpressResponse } from 'express' import { LRUCache } from 'lru-cache'; import { AgentEventEnum } from '../dtos/agent-event.enum'; import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; +import type { ReplyContentDto } from '../dtos/agent-reply-payload.dto'; import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request'; import { AgentCredentialService, ResolvedPlatformConfig } from './agent-credential.service'; import { AgentInboundHandler } from './agent-inbound-handler.service'; @@ -100,7 +101,7 @@ export class ChatSdkService implements OnModuleDestroy { integrationIdentifier: string, platform: string, serializedThread: Record, - message: string + content: ReplyContentDto ): Promise { const config = await this.agentCredentialService.resolve(agentId, integrationIdentifier); const instanceKey = `${agentId}:${integrationIdentifier}`; @@ -109,7 +110,14 @@ export class ChatSdkService implements OnModuleDestroy { const { ThreadImpl } = await esmImport('chat'); const adapter = chat.getAdapter(platform); const thread = ThreadImpl.fromJSON(serializedThread, adapter); - await thread.post(message); + + if (content.card) { + await thread.post(content.card); + } else if (content.markdown !== undefined) { + await thread.post({ markdown: content.markdown, files: content.files }); + } else { + await thread.post(content.text ?? ''); + } } private async getOrCreate( @@ -237,5 +245,22 @@ export class ChatSdkService implements OnModuleDestroy { this.logger.error(err, `[agent:${agentId}] Error handling subscribed message`); } }); + + chat.onAction(async (event) => { + try { + if (!event.thread) { + this.logger.warn(`[agent:${agentId}] Action received without thread context, skipping`); + + return; + } + + await this.inboundHandler.handleAction(agentId, config, event.thread as Thread, { + actionId: event.actionId, + value: event.value, + }, event.user.userId); + } catch (err) { + this.logger.error(err, `[agent:${agentId}] Error handling action ${event.actionId}`); + } + }); } } diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts index 170018decf6..8873f45a508 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts @@ -1,5 +1,7 @@ -import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; export class HandleAgentReplyCommand extends EnvironmentWithUserCommand { @IsString() @@ -15,12 +17,14 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand { integrationIdentifier: string; @IsOptional() - @IsObject() - reply?: { text: string }; + @ValidateNested() + @Type(() => ReplyContentDto) + reply?: ReplyContentDto; @IsOptional() - @IsObject() - update?: { text: string }; + @ValidateNested() + @Type(() => ReplyContentDto) + update?: ReplyContentDto; @IsOptional() @IsObject() diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts index 0f5864cd8dd..a893b3b5a58 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts @@ -14,6 +14,7 @@ import { AgentCredentialService } from '../../services/agent-credential.service' import { AgentConversationService } from '../../services/agent-conversation.service'; import { BridgeExecutorService } from '../../services/bridge-executor.service'; import { ChatSdkService } from '../../services/chat-sdk.service'; +import type { ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; import { HandleAgentReplyCommand } from './handle-agent-reply.command'; @Injectable() @@ -52,13 +53,13 @@ export class HandleAgentReply { const channel = this.getPrimaryChannel(conversation); if (command.update) { - await this.deliverMessage(command, conversation, channel, command.update.text, ConversationActivityTypeEnum.UPDATE); + await this.deliverMessage(command, conversation, channel, command.update, ConversationActivityTypeEnum.UPDATE); return { status: 'update_sent' }; } if (command.reply) { - await this.deliverMessage(command, conversation, channel, command.reply.text, ConversationActivityTypeEnum.MESSAGE); + await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE); } if (command.signals?.length) { @@ -85,16 +86,18 @@ export class HandleAgentReply { command: HandleAgentReplyCommand, conversation: ConversationEntity, channel: ConversationChannel, - text: string, + content: ReplyContentDto, type: ConversationActivityTypeEnum ): Promise { + const textFallback = this.extractTextFallback(content); + await Promise.all([ this.chatSdkService.postToConversation( conversation._agentId, command.integrationIdentifier, channel.platform, channel.serializedThread!, - text + content ), this.activityRepository.createAgentActivity({ identifier: `act-${shortId(8)}`, @@ -103,7 +106,8 @@ export class HandleAgentReply { integrationId: channel._integrationId, platformThreadId: channel.platformThreadId, agentId: command.agentIdentifier, - content: text, + content: textFallback, + richContent: (content.card || content.files?.length) ? (content as Record) : undefined, type, environmentId: command.environmentId, organizationId: command.organizationId, @@ -112,11 +116,23 @@ export class HandleAgentReply { command.environmentId, command.organizationId, conversation._id, - text + textFallback ), ]); } + private extractTextFallback(content: ReplyContentDto): string { + if (content.text) return content.text; + if (content.markdown) return content.markdown; + if (content.card) { + const title = (content.card as { title?: string }).title; + + return title ?? '[Card]'; + } + + return ''; + } + private async executeSignals( command: HandleAgentReplyCommand, conversation: ConversationEntity, diff --git a/libs/dal/src/repositories/conversation-activity/conversation-activity.entity.ts b/libs/dal/src/repositories/conversation-activity/conversation-activity.entity.ts index c42a1d29480..0a5fc74c3f1 100644 --- a/libs/dal/src/repositories/conversation-activity/conversation-activity.entity.ts +++ b/libs/dal/src/repositories/conversation-activity/conversation-activity.entity.ts @@ -56,6 +56,9 @@ export class ConversationActivityEntity { /** Platform-native message ID (e.g. Slack ts) — used for deduplication */ platformMessageId?: string; + /** Structured content for markdown, card, or file messages — absent for plain text */ + richContent?: Record; + /** Populated only when type === SIGNAL */ signalData?: ConversationActivitySignalData; diff --git a/libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts b/libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts index 4f9a9ae4f78..28271b83d28 100644 --- a/libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts +++ b/libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts @@ -70,6 +70,7 @@ export class ConversationActivityRepository extends BaseRepositoryV2< platformThreadId: string; agentId: string; content: string; + richContent?: Record; type?: ConversationActivityTypeEnum; senderName?: string; environmentId: string; @@ -85,6 +86,7 @@ export class ConversationActivityRepository extends BaseRepositoryV2< senderType: ConversationActivitySenderTypeEnum.AGENT, senderId: params.agentId, content: params.content, + richContent: params.richContent, senderName: params.senderName, _environmentId: params.environmentId, _organizationId: params.organizationId, diff --git a/libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts b/libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts index 9b4e69263ef..5719d2141ed 100644 --- a/libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts +++ b/libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts @@ -54,6 +54,9 @@ const conversationActivitySchema = new Schema( senderName: { type: Schema.Types.String, }, + richContent: { + type: Schema.Types.Mixed, + }, signalData: { type: Schema.Types.Mixed, }, diff --git a/packages/framework/jsx-runtime/package.json b/packages/framework/jsx-runtime/package.json new file mode 100644 index 00000000000..7590ad51789 --- /dev/null +++ b/packages/framework/jsx-runtime/package.json @@ -0,0 +1,3 @@ +{ + "main": "../dist/cjs/jsx-runtime.cjs" +} diff --git a/packages/framework/package.json b/packages/framework/package.json index 340e31ebf5e..d09b1e14652 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -26,6 +26,7 @@ "remix", "step-resolver", "sveltekit", + "jsx-runtime", "validators", "README.md" ], @@ -73,6 +74,16 @@ "default": "./dist/esm/index.js" } }, + "./jsx-runtime": { + "require": { + "types": "./dist/cjs/jsx-runtime.d.cts", + "default": "./dist/cjs/jsx-runtime.cjs" + }, + "import": { + "types": "./dist/esm/jsx-runtime.d.ts", + "default": "./dist/esm/jsx-runtime.js" + } + }, "./express": { "require": { "types": "./dist/cjs/servers/express.d.cts", @@ -252,6 +263,7 @@ "zod-to-json-schema": "^3.23.3" }, "dependencies": { + "chat": "^4.25.0", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", "better-ajv-errors": "^1.2.0", diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts index b217999bcb7..500bc342ef6 100644 --- a/packages/framework/src/handler.ts +++ b/packages/framework/src/handler.ts @@ -301,6 +301,10 @@ export class NovuRequestHandler { if (registeredAgent.handlers.onResolve) { await registeredAgent.handlers.onResolve(ctx); } + } else if (event === AgentEventEnum.ON_ACTION) { + if (registeredAgent.handlers.onAction) { + await registeredAgent.handlers.onAction(ctx); + } } else if (event === AgentEventEnum.ON_MESSAGE) { await registeredAgent.handlers.onMessage(ctx); } else { diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 0a583ac5d8f..69e0a18379a 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -6,7 +6,12 @@ export type { Agent, AgentContext, AgentHandlers, + CardElement, + CardChild, + FileRef, + MessageContent, } from './resources'; +export { Actions, Card, Button, CardText, TextInput, Select, SelectOption, Divider, CardLink } from './resources'; export type { AnyStepResolver, ChatStepResolver, diff --git a/packages/framework/src/jsx-runtime.ts b/packages/framework/src/jsx-runtime.ts new file mode 100644 index 00000000000..e271508fcf2 --- /dev/null +++ b/packages/framework/src/jsx-runtime.ts @@ -0,0 +1,2 @@ +export type { JSX } from 'chat/jsx-runtime'; +export { Fragment, jsx, jsxDEV, jsxs } from 'chat/jsx-runtime'; diff --git a/packages/framework/src/resources/agent/agent.context.ts b/packages/framework/src/resources/agent/agent.context.ts index 25ad22b6aa1..e7c4b0e65f4 100644 --- a/packages/framework/src/resources/agent/agent.context.ts +++ b/packages/framework/src/resources/agent/agent.context.ts @@ -1,4 +1,5 @@ import type { + AgentAction, AgentBridgeRequest, AgentContext, AgentConversation, @@ -7,11 +8,39 @@ import type { AgentPlatformContext, AgentReplyPayload, AgentSubscriber, + MessageContent, + ReplyContent, Signal, } from './agent.types'; +function isCardElement(content: object): content is import('chat').CardElement { + return 'type' in content && (content as { type: string }).type === 'card'; +} + +function serializeContent(content: MessageContent): ReplyContent { + if (typeof content === 'string') { + return { text: content }; + } + + if (isCardElement(content)) { + return { card: content }; + } + + if ('markdown' in content && typeof content.markdown === 'string') { + const result: ReplyContent = { markdown: content.markdown }; + if (content.files?.length) { + result.files = content.files; + } + + return result; + } + + throw new Error('Invalid message content — expected string, { markdown }, or CardElement'); +} + export class AgentContextImpl implements AgentContext { readonly event: string; + readonly action: AgentAction | null; readonly message: AgentMessage | null; readonly conversation: AgentConversation; readonly subscriber: AgentSubscriber | null; @@ -30,6 +59,7 @@ export class AgentContextImpl implements AgentContext { constructor(request: AgentBridgeRequest, secretKey: string) { this.event = request.event; + this.action = request.action ?? null; this.message = request.message; this.conversation = request.conversation; this.subscriber = request.subscriber; @@ -49,11 +79,11 @@ export class AgentContextImpl implements AgentContext { }; } - async reply(text: string): Promise { + async reply(content: MessageContent): Promise { const body: AgentReplyPayload = { conversationId: this._conversationId, integrationIdentifier: this._integrationIdentifier, - reply: { text }, + reply: serializeContent(content), }; if (this._signals.length) { @@ -69,11 +99,11 @@ export class AgentContextImpl implements AgentContext { await this._post(body); } - async update(text: string): Promise { + async update(content: MessageContent): Promise { const body: AgentReplyPayload = { conversationId: this._conversationId, integrationIdentifier: this._integrationIdentifier, - update: { text }, + update: serializeContent(content), }; await this._post(body); diff --git a/packages/framework/src/resources/agent/agent.test.ts b/packages/framework/src/resources/agent/agent.test.ts index 62b3b684d3c..4d320e7ddd0 100644 --- a/packages/framework/src/resources/agent/agent.test.ts +++ b/packages/framework/src/resources/agent/agent.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Client } from '../../client'; import { PostActionEnum } from '../../constants'; import { NovuRequestHandler } from '../../handler'; +import { Card, CardText, Button } from './index'; import { agent } from './agent.resource'; import type { AgentBridgeRequest } from './agent.types'; @@ -16,6 +17,7 @@ function createMockBridgeRequest(overrides?: Partial): Agent replyUrl: 'https://api.novu.co/v1/agents/test-bot/reply', conversationId: 'conv-456', integrationIdentifier: 'slack-main', + action: null, message: { text: 'Hello bot!', platformMessageId: 'msg-789', @@ -66,7 +68,11 @@ describe('agent dispatch via NovuRequestHandler', () => { beforeEach(() => { client = new Client({ secretKey: 'test-secret-key', strictAuthentication: false }); - fetchMock = vi.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve('{}'), json: () => Promise.resolve({ status: 'ok' }) }); + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('{}'), + json: () => Promise.resolve({ status: 'ok' }), + }); global.fetch = fetchMock as typeof fetch; }); @@ -127,7 +133,9 @@ describe('agent dispatch via NovuRequestHandler', () => { agents: [], client, handler: () => { - const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=unknown-bot&event=onMessage`); + const url = new URL( + `http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=unknown-bot&event=onMessage` + ); return { body: () => ({}), @@ -320,4 +328,329 @@ describe('agent dispatch via NovuRequestHandler', () => { expect(capturedCtx.platformContext.threadId).toBe('t1'); expect(capturedCtx.history).toEqual([]); }); + + it('should serialize markdown content on reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + await ctx.reply({ markdown: '**bold** text' }); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const replyBody = JSON.parse(replyCall![1].body); + + expect(replyBody.reply.markdown).toBe('**bold** text'); + expect(replyBody.reply.text).toBeUndefined(); + expect(replyBody.reply.card).toBeUndefined(); + }); + + it('should serialize markdown with file attachments', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + await ctx.reply({ + markdown: 'Here is the report', + files: [{ filename: 'report.pdf', url: 'https://example.com/report.pdf' }], + }); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const replyBody = JSON.parse(replyCall![1].body); + + expect(replyBody.reply.markdown).toBe('Here is the report'); + expect(replyBody.reply.files).toHaveLength(1); + expect(replyBody.reply.files[0]).toEqual({ filename: 'report.pdf', url: 'https://example.com/report.pdf' }); + }); + + it('should serialize CardElement on reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + await ctx.reply( + Card({ + title: 'Order #123', + children: [ + CardText('Your order is ready'), + Button({ id: 'confirm', label: 'Confirm', style: 'primary' }), + ], + }) + ); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const replyBody = JSON.parse(replyCall![1].body); + + expect(replyBody.reply.card).toBeDefined(); + expect(replyBody.reply.card.type).toBe('card'); + expect(replyBody.reply.card.title).toBe('Order #123'); + expect(replyBody.reply.card.children).toHaveLength(2); + expect(replyBody.reply.card.children[1].type).toBe('button'); + expect(replyBody.reply.card.children[1].id).toBe('confirm'); + expect(replyBody.reply.text).toBeUndefined(); + expect(replyBody.reply.markdown).toBeUndefined(); + }); + + it('should serialize CardElement on update', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + await ctx.update(Card({ title: 'Loading...', children: [] })); + await ctx.reply('Done'); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2)); + + const replyCalls = fetchMock.mock.calls.filter( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const parsedBodies = replyCalls.map(([, init]: any[]) => JSON.parse(init.body)); + + const updateBody = parsedBodies.find((body: any) => body.update); + expect(updateBody.update.card).toBeDefined(); + expect(updateBody.update.card.type).toBe('card'); + expect(updateBody.update.card.title).toBe('Loading...'); + + const replyBody = parsedBodies.find((body: any) => body.reply); + expect(replyBody.reply.text).toBe('Done'); + }); + + it('should batch signals with card reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + ctx.metadata.set('intent', 'order_confirm'); + await ctx.reply(Card({ title: 'Confirm?', children: [Button({ id: 'yes', label: 'Yes', style: 'primary' })] })); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const replyBody = JSON.parse(replyCall![1].body); + + expect(replyBody.reply.card.type).toBe('card'); + expect(replyBody.signals).toHaveLength(1); + expect(replyBody.signals[0]).toEqual({ type: 'metadata', key: 'intent', value: 'order_confirm' }); + }); + + it('should dispatch onAction event with action data on ctx', async () => { + let capturedCtx: any; + + const testBot = agent('test-bot', { + onMessage: async () => {}, + onAction: async (ctx) => { + capturedCtx = ctx; + await ctx.reply('Action received'); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onAction', + action: { actionId: 'confirm', value: 'yes' }, + message: null, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onAction`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(capturedCtx).toBeDefined()); + + expect(capturedCtx.event).toBe('onAction'); + expect(capturedCtx.action).toEqual({ actionId: 'confirm', value: 'yes' }); + expect(capturedCtx.message).toBeNull(); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const replyBody = JSON.parse(replyCall![1].body); + expect(replyBody.reply.text).toBe('Action received'); + }); + + it('should have null action on onMessage events', async () => { + let capturedCtx: any; + + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + capturedCtx = ctx; + await ctx.reply('ok'); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(capturedCtx).toBeDefined()); + + expect(capturedCtx.action).toBeNull(); + }); + + it('should silently skip onAction when no handler registered', async () => { + const testBot = agent('test-bot', { + onMessage: async () => {}, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onAction', + action: { actionId: 'btn-1' }, + message: null, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onAction`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + const result = await handler.createHandler()(); + expect(result.status).toBe(200); + expect(JSON.parse(result.body).status).toBe('ack'); + }); }); diff --git a/packages/framework/src/resources/agent/agent.types.ts b/packages/framework/src/resources/agent/agent.types.ts index d3ed01cc52d..a4f36518bf7 100644 --- a/packages/framework/src/resources/agent/agent.types.ts +++ b/packages/framework/src/resources/agent/agent.types.ts @@ -1,5 +1,8 @@ +import type { CardElement, ChatElement } from 'chat'; + export enum AgentEventEnum { ON_MESSAGE = 'onMessage', + ON_ACTION = 'onAction', ON_RESOLVE = 'onResolve', } @@ -56,8 +59,52 @@ export interface AgentPlatformContext { isDM: boolean; } +// --------------------------------------------------------------------------- +// Rich content types +// --------------------------------------------------------------------------- + +export interface FileRef { + filename: string; + mimeType?: string; + /** Base64-encoded file data (< 1 MB decoded) */ + data?: string; + /** Publicly-accessible HTTPS URL */ + url?: string; +} + +/** + * Content accepted by ctx.reply() and ctx.update(). + * + * - `string` — plain text + * - `{ markdown, files? }` — markdown-formatted text, optionally with file attachments + * - `ChatElement` — interactive card built with Card(), Button(), etc. + * (must be a CardElement at runtime; validated by serializeContent) + */ +export type MessageContent = + | string + | { markdown: string; files?: FileRef[] } + | ChatElement; + +/** Normalized content shape sent over HTTP to the reply endpoint. */ +export interface ReplyContent { + text?: string; + markdown?: string; + card?: CardElement; + files?: FileRef[]; +} + +export interface AgentAction { + actionId: string; + value?: string; +} + +// --------------------------------------------------------------------------- +// Context + handlers +// --------------------------------------------------------------------------- + export interface AgentContext { readonly event: string; + readonly action: AgentAction | null; readonly message: AgentMessage | null; readonly conversation: AgentConversation; readonly subscriber: AgentSubscriber | null; @@ -65,8 +112,8 @@ export interface AgentContext { readonly platform: string; readonly platformContext: AgentPlatformContext; - reply(text: string): Promise; - update(text: string): Promise; + reply(content: MessageContent): Promise; + update(content: MessageContent): Promise; resolve(summary?: string): void; metadata: { set(key: string, value: unknown): void; @@ -76,6 +123,7 @@ export interface AgentContext { export interface AgentHandlers { onMessage: (ctx: AgentContext) => Promise; + onAction?: (ctx: AgentContext) => Promise; onResolve?: (ctx: AgentContext) => Promise; } @@ -97,6 +145,7 @@ export interface AgentBridgeRequest { replyUrl: string; conversationId: string; integrationIdentifier: string; + action: AgentAction | null; message: AgentMessage | null; conversation: AgentConversation; subscriber: AgentSubscriber | null; @@ -112,8 +161,8 @@ export type Signal = MetadataSignal | TriggerSignal; export interface AgentReplyPayload { conversationId: string; integrationIdentifier: string; - reply?: { text: string }; - update?: { text: string }; + reply?: ReplyContent; + update?: ReplyContent; resolve?: { summary?: string }; signals?: Signal[]; } diff --git a/packages/framework/src/resources/agent/index.ts b/packages/framework/src/resources/agent/index.ts index 9f7a2439a91..122676c8041 100644 --- a/packages/framework/src/resources/agent/index.ts +++ b/packages/framework/src/resources/agent/index.ts @@ -2,6 +2,7 @@ export { AgentContextImpl } from './agent.context'; export { agent } from './agent.resource'; export type { Agent, + AgentAction, AgentBridgeRequest, AgentContext, AgentConversation, @@ -12,5 +13,22 @@ export type { AgentPlatformContext, AgentReplyPayload, AgentSubscriber, + FileRef, + MessageContent, + ReplyContent, } from './agent.types'; export { AgentEventEnum } from './agent.types'; + +export { Actions, Card, Button, CardText, TextInput, Select, SelectOption, Divider, CardLink } from 'chat'; +export type { + ActionsElement, + CardElement, + CardChild, + TextElement, + ButtonElement, + TextInputElement, + SelectElement, + SelectOptionElement, + DividerElement, + LinkElement, +} from 'chat'; diff --git a/packages/framework/tsup.config.ts b/packages/framework/tsup.config.ts index c03b92e5b0f..3812f742391 100644 --- a/packages/framework/tsup.config.ts +++ b/packages/framework/tsup.config.ts @@ -7,6 +7,7 @@ const frameworks: SupportedFrameworkName[] = ['h3', 'express', 'next', 'nuxt', ' const baseConfig: Options = { entry: [ 'src/index.ts', + 'src/jsx-runtime.ts', 'src/internal/index.ts', 'src/step-resolver.ts', 'src/validators.ts', @@ -19,6 +20,7 @@ const baseConfig: Options = { minifyWhitespace: true, minifyIdentifiers: true, minifySyntax: true, + noExternal: ['chat'], define: { SDK_VERSION: `"${version}"`, FRAMEWORK_VERSION: `"2024-06-26"`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e451a4588c3..816087d9fd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3311,6 +3311,9 @@ importers: chalk: specifier: ^4.1.2 version: 4.1.2 + chat: + specifier: ^4.25.0 + version: 4.25.0 cross-fetch: specifier: ^4.0.0 version: 4.0.0(encoding@0.1.13)