Skip to content

feat(dashboard, api-service): enforce agent active toggle fixes NV-7393#10803

Merged
ChmaraX merged 1 commit intonextfrom
nv-7393-enforce-agent-active-toggle-gate-inbound-processing
Apr 21, 2026
Merged

feat(dashboard, api-service): enforce agent active toggle fixes NV-7393#10803
ChmaraX merged 1 commit intonextfrom
nv-7393-enforce-agent-active-toggle-gate-inbound-processing

Conversation

@ChmaraX
Copy link
Copy Markdown
Contributor

@ChmaraX ChmaraX commented Apr 20, 2026

Summary

  • Backend: AgentConfigResolver now throws AgentInactiveException when agent.active is false — inbound webhook returns 200 OK (prevents platform retry loops); reply endpoint returns 422
  • Dashboard: deactivation confirmation modal when toggling active → inactive (same pattern as pause-workflow); dedicated Status column in the agents list consistent with the workflow list
  • Tests: two new e2e cases — inactive webhook returns 200 with zero bridge calls; inactive reply returns 422

How it works

Inbound webhook (Slack/Discord/etc.)
  └─► AgentConfigResolver.resolve()
        └─► agent.active === false → AgentInactiveException
              └─► AgentsWebhookController.routeWebhook()
                    └─► catches AgentInactiveException → res 200 {} (no retry)

Bridge reply (POST /agents/:id/reply)
  └─► HandleAgentReply → AgentConfigResolver.resolve()
        └─► agent.active === false → AgentInactiveException → 422 to bridge

UI changes

Agent list — Status column (matches workflow list):

Agent Status Integrations Last updated
my-agent ✅ Active ... ...
old-agent ⛔ Inactive ... ...

Agent detail — deactivation modal on toggle:

"Deactivating my-agent will immediately stop it from processing new inbound messages. The agent can be reactivated at any time."

Test plan

  • Toggle active → inactive on an agent → confirmation modal appears
  • Confirm deactivation → badge in sidebar turns red "Inactive", Status column in list shows "Inactive"
  • Send a message to the webhook endpoint of an inactive agent → response is 200 OK, no bridge call fired
  • Reactivate agent → messages process normally
  • Run agent-webhook.e2e.ts and agent-reply.e2e.ts

Made with Cursor

What changed

This PR enforces the agent "active" toggle across inbound and reply flows so deactivated agents no longer process messages. The backend now prevents processing by throwing an AgentInactiveException when agent.active is false; inbound webhooks return 200 OK (to avoid platform retry loops) and bridge replies return 422. The dashboard adds a deactivation confirmation modal and a Status column/badge so users see and confirm inactive agents.

Affected areas

api

  • AgentConfigResolver now checks agent.active and throws AgentInactiveException to gate processing.
  • AgentsWebhookController catches AgentInactiveException and returns HTTP 200 with an empty body to avoid retry loops.
  • Reply handling (POST /v1/agents/:id/reply) returns 422 when the agent is inactive.
  • New AgentInactiveException class (extends UnprocessableEntityException) communicates the inactive state.

dashboard

  • Agent sidebar adds a confirmation modal when toggling Active → Inactive (matches pause-workflow UX).
  • Agents table gains a Status column and badges indicating Active/Inactive consistent with workflow listing.

Key technical decisions

  • AgentInactiveException (422) is used internally but is converted to HTTP 200 for inbound webhooks to prevent external retry loops while still blocking processing.
  • Bridge reply flows return 422 so bridges receive explicit error responses for inactive agents.
  • UI uses an explicit confirmation modal to reduce accidental deactivations and surface state changes immediately in lists and details.

Testing

Added e2e tests: inactive webhook returns 200 with zero bridge calls, and inactive reply returns 422; tests include reactivation to confirm normal processing resumes. Manual UI checks for modal, status badge, and list updates are included in the PR test plan.

@linear
Copy link
Copy Markdown

linear Bot commented Apr 20, 2026

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf1f12cf-34a6-42e0-bc87-285252bdd49a

📥 Commits

Reviewing files that changed from the base of the PR and between 273e01e and 6263b8e.

📒 Files selected for processing (8)
  • apps/api/src/app/agents/agents-webhook.controller.ts
  • apps/api/src/app/agents/e2e/agent-reply.e2e.ts
  • apps/api/src/app/agents/e2e/agent-webhook.e2e.ts
  • apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
  • apps/api/src/app/agents/services/agent-config-resolver.service.ts
  • apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts
  • apps/dashboard/src/components/agents/agent-sidebar-widget.tsx
  • apps/dashboard/src/components/agents/agents-table.tsx
