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 ? ( - +