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 d5ee575d492..2492828f614 100644 --- a/apps/api/src/app/agents/e2e/mock-agent-handler.ts +++ b/apps/api/src/app/agents/e2e/mock-agent-handler.ts @@ -39,7 +39,7 @@ if (!NOVU_SECRET_KEY) { } const echoBot = agent('novu-agent', { - onMessage: async ({ message, ctx }) => { + onMessage: async (message, ctx) => { console.log('\n─────────────────────────────────────────'); console.log(`[onMessage] from ${ctx.subscriber?.firstName ?? 'unknown'} on ${ctx.platform}`); console.log(`Message: ${message.text ?? '(none)'}`); @@ -128,12 +128,12 @@ const echoBot = agent('novu-agent', { await ctx.reply(`Echo: ${userText}`); }, - onAction: async ({ actionId, value, ctx }) => { + onAction: async (action, ctx) => { console.log('\n─────────────────────────────────────────'); - console.log(`[onAction] action: ${actionId} = ${value ?? '(no value)'}`); + console.log(`[onAction] action: ${action.id} = ${action.value ?? '(no value)'}`); console.log('─────────────────────────────────────────'); - if (actionId === 'ack') { + if (action.id === 'ack') { await ctx.reply( Card({ title: 'Incident Acknowledged', @@ -145,21 +145,21 @@ const echoBot = agent('novu-agent', { ], }) ); - } else if (actionId === 'resolve') { + } else if (action.id === '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') { + } else if (action.id === 'assign') { + await ctx.reply(`On-call assignment updated to *${action.value}*.`); + } else if (action.id === 'escalate') { await ctx.reply( `**Escalated** — paging the secondary on-call team.\n\n_Triggered by ${ctx.subscriber?.firstName ?? 'unknown'}_` ); } else { - await ctx.reply(`Got action: *${actionId}*${value ? ` = ${value}` : ''}`); + await ctx.reply(`Got action: *${action.id}*${action.value ? ` = ${action.value}` : ''}`); } }, - onResolve: async ({ ctx }) => { + onResolve: async (ctx) => { console.log(`\n[onResolve] Conversation ${ctx.conversation.identifier} closed.`); ctx.metadata.set('resolvedAt', new Date().toISOString()); }, 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 13f7cd92af7..091a7515252 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 @@ -497,7 +497,7 @@ export class AgentInboundHandler { participantId, participantType, platformUserId: userId, - firstMessageText: `[action:${action.actionId}]`, + firstMessageText: `[action:${action.id}]`, }); const serializedThread = thread.toJSON() as unknown as Record; @@ -517,7 +517,7 @@ export class AgentInboundHandler { integrationIdentifier: config.integrationIdentifier, platform: config.platform, conversationId: conversation._id, - actionId: action.actionId, + actionId: action.id, }); const [subscriber, history] = await Promise.all([ 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 fe8cb64f45c..647bf9eefbe 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -238,7 +238,7 @@ export class BridgeExecutorService { if (message?.id) { deliveryId = `${conversation._id}:${message.id}`; } else if (action) { - deliveryId = `${conversation._id}:${event}:${action.actionId}:${timestamp}`; + deliveryId = `${conversation._id}:${event}:${action.id}:${timestamp}`; } else if (reaction) { deliveryId = `${conversation._id}:${event}:${reaction.messageId}:${timestamp}`; } else { 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 6fb4fd2967b..109b82d8bb2 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -1080,7 +1080,7 @@ export class ChatSdkService implements OnModuleDestroy { cached.config, event.thread as Thread, { - actionId: event.actionId, + id: event.actionId, value: event.value, sourceMessageId: event.messageId, }, diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts index b7686793932..d5d1a7e637b 100644 --- a/packages/framework/src/handler.ts +++ b/packages/framework/src/handler.ts @@ -323,41 +323,21 @@ export class NovuRequestHandler { switch (event) { case AgentEventEnum.ON_MESSAGE: - await replyIfPresent( - await registeredAgent.handlers.onMessage({ - message: ctx.message!, - ctx: ctx as unknown as AgentMessageContext, - }) - ); + await replyIfPresent(await registeredAgent.handlers.onMessage(ctx.message!, ctx as AgentMessageContext)); break; case AgentEventEnum.ON_ACTION: if (registeredAgent.handlers.onAction) { - await replyIfPresent( - await registeredAgent.handlers.onAction({ - actionId: ctx.action!.actionId, - value: ctx.action!.value, - ctx: ctx as unknown as AgentActionContext, - }) - ); + await replyIfPresent(await registeredAgent.handlers.onAction(ctx.action!, ctx as AgentActionContext)); } break; case AgentEventEnum.ON_REACTION: if (registeredAgent.handlers.onReaction) { - await replyIfPresent( - await registeredAgent.handlers.onReaction({ - reaction: ctx.reaction!, - ctx: ctx as unknown as AgentReactionContext, - }) - ); + await replyIfPresent(await registeredAgent.handlers.onReaction(ctx.reaction!, ctx as AgentReactionContext)); } break; case AgentEventEnum.ON_RESOLVE: if (registeredAgent.handlers.onResolve) { - await replyIfPresent( - await registeredAgent.handlers.onResolve({ - ctx: ctx as unknown as AgentResolveContext, - }) - ); + await replyIfPresent(await registeredAgent.handlers.onResolve(ctx as AgentResolveContext)); } break; default: diff --git a/packages/framework/src/resources/agent/agent.context.ts b/packages/framework/src/resources/agent/agent.context.ts index e3aeb31d375..41432313868 100644 --- a/packages/framework/src/resources/agent/agent.context.ts +++ b/packages/framework/src/resources/agent/agent.context.ts @@ -20,6 +20,7 @@ import type { Signal, TriggerRecipientsPayload, } from './agent.types'; +import { AgentEventEnum } from './agent.types'; const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024; const MAX_INLINE_AGGREGATE_FILE_BYTES = 5 * 1024 * 1024; @@ -247,7 +248,7 @@ class ReplyHandleImpl implements ReplyHandle { } export class AgentContextImpl { - readonly event: string; + readonly event: AgentEventEnum; readonly action: AgentAction | null; readonly message: AgentMessage | null; readonly reaction: AgentReaction | null; @@ -276,7 +277,7 @@ export class AgentContextImpl { private readonly _poster: ReplyPoster; constructor(request: AgentBridgeRequest, secretKey: string) { - this.event = request.event; + this.event = request.event as AgentEventEnum; this.action = request.action ?? null; this.message = request.message; this.reaction = request.reaction; diff --git a/packages/framework/src/resources/agent/agent.test.ts b/packages/framework/src/resources/agent/agent.test.ts index e01c4624501..f005078079c 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 }: { ctx: any }) => { + const onMessageSpy = vi.fn(async (_message: any, 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 (_message, 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 (_message, 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 (_message, 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' } }); }, @@ -374,7 +374,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let capturedMessage: any; const testBot = agent('test-bot', { - onMessage: async ({ message, ctx }) => { + onMessage: async (message, ctx) => { capturedCtx = ctx; capturedMessage = message; await ctx.reply('ok'); @@ -413,7 +413,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize markdown content on reply', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { await ctx.reply('**bold** text'); }, }); @@ -450,7 +450,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize markdown with file attachments', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { await ctx.reply('Here is the report', { files: [{ filename: 'report.pdf', url: 'https://example.com/report.pdf' }], }); @@ -495,7 +495,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 (_message, ctx) => { await ctx.reply('Here is the report', { files: [{ filename: 'sample.txt', mimeType: 'text/plain', data }], }); @@ -538,7 +538,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 (_message, ctx) => { await ctx.reply('Here is the report', { files: [{ filename: 'sample.bin', data: bytes }], }); @@ -578,7 +578,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 (_message, ctx) => { try { await ctx.reply('Here are the files', { files: [ @@ -627,7 +627,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 (_message, ctx) => { try { await ctx.reply('Here is the report', { files: [{ filename: 'sample.txt', data: { type: 'Buffer', data: [104, 101, 108, 108, 111] } } as any], @@ -672,7 +672,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize CardElement on reply', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { await ctx.reply( Card({ title: 'Order #123', @@ -727,7 +727,7 @@ describe('agent dispatch via NovuRequestHandler', () => { }); const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { await ctx.reply(jsxCard); }, }); @@ -766,7 +766,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should serialize CardElement on edit', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { const msg = await ctx.reply('Loading...'); await msg.edit(Card({ title: 'Loaded', children: [] })); }, @@ -810,7 +810,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should batch signals with card reply', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.metadata.set('intent', 'order_confirm'); await ctx.reply(Card({ title: 'Confirm?', children: [Button({ id: 'yes', label: 'Yes', style: 'primary' })] })); }, @@ -849,7 +849,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should emit delete signal for ctx.metadata.delete()', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.metadata.delete('board'); await ctx.reply('Deleted'); }, @@ -893,7 +893,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should emit clear signal for ctx.metadata.clear()', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.metadata.clear(); await ctx.reply('Cleared'); }, @@ -927,7 +927,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should preserve signal ordering for mixed clear, set, and delete', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.metadata.clear(); ctx.metadata.set('newGame', true); ctx.metadata.delete('oldKey'); @@ -968,7 +968,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let currentSnapshot: Record; const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.metadata.set('score', 42); getResult = ctx.metadata.get('score'); ctx.metadata.delete('score'); @@ -1002,7 +1002,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onAction: async ({ ctx }) => { + onAction: async (_action, ctx) => { capturedCtx = ctx; await ctx.reply('Action received'); }, @@ -1015,7 +1015,7 @@ describe('agent dispatch via NovuRequestHandler', () => { handler: () => { const body = createMockBridgeRequest({ event: 'onAction', - action: { actionId: 'confirm', value: 'yes', sourceMessageId: 'msg-card-001' }, + action: { id: 'confirm', value: 'yes', sourceMessageId: 'msg-card-001' }, message: null, }); const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onAction`); @@ -1034,7 +1034,7 @@ describe('agent dispatch via NovuRequestHandler', () => { await vi.waitFor(() => expect(capturedCtx).toBeDefined()); expect(capturedCtx.event).toBe('onAction'); - expect(capturedCtx.action).toEqual({ actionId: 'confirm', value: 'yes', sourceMessageId: 'msg-card-001' }); + expect(capturedCtx.action).toEqual({ id: 'confirm', value: 'yes', sourceMessageId: 'msg-card-001' }); const replyCall = fetchMock.mock.calls.find( (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' @@ -1048,7 +1048,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onAction: async ({ ctx }) => { + onAction: async (_action, ctx) => { capturedCtx = ctx; if (ctx.action?.sourceMessageId) { ctx.addReaction(ctx.action.sourceMessageId, 'eyes'); @@ -1064,7 +1064,7 @@ describe('agent dispatch via NovuRequestHandler', () => { handler: () => { const body = createMockBridgeRequest({ event: 'onAction', - action: { actionId: 'play', sourceMessageId: 'msg-ttt-board' }, + action: { id: 'play', sourceMessageId: 'msg-ttt-board' }, message: null, }); const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onAction`); @@ -1095,7 +1095,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let capturedCtx: any; const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -1137,7 +1137,7 @@ describe('agent dispatch via NovuRequestHandler', () => { handler: () => { const body = createMockBridgeRequest({ event: 'onAction', - action: { actionId: 'btn-1' }, + action: { id: 'btn-1' }, message: null, }); const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onAction`); @@ -1199,7 +1199,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onReaction: async ({ ctx }) => { + onReaction: async (_reaction, ctx) => { capturedCtx = ctx; await ctx.reply('Reaction received'); }, @@ -1261,7 +1261,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const testBot = agent('test-bot', { onMessage: async () => {}, - onReaction: async ({ ctx }) => { + onReaction: async (_reaction, ctx) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -1304,7 +1304,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should flush addReaction without a reply', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.addReaction('msg-123', 'eyes'); }, }); @@ -1342,7 +1342,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should batch addReaction with reply', async () => { const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { ctx.addReaction('msg-reacted', 'thumbs_up'); await ctx.reply('Got it'); }, @@ -1383,7 +1383,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let capturedCtx: any; const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { capturedCtx = ctx; await ctx.reply('ok'); }, @@ -1415,7 +1415,7 @@ describe('agent dispatch via NovuRequestHandler', () => { it('should send handler return value as reply', async () => { const testBot = agent('test-bot', { - onMessage: async (_payload) => 'hello from return', + onMessage: async () => 'hello from return', }); const handler = new NovuRequestHandler({ @@ -1449,10 +1449,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 (_message, ctx) => { await ctx.reply('noop'); }, - onAction: async (_payload) => 'action handled', + onAction: async () => 'action handled', }); const handler = new NovuRequestHandler({ @@ -1460,7 +1460,7 @@ describe('agent dispatch via NovuRequestHandler', () => { agents: [testBot], client, handler: () => { - const body = createMockBridgeRequest({ event: 'onAction', action: { actionId: 'btn', value: '1' } }); + const body = createMockBridgeRequest({ event: 'onAction', action: { id: 'btn', value: '1' } }); const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onAction`); return { @@ -1486,10 +1486,10 @@ 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 (_message, ctx) => { await ctx.reply('noop'); }, - onReaction: ({ reaction }) => { + onReaction: (reaction) => { if (!reaction.added) return; return "Sorry that wasn't helpful!"; @@ -1561,7 +1561,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let caughtError: unknown; const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { try { await ctx.reply('Hello'); } catch (err) { @@ -1611,7 +1611,7 @@ describe('agent dispatch via NovuRequestHandler', () => { let caughtError: unknown; const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { try { await ctx.reply('Hello'); } catch (err) { @@ -1654,7 +1654,7 @@ describe('agent dispatch via NovuRequestHandler', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const testBot = agent('test-bot', { - onMessage: async ({ ctx }) => { + onMessage: async (_message, ctx) => { await ctx.reply('Hello'); }, }); @@ -1688,10 +1688,10 @@ 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 (_message, ctx) => { await ctx.reply('noop'); }, - onReaction: ({ reaction }) => { + onReaction: (reaction) => { if (!reaction.added) return; return 'thumbs up noted'; @@ -1736,10 +1736,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 (_message, ctx) => { await ctx.reply('noop'); }, - onResolve: async (_payload) => 'Conversation closed. Thanks for reaching out!', + onResolve: async () => 'Conversation closed. Thanks for reaching out!', }); const handler = new NovuRequestHandler({ @@ -1773,7 +1773,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 (_message, 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 dee8277f127..93df1eb5c0b 100644 --- a/packages/framework/src/resources/agent/agent.types.ts +++ b/packages/framework/src/resources/agent/agent.types.ts @@ -141,10 +141,15 @@ export interface ReplyContent { files?: FileRef[]; } -/** Data carried by a button click or other interactive action. */ +/** + * Data carried by a button click or other interactive action. + * + * Used both on the bridge wire (`AgentBridgeRequest.action`) and as the + * handler-facing argument passed to `onAction(action, ctx)`. + */ export interface AgentAction { /** The `id` prop of the clicked `