Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/api/src/app/agents/agents-webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/app/agents/e2e/agent-reply.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
});
43 changes: 42 additions & 1 deletion apps/api/src/app/agents/e2e/agent-webhook.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}` };
Expand Down Expand Up @@ -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()}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { UnprocessableEntityException } from '@nestjs/common';

export class AgentInactiveException extends UnprocessableEntityException {
constructor(agentId: string) {
super(`Agent ${agentId} is inactive`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion apps/dashboard/src/components/agents/agent-sidebar-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
}}
/>
</SidebarRow>
Expand Down Expand Up @@ -418,6 +425,24 @@ export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) {
<span className="text-text-soft">Last updated </span>
<span className="text-text-sub">{formatDistanceToNow(new Date(agent.updatedAt), { addSuffix: true })}</span>
</p>

<ConfirmationModal
open={isDeactivateModalOpen}
onOpenChange={setIsDeactivateModalOpen}
onConfirm={() => {
void updateAgentAsync({ active: false }).finally(() => setIsDeactivateModalOpen(false));
}}
title="Deactivate agent?"
description={
<>
Deactivating <span className="font-semibold">{agent.name}</span> will immediately stop it from processing
new inbound messages. The agent can be reactivated at any time.
</>
}
confirmButtonText="Deactivate"
isLoading={isUpdatePending}
confirmButtonVariant="error"
/>
</div>
);
}
22 changes: 20 additions & 2 deletions apps/dashboard/src/components/agents/agents-table.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -131,6 +132,9 @@ function AgentsTableSkeletonRow() {
</div>
</div>
</TableCell>
<TableCell className="p-3">
<Skeleton className="h-6 w-[9ch] rounded-md" />
</TableCell>
<TableCell className="p-3">
<div className="flex min-h-[41px] items-center">
<div className="flex items-center">
Expand Down Expand Up @@ -161,6 +165,7 @@ export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProp
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 py-2.5">Agent</TableHead>
<TableHead className="h-11 px-3 py-2.5">Status</TableHead>
<TableHead className="h-11 px-3 py-2.5">Integrations</TableHead>
<TableHead className="h-11 px-3 py-2.5">Last updated</TableHead>
<TableHead className="h-11 w-[52px] px-3 py-2.5">
Expand Down Expand Up @@ -194,6 +199,19 @@ export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProp
</div>
</div>
</AgentNavTableCell>
<AgentNavTableCell to={agentDetailsPath} className="p-3 align-middle">
{agent.active ? (
<StatusBadge variant="light" status="completed">
<StatusBadgeIcon as={RiCheckboxCircleFill} />
Active
</StatusBadge>
) : (
<StatusBadge variant="light" status="disabled">
<StatusBadgeIcon as={RiForbidFill} />
Inactive
</StatusBadge>
)}
</AgentNavTableCell>
<AgentNavTableCell to={agentDetailsPath} className="p-3 align-middle">
<AgentIntegrationsCell agent={agent} />
</AgentNavTableCell>
Expand Down Expand Up @@ -230,7 +248,7 @@ export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProp
{!isLoading && agents.length > 0 ? (
<TableFooter>
<TableRow>
<TableCell colSpan={4} className="p-0">
<TableCell colSpan={5} className="p-0">
<TablePaginationFooter
pageSize={paginationProps.pageSize}
currentPageItemsCount={paginationProps.currentItemsCount}
Expand Down
Loading