✅ Files skipped from review due to trivial changes (2)
  • apps/api/src/app/agents/services/agent-config-resolver.service.ts
  • apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/api/src/app/agents/e2e/agent-reply.e2e.ts
  • apps/dashboard/src/components/agents/agents-table.tsx
  • apps/dashboard/src/components/agents/agent-sidebar-widget.tsx
  • apps/api/src/app/agents/e2e/agent-webhook.e2e.ts

📝 Walkthrough

Walkthrough

Adds agent-inactive gating: new AgentInactiveException, resolver throws when agent is inactive, webhook controller handles it with early HTTP 200 + {}, dashboard UI shows status and deactivation confirmation, and e2e tests cover inactive-agent reply and webhook behavior. (50 words)

Changes

Cohort / File(s) Summary
Exception Definition
apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
New AgentInactiveException class extending UnprocessableEntityException with agentId-based message.
Webhook Controller
apps/api/src/app/agents/agents-webhook.controller.ts
Import and handle AgentInactiveException in routeWebhook(...): return HTTP 200 with {} and short-circuit further error handling.
Agent Config Resolver
apps/api/src/app/agents/services/agent-config-resolver.service.ts
Resolver now throws AgentInactiveException when agent.active === false, preventing further config resolution.
Usecase Adjustment
apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts
Resolve agent config when command.signals is non-empty in addition to prior conditions.
E2E Tests
apps/api/src/app/agents/e2e/agent-reply.e2e.ts, apps/api/src/app/agents/e2e/agent-webhook.e2e.ts
Added tests verifying reply endpoint returns 422 for inactive agents and webhook returns 200 with no bridge calls; reactivation resumes processing.
Dashboard UI
apps/dashboard/src/components/agents/agent-sidebar-widget.tsx, apps/dashboard/src/components/agents/agents-table.tsx
Added deactivation confirmation modal on sidebar toggle and new Status column + badges in agents table (adjusted footer colspan).

Sequence Diagram(s)

sequenceDiagram
    participant Client as Webhook Client
    participant Controller as AgentsWebhookController
    participant Resolver as AgentConfigResolver
    participant Repo as AgentRepository

    Client->>Controller: POST /v1/agents/:id/webhook
    Controller->>Resolver: resolve(agentId)
    Resolver->>Repo: findByIdForWebhook(agentId)
    Repo-->>Resolver: agent { active: false }
    Resolver-->>Controller: throw AgentInactiveException(agentId)
    Controller-->>Client: HTTP 200 OK + {}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PR #10709: Adds and extends webhook/reply e2e tests that overlap with the inactive-agent test additions here.
  • PR #10802: Modifies the HandleAgentReply usecase; related to when/where config is resolved (touches same usecase).
  • PR #10726: Changes AgentConfigResolver/ResolvedAgentConfig shape and wiring, touching the same resolver this PR updates.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title follows Conventional Commits format with valid type and scopes, uses lowercase imperative description, and includes Linear ticket reference in the correct format.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
apps/api/src/app/agents/e2e/agent-webhook.e2e.ts (1)

293-301: Reactivation test bypasses the HTTP webhook path.

invokeInbound calls configResolver.resolve(...) + inboundHandler.handle(...) directly, so this test doesn't actually exercise the POST /v1/agents/:agentId/webhook/... controller after reactivation — which is the path the webhook behavior change applies to. The "inactive returns 200" test above does go through HTTP; consider mirroring that here (sign and POST a Slack app_mention after the reactivation PATCH) so the full controller + exception-handling path is validated end-to-end on reactivation as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/e2e/agent-webhook.e2e.ts` around lines 293 - 301, The
test currently calls invokeInbound directly (which resolves config and calls
inboundHandler.handle) so it bypasses the HTTP controller path; replace the
direct invokeInbound call in the 'should process inbound again after
reactivation' test with an actual signed POST to the webhook endpoint (POST
/v1/agents/:agentId/webhook/...) using the same signing helper used in the
"inactive returns 200" test and send a Slack app_mention payload (e.g.,
mockMessage with userId 'U_REACTIVATED'), so the request goes through the
controller, middleware, and exception handling just like real webhook traffic;
keep the existing reactivation PATCH calls and assert bridgeCalls.length === 1
after the POST.
apps/api/src/app/agents/e2e/agent-reply.e2e.ts (1)

320-333: Assert the PATCH succeeded before posting the reply.

