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.` };
}
},