Skip to content

Commit 29d144e

Browse files
ChmaraXcursoragent
andcommitted
feat(api-service): improve managed-agent MCP setup gate UX fixes NV-7906
Show typing before the setup gate and before OAuth replay, nudge on gated follow-ups, and use platform-aware setup-complete card copy. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6911f29 commit 29d144e

7 files changed

Lines changed: 177 additions & 27 deletions

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ describe('AgentInboundHandler', () => {
125125
subscriberResolver,
126126
startCodeService,
127127
channelEndpointRepository,
128+
handleManagedAgentSetupInbound,
129+
managedAgentService,
130+
agentRepository,
131+
subscriberRepository,
128132
};
129133
}
130134

@@ -290,6 +294,61 @@ describe('AgentInboundHandler', () => {
290294
});
291295
expect(bridgeExecutor.execute.firstCall.args[0].storedAttachments).to.deep.equal(storedAttachments);
292296
});
297+
298+
it('should show typing before managed-agent setup gate when acknowledgeOnReceived is enabled', async () => {
299+
const setupInbound = sinon.stub().resolves(true);
300+
const logger = makeLogger();
301+
const subscriberResolver = { resolve: sinon.stub().resolves('sub-1') };
302+
const conversationService = {
303+
createOrGetConversation: sinon.stub().resolves(conversation),
304+
getPrimaryChannel: sinon.stub().callsFake((conv) => conv.channels[0]),
305+
persistInboundMessage: sinon.stub().resolves({ _id: 'activity1' }),
306+
persistAgentMessage: sinon.stub().resolves({ _id: 'agent-activity1' }),
307+
setFirstPlatformMessageId: sinon.stub().resolves(undefined),
308+
findByPlatformThread: sinon.stub().resolves(conversation),
309+
getHistory: sinon.stub().resolves([]),
310+
};
311+
const managedAgentService = { dispatch: sinon.stub().resolves(undefined) };
312+
const handleManagedAgentSetupInbound = { execute: setupInbound };
313+
const subscriberRepository = {
314+
findBySubscriberId: sinon.stub().resolves({ subscriberId: 'sub-1' }),
315+
};
316+
const agentRepository = {
317+
findOne: sinon.stub().resolves({
318+
_id: 'agent1',
319+
runtime: 'managed',
320+
managedRuntime: { providerId: 'anthropic', _integrationId: 'int1', externalAgentId: 'ext1' },
321+
}),
322+
};
323+
const handler = new AgentInboundHandler(
324+
logger as any,
325+
subscriberResolver as any,
326+
conversationService as any,
327+
{ execute: sinon.stub().resolves(undefined) } as any,
328+
managedAgentService as any,
329+
{ execute: sinon.stub().resolves(undefined) } as any,
330+
handleManagedAgentSetupInbound as any,
331+
{ registerInboundCallbacks: sinon.stub() } as any,
332+
agentRepository as any,
333+
subscriberRepository as any,
334+
{ findOne: sinon.stub().resolves(null) } as any,
335+
{ track: sinon.stub() } as any,
336+
{ storeInbound: sinon.stub().resolves([]) } as any,
337+
{ consumeIfMatches: sinon.stub().resolves({ status: 'missing' }) } as any,
338+
{ findByPlatformIdentity: sinon.stub().resolves(null) } as any,
339+
{ execute: sinon.stub().resolves({ created: true }) } as any
340+
);
341+
342+
const thread = makeSlackDmThread();
343+
const message = makeSlackDmMessage();
344+
const slackConfig = { ...config, acknowledgeOnReceived: true };
345+
346+
await handler.handle('agent1', slackConfig as any, thread as any, message as any, AgentEventEnum.ON_MESSAGE);
347+
348+
expect(thread.startTyping.calledOnceWith('Thinking...')).to.equal(true);
349+
expect(setupInbound.calledOnce).to.equal(true);
350+
expect(managedAgentService.dispatch.called).to.equal(false);
351+
});
293352
});
294353