If the PATCH /v1/agents/:agentIdentifier call silently fails (e.g., auth/route regression), the agent would remain active and postReply would return 200, making the test pass spuriously until the actual 422 assertion line runs. A small guard like expect(patchRes.status).to.equal(200) on the PATCH response would make the failure mode obvious.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/e2e/agent-reply.e2e.ts` around lines 320 - 333, The
test doesn't assert the PATCH succeeded before calling postReply; capture the
PATCH response (e.g., const patchRes = await
ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active:
false })) and assert its status is 200 (expect(patchRes.status).to.equal(200))
before calling postReply so the test fails clearly if the agent deactivation did
not actually occur; keep the existing postReply/assertion unchanged.
apps/dashboard/src/components/agents/agent-overview-tab.tsx (1)

22-32: Consider role="status" for the deactivation banner.

The banner communicates a non-transient operational state; adding role="status" (or role="alert" if you want it announced immediately on deactivation) would make the warning discoverable to screen readers rather than being just a visual affordance. Using RiErrorWarningLine with aria-hidden only leaves the message text as an unannounced span.

a11y tweak
-        <div className="border-b border-warning-base/20 bg-warning-lighter px-6 py-2.5">
+        <div role="status" className="border-b border-warning-base/20 bg-warning-lighter px-6 py-2.5">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/agents/agent-overview-tab.tsx` around lines 22
- 32, The deactivation banner in agent-overview-tab.tsx is only visual and not
announced to screen readers; add an appropriate ARIA role to the outer banner
div (the <div className="border-b border-warning-base/20 bg-warning-lighter px-6
py-2.5"> block) — use role="status" to make the non-transient state discoverable
(or role="alert" if you need immediate announcement on change) and keep the icon
aria-hidden so the <span> text is announced; ensure the role is applied to the
same element that contains the message text.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/api/src/app/agents/e2e/agent-reply.e2e.ts`:
- Around line 320-333: The test doesn't assert the PATCH succeeded before
calling postReply; capture the PATCH response (e.g., const patchRes = await
ctx.session.testAgent.patch(`/v1/agents/${ctx.agentIdentifier}`).send({ active:
false })) and assert its status is 200 (expect(patchRes.status).to.equal(200))
before calling postReply so the test fails clearly if the agent deactivation did
not actually occur; keep the existing postReply/assertion unchanged.

In `@apps/api/src/app/agents/e2e/agent-webhook.e2e.ts`:
- Around line 293-301: The test currently calls invokeInbound directly (which
resolves config and calls inboundHandler.handle) so it bypasses the HTTP
controller path; replace the direct invokeInbound call in the 'should process
inbound again after reactivation' test with an actual signed POST to the webhook
endpoint (POST /v1/agents/:agentId/webhook/...) using the same signing helper
used in the "inactive returns 200" test and send a Slack app_mention payload
(e.g., mockMessage with userId 'U_REACTIVATED'), so the request goes through the
controller, middleware, and exception handling just like real webhook traffic;
keep the existing reactivation PATCH calls and assert bridgeCalls.length === 1
after the POST.

In `@apps/dashboard/src/components/agents/agent-overview-tab.tsx`:
- Around line 22-32: The deactivation banner in agent-overview-tab.tsx is only
visual and not announced to screen readers; add an appropriate ARIA role to the
outer banner div (the <div className="border-b border-warning-base/20
bg-warning-lighter px-6 py-2.5"> block) — use role="status" to make the
non-transient state discoverable (or role="alert" if you need immediate
announcement on change) and keep the icon aria-hidden so the <span> text is
announced; ensure the role is applied to the same element that contains the
message text.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1fd339e5-2f87-41d2-8c02-f75ec5ebb5a9

📥 Commits

Reviewing files that changed from the base of the PR and between f7f7942 and 338cc71.

📒 Files selected for processing (8)
  • apps/api/src/app/agents/agents-webhook.controller.ts
  • apps/api/src/app/agents/e2e/agent-reply.e2e.ts
  • apps/api/src/app/agents/e2e/agent-webhook.e2e.ts
  • apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
  • apps/api/src/app/agents/services/agent-config-resolver.service.ts
  • apps/dashboard/src/components/agents/agent-overview-tab.tsx
  • apps/dashboard/src/components/agents/agent-sidebar-widget.tsx
  • apps/dashboard/src/components/agents/agents-table.tsx

Base automatically changed from refactor/agent-conversation-architecture to next April 20, 2026 15:42
@ChmaraX ChmaraX force-pushed the nv-7393-enforce-agent-active-toggle-gate-inbound-processing branch from 338cc71 to 273e01e Compare April 20, 2026 15:44
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 20, 2026

Deploy Preview for dashboard-v2-novu-staging canceled.

Name Link
🔨 Latest commit 6263b8e
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/69e64beb7506f400083505ac

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/api/src/app/agents/e2e/agent-webhook.e2e.ts (1)

293-300: Exercise the actual webhook route after reactivation.

This test bypasses the controller by calling invokeInbound(), so it won’t catch regressions in AgentsWebhookController.routeWebhook() or ChatSdkService.handleWebhook() after an inactive-agent short-circuit. Reuse the signed Slack POST path from the inactive test for the reactivation assertion.

🧪 Proposed test adjustment
     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 threadId = `T_REACTIVATE_${Date.now()}`;
-      await invokeInbound(threadId, mockMessage({ userId: 'U_REACTIVATED', text: 'Back online' }));
+      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);
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/e2e/agent-webhook.e2e.ts` around lines 293 - 300,
Replace the direct helper call invokeInbound(...) with an actual signed Slack
POST request that hits the controller route used in the inactive-agent test so
the request flows through AgentsWebhookController.routeWebhook and
ChatSdkService.handleWebhook; specifically, reuse the same signed Slack POST
helper/fixture used earlier in the inactive test to post the mock message
(userId 'U_REACTIVATED', text 'Back online') to the webhook endpoint after
reactivation, then assert bridgeCalls.length === 1 as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/app/agents/e2e/agent-reply.e2e.ts`:
- Around line 320-332: The test currently only exercises the "reply" path and
misses the signals-only branch that calls AgentConfigResolver.resolve()
conditionally; add a signals-only assertion so inactive agents cannot be
bypassed by signals: in apps/api/src/app/agents/e2e/agent-reply.e2e.ts (the
describe 'Inactive agent' block) add or modify an it() case that posts a payload
with reply null/omitted and a non-empty signals array via postReply (or the same
helper used) for the inactive ctx.agentIdentifier and assert the response is
422; this ensures the handle-agent-reply usecase (see
AgentConfigResolver.resolve and AgentInactiveException in
handle-agent-reply.usecase.ts) is exercised for signals-only requests.

In `@apps/api/src/app/agents/services/agent-config-resolver.service.ts`:
- Around line 81-83: The current check "if (!agent.active) { throw new
AgentInactiveException(agentId); }" treats undefined as inactive; change it to
explicitly check for a false boolean by replacing the condition with "if
(agent.active === false) ..." so only agents with active === false are rejected
(treat undefined/null as active), leaving the rest of the method (and the
AgentInactiveException(agentId) throw) unchanged.

---

Nitpick comments:
In `@apps/api/src/app/agents/e2e/agent-webhook.e2e.ts`:
- Around line 293-300: Replace the direct helper call invokeInbound(...) with an
actual signed Slack POST request that hits the controller route used in the
inactive-agent test so the request flows through
AgentsWebhookController.routeWebhook and ChatSdkService.handleWebhook;
specifically, reuse the same signed Slack POST helper/fixture used earlier in
the inactive test to post the mock message (userId 'U_REACTIVATED', text 'Back
online') to the webhook endpoint after reactivation, then assert
bridgeCalls.length === 1 as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6f8b3048-c395-473d-82b1-565b1a492af5

📥 Commits

Reviewing files that changed from the base of the PR and between 338cc71 and 273e01e.

📒 Files selected for processing (7)
  • apps/api/src/app/agents/agents-webhook.controller.ts
  • apps/api/src/app/agents/e2e/agent-reply.e2e.ts
  • apps/api/src/app/agents/e2e/agent-webhook.e2e.ts
  • apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
  • apps/api/src/app/agents/services/agent-config-resolver.service.ts
  • apps/dashboard/src/components/agents/agent-sidebar-widget.tsx
  • apps/dashboard/src/components/agents/agents-table.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/api/src/app/agents/exceptions/agent-inactive.exception.ts
  • apps/dashboard/src/components/agents/agents-table.tsx
  • apps/dashboard/src/components/agents/agent-sidebar-widget.tsx

Comment thread apps/api/src/app/agents/e2e/agent-reply.e2e.ts
Comment thread apps/api/src/app/agents/services/agent-config-resolver.service.ts Outdated
- 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
@ChmaraX ChmaraX force-pushed the nv-7393-enforce-agent-active-toggle-gate-inbound-processing branch from 273e01e to 6263b8e Compare April 20, 2026 15:53
@ChmaraX ChmaraX merged commit 217f262 into next Apr 21, 2026
34 checks passed
@ChmaraX ChmaraX deleted the nv-7393-enforce-agent-active-toggle-gate-inbound-processing branch April 21, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant