diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts index 455418b5630..8d6e7de92e0 100644 --- a/packages/framework/src/handler.ts +++ b/packages/framework/src/handler.ts @@ -21,7 +21,7 @@ import { SigningKeyNotFoundError, } from './errors'; import { isPlatformError } from './errors/guard.errors'; -import type { Agent, AgentBridgeRequest } from './resources/agent'; +import type { Agent, AgentBridgeRequest, MessageContent } from './resources/agent'; import { AgentContextImpl, AgentEventEnum } from './resources/agent'; import type { Awaitable, EventTriggerParams, Workflow } from './types'; import { createHmacSubtle, initApiClient } from './utils'; @@ -305,7 +305,7 @@ export class NovuRequestHandler { } private async runAgentHandler(registeredAgent: Agent, event: string, ctx: AgentContextImpl): Promise { - const handlerMap: Partial Promise>> = { + const handlerMap: Partial Awaitable>> = { [AgentEventEnum.ON_MESSAGE]: registeredAgent.handlers.onMessage, [AgentEventEnum.ON_REACTION]: registeredAgent.handlers.onReaction, [AgentEventEnum.ON_ACTION]: registeredAgent.handlers.onAction, @@ -318,7 +318,8 @@ export class NovuRequestHandler { const handler = handlerMap[event as AgentEventEnum]; if (handler) { - await handler(ctx); + const result = await handler(ctx); + if (result != null) await ctx.reply(result); } await ctx.flush(); diff --git a/packages/framework/src/resources/agent/agent.test.ts b/packages/framework/src/resources/agent/agent.test.ts index 521be437a46..d54aa1a21fd 100644 --- a/packages/framework/src/resources/agent/agent.test.ts +++ b/packages/framework/src/resources/agent/agent.test.ts @@ -1031,4 +1031,240 @@ describe('agent dispatch via NovuRequestHandler', () => { expect(capturedCtx.reaction).toBeNull(); }); + + it('should send handler return value as reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (_ctx) => 'hello from return', + }); + + 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' + ); + expect(replyCall).toBeDefined(); + const replyBody = JSON.parse(replyCall![1].body); + expect(replyBody.reply.markdown).toBe('hello from return'); + }); + + it('should send onAction handler return value as reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { await ctx.reply('noop'); }, + onAction: async (_ctx) => 'action handled', + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ event: 'onAction', action: { actionId: 'btn', value: '1' } }); + 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(fetchMock).toHaveBeenCalled()); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + expect(replyCall).toBeDefined(); + const replyBody = JSON.parse(replyCall![1].body); + expect(replyBody.reply.markdown).toBe('action handled'); + }); + + it('should send onReaction handler return value as reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { await ctx.reply('noop'); }, + onReaction: (ctx) => { + if (!ctx.reaction?.added) return; + + return "Sorry that wasn't helpful!"; + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onReaction', + message: null, + reaction: { + messageId: 'msg-reacted', + emoji: { name: 'thumbs_down' }, + added: true, + message: null, + }, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onReaction`); + + 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' + ); + expect(replyCall).toBeDefined(); + const replyBody = JSON.parse(replyCall![1].body); + expect(replyBody.reply.markdown).toBe("Sorry that wasn't helpful!"); + }); + + it('should not send a reply when onReaction returns nothing (reaction removed)', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { await ctx.reply('noop'); }, + onReaction: (ctx) => { + if (!ctx.reaction?.added) return; + + return 'thumbs up noted'; + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onReaction', + message: null, + reaction: { + messageId: 'msg-reacted', + emoji: { name: 'thumbs_down' }, + added: false, + message: null, + }, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onReaction`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await new Promise((r) => setTimeout(r, 50)); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + expect(replyCall).toBeUndefined(); + }); + + it('should send onResolve handler return value as reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { await ctx.reply('noop'); }, + onResolve: async (_ctx) => 'Conversation closed. Thanks for reaching out!', + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ event: 'onResolve', message: null }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onResolve`); + + 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' + ); + expect(replyCall).toBeDefined(); + const replyBody = JSON.parse(replyCall![1].body); + expect(replyBody.reply.markdown).toBe('Conversation closed. Thanks for reaching out!'); + }); + + it('should send two replies when ctx.reply() is called and handler also returns a value', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + await ctx.reply('Thinking…'); + + return 'Final answer'; + }, + }); + + 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).toHaveBeenCalledTimes(2)); + + const replyCalls = fetchMock.mock.calls.filter( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + expect(replyCalls).toHaveLength(2); + expect(JSON.parse(replyCalls[0][1].body).reply.markdown).toBe('Thinking…'); + expect(JSON.parse(replyCalls[1][1].body).reply.markdown).toBe('Final answer'); + }); }); diff --git a/packages/framework/src/resources/agent/agent.types.ts b/packages/framework/src/resources/agent/agent.types.ts index a4e5f94f315..5664d752bc5 100644 --- a/packages/framework/src/resources/agent/agent.types.ts +++ b/packages/framework/src/resources/agent/agent.types.ts @@ -1,5 +1,6 @@ import type { CardElement, ChatElement, Emoji } from 'chat'; import type { TriggerRecipientsPayload } from '../../shared'; +import type { Awaitable } from '../../types/util.types'; export type { TriggerRecipientsPayload }; export enum AgentEventEnum { @@ -194,10 +195,10 @@ export interface AgentContext { } export interface AgentHandlers { - onMessage: (ctx: AgentContext) => Promise; - onReaction?: (ctx: AgentContext) => Promise; - onAction?: (ctx: AgentContext) => Promise; - onResolve?: (ctx: AgentContext) => Promise; + onMessage: (ctx: AgentContext) => Awaitable; + onReaction?: (ctx: AgentContext) => Awaitable; + onAction?: (ctx: AgentContext) => Awaitable; + onResolve?: (ctx: AgentContext) => Awaitable; } export interface Agent { diff --git a/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx b/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx index 9f98d9a5b9a..389499e4c00 100644 --- a/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx +++ b/packages/novu/src/commands/init/templates/app-agent/ts/app/novu/agents/support-agent.tsx @@ -8,7 +8,8 @@ export const supportAgent = agent('support-agent', { if (isFirstMessage) { ctx.metadata.set('topic', 'unknown'); - await ctx.reply( + + return ( How can I help you today? @@ -18,35 +19,32 @@ export const supportAgent = agent('support-agent', { ); - - return; } if (text.includes('resolve') || text.includes('thanks')) { ctx.resolve(`Resolved by user: ${text}`); - await ctx.reply('Glad I could help! Marking this resolved.'); - return; + return 'Glad I could help! Marking this resolved.'; } // Replace this block with your LLM call (OpenAI, Anthropic, etc.) ctx.metadata.set('lastMessage', text); - await ctx.reply({ + + return { markdown: `**Got it.** You said: "${ctx.message?.text}"\n\n` + `_This is a demo agent. Replace this handler with your LLM call._\n\n` + `**Conversation so far:** ${ctx.history.length} messages | ` + `**Topic:** ${ctx.conversation.metadata?.topic ?? 'unknown'}`, - }); + }; }, onAction: async (ctx) => { const { actionId, value } = ctx.action!; if (actionId.startsWith('topic-') && value) { ctx.metadata.set('topic', value); - await ctx.reply({ - markdown: `Topic set to **${value}**. Describe your issue and I'll help.`, - }); + + return { markdown: `Topic set to **${value}**. Describe your issue and I'll help.` }; } },