Skip to content
1 change: 1 addition & 0 deletions apps/api/src/app/agents/agents-webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class AgentsWebhookController {
edit: body.edit,
resolve: body.resolve,
signals: body.signals as Signal[],
addReactions: body.addReactions,
})
);
}
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ export class ResolveDto {
summary?: string;
}

export class AddReactionPayloadDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
messageId: string;

@ApiProperty()
@IsString()
@IsNotEmpty()
emojiName: string;
}

export class SignalDto {
@ApiProperty({ enum: SIGNAL_TYPES })
@IsString()
Expand Down Expand Up @@ -199,4 +211,11 @@ export class AgentReplyPayloadDto {
@Validate(IsValidSignal, { each: true })
@Type(() => SignalDto)
signals?: SignalDto[];

@ApiPropertyOptional({ type: [AddReactionPayloadDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AddReactionPayloadDto)
addReactions?: AddReactionPayloadDto[];
}
40 changes: 40 additions & 0 deletions apps/api/src/app/agents/e2e/agent-reply.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,46 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
});
});

describe('addReactions', () => {
it('should call reactToMessage for each addReaction entry', async () => {
const conversationId = await seedConversation(ctx);
const chatSdkService = testServer.getService(ChatSdkService);

const res = await postReply({
conversationId,
integrationIdentifier: ctx.integrationIdentifier,
addReactions: [
{ messageId: 'msg-abc', emojiName: 'thumbs_up' },
{ messageId: 'msg-def', emojiName: 'check' },
],
});

expect(res.status).to.equal(200);
expect((chatSdkService.reactToMessage as sinon.SinonStub).callCount).to.equal(2);

const firstCall = (chatSdkService.reactToMessage as sinon.SinonStub).getCall(0).args;
expect(firstCall[4]).to.equal('msg-abc');
expect(firstCall[5]).to.equal('thumbs_up');

const secondCall = (chatSdkService.reactToMessage as sinon.SinonStub).getCall(1).args;
expect(secondCall[4]).to.equal('msg-def');
expect(secondCall[5]).to.equal('check');
});

it('should return 400 when edit and addReactions are combined', async () => {
const conversationId = await seedConversation(ctx);

const res = await postReply({
conversationId,
integrationIdentifier: ctx.integrationIdentifier,
edit: { messageId: 'msg-edit', content: { markdown: 'updated' } },
addReactions: [{ messageId: 'msg-abc', emojiName: 'thumbs_up' }],
});

expect(res.status).to.equal(400);
});
});

