From 6263b8efbe17de7098667fac80d1341c6a357bf9 Mon Sep 17 00:00:00 2001
From: Adam Chmara
Date: Mon, 20 Apr 2026 17:17:13 +0200
Subject: [PATCH] feat(dashboard, api-service): enforce agent active toggle
fixes NV-7393
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- AgentConfigResolver throws AgentInactiveException when agent.active is false
- Webhook controller catches AgentInactiveException and returns 200 OK to prevent platform retries
- Reply endpoint returns 422 when agent is inactive
- AgentSidebarWidget shows ConfirmationModal when toggling active → inactive
- AgentsTable shows a dedicated Status column consistent with the workflow list
- E2E tests: inactive webhook returns 200 with no bridge call; inactive reply returns 422
Made-with: Cursor
---
.../app/agents/agents-webhook.controller.ts | 8 ++++
.../api/src/app/agents/e2e/agent-reply.e2e.ts | 30 +++++++++++++
.../src/app/agents/e2e/agent-webhook.e2e.ts | 43 ++++++++++++++++++-
.../exceptions/agent-inactive.exception.ts | 7 +++
.../services/agent-config-resolver.service.ts | 5 +++
.../handle-agent-reply.usecase.ts | 2 +-
.../agents/agent-sidebar-widget.tsx | 27 +++++++++++-
.../src/components/agents/agents-table.tsx | 22 +++++++++-
8 files changed, 139 insertions(+), 5 deletions(-)
create mode 100644 apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
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 })}
+
+ {
+ void updateAgentAsync({ active: false }).finally(() => setIsDeactivateModalOpen(false));
+ }}
+ title="Deactivate agent?"
+ description={
+ <>
+ Deactivating {agent.name} will immediately stop it from processing
+ new inbound messages. The agent can be reactivated at any time.
+ >
+ }
+ confirmButtonText="Deactivate"
+ isLoading={isUpdatePending}
+ confirmButtonVariant="error"
+ />
);
}
diff --git a/apps/dashboard/src/components/agents/agents-table.tsx b/apps/dashboard/src/components/agents/agents-table.tsx
index f37b55b455a..6f538ef75eb 100644
--- a/apps/dashboard/src/components/agents/agents-table.tsx
+++ b/apps/dashboard/src/components/agents/agents-table.tsx
@@ -1,10 +1,11 @@
import { providers as novuProviders, PermissionsEnum } from '@novu/shared';
import { ComponentProps } from 'react';
-import { RiMore2Fill, RiRobot2Line } from 'react-icons/ri';
+import { RiCheckboxCircleFill, RiForbidFill, RiMore2Fill, RiRobot2Line } from 'react-icons/ri';
import { Link, useLocation } from 'react-router-dom';
import type { AgentResponse } from '@/api/agents';
import { ProviderIcon } from '@/components/integrations/components/provider-icon';
import { CompactButton } from '@/components/primitives/button-compact';
+import { StatusBadge, StatusBadgeIcon } from '@/components/primitives/status-badge';
import {
DropdownMenu,
DropdownMenuContent,
@@ -131,6 +132,9 @@ function AgentsTableSkeletonRow() {
+
+
+
@@ -161,6 +165,7 @@ export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProp
Agent
+ Status
Integrations
Last updated
@@ -194,6 +199,19 @@ export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProp
+
+ {agent.active ? (
+
+
+ Active
+
+ ) : (
+
+
+ Inactive
+
+ )}
+
@@ -230,7 +248,7 @@ export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProp
{!isLoading && agents.length > 0 ? (
-
+