diff --git a/apps/api/src/app/agents/agents-webhook.controller.ts b/apps/api/src/app/agents/agents-webhook.controller.ts index fec394854c3..7432b08af77 100644 --- a/apps/api/src/app/agents/agents-webhook.controller.ts +++ b/apps/api/src/app/agents/agents-webhook.controller.ts @@ -18,6 +18,7 @@ import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { AgentReplyPayloadDto } from './dtos/agent-reply-payload.dto'; +import { AgentInactiveException } from './exceptions/agent-inactive.exception'; import { AgentConversationEnabledGuard } from './guards/agent-conversation-enabled.guard'; import { ChatSdkService } from './services/chat-sdk.service'; import { HandleAgentReplyCommand, Signal } from './usecases/handle-agent-reply/handle-agent-reply.command'; @@ -82,6 +83,13 @@ export class AgentsWebhookController { try { await this.chatSdkService.handleWebhook(agentId, integrationIdentifier, req, res); } catch (err) { + if (err instanceof AgentInactiveException) { + // Return 200 to avoid retries by the delivery provider + res.status(HttpStatus.OK).json({}); + + return; + } + if (err instanceof HttpException) { res.status(err.getStatus()).json(err.getResponse()); } else { diff --git a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts index 69b13b036c8..f64bae5be8c 100644 --- a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts @@ -316,4 +316,34 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => { expect(resolveActivity).to.exist; }); }); + + describe('Inactive agent', () => { + it('should return 422 when agent is inactive', async () => { + const conversationId = await seedConversation(ctx); + const patchRes = await ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active: false }); + expect(patchRes.status).to.equal(200); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + reply: { text: 'This should fail' }, + }); + + expect(res.status).to.equal(422); + }); + + it('should return 422 for signal-only requests when agent is inactive', async () => { + const conversationId = await seedConversation(ctx); + const patchRes = await ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active: false }); + expect(patchRes.status).to.equal(200); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + signals: [{ type: 'metadata', key: 'blocked', value: true }], + }); + + expect(res.status).to.equal(422); + }); + }); }); diff --git a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts index f374ae80eea..d1d22312b7a 100644 --- a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts @@ -19,7 +19,7 @@ import { seedChannelEndpoint, setupAgentTestContext, } from './helpers/agent-test-setup'; -import { buildSlackChallenge, signSlackRequest } from './helpers/providers/slack'; +import { buildSlackAppMention, buildSlackChallenge, signSlackRequest } from './helpers/providers/slack'; function mockEmoji(name: string): EmojiValue { return { name, toJSON: () => `{{emoji:${name}}}`, toString: () => `{{emoji:${name}}}` }; @@ -270,6 +270,47 @@ describe('Agent Webhook - inbound flow #novu-v2', () => { }); }); + describe('Inactive agent', () => { + it('should return 200 and not process inbound when agent is inactive', async () => { + await ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active: false }); + + const body = JSON.stringify( + buildSlackAppMention({ userId: 'U_INACTIVE', channel: 'C_TEST', threadTs: `T_INACTIVE_${Date.now()}` }) + ); + const timestamp = Math.floor(Date.now() / 1000); + const headers = signSlackRequest(ctx.signingSecret, timestamp, body); + + const res = await ctx.session.testAgent + .post(`/v1/agents/${ctx.agentId}/webhook/${ctx.integrationIdentifier}`) + .set(headers) + .set('content-type', 'application/json') + .send(body); + + expect(res.status).to.equal(200); + expect(bridgeCalls.length).to.equal(0); + }); + + it('should process inbound again after reactivation', async () => { + await ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active: false }); + await ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active: true }); + + const body = JSON.stringify( + buildSlackAppMention({ userId: 'U_REACTIVATED', channel: 'C_TEST', threadTs: `T_REACTIVATE_${Date.now()}` }) + ); + const timestamp = Math.floor(Date.now() / 1000); + const headers = signSlackRequest(ctx.signingSecret, timestamp, body); + + const res = await ctx.session.testAgent + .post(`/v1/agents/${ctx.agentId}/webhook/${ctx.integrationIdentifier}`) + .set(headers) + .set('content-type', 'application/json') + .send(body); + + expect(res.status).to.equal(200); + expect(bridgeCalls.length).to.equal(1); + }); + }); + describe('Conversation lifecycle', () => { it('should reopen resolved conversation on new inbound message', async () => { const threadId = `T_REOPEN_${Date.now()}`; diff --git a/apps/api/src/app/agents/exceptions/agent-inactive.exception.ts b/apps/api/src/app/agents/exceptions/agent-inactive.exception.ts new file mode 100644 index 00000000000..f0a1114073d --- /dev/null +++ b/apps/api/src/app/agents/exceptions/agent-inactive.exception.ts @@ -0,0 +1,7 @@ +import { UnprocessableEntityException } from '@nestjs/common'; + +export class AgentInactiveException extends UnprocessableEntityException { + constructor(agentId: string) { + super(`Agent ${agentId} is inactive`); + } +} diff --git a/apps/api/src/app/agents/services/agent-config-resolver.service.ts b/apps/api/src/app/agents/services/agent-config-resolver.service.ts index 8e9326cd999..a33a102f51b 100644 --- a/apps/api/src/app/agents/services/agent-config-resolver.service.ts +++ b/apps/api/src/app/agents/services/agent-config-resolver.service.ts @@ -9,6 +9,7 @@ import { } from '@novu/dal'; import { FeatureFlagsKeysEnum } from '@novu/shared'; import type { WellKnownEmoji } from 'chat'; +import { AgentInactiveException } from '../exceptions/agent-inactive.exception'; import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; import { esmImport } from '../utils/esm-import'; import { resolveAgentPlatform } from '../utils/provider-to-platform'; @@ -77,6 +78,10 @@ export class AgentConfigResolver { throw new NotFoundException(`Agent ${agentId} not found`); } + if (agent.active === false) { + throw new AgentInactiveException(agentId); + } + const { _environmentId: environmentId, _organizationId: organizationId } = agent; const isEnabled = await this.featureFlagsService.getFlag({ diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts index f4273a999f4..c441b806935 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts @@ -56,7 +56,7 @@ export class HandleAgentReply { return this.deliverEdit(command, conversation, channel, command.edit, agentName); } - const needsConfig = !!(command.reply || command.resolve); + const needsConfig = !!(command.reply || command.resolve || command.signals?.length); const config = needsConfig ? await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier) : null; diff --git a/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx b/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx index a74191579b9..db9de14b255 100644 --- a/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx +++ b/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx @@ -15,6 +15,7 @@ import { Switch } from '@/components/primitives/switch'; import { Textarea } from '@/components/primitives/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; +import { ConfirmationModal } from '@/components/confirmation-modal'; import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; import { useHasPermission } from '@/hooks/use-has-permission'; import { cn } from '@/utils/ui'; @@ -131,6 +132,8 @@ export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) { const has = useHasPermission(); const canWrite = has({ permission: PermissionsEnum.AGENT_WRITE }); + const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false); + const [isEditingName, setIsEditingName] = useState(false); const [name, setName] = useState(agent.name); const nameBeforeEditRef = useRef(agent.name); @@ -266,7 +269,11 @@ export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) { checked={agent.active} disabled={!canWrite || isUpdatePending} onCheckedChange={(checked) => { - void updateAgentAsync({ active: checked }); + if (!checked) { + setIsDeactivateModalOpen(true); + } else { + void updateAgentAsync({ active: true }); + } }} /> @@ -418,6 +425,24 @@ export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) { Last updated {formatDistanceToNow(new Date(agent.updatedAt), { addSuffix: true })}
+ +