Skip to content

Commit 8389bc4

Browse files
authored
refactor(dashboard, api-service): ack flow for conversational (#10788)
1 parent 428d753 commit 8389bc4

16 files changed

Lines changed: 284 additions & 208 deletions
Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsBoolean, IsOptional, ValidateIf, ValidateNested } from 'class-validator';
3-
import { Type } from 'class-transformer';
2+
import { IsBoolean, IsOptional, ValidateIf } from 'class-validator';
43
import { IsWellKnownEmoji } from '../validators/is-well-known-emoji.validator';
54

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

1818
@ApiPropertyOptional({
1919
description:
@@ -24,18 +24,5 @@ export class AgentReactionSettingsDto {
2424
@IsOptional()
2525
@ValidateIf((_, value) => value !== null)
2626
@IsWellKnownEmoji()
27-
onResolved?: string | null;
28-
}
29-
30-
export class AgentBehaviorDto {
31-
@ApiPropertyOptional({ description: 'Show a "Thinking..." indicator while the agent is processing a message' })
32-
@IsBoolean()
33-
@IsOptional()
34-
thinkingIndicatorEnabled?: boolean;
35-
36-
@ApiPropertyOptional({ type: AgentReactionSettingsDto, description: 'Automatic emoji reactions on messages' })
37-
@ValidateNested()
38-
@Type(() => AgentReactionSettingsDto)
39-
@IsOptional()
40-
reactions?: AgentReactionSettingsDto;
27+
reactionOnResolved?: string | null;
4128
}

apps/api/src/app/agents/dtos/agent-platform.enum.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export enum AgentPlatformEnum {
44
TEAMS = 'teams',
55
}
66

7-
export const PLATFORMS_WITHOUT_TYPING_INDICATOR = new Set<AgentPlatformEnum>([
8-
AgentPlatformEnum.WHATSAPP,
7+
export const PLATFORMS_WITH_TYPING_INDICATOR = new Set<AgentPlatformEnum>([
8+
AgentPlatformEnum.SLACK,
9+
AgentPlatformEnum.TEAMS,
910
]);

apps/api/src/app/agents/e2e/agents.e2e.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('Agents API - /agents #novu-v2', () => {
6565
expect(afterDelete.status).to.equal(404);
6666
});
6767

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

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

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

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

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

8888
expect(getRes.status).to.equal(200);
89-
expect(getRes.body.data.behavior.thinkingIndicatorEnabled).to.equal(false);
89+
expect(getRes.body.data.behavior.acknowledgeOnReceived).to.equal(false);
9090

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

9595
expect(reEnableRes.status).to.equal(200);
96-
expect(reEnableRes.body.data.behavior.thinkingIndicatorEnabled).to.equal(true);
96+
expect(reEnableRes.body.data.behavior.acknowledgeOnReceived).to.equal(true);
9797

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

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

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

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

118-
expect(setReactionsRes.status).to.equal(200);
119-
expect(setReactionsRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
120-
expect(setReactionsRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
116+
expect(setRes.status).to.equal(200);
117+
expect(setRes.body.data.behavior.reactionOnResolved).to.equal('thumbs_up');
121118

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

124121
expect(getRes.status).to.equal(200);
125-
expect(getRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
126-
expect(getRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
122+
expect(getRes.body.data.behavior.reactionOnResolved).to.equal('thumbs_up');
127123

128124
const disableRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
129-
behavior: {
130-
reactions: { onMessageReceived: null },
131-
},
125+
behavior: { reactionOnResolved: null },
132126
});
133127

134128
expect(disableRes.status).to.equal(200);
135-
expect(disableRes.body.data.behavior.reactions.onMessageReceived).to.equal(null);
136-
expect(disableRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
129+
expect(disableRes.body.data.behavior.reactionOnResolved).to.equal(null);
137130

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

apps/api/src/app/agents/services/agent-config-resolver.service.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,15 @@ export interface ResolvedAgentConfig {
3333
agentIdentifier: string;
3434
integrationIdentifier: string;
3535
integrationId: string;
36-
thinkingIndicatorEnabled: boolean;
37-
reactionOnMessageReceived: WellKnownEmoji | null;
36+
acknowledgeOnReceived: boolean;
3837
reactionOnResolved: WellKnownEmoji | null;
3938
bridgeUrl?: string;
4039
devBridgeUrl?: string;
4140
devBridgeActive?: boolean;
4241
}
4342

44-
const DEFAULT_REACTION_ON_MESSAGE: WellKnownEmoji = 'eyes';
4543
const DEFAULT_REACTION_ON_RESOLVED: WellKnownEmoji = 'check';
4644

47-
function resolveThinkingIndicator(agent: { behavior?: { thinkingIndicatorEnabled?: boolean } }): boolean {
48-
return agent.behavior?.thinkingIndicatorEnabled !== false;
49-
}
50-
5145
async function resolveReaction(
5246
value: string | null | undefined,
5347
defaultEmoji: WellKnownEmoji,
@@ -156,14 +150,9 @@ export class AgentConfigResolver {
156150
agentIdentifier: agent.identifier,
157151
integrationIdentifier,
158152
integrationId: integration._id,
159-
thinkingIndicatorEnabled: resolveThinkingIndicator(agent),
160-
reactionOnMessageReceived: await resolveReaction(
161-
agent.behavior?.reactions?.onMessageReceived,
162-
DEFAULT_REACTION_ON_MESSAGE,
163-
this.logger
164-
),
153+
acknowledgeOnReceived: agent.behavior?.acknowledgeOnReceived !== false,
165154
reactionOnResolved: await resolveReaction(
166-
agent.behavior?.reactions?.onResolved,
155+
agent.behavior?.reactionOnResolved,
167156
DEFAULT_REACTION_ON_RESOLVED,
168157
this.logger
169158
),

apps/api/src/app/agents/services/agent-inbound-handler.service.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@novu/dal';
99
import type { EmojiValue, Message, Thread } from 'chat';
1010
import { AgentEventEnum } from '../dtos/agent-event.enum';
11-
import { PLATFORMS_WITHOUT_TYPING_INDICATOR } from '../dtos/agent-platform.enum';
11+
import { PLATFORMS_WITH_TYPING_INDICATOR } from '../dtos/agent-platform.enum';
1212
import { HandleAgentReplyCommand } from '../usecases/handle-agent-reply/handle-agent-reply.command';
1313
import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-reply.usecase';
1414
import { ResolvedAgentConfig } from './agent-config-resolver.service';
@@ -17,6 +17,8 @@ import { AgentSubscriberResolver } from './agent-subscriber-resolver.service';
1717
import type { AgentAction } from '@novu/framework';
1818
import { BridgeExecutorService, type BridgeReaction, NoBridgeUrlError } from './bridge-executor.service';
1919

20+
const ACKNOWLEDGE_FALLBACK_EMOJI = 'eyes' as const;
21+
2022
const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu*
2123
2224
Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`;
@@ -116,23 +118,31 @@ export class AgentInboundHandler {
116118
const channel = conversation.channels?.[0];
117119
const isFirstMessage = !channel?.firstPlatformMessageId;
118120

119-
if (isFirstMessage && config.reactionOnMessageReceived && message.id) {
120-
thread
121-
.createSentMessageFromMessage(message)
122-
.addReaction(config.reactionOnMessageReceived)
123-
.catch((err) => {
124-
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
125-
});
126-
127-
this.conversationRepository
128-
.setFirstPlatformMessageId(config.environmentId, config.organizationId, conversation._id, thread.id, message.id)
129-
.catch((err) => {
130-
this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`);
131-
});
132-
}
121+
if (config.acknowledgeOnReceived) {
122+
const supportsTyping = PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform);
133123

134-
if (config.thinkingIndicatorEnabled && !PLATFORMS_WITHOUT_TYPING_INDICATOR.has(config.platform)) {
135-
await thread.startTyping('Thinking...');
124+
if (supportsTyping) {
125+
await thread.startTyping('Thinking...');
126+
} else if (isFirstMessage && message.id) {
127+
thread
128+
.createSentMessageFromMessage(message)
129+
.addReaction(ACKNOWLEDGE_FALLBACK_EMOJI)
130+
.catch((err) => {
131+
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
132+
});
133+
134+
this.conversationRepository
135+
.setFirstPlatformMessageId(
136+
config.environmentId,
137+
config.organizationId,
138+
conversation._id,
139+
thread.id,
140+
message.id
141+
)
142+
.catch((err) => {
143+
this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`);
144+
});
145+
}
136146
}
137147

138148
const serializedThread = thread.toJSON() as unknown as Record<string, unknown>;

apps/api/src/app/agents/services/chat-sdk.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const INSTANCE_TTL_MS = 1000 * 60 * 30;
3636
* resolved config. Event handlers registered via registerEventHandlers() close
3737
* over this box instead of the config value, so updates to fields that the
3838
* bridge executor and inbound handler read at event time (bridgeUrl,
39-
* devBridgeUrl, devBridgeActive, thinkingIndicatorEnabled, reactions) take
39+
* devBridgeUrl, devBridgeActive, acknowledgeOnReceived, reactionOnResolved) take
4040
* effect on the next inbound event without rebuilding the Chat instance.
4141
*
4242
* adapterFingerprint captures fields that are baked into the platform adapter

apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,15 +346,15 @@ export class HandleAgentReply {
346346
channel: ConversationChannel
347347
): Promise<void> {
348348
const firstMessageId = channel.firstPlatformMessageId;
349-
if (!firstMessageId || !config.reactionOnMessageReceived) return;
349+
if (!firstMessageId || !config.acknowledgeOnReceived) return;
350350

351351
await this.chatSdkService.removeReaction(
352352
conversation._agentId,
353353
config.integrationIdentifier,
354354
channel.platform,
355355
channel.platformThreadId,
356356
firstMessageId,
357-
config.reactionOnMessageReceived
357+
'eyes'
358358
);
359359
}
360360

apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@ export class UpdateAgent {
1414

1515
async execute(command: UpdateAgentCommand): Promise<AgentResponseDto> {
1616
const hasBehaviorFields =
17-
command.behavior?.thinkingIndicatorEnabled !== undefined ||
18-
command.behavior?.reactions?.onMessageReceived !== undefined ||
19-
command.behavior?.reactions?.onResolved !== undefined;
17+
command.behavior?.acknowledgeOnReceived !== undefined ||
18+
command.behavior?.reactionOnResolved !== undefined;
2019

2120
const hasGeneralFields =
2221
command.name !== undefined ||
@@ -64,17 +63,11 @@ export class UpdateAgent {
6463
}
6564

6665
if (hasBehaviorFields) {
67-
if (command.behavior!.thinkingIndicatorEnabled !== undefined) {
68-
$set['behavior.thinkingIndicatorEnabled'] = command.behavior!.thinkingIndicatorEnabled;
66+
if (command.behavior!.acknowledgeOnReceived !== undefined) {
67+
$set['behavior.acknowledgeOnReceived'] = command.behavior!.acknowledgeOnReceived;
6968
}
70-
71-
if (command.behavior!.reactions !== undefined) {
72-
if (command.behavior!.reactions.onMessageReceived !== undefined) {
73-
$set['behavior.reactions.onMessageReceived'] = command.behavior!.reactions.onMessageReceived;
74-
}
75-
if (command.behavior!.reactions.onResolved !== undefined) {
76-
$set['behavior.reactions.onResolved'] = command.behavior!.reactions.onResolved;
77-
}
69+
if (command.behavior!.reactionOnResolved !== undefined) {
70+
$set['behavior.reactionOnResolved'] = command.behavior!.reactionOnResolved;
7871
}
7972
}
8073

apps/dashboard/src/api/agents.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,18 @@ export type AgentIntegrationSummary = {
3636
active: boolean;
3737
};
3838

39+
export type AgentBehavior = {
40+
acknowledgeOnReceived?: boolean;
41+
reactionOnResolved?: string | null;
42+
};
43+
3944
export type AgentResponse = {
4045
_id: string;
4146
name: string;
4247
identifier: string;
4348
description?: string;
4449
active: boolean;
50+
behavior?: AgentBehavior;
4551
bridgeUrl?: string;
4652
devBridgeUrl?: string;
4753
devBridgeActive?: boolean;
@@ -71,6 +77,7 @@ export type UpdateAgentBody = {
7177
name?: string;
7278
description?: string;
7379
active?: boolean;
80+
behavior?: AgentBehavior;
7481
bridgeUrl?: string;
7582
devBridgeUrl?: string;
7683
devBridgeActive?: boolean;

0 commit comments

Comments
 (0)