Skip to content

test(api-service): e2e the agent ↔ Slack outbound contract via emulate.dev fixes NV-7630#11091

Merged
scopsy merged 4 commits into
nextfrom
cursor/agent-slack-emulator-roundtrip-c5f8
May 12, 2026
Merged

test(api-service): e2e the agent ↔ Slack outbound contract via emulate.dev fixes NV-7630#11091
scopsy merged 4 commits into
nextfrom
cursor/agent-slack-emulator-roundtrip-c5f8

Conversation

@scopsy
Copy link
Copy Markdown
Contributor

@scopsy scopsy commented May 12, 2026

Why

Today's agent e2e tests at apps/api/src/app/agents/e2e/ cover inbound webhook parsing well, but every outbound Slack call (chat.postMessage, chat.update, reactions.add, threading, Block Kit serialization) is stubbed via sinon on ChatSdkService. The real @chat-adapter/slack adapter never makes an HTTP call in CI, so a contract drift in the adapter, @slack/web-api, or our Card → Block Kit serialization can land without a regression test catching it.

What

Stand up an in-process Slack Web API mock (emulate.dev/slack) and an in-process bridge SDK server, then assert against the messages the production code path actually delivered to Slack.

Five scenarios, all green locally:

  • A — outbound roundtrip: inbound app_mention → bridge onMessagectx.reply('pong') → assert message + thread_ts in the emulator and the matching ConversationActivity row.
  • A2 — top-level (non-threaded) replies surface in conversations.history.
  • B — Card + Block Kit fidelity: assert confirm / cancel button action_ids and styles survive Card → cardToBlockKit → wire serialization. emulate@0.5 drops blocks when storing, so we patch WebClient.prototype.apiCall to record every Slack request and assert the wire payload directly.
  • C — reactions.add on resolve: ctx.resolve fires reactionOnResolve against the conversation's firstPlatformMessageId, which lives only in the webhook payload (never chat.postMessage'd into the emulator), so reactions.add resolves to message_not_found server-side. We assert via the recorded WebClient call instead and cross-check the conversation moved to RESOLVED in Mongo.
  • D — edit via /reply: full roundtrip exercising chat.update via POST /v1/agents/:id/reply with edit: { messageId, content }. Asserts the emulator-stored message text changed in place.

How it wires together

sequenceDiagram
    participant Test
    participant API as Novu API
    participant Bridge as In-process bridge<br/>(@novu/framework/express)
    participant Adapter as @chat-adapter/slack
    participant Emulator as emulate.dev/slack
    Test->>API: POST /v1/agents/:id/webhook (signed app_mention)
    API->>Adapter: handleWebhook
    Adapter-->>API: 200 OK (fire-and-forget)
    Adapter->>Adapter: parseSlackMessage → mention handler
    API->>Bridge: POST /api/novu (HMAC, real HTTP)
    Bridge->>Bridge: onMessage → ctx.reply(...)
    Bridge->>API: POST /v1/agents/:id/reply (ApiKey)
    API->>Adapter: postToConversation / addReaction / editMessage
    Adapter->>Emulator: chat.postMessage / reactions.add / chat.update<br/>(patched WebClient)
    Test->>Emulator: conversations.replies / recorded calls → assert
Loading