295354
describe('Telegram /start subscriber-link handling', () => {

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

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,26 @@ export class AgentInboundHandler implements OnModuleInit {
388388

389389
const isManagedAgent = agent?.runtime === 'managed' && agent.managedRuntime;
390390

391+
if (config.acknowledgeOnReceived) {
392+
const supportsTyping = PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform);
393+
394+
if (supportsTyping) {
395+
await thread.startTyping('Thinking...');
396+
} else if (isFirstMessage && message.id) {
397+
thread
398+
.createSentMessageFromMessage(message)
399+
.addReaction(ACKNOWLEDGE_FALLBACK_EMOJI)
400+
.catch((err) => {
401+
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
402+
captureAgentWarning(err, {
403+
component: 'agent-inbound-handler',
404+
operation: 'add-ack-reaction',
405+
agentId,
406+
});
407+
});
408+
}
409+
}
410+
391411
// Subscriber still owes MCP OAuth: hold this message, show the setup card, skip dispatch.
392412
// After OAuth completes, CompleteManagedAgentSetup replays the held message.
393413
if (isManagedAgent && subscriber && message.id) {
@@ -410,26 +430,6 @@ export class AgentInboundHandler implements OnModuleInit {
410430
}
411431
}
412432

413-
if (config.acknowledgeOnReceived) {
414-
const supportsTyping = PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform);
415-
416-
if (supportsTyping) {
417-
await thread.startTyping('Thinking...');
418-
} else if (isFirstMessage && message.id) {
419-
thread
420-
.createSentMessageFromMessage(message)
421-
.addReaction(ACKNOWLEDGE_FALLBACK_EMOJI)
422-
.catch((err) => {
423-
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
424-
captureAgentWarning(err, {
425-
component: 'agent-inbound-handler',
426-
operation: 'add-ack-reaction',
427-
agentId,
428-
});
429-
});
430-
}
431-
}
432-
433433
try {
434434
if (isManagedAgent) {
435435
await this.managedAgentService.dispatch(

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ export class ChatSdkService implements OnModuleDestroy {
213213
dispose: (cached, key) => {
214214
cached.chat.shutdown().catch((err) => {
215215
this.logger.error(err, `Failed to shut down evicted Chat instance ${key}`);
216-
captureAgentException(err, { component: 'chat-sdk', operation: 'shutdown-evicted', extra: { instanceKey: key } });
216+
captureAgentException(err, {
217+
component: 'chat-sdk',
218+
operation: 'shutdown-evicted',
219+
extra: { instanceKey: key },
220+
});
217221
});
218222
},
219223
});
@@ -348,6 +352,24 @@ export class ChatSdkService implements OnModuleDestroy {
348352
return { messageId: sent.id, platformThreadId: sent.threadId };
349353
}
350354

355+
async startTypingInConversation(
356+
agentId: string,
357+
integrationIdentifier: string,
358+
platformThreadId: string,
359+
status = 'Thinking...'
360+
): Promise<void> {
361+
const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier);
362+
const instanceKey = `${agentId}:${integrationIdentifier}`;
363+
const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config);
364+
const thread = chat.thread(platformThreadId);
365+
366+
if (typeof thread.startTyping !== 'function') {
367+
return;
368+
}
369+
370+
await thread.startTyping(status).catch(toDeliveryError);
371+
}
372+
351373
async sendDirectMessage(
352374
agentId: string,
353375
integrationIdentifier: string,
@@ -1311,13 +1333,21 @@ export class ChatSdkService implements OnModuleDestroy {
13111333
warn: (msg: string, ctx?: Record<string, unknown>) => {
13121334
this.logger.warn(ctx ?? {}, msg);
13131335
if (ctx?.err) {
1314-
captureAgentWarning(ctx.err, { component: 'chat-sdk', operation: 'chat-state-warn', extra: { message: msg } });
1336+
captureAgentWarning(ctx.err, {
1337+
component: 'chat-sdk',
1338+
operation: 'chat-state-warn',
1339+
extra: { message: msg },
1340+
});
13151341
}
13161342
},
13171343
error: (msg: string, ctx?: Record<string, unknown>) => {
13181344
this.logger.error(ctx ?? {}, msg);
13191345
if (ctx?.err) {
1320-
captureAgentException(ctx.err, { component: 'chat-sdk', operation: 'chat-state-error', extra: { message: msg } });
1346+
captureAgentException(ctx.err, {
1347+
component: 'chat-sdk',
1348+
operation: 'chat-state-error',
1349+
extra: { message: msg },
1350+
});
13211351
}
13221352
},
13231353
};

apps/api/src/app/agents/usecases/managed-agent-setup/complete-managed-agent-setup.usecase.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
SubscriberRepository,
1414
} from '@novu/dal';
1515

16+
import { PLATFORMS_WITH_TYPING_INDICATOR } from '../../dtos/agent-platform.enum';
1617
import { AgentConfigResolver, type ResolvedAgentConfig } from '../../services/agent-config-resolver.service';
18+
import { ChatSdkService } from '../../services/chat-sdk.service';
1719
import { ManagedAgentService } from '../../services/managed-agent.service';
1820
import { GenerateMcpOAuthUrl } from '../generate-mcp-oauth-url/generate-mcp-oauth-url.usecase';
1921
import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command';
@@ -40,6 +42,7 @@ export class CompleteManagedAgentSetup {
4042
private readonly managedAgentService: ManagedAgentService,
4143
private readonly generateMcpOAuthUrl: GenerateMcpOAuthUrl,
4244
private readonly handleAgentReply: HandleAgentReply,
45+
private readonly chatSdkService: ChatSdkService,
4346
private readonly logger: PinoLogger
4447
) {
4548
this.logger.setContext(this.constructor.name);
@@ -267,10 +270,15 @@ export class CompleteManagedAgentSetup {
267270
}): Promise<void> {
268271
const { conversation, pending, agent, config, subscriber, mcps } = params;
269272

273+
const platformThreadId = conversation.channels?.[0]?.platformThreadId;
274+
const willShowTypingBeforeReplay =
275+
config.acknowledgeOnReceived && PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform) && !!platformThreadId;
276+
270277
if (pending.setupMessageId) {
271278
const resolvedCard = await buildSetupCardForMcps({
272279
mcps,
273280
resolved: true,
281+
showProcessingHint: !willShowTypingBeforeReplay,
274282
environmentId: config.environmentId,
275283
organizationId: config.organizationId,
276284
agentIdentifier: config.agentIdentifier,
@@ -311,6 +319,8 @@ export class CompleteManagedAgentSetup {
311319

312320
delete conversation.pendingManagedAgentSetup;
313321

322+
await this.showTypingBeforeSetupReplay(conversation, agent, config);
323+
314324
await this.managedAgentService.replayParkedInboundTurn({
315325
conversation,
316326
config,
@@ -319,4 +329,25 @@ export class CompleteManagedAgentSetup {
319329
agent,
320330
});
321331
}
332+
333+
private async showTypingBeforeSetupReplay(
334+
conversation: ConversationEntity,
335+
agent: Pick<AgentEntity, '_id'>,
336+
config: ResolvedAgentConfig
337+
): Promise<void> {
338+
const platformThreadId = conversation.channels?.[0]?.platformThreadId;
339+
340+
if (!config.acknowledgeOnReceived || !PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform) || !platformThreadId) {
341+
return;
342+
}
343+
344+
try {
345+
await this.chatSdkService.startTypingInConversation(agent._id, config.integrationIdentifier, platformThreadId);
346+
} catch (err) {
347+
this.logger.warn(
348+
err,
349+
`Failed to show typing before managed-agent setup replay for conversation ${conversation._id}`
350+
);
351+
}
352+
}
322353
}

apps/api/src/app/agents/usecases/managed-agent-setup/handle-managed-agent-setup-inbound.usecase.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { listOAuthMcps } from './list-oauth-mcps.helper';
1616
import { ManagedAgentSetupInboundCommand } from './managed-agent-setup-inbound.command';
1717
import { isOAuthMcpPending, type OAuthMcp } from './oauth-mcp.types';
1818
import { buildSetupCardForMcps } from './setup-card.builder';
19+
import { SETUP_GATE_NUDGE_MARKDOWN } from './setup-card.helpers';
1920

2021
/**
2122
* Inbound gate for managed agents: park the user turn and post/edit a setup
@@ -126,6 +127,9 @@ export class HandleManagedAgentSetupInbound {
126127
};
127128

128129
if (pendingState.setupMessageId) {
130+
// If a setup card was already posted previously, edit the existing card
131+
// to update its contents (for example, to refresh the list of available MCPs
132+
// or to provide refreshed OAuth URLs).
129133
await this.handleAgentReply.execute(
130134
HandleAgentReplyCommand.create({
131135
...replyCommandBase,
@@ -136,6 +140,14 @@ export class HandleManagedAgentSetupInbound {
136140
})
137141
);
138142

143+
// Post a nudge message to the user to complete the setup.
144+
await this.handleAgentReply.execute(
145+
HandleAgentReplyCommand.create({
146+
...replyCommandBase,
147+
reply: { markdown: SETUP_GATE_NUDGE_MARKDOWN },
148+
})
149+
);
150+
139151
return;
140152
}
141153

@@ -186,6 +198,7 @@ export class HandleManagedAgentSetupInbound {
186198
const card = await buildSetupCardForMcps({
187199
mcps,
188200
resolved: true,
201+
showProcessingHint: false,
189202
environmentId: command.environmentId,
190203
organizationId: command.organizationId,
191204
agentIdentifier: command.agentIdentifier,

apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.builder.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildSetupCard, type SetupCardRow } from './setup-card.helpers';
99
export async function buildSetupCardForMcps(params: {
1010
mcps: OAuthMcp[];
1111
resolved?: boolean;
12+
showProcessingHint?: boolean;
1213
environmentId: string;
1314
organizationId: string;
1415
agentIdentifier: string;
@@ -54,5 +55,9 @@ export async function buildSetupCardForMcps(params: {
5455
}
5556
}
5657

57-
return buildSetupCard({ mcps: rows, resolved: params.resolved });
58+
return buildSetupCard({
59+
mcps: rows,
60+
resolved: params.resolved,
61+
showProcessingHint: params.showProcessingHint,
62+
});
5863
}

apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.helpers.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ export interface SetupCardRow extends OAuthMcp {
99
const SETUP_REQUIRED_TEXT =
1010
'Connect the tools below to continue. Your message will be handled automatically once setup is complete.';
1111

12-
const SETUP_COMPLETE_TEXT = 'All tools connected. Working on your message…';
12+
const SETUP_COMPLETE_TEXT_CELEBRATION = "You're all set!";
13+
14+
const SETUP_COMPLETE_TEXT_WITH_PROCESSING_HINT = 'All tools connected. Your message will run automatically.';
15+
16+
export const SETUP_GATE_NUDGE_MARKDOWN =
17+
'Please finish connecting your tools using the card above. Your latest message will run automatically once setup is complete.';
1318

1419
function isErrorStatus(status: OAuthMcp['status']): boolean {
1520
return (
@@ -55,14 +60,21 @@ function buildMcpRowBlocks(mcp: SetupCardRow): Record<string, unknown>[] {
5560
return buildPendingRowBlocks(mcp);
5661
}
5762

58-
export function buildSetupCard(params: { mcps: SetupCardRow[]; resolved?: boolean }): Record<string, unknown> {
63+
export function buildSetupCard(params: {
64+
mcps: SetupCardRow[];
65+
resolved?: boolean;
66+
showProcessingHint?: boolean;
67+
}): Record<string, unknown> {
5968
const title = params.resolved ? 'Setup complete' : 'Connect your tools';
6069

6170
if (params.resolved) {
71+
const showProcessingHint = params.showProcessingHint !== false;
72+
const body = showProcessingHint ? SETUP_COMPLETE_TEXT_WITH_PROCESSING_HINT : SETUP_COMPLETE_TEXT_CELEBRATION;
73+
6274
return {
6375
type: 'card',
6476
title,
65-
children: [{ type: 'text', content: SETUP_COMPLETE_TEXT }],
77+
children: [{ type: 'text', content: body }],
6678
};
6779
}
6880

0 commit comments

Comments
 (0)