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
7 changes: 4 additions & 3 deletions packages/framework/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -305,7 +305,7 @@ export class NovuRequestHandler<Input extends any[] = any[], Output = any> {
}

private async runAgentHandler(registeredAgent: Agent, event: string, ctx: AgentContextImpl): Promise<void> {
const handlerMap: Partial<Record<AgentEventEnum, (ctx: AgentContextImpl) => Promise<void>>> = {
const handlerMap: Partial<Record<AgentEventEnum, (ctx: AgentContextImpl) => Awaitable<MessageContent | void>>> = {
[AgentEventEnum.ON_MESSAGE]: registeredAgent.handlers.onMessage,
[AgentEventEnum.ON_REACTION]: registeredAgent.handlers.onReaction,
[AgentEventEnum.ON_ACTION]: registeredAgent.handlers.onAction,
Expand All @@ -318,7 +318,8 @@ export class NovuRequestHandler<Input extends any[] = any[], Output = any> {

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();
Expand Down
236 changes: 236 additions & 0 deletions packages/framework/src/resources/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
9 changes: 5 additions & 4 deletions packages/framework/src/resources/agent/agent.types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -194,10 +195,10 @@ export interface AgentContext {
}

export interface AgentHandlers {
onMessage: (ctx: AgentContext) => Promise<void>;
onReaction?: (ctx: AgentContext) => Promise<void>;
onAction?: (ctx: AgentContext) => Promise<void>;
onResolve?: (ctx: AgentContext) => Promise<void>;
onMessage: (ctx: AgentContext) => Awaitable<MessageContent | void>;
onReaction?: (ctx: AgentContext) => Awaitable<MessageContent | void>;
onAction?: (ctx: AgentContext) => Awaitable<MessageContent | void>;
onResolve?: (ctx: AgentContext) => Awaitable<MessageContent | void>;
}

export interface Agent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export const supportAgent = agent('support-agent', {

if (isFirstMessage) {
ctx.metadata.set('topic', 'unknown');
await ctx.reply(

return (
<Card title="Hi, I'm Support Agent">
<CardText>How can I help you today?</CardText>
<Actions>
Expand All @@ -18,35 +19,32 @@ export const supportAgent = agent('support-agent', {
</Actions>
</Card>
);

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

Expand Down
Loading