Key implementation details

  • emulate@^0.5.0 as a dev dependency on apps/api only.
  • helpers/slack-emulator.ts boots the emulator on a free port (via get-port) and patches @slack/web-api's WebClient so every Slack call is rerouted via process.env.SLACK_API_URL. The plan's "env-var path works out of the box" claim assumed a newer @chat-adapter/slack that threads apiUrl through; the version we ship (4.25.0) calls new WebClient(botToken) with no options, so we patch the constructor (via Object.defineProperty because the export is a getter) AND the apiCall prototype method as a safety net. The same apiCall patch records every call into a per-test buffer (getRecordedCalls/clearRecordedCalls) so tests can assert against the wire payload — necessary because emulate@0.5 only persists a subset of fields per chat.postMessage (no blocks, etc.).
  • helpers/bridge-server.ts spins up a real @novu/framework/express serve() handler bound to 0.0.0.0:port (already in NOVU_SAFE_OUTBOUND_TEST_ALLOW_IPS). Test passes the agent handler in.
  • helpers/bridge-executor-test-stub.ts stubs BridgeExecutorService.execute with a real-HTTP implementation that exercises the production resolveBridgeUrl / buildPayload / signature path via reflection, but skips the redundant resolvePublicAddresses pre-flight (which doesn't honor NOVU_SAFE_OUTBOUND_TEST_ALLOW_IPS).
  • agent-slack-roundtrip.e2e.ts disables acknowledgeOnReceived on the test agent because the inbound handler awaits assistant.threads.setStatus (the typing indicator), which the emulator returns 404 for and @slack/web-api retries for ~30 minutes — long enough to deadlock the handler.

Production code

Zero changes. The test stub bypasses the redundant SSRF pre-flight only at test time; the actual safeOutboundJsonRequest call (which is the load-bearing SSRF check) still runs untouched.

Risks

  • @chat-adapter/slack's adapter still pulls in @slack/socket-mode transitively. Verified that the agent flow uses the HTTP Events API only (chat-sdk.service.ts:handleWebhook) and never opens a socket-mode connection.
  • emulate@0.5 is early; some Block Kit elements may serialize differently than Slack's real backend. We work around the missing blocks storage by asserting on recorded WebClient calls.

Testing

  • pnpm exec mocha … src/**/agent-slack-roundtrip.e2e.ts — 5 passing
  • ✅ All other agent e2e tests still pass (agents.e2e.ts, agent-webhook.e2e.ts, agent-reply.e2e.ts)
  • ⚠️ One pre-existing flake in agent-webhook.e2e.ts (should process inbound again after reactivation) reproduces on next without these changes — unrelated.
Open in Web Open in Cursor 

What changed

Added end-to-end test infrastructure in apps/api to validate outbound Slack Web API calls from agent bridge code against an in-process Slack emulator (emulate.dev/slack). The tests patch @slack/web-api so production code paths send real chat calls to the emulator, enabling verification of threading, message edits, reactions, and Card → Block Kit fidelity without stubbing the adapter layer.

Affected areas

api: New e2e test infra and tests under apps/api including a Slack emulator helper, an in-process bridge server, a BridgeExecutorTest stub that issues real HTTP bridge requests, and the agent-slack-roundtrip.e2e.ts test suite exercising inbound app_mention → bridge → reply flows and asserting emulator state and DB persistence.

Key technical decisions

  • Added emulate@^0.5.0 as a dev dependency to run an in-process Slack Web API mock.
  • Monkey-patch @slack/web-api's WebClient constructor and apiCall to route traffic via process.env.SLACK_API_URL and to record calls for assertions, handling clients created before/after patching.
  • Stub BridgeExecutorService.execute to perform real outbound HTTP bridge calls (exercising resolveBridgeUrl/buildPayload/signature) while skipping redundant SSRF pre-flight to simplify test flow.
  • Disable agent behavior.acknowledgeOnReceived in tests to avoid emulator 404/retry deadlocks.

Testing

New e2e tests (mocha) exercise multiple scenarios: threaded and non-threaded mentions, Card → Block Kit serialization checks, reaction-on-resolve behavior, and reply-edit flows; tests assert emulator-received API calls and ConversationActivity DB records. No production code changes; test-only helpers are included as dev/test code.

Review Change Stack

cursoragent and others added 2 commits May 12, 2026 10:47
…ev (NV-7630)

Add the test infrastructure to exercise the outbound Slack contract end-to-end
through @chat-adapter/slack and @slack/web-api against an in-process Slack
emulator (https://emulate.dev/slack), so contract drift gets caught by CI
instead of shipping silently.

Pieces:
- emulate@^0.5.0 dev dependency on apps/api
- helpers/slack-emulator.ts: starts an emulator on a free port and patches
  @slack/web-api WebClient (constructor wrap + prototype apiCall override)
  so every Slack call is rerouted via process.env.SLACK_API_URL — the only
  knob that works since @chat-adapter/slack@4.25.0 calls new WebClient(token)
  with no options
- helpers/bridge-server.ts: in-process @novu/framework/express bridge so the
  inbound webhook hits a real bridge HTTP roundtrip into a configurable
  onMessage handler
- helpers/bridge-executor-test-stub.ts: sinon stub that performs the real
  bridge call via safeOutboundJsonRequest (which honors
  NOVU_SAFE_OUTBOUND_TEST_ALLOW_IPS), bypassing only the redundant
  resolvePublicAddresses pre-flight
- agent-slack-roundtrip.e2e.ts: Scenario A — inbound app_mention triggers
  ctx.reply('pong'), assert message + thread_ts in the emulator and the
  matching ConversationActivity row

Co-authored-by: Dima Grossman <dima@grossman.io>
Wire the suite end-to-end and unblock the contract test:

- WebClient patch: `@slack/web-api` exports `WebClient` as a configurable
  getter, so plain assignment throws `Cannot set property … which has only
  a getter`. Switch to `Object.defineProperty` and add a prototype
  `apiCall` interceptor as a safety net for instances constructed before
  the patch lands.
- Don't reset the emulator between tests: `store.reset()` re-runs
  `seed()` which assigns fresh random user/channel IDs, invalidating the
  IDs the suite-level `before()` cached.
- Disable `acknowledgeOnReceived` on the test agent: the inbound handler
  awaits `thread.startTyping(...)` → `assistant.threads.setStatus`,
  which the emulator returns 404 for. `@slack/web-api` retries that for
  ~30 minutes, deadlocking the handler.
- Poll for the bridge stub to fire before draining; the chat SDK
  dispatches `processMessage` as a fire-and-forget task after returning
  200 to the webhook.

Co-authored-by: Dima Grossman <dima@grossman.io>
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 12, 2026

NV-7630

@netlify
Copy link
Copy Markdown

netlify Bot commented May 12, 2026

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

Name Link
🔨 Latest commit 3c8a123
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/6a033c21963d400008998afa

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

Adds in-process Slack emulator, an in-process Novu bridge server, a bridge-executor test stub that issues real HTTP POSTs, package dependency for the emulator, and a comprehensive e2e test suite validating Slack inbound events → bridge → emulator roundtrips and persistence.

Changes

Slack Agent E2E Testing Infrastructure

Layer / File(s) Summary
Package configuration and emulate dependency
apps/api/package.json
Configures portless.name and adds emulate ^0.5.0 to devDependencies.
Slack API emulator and WebClient redirection
apps/api/src/app/agents/e2e/helpers/slack-emulator.ts
In-process Slack emulator lifecycle (start/stop/reset), seeding workspace data, monkey-patches @slack/web-api WebClient to route requests to the emulator, records calls, and exports typed HTTP helpers for conversations.history, conversations.replies, and reactions.get.
In-process bridge server helper
apps/api/src/app/agents/e2e/helpers/bridge-server.ts
Exports startBridgeServer() to start an Express server on a free port, mount @novu/framework/express agent middleware at /api/novu with provided agentId, handlers, and a Client using secretKey, returning { url, port, close }.
Bridge executor stub for real HTTP testing
apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts
Exports stubBridgeExecutorWithRealHttp() which stubs BridgeExecutorService.execute to perform real outbound bridge HTTP POSTs using internal helpers (resolveBridgeUrl, buildPayload, getDecryptedSecretKey), records calls, and exposes drain() to await in-flight requests.
E2E test scaffolding and Slack roundtrip scenarios
apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts
Adds e2e suite wiring emulator/bridge/stub: helper functions (pollFor, findEmulatorChannel/user), suite lifecycle to start/stop emulator and resolve IDs, per-test bridge setup/teardown, and tests for threaded mention routing, non-threaded mention handling, Card serialization, reaction-on-resolved, and reply edit flow with persistence checks.

Sequence Diagram

sequenceDiagram
  participant TestSuite as Test Suite
  participant Emulator as Slack Emulator
  participant BridgeServer as Bridge Server
  participant ExecutorStub as BridgeExecutor Stub
  participant DB as Database

  TestSuite->>Emulator: startSlackEmulator(), seed workspace
  TestSuite->>BridgeServer: startBridgeServer(agent handlers, secretKey)
  TestSuite->>ExecutorStub: stubBridgeExecutorWithRealHttp()
  TestSuite->>BridgeServer: POST signed app_mention webhook
  BridgeServer->>ExecutorStub: BridgeExecutorService.execute invoked
  ExecutorStub->>BridgeServer: HTTP POST to bridge URL (real outbound)
  BridgeServer->>Emulator: agent handler posts message via Slack API
  Emulator->>Emulator: store message/thread
  Emulator->>TestSuite: recorded calls / API responses
  TestSuite->>DB: assert conversation and activity persisted
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • novuhq/novu#11026: Both PRs add portless configuration with name "api.novu" to apps/api/package.json.
  • novuhq/novu#11003: Agent handler signature and handler-shape changes overlap with the e2e helpers and tests added in this PR.

Suggested labels

novu/api-service

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 'test', valid scope 'api-service', and includes Linear ticket reference 'NV-7630' at the end as required.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts`:
- Around line 141-152: The before() hook sets
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED without restoring it; update the
test suite to capture the original value (e.g., const prev =
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED) before setting it in before(), and
restore that original value in after() (alongside calling stopSlackEmulator()).
Modify the existing before() and after() that call startSlackEmulator() and
stopSlackEmulator() to save and restore the env var so other tests do not
inherit the mutated flag.
- Around line 286-294: The test currently passes threadTs: ts to
buildSlackAppMention which forces a thread_ts in the fixture and prevents
exercising the non-threaded code path; remove the threadTs: ts argument from the
call in agent-slack-roundtrip.e2e.ts and update buildSlackAppMention to accept
threadTs as optional (make its parameter optional and only set the thread_ts
field on the returned payload when threadTs is provided) so the fixture can be
created without thread_ts and the adapter fallback logic (deriving thread_ts
from ts) is tested.

In `@apps/api/src/app/agents/e2e/helpers/bridge-server.ts`:
- Around line 28-68: The helper currently uses getPort() then app.listen(port,
...) which has a race; change startBridgeServer to bind to port 0 directly
(remove getPort usage) and after listen resolve the server and read the actual
assigned port from server.address() (cast to AddressInfo) and use that value for
the returned port and url; keep using app.listen('0.0.0.0' or appropriate host)
but ensure url uses the resolved port, and ensure the returned close() uses the
same server.close promise to shut down the server.
🪄 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: a5a2836a-82ff-4cad-98e5-ded9403bd097

📥 Commits

Reviewing files that changed from the base of the PR and between e88dfab and 38878ba.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • apps/api/package.json
  • apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts
  • apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts
  • apps/api/src/app/agents/e2e/helpers/bridge-server.ts
  • apps/api/src/app/agents/e2e/helpers/slack-emulator.ts

Comment on lines +141 to +152
before(async () => {
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true';
const emulator = await startSlackEmulator();
emulatorUrl = emulator.url;

channel = await findEmulatorChannel(emulatorUrl, 'incidents');
user = await findEmulatorUser(emulatorUrl, 'e2e@novu.test');
});

after(async () => {
await stopSlackEmulator();
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore IS_CONVERSATIONAL_AGENTS_ENABLED after the suite.

This suite mutates a process-global flag in before() and never puts it back. Because Mocha runs other e2e files in the same process, later suites can accidentally inherit conversational agents being forced on.

♻️ Proposed fix
 describe('Agent Slack Roundtrip - emulate.dev `#novu-v2`', () => {
+  const previousConversationalAgentsEnabled = process.env.IS_CONVERSATIONAL_AGENTS_ENABLED;
   let ctx: AgentTestContext;
@@
   after(async () => {
+    if (previousConversationalAgentsEnabled === undefined) {
+      delete process.env.IS_CONVERSATIONAL_AGENTS_ENABLED;
+    } else {
+      process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = previousConversationalAgentsEnabled;
+    }
     await stopSlackEmulator();
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
before(async () => {
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true';
const emulator = await startSlackEmulator();
emulatorUrl = emulator.url;
channel = await findEmulatorChannel(emulatorUrl, 'incidents');
user = await findEmulatorUser(emulatorUrl, 'e2e@novu.test');
});
after(async () => {
await stopSlackEmulator();
});
describe('Agent Slack Roundtrip - emulate.dev `#novu-v2`', () => {
const previousConversationalAgentsEnabled = process.env.IS_CONVERSATIONAL_AGENTS_ENABLED;
let ctx: AgentTestContext;
before(async () => {
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true';
const emulator = await startSlackEmulator();
emulatorUrl = emulator.url;
channel = await findEmulatorChannel(emulatorUrl, 'incidents');
user = await findEmulatorUser(emulatorUrl, 'e2e@novu.test');
});
after(async () => {
if (previousConversationalAgentsEnabled === undefined) {
delete process.env.IS_CONVERSATIONAL_AGENTS_ENABLED;
} else {
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = previousConversationalAgentsEnabled;
}
await stopSlackEmulator();
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts` around lines 141 -
152, The before() hook sets process.env.IS_CONVERSATIONAL_AGENTS_ENABLED without
restoring it; update the test suite to capture the original value (e.g., const
prev = process.env.IS_CONVERSATIONAL_AGENTS_ENABLED) before setting it in
before(), and restore that original value in after() (alongside calling
stopSlackEmulator()). Modify the existing before() and after() that call
startSlackEmulator() and stopSlackEmulator() to save and restore the env var so
other tests do not inherit the mutated flag.

Comment on lines +286 to +294
const ts = `${Math.floor(Date.now() / 1000)}.000200`;
const body = JSON.stringify(
buildSlackAppMention({
userId: user.id,
channel: channel.id,
threadTs: ts,
text: '<@UBOT> hello',
eventTs: ts,
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i 'slack.ts' apps/api/src/app/agents/e2e/helpers/providers -x sed -n '1,220p' {}
rg -n -C3 'buildSlackAppMention|threadTs|thread_ts|eventTs' apps/api/src/app/agents/e2e/helpers/providers

Repository: novuhq/novu

Length of output: 4919


🏁 Script executed:

# First, let's look at the test file around lines 286-294
fd -i 'agent-slack-roundtrip.e2e.ts' apps/api/src -x cat -n {} | sed -n '270,310p'

Repository: novuhq/novu

Length of output: 1921


🏁 Script executed:

# Check the full signature and other test calls to buildSlackAppMention
rg -n 'buildSlackAppMention' apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts -A 6

Repository: novuhq/novu

Length of output: 663


🏁 Script executed:

# Look at the context around line 217 to understand the first test
sed -n '200,230p' apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts

Repository: novuhq/novu

Length of output: 1152


🏁 Script executed:

# Check if buildSlackAppMention is called elsewhere to see if threadTs should be optional
rg -n 'buildSlackAppMention|buildSlackSubscribedMessage' apps/api/src/app/agents/e2e --context 2

Repository: novuhq/novu

Length of output: 3772


🏁 Script executed:

# Check if threadTs is ever omitted in calls to buildSlackAppMention or if it should be optional
rg -B2 -A5 'buildSlackAppMention\s*\(' apps/api/src/app/agents/e2e

Repository: novuhq/novu

Length of output: 3505


🏁 Script executed:

# Check what the actual behavior should be - look for any handling of undefined threadTs in Slack adapter code
rg -n 'thread_ts|threadTs' apps/api/src/app/agents --context 3 -t ts | head -100

Repository: novuhq/novu

Length of output: 9478


🏁 Script executed:

# Let's look more carefully at the adapter code to understand what behavior should be tested
rg -B5 -A10 'rawThreadTs.*thread_ts' apps/api/src/app/agents/services/agent-inbound-handler.service.ts

Repository: novuhq/novu

Length of output: 697


🏁 Script executed:

# Check the full buildSlackAppMention implementation to see if threadTs could be made optional
sed -n '14,38p' apps/api/src/app/agents/e2e/helpers/providers/slack.ts

Repository: novuhq/novu

Length of output: 631


Remove threadTs: ts from the test fixture to properly test the non-threaded scenario.

The test claims to verify behavior when an inbound Slack message has no thread_ts, but it currently passes threadTs: ts to buildSlackAppMention(), which sets thread_ts in the fixture. This defeats the test's purpose—it only exercises the existing-thread code path and never covers the adapter's fallback logic that derives thread_ts from the message ts when no thread exists.

To fix this, remove threadTs: ts from the fixture call. Note: This will also require making threadTs optional in buildSlackAppMention() (currently it's required) and conditionally setting thread_ts only when provided.

♻️ Proposed fix
       buildSlackAppMention({
         userId: user.id,
         channel: channel.id,
-        threadTs: ts,
         text: '<@UBOT> hello',
         eventTs: ts,
       })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts` around lines 286 -
294, The test currently passes threadTs: ts to buildSlackAppMention which forces
a thread_ts in the fixture and prevents exercising the non-threaded code path;
remove the threadTs: ts argument from the call in agent-slack-roundtrip.e2e.ts
and update buildSlackAppMention to accept threadTs as optional (make its
parameter optional and only set the thread_ts field on the returned payload when
threadTs is provided) so the fixture can be created without thread_ts and the
adapter fallback logic (deriving thread_ts from ts) is tested.

Comment on lines +28 to +68
import getPort from 'get-port';

type AgentHandlers = Parameters<typeof agent>[1];

export interface BridgeServerHandle {
url: string;
port: number;
close: () => Promise<void>;
}

export async function startBridgeServer(opts: {
agentId: string;
handlers: AgentHandlers;
secretKey: string;
}): Promise<BridgeServerHandle> {
const port = await getPort();
const app = express();

app.use(express.json({ limit: '10mb' }));

const novuAgent = agent(opts.agentId, opts.handlers);

app.use(
'/api/novu',
serve({
agents: [novuAgent],
client: new Client({
secretKey: opts.secretKey,
strictAuthentication: false,
}),
})
);

const server: http.Server = await new Promise((resolve, reject) => {
const s = app.listen(port, '0.0.0.0', () => resolve(s));
s.once('error', reject);
});

return {
port,
url: `http://0.0.0.0:${port}/api/novu`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bind to port 0 instead of preselecting one.

getPort() followed by app.listen(port, ...) still has a race window. Under parallel e2e shards another process can grab that port before this server binds, which turns this helper into an intermittent startup flake.

♻️ Proposed fix
 import http from 'node:http';
 import { agent, Client, serve } from '@novu/framework/express';
 import express from 'express';
-import getPort from 'get-port';
@@
 }): Promise<BridgeServerHandle> {
-  const port = await getPort();
   const app = express();
@@
   const server: http.Server = await new Promise((resolve, reject) => {
-    const s = app.listen(port, '0.0.0.0', () => resolve(s));
+    const s = app.listen(0, '0.0.0.0', () => resolve(s));
     s.once('error', reject);
   });
+  const address = server.address();
+  if (!address || typeof address === 'string') {
+    throw new Error('Bridge server did not expose a numeric port');
+  }
+  const port = address.port;
 
   return {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/app/agents/e2e/helpers/bridge-server.ts` around lines 28 - 68,
The helper currently uses getPort() then app.listen(port, ...) which has a race;
change startBridgeServer to bind to port 0 directly (remove getPort usage) and
after listen resolve the server and read the actual assigned port from
server.address() (cast to AddressInfo) and use that value for the returned port
and url; keep using app.listen('0.0.0.0' or appropriate host) but ensure url
uses the resolved port, and ensure the returned close() uses the same
server.close promise to shut down the server.

…NV-7630)

Three new outbound contract assertions building on the Scenario A scaffolding:

- **Card → Block Kit fidelity**: emulate@0.5's chat.postMessage drops the
  blocks field when storing messages, so we can't read blocks back via
  conversations.history. Patch WebClient.prototype.apiCall to record every
  Slack request and assert the wire payload directly — that's exactly what
  the production adapter sends to Slack. Verifies button action_ids and
  styles survive Card → cardToBlockKit serialization.

- **reactions.add on resolve**: ctx.resolve fires reactionOnResolve against
  the conversation's firstPlatformMessageId (the inbound user message),
  which lives only in the webhook payload — never chat.postMessage'd into
  the emulator — so reactions.add resolves to message_not_found
  server-side. We assert via the recorded WebClient call instead and
  cross-check the conversation status moved to 'resolved' in Mongo.

- **Edit via /reply edit path**: full roundtrip exercising chat.update via
  the public /v1/agents/:agentId/reply endpoint with edit: { messageId,
  content }. Asserts the emulator-stored message text changed in place.

clearRecordedCalls() runs in beforeEach so each test sees only its own
WebClient calls, and the recorded-calls store lives next to the emulator
helpers since both are part of the same test scaffolding.

Co-authored-by: Dima Grossman <dima@grossman.io>
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.

♻️ Duplicate comments (2)
apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts (2)

130-152: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore IS_CONVERSATIONAL_AGENTS_ENABLED after the suite.

This suite mutates a process-global flag in before() and never puts it back, so later e2e suites in the same Mocha process can inherit conversational agents being forced on.

♻️ Proposed fix
 describe('Agent Slack Roundtrip - emulate.dev `#novu-v2`', () => {
+  const previousConversationalAgentsEnabled = process.env.IS_CONVERSATIONAL_AGENTS_ENABLED;
   let ctx: AgentTestContext;
   let bridge: BridgeServerHandle | undefined;
   let bridgeStub: BridgeExecutorStubHandle;
   let emulatorUrl: string;
@@
   after(async () => {
+    if (previousConversationalAgentsEnabled === undefined) {
+      delete process.env.IS_CONVERSATIONAL_AGENTS_ENABLED;
+    } else {
+      process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = previousConversationalAgentsEnabled;
+    }
     await stopSlackEmulator();
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts` around lines 130 -
152, The test suite sets process.env.IS_CONVERSATIONAL_AGENTS_ENABLED in
before() but never restores it; update the suite to save the original value
(e.g., const prevIsConv = process.env.IS_CONVERSATIONAL_AGENTS_ENABLED) at the
start of before(), then in after() restore it (if prevIsConv is undefined delete
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED else set it back to prevIsConv) so
the global environment is not leaked between tests; modify the before() and
after() blocks around startSlackEmulator()/stopSlackEmulator() and reference the
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED symbol when saving/restoring.

281-299: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drop threadTs from the "non-threaded" fixture.

This test says it covers an inbound message with no thread_ts, but threadTs: ts makes the payload threaded up front, so the adapter's fallback path never gets exercised.

♻️ Proposed fix
     const body = JSON.stringify(
       buildSlackAppMention({
         userId: user.id,
         channel: channel.id,
-        threadTs: ts,
         text: '<@UBOT> hello',
         eventTs: ts,
       })
     );

You'll also need to make threadTs optional in apps/api/src/app/agents/e2e/helpers/providers/slack.ts and only include thread_ts when it is provided.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts` around lines 281 -
299, The test "serializes top-level (non-threaded) replies into channel history"
wrongly sets threadTs: ts so the non-threaded code path is never exercised;
remove the threadTs property from the fixture in agent-slack-roundtrip.e2e.ts
(i.e., stop passing threadTs when building the inbound payload) and update the
helper buildSlackAppMention / provider code in
apps/api/src/app/agents/e2e/helpers/providers/slack.ts to make the threadTs
parameter optional and only add the thread_ts field to the generated payload
when threadTs is defined, ensuring callers can omit it to simulate non-threaded
messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts`:
- Around line 130-152: The test suite sets
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED in before() but never restores it;
update the suite to save the original value (e.g., const prevIsConv =
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED) at the start of before(), then in
after() restore it (if prevIsConv is undefined delete
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED else set it back to prevIsConv) so
the global environment is not leaked between tests; modify the before() and
after() blocks around startSlackEmulator()/stopSlackEmulator() and reference the
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED symbol when saving/restoring.
- Around line 281-299: The test "serializes top-level (non-threaded) replies
into channel history" wrongly sets threadTs: ts so the non-threaded code path is
never exercised; remove the threadTs property from the fixture in
agent-slack-roundtrip.e2e.ts (i.e., stop passing threadTs when building the
inbound payload) and update the helper buildSlackAppMention / provider code in
apps/api/src/app/agents/e2e/helpers/providers/slack.ts to make the threadTs
parameter optional and only add the thread_ts field to the generated payload
when threadTs is defined, ensuring callers can omit it to simulate non-threaded
messages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 34fdf6bb-ee7d-4009-af3d-c0bf70cd3e1e

📥 Commits

Reviewing files that changed from the base of the PR and between 38878ba and f7419ba.

📒 Files selected for processing (3)
  • apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts
  • apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts
  • apps/api/src/app/agents/e2e/helpers/slack-emulator.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/src/app/agents/e2e/helpers/slack-emulator.ts
  • apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts

@scopsy scopsy marked this pull request as ready for review May 12, 2026 13:54
@scopsy scopsy merged commit 73ba5fa into next May 12, 2026
32 checks passed
@scopsy scopsy deleted the cursor/agent-slack-emulator-roundtrip-c5f8 branch May 12, 2026 14:57
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.

2 participants