describe('Inactive agent', () => {
it('should return 422 when agent is inactive', async () => {
const conversationId = await seedConversation(ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Signal } from '@novu/framework';
import { Type } from 'class-transformer';
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command';
import { EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { AddReactionPayloadDto, EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto';

export type { Signal } from '@novu/framework';

Expand Down Expand Up @@ -36,4 +36,10 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand {
@IsOptional()
@IsArray()
signals?: Signal[];

@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AddReactionPayloadDto)
addReactions?: AddReactionPayloadDto[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ export class HandleAgentReply {
if (command.reply && command.edit) {
throw new BadRequestException('Only one of reply or edit can be provided');
}
if (command.edit && (command.resolve || command.signals?.length)) {
throw new BadRequestException('edit cannot be combined with resolve or signals');
if (command.edit && (command.resolve || command.signals?.length || command.addReactions?.length)) {
throw new BadRequestException('edit cannot be combined with resolve, signals, or addReactions');
}
if (!command.reply && !command.edit && !command.resolve && !command.signals?.length) {
throw new BadRequestException('At least one of reply, edit, resolve, or signals must be provided');
if (!command.reply && !command.edit && !command.resolve && !command.signals?.length && !command.addReactions?.length) {
throw new BadRequestException('At least one of reply, edit, resolve, signals, or addReactions must be provided');
}

const conversation = await this.conversationService.getConversation(
Expand Down Expand Up @@ -80,6 +80,31 @@ export class HandleAgentReply {
await this.executeSignals(command, conversation, channel, command.signals);
}

if (command.addReactions?.length) {
const results = await Promise.allSettled(
command.addReactions.map((r) =>
this.chatSdkService.reactToMessage(
conversation._agentId,
command.integrationIdentifier,
channel.platform,
channel.platformThreadId,
r.messageId,
r.emojiName
)
)
);

for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'rejected') {
this.logger.warn(
{ err: result.reason, reaction: command.addReactions[i], agentIdentifier: command.agentIdentifier },
`[agent:${command.agentIdentifier}] Failed to add reaction`
);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (command.resolve) {
await this.resolveConversation(command, config!, conversation, channel, command.resolve);
}
Expand Down
19 changes: 18 additions & 1 deletion packages/framework/src/resources/agent/agent.context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isJSX, toCardElement } from 'chat/jsx-runtime';
import { AgentDeliveryError } from './agent.errors';
import type { Emoji } from 'chat';
import type {
AddReactionPayload,
AgentAction,
AgentBridgeRequest,
AgentContext,
Expand Down Expand Up @@ -108,6 +110,7 @@ export class AgentContextImpl implements AgentContext {
readonly metadata: { set: (key: string, value: unknown) => void };

private _signals: Signal[] = [];
private _pendingReactions: AddReactionPayload[] = [];
private _resolveSignal: { summary?: string } | null = null;
private readonly _replyUrl: string;
private readonly _conversationId: string;
Expand Down Expand Up @@ -151,6 +154,11 @@ export class AgentContextImpl implements AgentContext {
this._signals = [];
}

if (this._pendingReactions.length) {
body.addReactions = this._pendingReactions;
this._pendingReactions = [];
}

if (this._resolveSignal) {
body.resolve = this._resolveSignal;
this._resolveSignal = null;
Expand Down Expand Up @@ -178,12 +186,16 @@ export class AgentContextImpl implements AgentContext {
this._signals.push({ ...opts, type: 'trigger', workflowId });
}

addReaction(messageId: string, emojiName: Emoji): void {
this._pendingReactions.push({ messageId, emojiName });
}

/**
* Flush any remaining signals that weren't sent with reply().
* Called internally after onResolve returns.
*/
async flush(): Promise<void> {
if (!this._signals.length && !this._resolveSignal) {
if (!this._signals.length && !this._resolveSignal && !this._pendingReactions.length) {
return;
}

Expand All @@ -197,6 +209,11 @@ export class AgentContextImpl implements AgentContext {
this._signals = [];
}

if (this._pendingReactions.length) {
body.addReactions = this._pendingReactions;
this._pendingReactions = [];
}

if (this._resolveSignal) {
body.resolve = this._resolveSignal;
this._resolveSignal = null;
Expand Down
77 changes: 77 additions & 0 deletions packages/framework/src/resources/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,83 @@ describe('agent dispatch via NovuRequestHandler', () => {
expect(capturedCtx.reaction.message).toBeNull();
});

it('should flush addReaction without a reply', async () => {
const testBot = agent('test-bot', {
onMessage: async (ctx) => {
ctx.addReaction('msg-123', 'eyes');
},
});

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'
);
const flushBody = JSON.parse(replyCall![1].body);

expect(flushBody.reply).toBeUndefined();
expect(flushBody.addReactions).toHaveLength(1);
expect(flushBody.addReactions[0]).toEqual({ messageId: 'msg-123', emojiName: 'eyes' });
});

it('should batch addReaction with reply', async () => {
const testBot = agent('test-bot', {
onMessage: async (ctx) => {
ctx.addReaction('msg-reacted', 'thumbs_up');
await ctx.reply('Got it');
},
});

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'
);
const replyBody = JSON.parse(replyCall![1].body);

expect(replyBody.reply.markdown).toBe('Got it');
expect(replyBody.addReactions).toHaveLength(1);
expect(replyBody.addReactions[0]).toEqual({ messageId: 'msg-reacted', emojiName: 'thumbs_up' });
});

it('should have null reaction on non-reaction events', async () => {
let capturedCtx: any;

Expand Down
22 changes: 21 additions & 1 deletion packages/framework/src/resources/agent/agent.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CardElement, ChatElement } from 'chat';
import type { CardElement, ChatElement, Emoji } from 'chat';
import type { TriggerRecipientsPayload } from '../../shared';
export type { TriggerRecipientsPayload };

Expand Down Expand Up @@ -178,6 +178,19 @@ export interface AgentContext {
* ctx.trigger('team-alert', { to: { type: 'Topic', topicKey: 'support-team' } });
*/
trigger(workflowId: string, opts?: { to?: TriggerRecipientsPayload; payload?: Record<string, unknown> }): void;
/**
* Add an emoji reaction to any platform message.
* Reactions are queued and sent with the next `ctx.reply()`, or flushed automatically
* when the handler completes (same batching contract as `ctx.trigger()`).
*
* @param messageId - Platform-native message ID to react to (e.g. Slack `ts`).
* @param emojiName - Emoji short-name (e.g. `'thumbs_up'`, `'check_mark'`).
*
* @example
* ctx.addReaction(ctx.reaction!.messageId, 'check_mark');
* await ctx.reply('Done!');
*/
addReaction(messageId: string, emojiName: Emoji): void;
}
Comment on lines +181 to 194
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'package.json' packages/framework -d 2 --exec cat {} \; | head -40

Repository: novuhq/novu

Length of output: 875


🏁 Script executed:

git diff HEAD packages/framework/package.json 2>/dev/null || echo "No diff found or git not available"

Repository: novuhq/novu

Length of output: 37


🏁 Script executed:

# Also check if there were any version-related changes
git diff --no-ext-diff HEAD -- packages/framework/package.json | head -50

Repository: novuhq/novu

Length of output: 37


🏁 Script executed:

# List the agent.types.ts file to see full exports
cat -n packages/framework/src/resources/agent/agent.types.ts | tail -30

Repository: novuhq/novu

Length of output: 1041


Ensure @novu/framework version is bumped for new public exports.

The PR adds addReaction method to AgentContext and exports AddReactionPayload—both new public API surface additions to the framework SDK. Per the repo's semver convention for packages/** (new exports require a minor version bump), the version in packages/framework/package.json must be incremented. Currently no version bump is present in this PR.

Update the version from 2.10.1-alpha.2 to reflect the minor bump (e.g., 2.11.0-alpha.0 or appropriate pre-release) before merge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/framework/src/resources/agent/agent.types.ts` around lines 181 -
194, The PR adds a new public API (AgentContext.addReaction and exported
AddReactionPayload) but the framework package version was not bumped; update
packages/framework/package.json to a new minor pre-release (for example from
2.10.1-alpha.2 to 2.11.0-alpha.0 or your repo's agreed pre-release scheme) so
the new exports are published, commit the version change and update any
changelog/release metadata as required.


export interface AgentHandlers {
Expand Down Expand Up @@ -241,13 +254,20 @@ export interface EditPayload {
content: ReplyContent;
}

/** An emoji reaction to be added to a platform message. */
export interface AddReactionPayload {
messageId: string;
emojiName: Emoji;
}

export interface AgentReplyPayload {
conversationId: string;
integrationIdentifier: string;
reply?: ReplyContent;
edit?: EditPayload;
resolve?: { summary?: string };
signals?: Signal[];
addReactions?: AddReactionPayload[];
}

/** Shape returned by /agents/:id/reply when a reply or edit was delivered. */
Expand Down
Loading