From 09a431d17abfb6e845b610602eac2d75ca68f3e2 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Wed, 6 May 2026 15:16:48 +0200 Subject: [PATCH 1/3] feat(shared): improve agent framework DX and starter template fixes NV-7451 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite starter template: remove onResolve, add inline comments explaining history/subscriber/metadata, show ctx.trigger in resolution path, use subscriber.firstName in greeting - Add JSDoc to all undocumented public interfaces in agent.types.ts: AgentHandlers, AgentContextBase, AgentMessageContext, AgentActionContext, AgentReactionContext, AgentResolveContext, AgentMessage, AgentConversation, AgentSubscriber, AgentHistoryEntry, AgentAction, AgentReaction, AgentPlatformContext, AgentAttachment, AgentMessageAuthor - Change all AgentHandlers signatures from (ctx) to destructured payloads: onMessage({ message, ctx }), onAction({ actionId, value, ctx }), onReaction({ reaction, ctx }), onResolve({ ctx }) Promotes event-specific data to the top level; ctx still available for everything else. Breaking change — package is in alpha. - Update handler.ts dispatch from uniform handlerMap to per-event switch, constructing the correct payload for each handler - Update all 50 agent tests to use the new handler signatures Co-authored-by: Cursor --- packages/framework/src/handler.ts | 59 ++++++++--- .../src/resources/agent/agent.test.ts | 78 +++++++-------- .../src/resources/agent/agent.types.ts | 99 ++++++++++++++++++- .../ts/app/novu/agents/support-agent.tsx | 33 ++++--- 4 files changed, 196 insertions(+), 73 deletions(-) diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts index e6e03976171..8e9be20fe1e 100644 --- a/packages/framework/src/handler.ts +++ b/packages/framework/src/handler.ts @@ -21,7 +21,15 @@ import { SigningKeyNotFoundError, } from './errors'; import { isPlatformError } from './errors/guard.errors'; -import type { Agent, AgentBridgeRequest, MessageContent } from './resources/agent'; +import type { + Agent, + AgentActionContext, + AgentBridgeRequest, + AgentMessageContext, + AgentReactionContext, + AgentResolveContext, + MessageContent, +} from './resources/agent'; import { AgentContextImpl, AgentDeliveryError, AgentEventEnum } from './resources/agent'; import type { Awaitable, EventTriggerParams, Workflow } from './types'; import { createHmacSubtle, initApiClient } from './utils'; @@ -309,23 +317,44 @@ export class NovuRequestHandler { } private async runAgentHandler(registeredAgent: Agent, event: string, ctx: AgentContextImpl): Promise { - const handlerMap = { - [AgentEventEnum.ON_MESSAGE]: registeredAgent.handlers.onMessage, - [AgentEventEnum.ON_REACTION]: registeredAgent.handlers.onReaction, - [AgentEventEnum.ON_ACTION]: registeredAgent.handlers.onAction, - [AgentEventEnum.ON_RESOLVE]: registeredAgent.handlers.onResolve, - } as Partial Awaitable>>; - - if (!Object.prototype.hasOwnProperty.call(handlerMap, event)) { - throw new InvalidActionError(event, AgentEventEnum); - } + let result: MessageContent | void; - const handler = handlerMap[event as AgentEventEnum]; - if (handler) { - const result = await handler(ctx); - if (result != null) await ctx.reply(result); + switch (event) { + case AgentEventEnum.ON_MESSAGE: + result = await registeredAgent.handlers.onMessage({ + message: ctx.message!, + ctx: ctx as unknown as AgentMessageContext, + }); + break; + case AgentEventEnum.ON_ACTION: + if (registeredAgent.handlers.onAction) { + result = await registeredAgent.handlers.onAction({ + actionId: ctx.action!.actionId, + value: ctx.action!.value, + ctx: ctx as unknown as AgentActionContext, + }); + } + break; + case AgentEventEnum.ON_REACTION: + if (registeredAgent.handlers.onReaction) { + result = await registeredAgent.handlers.onReaction({ + reaction: ctx.reaction!, + ctx: ctx as unknown as AgentReactionContext, + }); + } + break; + case AgentEventEnum.ON_RESOLVE: + if (registeredAgent.handlers.onResolve) { + result = await registeredAgent.handlers.onResolve({ + ctx: ctx as unknown as AgentResolveContext, + }); + } + break; + default: + throw new InvalidActionError(event, AgentEventEnum); } + 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 dfde369997a..270437ff03a 100644 --- a/packages/framework/src/resources/agent/agent.test.ts +++ b/packages/framework/src/resources/agent/agent.test.ts @@ -119,7 +119,7 @@ describe('agent dispatch via NovuRequestHandler', () => { }); it('should ACK immediately and run onMessage handler in background', async () => { - const onMessageSpy = vi.fn(async (ctx) => { + const onMessageSpy = vi.fn(async ({ ctx }: { ctx: any }) => { await ctx.reply('Echo: Hello bot!'); }); @@ -193,7 +193,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should batch metadata signals with reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { ctx.metadata.set('turnCount', 1); ctx.metadata.set('language', 'en'); await ctx.reply('Got it'); @@ -235,7 +235,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should edit a previously sent reply via the returned handle', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { const msg = await ctx.reply('Thinking...'); await msg.edit('Done thinking'); }, @@ -283,7 +283,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should not attach signals or resolve to an edit call', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { ctx.metadata.set('step', 'thinking'); const msg = await ctx.reply('Thinking...'); await msg.edit('Done'); @@ -326,7 +326,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should flush remaining signals after onResolve', async () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onResolve: async (ctx) => { + onResolve: async ({ ctx }) => { ctx.metadata.set('archived', true); ctx.trigger('post-resolve-workflow', { payload: { reason: 'done' } }); }, @@ -373,7 +373,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let capturedCtx: any; const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -411,7 +411,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize markdown content on reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('**bold** text'); }, }); @@ -448,7 +448,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize markdown with file attachments', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('Here is the report', { files: [{ filename: 'report.pdf', url: 'https://example.com/report.pdf' }], }); @@ -493,7 +493,7 @@ describe('agent dispatch via NovuRequestHandler', () => { { label: 'Blob', data: new Blob(['hello'], { type: 'text/plain' }) }, ])('should serialize markdown with $label file data as base64', async ({ data }) => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('Here is the report', { files: [{ filename: 'sample.txt', mimeType: 'text/plain', data }], }); @@ -536,7 +536,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize large Uint8Array file data without overflowing the call stack', async () => { const bytes = Uint8Array.from({ length: 200 * 1024 }, (_, index) => index % 256); const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('Here is the report', { files: [{ filename: 'sample.bin', data: bytes }], }); @@ -576,7 +576,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let caughtError: unknown; const bytes = new Uint8Array(3 * 1024 * 1024); const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { try { await ctx.reply('Here are the files', { files: [ @@ -625,7 +625,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should reject unsupported file data before posting a reply', async () => { let caughtError: unknown; const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { try { await ctx.reply('Here is the report', { files: [{ filename: 'sample.txt', data: { type: 'Buffer', data: [104, 101, 108, 108, 111] } } as any], @@ -670,7 +670,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize CardElement on reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply( Card({ title: 'Order #123', @@ -725,7 +725,7 @@ describe('agent dispatch via NovuRequestHandler', () => { }); const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply(jsxCard); }, }); @@ -764,7 +764,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize CardElement on edit', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { const msg = await ctx.reply('Loading...'); await msg.edit(Card({ title: 'Loaded', children: [] })); }, @@ -808,7 +808,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should batch signals with card reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { ctx.metadata.set('intent', 'order_confirm'); await ctx.reply(Card({ title: 'Confirm?', children: [Button({ id: 'yes', label: 'Yes', style: 'primary' })] })); }, @@ -850,7 +850,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onAction: async (ctx) => { + onAction: async ({ ctx }) => { capturedCtx = ctx; await ctx.reply('Action received'); }, @@ -897,7 +897,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onAction: async (ctx) => { + onAction: async ({ ctx }) => { capturedCtx = ctx; if (ctx.action?.sourceMessageId) { ctx.addReaction(ctx.action.sourceMessageId, 'eyes'); @@ -944,7 +944,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let capturedCtx: any; const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -1048,7 +1048,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onReaction: async (ctx) => { + onReaction: async ({ ctx }) => { capturedCtx = ctx; await ctx.reply('Reaction received'); }, @@ -1110,7 +1110,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onReaction: async (ctx) => { + onReaction: async ({ ctx }) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -1153,7 +1153,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should flush addReaction without a reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { ctx.addReaction('msg-123', 'eyes'); }, }); @@ -1191,7 +1191,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should batch addReaction with reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { ctx.addReaction('msg-reacted', 'thumbs_up'); await ctx.reply('Got it'); }, @@ -1232,7 +1232,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let capturedCtx: any; const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -1264,7 +1264,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should send handler return value as reply', async () => { const testBot = agent('test-bot', { - onMessage: async (_ctx) => 'hello from return', + onMessage: async (_payload) => 'hello from return', }); const handler = new NovuRequestHandler({ @@ -1298,10 +1298,10 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should send onAction handler return value as reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('noop'); }, - onAction: async (_ctx) => 'action handled', + onAction: async (_payload) => 'action handled', }); const handler = new NovuRequestHandler({ @@ -1335,11 +1335,11 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should send onReaction handler return value as reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('noop'); }, - onReaction: (ctx) => { - if (!ctx.reaction?.added) return; + onReaction: ({ reaction }) => { + if (!reaction.added) return; return "Sorry that wasn't helpful!"; }, @@ -1410,7 +1410,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let caughtError: unknown; const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { try { await ctx.reply('Hello'); } catch (err) { @@ -1460,7 +1460,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let caughtError: unknown; const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { try { await ctx.reply('Hello'); } catch (err) { @@ -1503,7 +1503,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('Hello'); }, }); @@ -1537,11 +1537,11 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should not send a reply when onReaction returns nothing (reaction removed)', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('noop'); }, - onReaction: (ctx) => { - if (!ctx.reaction?.added) return; + onReaction: ({ reaction }) => { + if (!reaction.added) return; return 'thumbs up noted'; }, @@ -1585,10 +1585,10 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should send onResolve handler return value as reply', async () => { const testBot = agent('test-bot', { - onMessage: async (ctx) => { + onMessage: async ({ ctx }) => { await ctx.reply('noop'); }, - onResolve: async (_ctx) => 'Conversation closed. Thanks for reaching out!', + onResolve: async (_payload) => 'Conversation closed. Thanks for reaching out!', }); const handler = new NovuRequestHandler({ @@ -1622,7 +1622,7 @@ describe('agent dispatch via NovuRequestHandler', () => { 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) => { + onMessage: async ({ ctx }) => { await ctx.reply('Thinking…'); return 'Final answer'; diff --git a/packages/framework/src/resources/agent/agent.types.ts b/packages/framework/src/resources/agent/agent.types.ts index cfcc1b2652e..2c0bb0bbbc2 100644 --- a/packages/framework/src/resources/agent/agent.types.ts +++ b/packages/framework/src/resources/agent/agent.types.ts @@ -14,6 +14,7 @@ export enum AgentEventEnum { // User-facing types (visible on ctx properties) // --------------------------------------------------------------------------- +/** Identity of the user or bot that authored a message. */ export interface AgentMessageAuthor { userId: string; fullName: string; @@ -21,6 +22,7 @@ export interface AgentMessageAuthor { isBot: boolean | 'unknown'; } +/** A file or media attachment included with a message. */ export interface AgentAttachment { type: string; url?: string; @@ -29,24 +31,37 @@ export interface AgentAttachment { size?: number; } +/** An incoming message from the user in the current conversation. */ export interface AgentMessage { + /** Plain-text content of the message. */ text: string; + /** Platform-native message ID (e.g. Slack `ts`, Teams `activityId`). */ platformMessageId: string; author: AgentMessageAuthor; timestamp: string; attachments?: AgentAttachment[]; } +/** Live state of the current conversation thread. */ export interface AgentConversation { + /** Stable identifier for this conversation. */ identifier: string; + /** Lifecycle status (e.g. `'open'`, `'resolved'`). */ status: string; + /** + * Key/value store for this conversation. + * Values are written via `ctx.metadata.set()` and readable on subsequent messages. + */ metadata: Record; + /** Number of messages exchanged so far; starts at 1 for the first message. */ messageCount: number; createdAt: string; lastActivityAt: string; } +/** The Novu subscriber who initiated or is participating in the conversation. */ export interface AgentSubscriber { + /** Stable Novu subscriber ID. */ subscriberId: string; firstName?: string; lastName?: string; @@ -54,22 +69,36 @@ export interface AgentSubscriber { phone?: string; avatar?: string; locale?: string; + /** Arbitrary custom data attached to the subscriber in Novu. */ data?: Record; } +/** + * A single entry in the conversation history. + * `ctx.history` is an ordered array of these entries — map them to your LLM's + * message format before making a model call. + */ export interface AgentHistoryEntry { + /** Message role: `'user'`, `'assistant'`, or `'system'`. */ role: string; + /** Content type: `'text'`, `'card'`, etc. */ type: string; + /** Plain-text representation of the message content. */ content: string; richContent?: Record; senderName?: string; + /** Present on system entries that carry a Novu signal (e.g. metadata updates). */ signalData?: { type: string; payload?: Record }; createdAt: string; } +/** Platform-specific identifiers for the thread and channel. */ export interface AgentPlatformContext { + /** Platform-native thread ID (e.g. Slack thread `ts`, Teams conversation ID). */ threadId: string; + /** Platform-native channel or chat ID. */ channelId: string; + /** Whether the message arrived in a direct message rather than a shared channel. */ isDM: boolean; } @@ -112,8 +141,11 @@ export interface ReplyContent { files?: FileRef[]; } +/** Data carried by a button click or other interactive action. */ export interface AgentAction { + /** The `id` prop of the clicked `