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
33 changes: 10 additions & 23 deletions apps/api/src/app/agents/dtos/agent-behavior.dto.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, ValidateIf, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { IsBoolean, IsOptional, ValidateIf } from 'class-validator';
import { IsWellKnownEmoji } from '../validators/is-well-known-emoji.validator';

export class AgentReactionSettingsDto {
export class AgentBehaviorDto {
@ApiPropertyOptional({
description:
'Cross-platform emoji name for incoming messages (e.g. "eyes", "thumbs_up"). ' +
'Set to null to disable. Default: "eyes"',
default: 'eyes',
'Acknowledge incoming messages. On platforms that support a native typing indicator ' +
'(e.g. Slack, Microsoft Teams), shows a "Typing…" indicator while the agent processes the message. ' +
'On platforms that do not (e.g. WhatsApp), reacts with an "eyes" emoji to the first ' +
'inbound message in a thread. Default: true',
default: true,
})
@IsBoolean()
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsWellKnownEmoji()
onMessageReceived?: string | null;
acknowledgeOnReceived?: boolean;

@ApiPropertyOptional({
description:
Expand All @@ -24,18 +24,5 @@ export class AgentReactionSettingsDto {
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsWellKnownEmoji()
onResolved?: string | null;
}

export class AgentBehaviorDto {
@ApiPropertyOptional({ description: 'Show a "Thinking..." indicator while the agent is processing a message' })
@IsBoolean()
@IsOptional()
thinkingIndicatorEnabled?: boolean;

@ApiPropertyOptional({ type: AgentReactionSettingsDto, description: 'Automatic emoji reactions on messages' })
@ValidateNested()
@Type(() => AgentReactionSettingsDto)
@IsOptional()
reactions?: AgentReactionSettingsDto;
reactionOnResolved?: string | null;
}
5 changes: 3 additions & 2 deletions apps/api/src/app/agents/dtos/agent-platform.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum AgentPlatformEnum {
TEAMS = 'teams',
}

export const PLATFORMS_WITHOUT_TYPING_INDICATOR = new Set<AgentPlatformEnum>([
AgentPlatformEnum.WHATSAPP,
export const PLATFORMS_WITH_TYPING_INDICATOR = new Set<AgentPlatformEnum>([
AgentPlatformEnum.SLACK,
AgentPlatformEnum.TEAMS,
]);
35 changes: 14 additions & 21 deletions apps/api/src/app/agents/e2e/agents.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('Agents API - /agents #novu-v2', () => {
expect(afterDelete.status).to.equal(404);
});

it('should update and return agent behavior settings', async () => {
it('should update and return acknowledgeOnReceived behavior', async () => {
const identifier = `e2e-behavior-${Date.now()}`;

const createRes = await session.testAgent.post('/v1/agents').send({
Expand All @@ -77,28 +77,28 @@ describe('Agents API - /agents #novu-v2', () => {
expect(createRes.body.data.behavior).to.equal(undefined);

const patchRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: { thinkingIndicatorEnabled: false },
behavior: { acknowledgeOnReceived: false },
});

expect(patchRes.status).to.equal(200);
expect(patchRes.body.data.behavior).to.deep.equal({ thinkingIndicatorEnabled: false });
expect(patchRes.body.data.behavior).to.deep.equal({ acknowledgeOnReceived: false });

const getRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`);

expect(getRes.status).to.equal(200);
expect(getRes.body.data.behavior.thinkingIndicatorEnabled).to.equal(false);
expect(getRes.body.data.behavior.acknowledgeOnReceived).to.equal(false);

const reEnableRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: { thinkingIndicatorEnabled: true },
behavior: { acknowledgeOnReceived: true },
});

expect(reEnableRes.status).to.equal(200);
expect(reEnableRes.body.data.behavior.thinkingIndicatorEnabled).to.equal(true);
expect(reEnableRes.body.data.behavior.acknowledgeOnReceived).to.equal(true);

await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
});

it('should update and return agent reaction settings with defaults', async () => {
it('should update and return reactionOnResolved behavior', async () => {
const identifier = `e2e-reactions-${Date.now()}`;

const createRes = await session.testAgent.post('/v1/agents').send({
Expand All @@ -109,31 +109,24 @@ describe('Agents API - /agents #novu-v2', () => {
expect(createRes.status).to.equal(201);
expect(createRes.body.data.behavior).to.equal(undefined);

const setReactionsRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: {
reactions: { onMessageReceived: 'wave', onResolved: 'thumbs_up' },
},
const setRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: { reactionOnResolved: 'thumbs_up' },
});

expect(setReactionsRes.status).to.equal(200);
expect(setReactionsRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
expect(setReactionsRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
expect(setRes.status).to.equal(200);
expect(setRes.body.data.behavior.reactionOnResolved).to.equal('thumbs_up');

const getRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`);

expect(getRes.status).to.equal(200);
expect(getRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
expect(getRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
expect(getRes.body.data.behavior.reactionOnResolved).to.equal('thumbs_up');

const disableRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: {
reactions: { onMessageReceived: null },
},
behavior: { reactionOnResolved: null },
});

expect(disableRes.status).to.equal(200);
expect(disableRes.body.data.behavior.reactions.onMessageReceived).to.equal(null);
expect(disableRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
expect(disableRes.body.data.behavior.reactionOnResolved).to.equal(null);

await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
});
Expand Down
17 changes: 3 additions & 14 deletions apps/api/src/app/agents/services/agent-config-resolver.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,15 @@ export interface ResolvedAgentConfig {
agentIdentifier: string;
integrationIdentifier: string;
integrationId: string;
thinkingIndicatorEnabled: boolean;
reactionOnMessageReceived: WellKnownEmoji | null;
acknowledgeOnReceived: boolean;
reactionOnResolved: WellKnownEmoji | null;
bridgeUrl?: string;
devBridgeUrl?: string;
devBridgeActive?: boolean;
}

const DEFAULT_REACTION_ON_MESSAGE: WellKnownEmoji = 'eyes';
const DEFAULT_REACTION_ON_RESOLVED: WellKnownEmoji = 'check';

function resolveThinkingIndicator(agent: { behavior?: { thinkingIndicatorEnabled?: boolean } }): boolean {
return agent.behavior?.thinkingIndicatorEnabled !== false;
}

async function resolveReaction(
value: string | null | undefined,
defaultEmoji: WellKnownEmoji,
Expand Down Expand Up @@ -156,14 +150,9 @@ export class AgentConfigResolver {
agentIdentifier: agent.identifier,
integrationIdentifier,
integrationId: integration._id,
thinkingIndicatorEnabled: resolveThinkingIndicator(agent),
reactionOnMessageReceived: await resolveReaction(
agent.behavior?.reactions?.onMessageReceived,
DEFAULT_REACTION_ON_MESSAGE,
this.logger
),
acknowledgeOnReceived: agent.behavior?.acknowledgeOnReceived !== false,
reactionOnResolved: await resolveReaction(
agent.behavior?.reactions?.onResolved,
agent.behavior?.reactionOnResolved,
DEFAULT_REACTION_ON_RESOLVED,
this.logger
),
Expand Down
44 changes: 27 additions & 17 deletions apps/api/src/app/agents/services/agent-inbound-handler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@novu/dal';
import type { EmojiValue, Message, Thread } from 'chat';
import { AgentEventEnum } from '../dtos/agent-event.enum';
import { PLATFORMS_WITHOUT_TYPING_INDICATOR } from '../dtos/agent-platform.enum';
import { PLATFORMS_WITH_TYPING_INDICATOR } from '../dtos/agent-platform.enum';
import { HandleAgentReplyCommand } from '../usecases/handle-agent-reply/handle-agent-reply.command';
import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-reply.usecase';
import { ResolvedAgentConfig } from './agent-config-resolver.service';
Expand All @@ -17,6 +17,8 @@ import { AgentSubscriberResolver } from './agent-subscriber-resolver.service';
import type { AgentAction } from '@novu/framework';
import { BridgeExecutorService, type BridgeReaction, NoBridgeUrlError } from './bridge-executor.service';

const ACKNOWLEDGE_FALLBACK_EMOJI = 'eyes' as const;

const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu*

Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`;
Expand Down Expand Up @@ -116,23 +118,31 @@ export class AgentInboundHandler {
const channel = conversation.channels?.[0];
const isFirstMessage = !channel?.firstPlatformMessageId;

if (isFirstMessage && config.reactionOnMessageReceived && message.id) {
thread
.createSentMessageFromMessage(message)
.addReaction(config.reactionOnMessageReceived)
.catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
});

this.conversationRepository
.setFirstPlatformMessageId(config.environmentId, config.organizationId, conversation._id, thread.id, message.id)
.catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`);
});
}
if (config.acknowledgeOnReceived) {
const supportsTyping = PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform);

if (config.thinkingIndicatorEnabled && !PLATFORMS_WITHOUT_TYPING_INDICATOR.has(config.platform)) {
await thread.startTyping('Thinking...');
if (supportsTyping) {
await thread.startTyping('Thinking...');
Comment on lines +124 to +125
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

Keep typing acknowledgements best-effort.

thread.startTyping() is an external platform call. If Slack/Teams returns a transient error, this aborts the inbound handler before the bridge executes, so the agent can miss the message.

Proposed fix
       if (supportsTyping) {
-        await thread.startTyping('Thinking...');
+        await thread.startTyping('Thinking...').catch((err) => {
+          this.logger.warn(err, `[agent:${agentId}] Failed to start typing indicator`);
+        });
       } else if (isFirstMessage && message.id) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (supportsTyping) {
await thread.startTyping('Thinking...');
if (supportsTyping) {
await thread.startTyping('Thinking...').catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to start typing indicator`);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/services/agent-inbound-handler.service.ts` around
lines 124 - 125, The call to thread.startTyping inside the supportsTyping branch
is an external platform call and must be best-effort so it cannot abort the
inbound handler; change the code around supportsTyping/thread.startTyping to
make the typing ack non-blocking and swallow transient errors (e.g. call
thread.startTyping('Thinking...') without awaiting it and attach a .catch(...)
that logs a warning via the service logger, or wrap the await in try/catch and
log the error) so failures do not prevent the rest of
agent-inbound-handler.service (the handler that executes the bridge and
processes the message) from running.

} else if (isFirstMessage && message.id) {
thread
.createSentMessageFromMessage(message)
.addReaction(ACKNOWLEDGE_FALLBACK_EMOJI)
.catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
});

this.conversationRepository
.setFirstPlatformMessageId(
config.environmentId,
config.organizationId,
conversation._id,
thread.id,
message.id
)
.catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`);
});
}
Comment thread
scopsy marked this conversation as resolved.
}

const serializedThread = thread.toJSON() as unknown as Record<string, unknown>;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/agents/services/chat-sdk.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const INSTANCE_TTL_MS = 1000 * 60 * 30;
* resolved config. Event handlers registered via registerEventHandlers() close
* over this box instead of the config value, so updates to fields that the
* bridge executor and inbound handler read at event time (bridgeUrl,
* devBridgeUrl, devBridgeActive, thinkingIndicatorEnabled, reactions) take
* devBridgeUrl, devBridgeActive, acknowledgeOnReceived, reactionOnResolved) take
* effect on the next inbound event without rebuilding the Chat instance.
*
* adapterFingerprint captures fields that are baked into the platform adapter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,15 @@ export class HandleAgentReply {
channel: ConversationChannel
): Promise<void> {
const firstMessageId = channel.firstPlatformMessageId;
if (!firstMessageId || !config.reactionOnMessageReceived) return;
if (!firstMessageId || !config.acknowledgeOnReceived) return;

await this.chatSdkService.removeReaction(
conversation._agentId,
config.integrationIdentifier,
channel.platform,
channel.platformThreadId,
firstMessageId,
config.reactionOnMessageReceived
'eyes'
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ export class UpdateAgent {

async execute(command: UpdateAgentCommand): Promise<AgentResponseDto> {
const hasBehaviorFields =
command.behavior?.thinkingIndicatorEnabled !== undefined ||
command.behavior?.reactions?.onMessageReceived !== undefined ||
command.behavior?.reactions?.onResolved !== undefined;
command.behavior?.acknowledgeOnReceived !== undefined ||
command.behavior?.reactionOnResolved !== undefined;

const hasGeneralFields =
command.name !== undefined ||
Expand Down Expand Up @@ -64,17 +63,11 @@ export class UpdateAgent {
}

if (hasBehaviorFields) {
if (command.behavior!.thinkingIndicatorEnabled !== undefined) {
$set['behavior.thinkingIndicatorEnabled'] = command.behavior!.thinkingIndicatorEnabled;
if (command.behavior!.acknowledgeOnReceived !== undefined) {
$set['behavior.acknowledgeOnReceived'] = command.behavior!.acknowledgeOnReceived;
}

if (command.behavior!.reactions !== undefined) {
if (command.behavior!.reactions.onMessageReceived !== undefined) {
$set['behavior.reactions.onMessageReceived'] = command.behavior!.reactions.onMessageReceived;
}
if (command.behavior!.reactions.onResolved !== undefined) {
$set['behavior.reactions.onResolved'] = command.behavior!.reactions.onResolved;
}
if (command.behavior!.reactionOnResolved !== undefined) {
$set['behavior.reactionOnResolved'] = command.behavior!.reactionOnResolved;
}
}

Expand Down
7 changes: 7 additions & 0 deletions apps/dashboard/src/api/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@ export type AgentIntegrationSummary = {
active: boolean;
};

export type AgentBehavior = {
acknowledgeOnReceived?: boolean;
reactionOnResolved?: string | null;
};

export type AgentResponse = {
_id: string;
name: string;
identifier: string;
description?: string;
active: boolean;
behavior?: AgentBehavior;
bridgeUrl?: string;
devBridgeUrl?: string;
devBridgeActive?: boolean;
Expand Down Expand Up @@ -71,6 +77,7 @@ export type UpdateAgentBody = {
name?: string;
description?: string;
active?: boolean;
behavior?: AgentBehavior;
bridgeUrl?: string;
devBridgeUrl?: string;
devBridgeActive?: boolean;
Expand Down
Loading
Loading