Skip to content

feat(api-service,framework): add rich content support and onAction handler for agent cards fixes NV-7363#10727

Merged
ChmaraX merged 7 commits intonextfrom
nv-7363-agent-framework-rich-content-support-markdown-cards-onaction
Apr 15, 2026
Merged

feat(api-service,framework): add rich content support and onAction handler for agent cards fixes NV-7363#10727
ChmaraX merged 7 commits intonextfrom
nv-7363-agent-framework-rich-content-support-markdown-cards-onaction

Conversation

@ChmaraX
Copy link
Copy Markdown
Contributor

@ChmaraX ChmaraX commented Apr 15, 2026

Summary

  • Rich content replies: Agents can now reply with interactive cards (Card, Button, Actions, Select, Divider, CardLink), markdown with tables/formatting, and plain text via ctx.reply()
  • onAction handler: Slack button clicks and select changes are routed through chat.onAction() → API inbound handler → bridge executor → framework onAction handler, with ctx.action.actionId and ctx.action.value populated
  • CJS compatibility: Bundle chat SDK into the framework CJS output (noExternal: ['chat'] in tsup) to fix ESM-only ERR_PACKAGE_PATH_NOT_EXPORTED error at runtime
  • Validation consolidation: ReplyContentDto with IsValidReplyContent custom validator now lives in the DTO layer, enforcing one-of text/markdown/card constraint and file source validation at the HTTP boundary

Data flow

sequenceDiagram
    participant Slack
    participant API as Novu API
    participant Bridge as Agent Handler
    
    Note over Slack,Bridge: Card Reply Flow
    Slack->>API: @mention "incident"
    API->>Bridge: bridge call (onMessage)
    Bridge->>API: POST /reply { card: CardElement }
    API->>Slack: Block Kit card with buttons
    
    Note over Slack,Bridge: onAction Flow
    Slack->>API: button click (interactivity)
    API->>API: chat.onAction → inbound handler
    API->>Bridge: bridge call (onAction, actionId, value)
    Bridge->>API: POST /reply (response card/text)
    API->>Slack: response message
Loading

Changed files

Area Files What
Framework SDK tsup.config.ts noExternal: ['chat'] bundles chat into CJS
Framework SDK agent.types.ts Remove unused metadata from AgentAction
Framework SDK index.ts, agent/index.ts Re-export Actions + ActionsElement from chat
Framework SDK agent.test.ts Fix CardText call signature (string, not array)
API agent-event.enum.ts Add ON_ACTION = 'onAction'
API agent-reply-payload.dto.ts Replace TextContentDto with ReplyContentDto supporting text/markdown/card/files
API handle-agent-reply.command.ts Import ReplyContentDto from DTO, remove re-export
API handle-agent-reply.usecase.ts Import ReplyContentDto directly from DTO
API bridge-executor.service.ts Add BridgeAction type and action field to bridge request
API agent-inbound-handler.service.ts Add handleAction() for routing action events
API chat-sdk.service.ts Register chat.onAction(), import DTO directly
API mock-agent-handler.ts Full showcase: incident card, onAction responses, markdown tables

Test plan

  • Send "card" in Slack → simple order card with buttons renders
  • Send "incident" in Slack → full incident card with text, dividers, select, buttons, link renders
  • Send "markdown" in Slack → markdown with table and blockquote renders
  • Click "Acknowledge" button → onAction fires, response card posted
  • Click "Escalate" button → onAction fires, markdown response posted
  • Use select dropdown → onAction fires with selected value
  • Plain text echo still works
  • Framework build passes (check:exports all green, no circular deps)
  • API E2E tests pass

Made with Cursor


Note

Medium Risk
Medium risk because it changes the agent bridge contract and reply payload validation, adds a new action event path, and extends persisted conversation activity schema with richContent, which could affect integrations and stored data shape.

Overview
Agents can now send rich replies and handle interactive actions. The agent reply/update payload is expanded from plain text to ReplyContentDto supporting exactly-one-of text/markdown/card plus optional markdown file refs (validated at the API boundary), and message delivery now posts cards/markdown appropriately.

Interactive card actions are now first-class. A new onAction event is added end-to-end: chat onAction events are routed through a new inbound handler (handleAction), included in bridge payloads (action: { actionId, value }), exposed on ctx.action, and dispatched by the framework handler when an agent registers onAction.

Conversation history storage is extended for rich messages and SDK packaging is adjusted. Conversation activities gain optional richContent to persist structured payloads alongside a text fallback, the framework re-exports card helpers and adds a ./jsx-runtime export, and the CJS build bundles chat (noExternal: ['chat']) to avoid runtime export/ESM issues.

Reviewed by Cursor Bugbot for commit 956705e. Configure here.

What changed

This PR adds rich content reply support and action handlers for agent cards. Agents can now respond to Slack interactions with interactive cards (Card, Button, Actions, Select, Divider, CardLink), markdown with tables and formatting, and plain text. A new onAction flow routes Slack button clicks and select changes through the API to the framework's onAction handler, with action context fields (actionId, value) available via ctx.action.

Affected areas

api: Added ReplyContentDto with IsValidReplyContent validator to enforce one-of constraints (text/markdown/card) and file validation at the HTTP boundary. Implemented handleAction method in AgentInboundHandler to route actions to the bridge executor. Extended BridgeAction type to carry action metadata and updated ChatSdkService to dispatch chat.onAction events. Added ON_ACTION to AgentEventEnum and persisted richContent in conversation activities.

framework: Extended AgentContext to expose action: AgentAction | null and changed reply/update methods to accept MessageContent (string, markdown, or card). Added onAction handler to AgentHandlers interface and event routing in NovuRequestHandler. Re-exported chat SDK card components (Card, Button, Actions, Select, Divider, CardLink) and element types. Added jsx-runtime as a subpath export.

dal: Added optional richContent: Record<string, unknown> field to ConversationActivityEntity and schema to store structured content (cards, markdown, files).

package.json: Added chat dependency (^4.25.0) and jsx-runtime export subpath. Updated tsup config to bundle chat SDK into framework CJS output (noExternal: ['chat']).

Key technical decisions

  • Validation consolidated in DTO layer (ReplyContentDto with IsValidReplyContent) to enforce constraints at the HTTP boundary rather than in the executor.
  • Chat SDK bundled into framework CJS output to address ESM-only runtime export errors in certain environments.
  • MessageContent type unions string (plain text), objects with markdown and optional files, and CardElement types to support multiple reply formats through a single interface.
  • Bridge protocol extended with BridgeAction type to propagate actionId and optional value from Slack interactions to framework handlers.

Testing

Unit tests added for serialization behavior in framework and re-exports validation. Mock agent handler extended with card, markdown, and onAction examples demonstrating action routing. Manual/interactive tests verify card replies, incident cards, markdown tables, and select values. Framework build checks pass; API E2E tests pending.

ChmaraX added 6 commits April 15, 2026 10:50
Add CardElement, markdown, and file attachment support to agent ctx.reply()
and ctx.update(). Re-export card builder components (Card, Button, CardText,
etc.) and JSX runtime from the chat SDK so customers get a single-import DX
from @novu/framework.

Made-with: Cursor
Add unit tests for markdown, card, and file attachment serialization
paths in toWireContent(). Add explicit error guard for invalid content.

Made-with: Cursor
Add tests confirming Card, Button, CardText, Divider, TextInput,
Select, SelectOption, and CardLink produce correct element shapes
and satisfy the MessageContent type.

Made-with: Cursor
Move content validation to DTO layer with class-validator decorators.
Update delivery path to route cards, markdown, and text to chat SDK.
Add richContent field to conversation activity for structured persistence.
Rename WireContent to ReplyContent across SDK and API.

Made-with: Cursor
Add AgentAction type, onAction handler to AgentHandlers, ON_ACTION to
AgentEventEnum, and action property to AgentContext. Route onAction
events in handler dispatch. Silently skips if no onAction registered.

Made-with: Cursor
…ndler for agent cards

Enable agents to reply with interactive cards (buttons, selects, dividers),
markdown, and plain text. Wire up Slack button/select clicks through
chat.onAction → API → bridge → framework onAction handler.

Key changes:
- Bundle chat SDK into framework CJS output (noExternal) to fix ESM-only issue
- Re-export Actions, Card, Button, CardText, Select, etc. from @novu/framework
- Update ReplyContentDto to accept text, markdown, card, and files
- Add BridgeAction type and onAction event routing through inbound handler
- Register chat.onAction() in ChatSdkService for Slack interactivity
- Add ON_ACTION to AgentEventEnum on both API and framework sides
- Consolidate ReplyContentDto in the DTO layer, remove command re-export

Made-with: Cursor
@linear
Copy link
Copy Markdown

linear bot commented Apr 15, 2026

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 15, 2026

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

Name Link
🔨 Latest commit 4f67043
🔍 Latest deploy log https://app.netlify.com/projects/dashboard-v2-novu-staging/deploys/69df7dee61517e00086360db

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

This PR adds support for agent actions and richer reply content structures. It introduces an ON_ACTION event to the agent event enumeration, defines a new ReplyContentDto structure supporting text, markdown, cards, and files with validation, implements action-handling services across the inbound bridge layer, and extends the framework to export UI components and jsx-runtime support.

Changes

Cohort / File(s) Summary
Agent Event Enumeration
apps/api/src/app/agents/dtos/agent-event.enum.ts
Added ON_ACTION enum member to AgentEventEnum.
Reply Content Structure
apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts
Introduced ReplyContentDto (supporting text, markdown, card, and files) and custom validator IsValidReplyContent that enforces mutual exclusivity of content types and file constraints. Replaced TextContentDto with ReplyContentDto in AgentReplyPayloadDto. Added FileRef interface for file references.
Action Handling Services
apps/api/src/app/agents/services/agent-inbound-handler.service.ts, apps/api/src/app/agents/services/bridge-executor.service.ts
Added BridgeAction interface and extended BridgeExecutorParams and AgentBridgeRequest to support action payload. Implemented handleAction() method in AgentInboundHandler to process incoming actions from the chat bridge.
Reply Delivery & Activity
apps/api/src/app/agents/usecases/handle-agent-reply/...*, apps/api/src/app/agents/services/chat-sdk.service.ts
Updated reply usecase and command to use ReplyContentDto with nested DTO validation. Extended ChatSdkService to accept rich content and dispatch onAction events to the inbound handler. Implemented textFallback extraction and richContent activity recording.
Data Persistence
libs/dal/src/repositories/conversation-activity/.../*
Added richContent optional field to ConversationActivityEntity, ConversationActivityRepository, and database schema to store structured content (markdown, cards, files).
E2E Test Handler
apps/api/src/app/agents/e2e/mock-agent-handler.ts
Extended mock agent handler with card, markdown, and incident reply examples. Added onAction event handler with conditional replies for action IDs (ack, assign, escalate). Implemented server-level and process-level error listeners.
Framework Type Definitions
packages/framework/src/resources/agent/agent.types.ts, packages/framework/src/resources/agent/agent.context.ts
Added AgentAction, FileRef, MessageContent, and ReplyContent types. Updated AgentContext to include action field and changed reply()/update() methods to accept MessageContent. Added optional onAction handler to AgentHandlers. Implemented serializeContent helper for content serialization.
Framework Exports & Configuration
packages/framework/src/.../*, packages/framework/package.json, packages/framework/tsup.config.ts, packages/framework/jsx-runtime/package.json
Exported UI components (Card, Actions, Button, etc.) and new types from framework. Added ./jsx-runtime subpath export to package.json. Created jsx-runtime module re-exporting JSX types and runtime from chat. Added chat dependency and jsxd-runtime entry to tsup build config.
Framework Handler & Tests
packages/framework/src/handler.ts, packages/framework/src/resources/agent/agent.test.ts
Updated NovuRequestHandler to recognize ON_ACTION event and invoke onAction handler when present. Added comprehensive test coverage for action handling, markdown and card serialization, and file attachment support.

Sequence Diagram

sequenceDiagram
    participant ChatUI as Chat UI
    participant AgentHandler as Agent Handler
    participant InboundService as Inbound Handler Service
    participant BridgeExecutor as Bridge Executor
    participant ChatSDK as Chat SDK Service
    participant Database as Database

    ChatUI->>AgentHandler: POST /bridge with ON_ACTION event + action payload
    AgentHandler->>AgentHandler: Recognize ON_ACTION event
    AgentHandler->>AgentHandler: Call ctx.onAction() handler
    AgentHandler->>AgentHandler: ctx.reply(ReplyContent) serializes content
    AgentHandler->>BridgeExecutor: buildPayload() with action + reply content
    BridgeExecutor->>ChatSDK: Forward action to onAction handler + post reply
    ChatSDK->>InboundService: handleAction(action, userId)
    InboundService->>Database: Create/update conversation, store activity
    ChatSDK->>ChatUI: POST reply message to conversation
    ChatUI->>ChatUI: Render text, markdown, card, or files
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • scopsy
🚥 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 The title follows Conventional Commits format with valid type (feat), valid scopes (api-service and framework, comma-separated), lowercase imperative description, and includes the Linear ticket reference (NV-7363) at the end.

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

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


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

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 956705e. Configure here.

await thread.post({ markdown: content.markdown });
} else {
await thread.post(content.text ?? '');
}
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.

File attachments silently dropped during chat delivery

High Severity

When markdown content includes file attachments, postToConversation only passes { markdown: content.markdown } to thread.post(), completely ignoring content.files. The entire stack — framework serializeContent, the IsValidReplyContent DTO validator, and the richContent DB storage — explicitly handles files alongside markdown, but the actual chat platform delivery silently drops them. Users sending file attachments will see them accepted and validated, but the files never reach the chat platform.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 956705e. Configure here.

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/api/src/app/agents/services/bridge-executor.service.ts (1)

211-238: ⚠️ Potential issue | 🟠 Major

Make deliveryId unique for action events.

When message is null, every click/select in the same conversation is serialized as ${conversation._id}:onAction. That collapses distinct user actions into one delivery identifier, which is risky for downstream correlation and any deduping keyed off deliveryId.

Proposed fix
   private buildPayload(params: BridgeExecutorParams): AgentBridgeRequest {
     const { event, config, conversation, subscriber, history, message, platformContext, action } = params;
     const agentIdentifier = config.agentIdentifier;

     const apiRootUrl = process.env.API_ROOT_URL || 'http://localhost:3000';
     const replyUrl = `${apiRootUrl}/v1/agents/${agentIdentifier}/reply`;
-
-    const deliveryId = message?.id
-      ? `${conversation._id}:${message.id}`
-      : `${conversation._id}:${event}`;
+    const timestamp = new Date().toISOString();
+    let deliveryId = `${conversation._id}:${event}`;
+
+    if (message?.id) {
+      deliveryId = `${conversation._id}:${message.id}`;
+    } else if (action) {
+      deliveryId = `${conversation._id}:${event}:${action.actionId}:${timestamp}`;
+    }

     return {
       version: 1,
-      timestamp: new Date().toISOString(),
+      timestamp,
       deliveryId,
       event,
       agentId: agentIdentifier,
       replyUrl,
       conversationId: conversation._id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/services/bridge-executor.service.ts` around lines 211
- 238, The deliveryId built in buildPayload collapses all action events when
message is null because it uses `${conversation._id}:${event}` (e.g., onAction);
change it to include a unique action identifier so each user action gets a
distinct deliveryId—update the deliveryId construction in buildPayload (used in
AgentBridgeRequest) to prefer message?.id, otherwise use
`${conversation._id}:${event}:${action?.id ?? action?.type ?? action?.timestamp
?? Date.now()}` (or another reliable unique field from the
BridgeExecutorParams.action) so downstream systems can correlate/dedupe
correctly.
apps/api/src/app/agents/services/chat-sdk.service.ts (1)

99-120: ⚠️ Potential issue | 🟠 Major

Pass files through when posting markdown content.

content.files is accepted by the API contract but silently dropped in the markdown branch. The chat SDK v4.25.0 Thread.post() method supports a PostableMarkdown payload shape that includes an optional files parameter. Additionally, the truthy check on content.markdown treats markdown: '' as absent, which diverges from the DTO validator's undefined-based presence check.

    if (content.card) {
      await thread.post(content.card);
    } else if (content.markdown !== undefined) {
      await thread.post({ markdown: content.markdown, files: content.files });
    } else {
      await thread.post(content.text ?? '');
    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/services/chat-sdk.service.ts` around lines 99 - 120,
In postToConversation, the markdown branch currently drops content.files and
treats empty-string markdown as absent; update the condition to check
content.markdown !== undefined and pass files through to thread.post so it uses
the PostableMarkdown shape (e.g., call thread.post({ markdown: content.markdown,
files: content.files })) while leaving the card branch
(thread.post(content.card)) and fallback text branch unchanged; touch the
postToConversation method and ensure ThreadImpl.fromJSON / thread.post usage is
preserved.
🧹 Nitpick comments (3)
libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts (1)

65-78: Prefer an interface for createAgentActivity params in backend code.

Since this signature is being updated, this is a good point to extract the inline object type into a named interface for consistency and reuse.

Refactor sketch
+interface CreateAgentActivityParams {
+  identifier: string;
+  conversationId: string;
+  platform: string;
+  integrationId: string;
+  platformThreadId: string;
+  agentId: string;
+  content: string;
+  richContent?: Record<string, unknown>;
+  type?: ConversationActivityTypeEnum;
+  senderName?: string;
+  environmentId: string;
+  organizationId: string;
+}
+
-  async createAgentActivity(params: {
-    ...
-  }): Promise<ConversationActivityEntity> {
+  async createAgentActivity(params: CreateAgentActivityParams): Promise<ConversationActivityEntity> {
As per coding guidelines: `**/*.{ts,tsx}`: On the backend: use `interface` for type definitions.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts`
around lines 65 - 78, Extract the inline parameter object type for the
createAgentActivity method into a named interface (e.g.,
CreateAgentActivityParams) and replace the inline type in the method signature
with that interface; update any internal references to the parameter properties
to use the same param name and export the interface if it may be reused
elsewhere (keep ConversationActivityEntity return type unchanged). Ensure the
new interface includes all fields currently declared inline: identifier,
conversationId, platform, integrationId, platformThreadId, agentId, content,
richContent?, type?, senderName?, environmentId, organizationId.
libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts (1)

57-59: Constrain richContent to object-like values to avoid schema/type drift.

Schema.Types.Mixed is broader than Record<string, unknown> and allows primitives/arrays. Adding a validator here will keep persisted data aligned with the entity contract.

Suggested schema hardening
     richContent: {
       type: Schema.Types.Mixed,
+      validate: {
+        validator: (value: unknown) =>
+          value === undefined || (typeof value === 'object' && value !== null && !Array.isArray(value)),
+        message: 'richContent must be an object',
+      },
     },
As per coding guidelines: `libs/dal/**: Review with focus on data integrity and query performance.`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts`
around lines 57 - 59, The richContent field currently uses Schema.Types.Mixed
which allows primitives/arrays; add a validator on the richContent definition in
conversation-activity.schema.ts (the richContent property) to only accept
object-like values (allow plain objects or null, reject primitives and arrays)
so persisted data matches the Record<string, unknown> contract; implement the
validator function to return true for null or (typeof value === 'object' &&
!Array.isArray(value)) and false otherwise, and include a clear validation
message so invalid saves fail fast.
apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts (1)

19-24: Consider adding validation decorators to FileRef or converting to a class DTO.

FileRef is defined as an interface, so when used in files?: FileRef[], the individual file objects won't be validated by class-validator. Malformed file references (e.g., missing filename, or having both data and url) would only be caught by the custom IsValidReplyContent validator, which doesn't validate filename presence.

♻️ Proposed refactor to add file validation
-export interface FileRef {
-  filename: string;
-  mimeType?: string;
-  data?: string;
-  url?: string;
-}
+export class FileRefDto {
+  `@ApiProperty`()
+  `@IsString`()
+  `@IsNotEmpty`()
+  filename: string;
+
+  `@ApiPropertyOptional`()
+  `@IsOptional`()
+  `@IsString`()
+  mimeType?: string;
+
+  `@ApiPropertyOptional`()
+  `@IsOptional`()
+  `@IsString`()
+  data?: string;
+
+  `@ApiPropertyOptional`()
+  `@IsOptional`()
+  `@IsString`()
+  url?: string;
+}

Then update ReplyContentDto:

   `@ApiPropertyOptional`()
   `@IsOptional`()
   `@IsArray`()
-  files?: FileRef[];
+  `@ValidateNested`({ each: true })
+  `@Type`(() => FileRefDto)
+  files?: FileRefDto[];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts` around lines 19 -
24, FileRef is an interface so class-validator won't validate entries in files?:
FileRef[]; convert FileRef into a class DTO (e.g., class FileRefDto) and add
decorators like `@IsString/`@IsNotEmpty for filename, `@IsOptional` + `@IsString` for
mimeType/url/data and a custom validator to ensure only one of data or url is
present; then update ReplyContentDto to use files?: FileRefDto[] so
Nest/class-validator will validate each file object during DTO validation
(adjust any usages of FileRef to the new FileRefDto).
🤖 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/mock-agent-handler.ts`:
- Around line 139-159: The ack card includes a Button({ id: 'resolve', ... })
but the handler never checks for actionId === 'resolve', so clicks fall to the
fallback; add an explicit branch checking actionId === 'resolve' (near the
existing checks for 'ack', 'assign', 'escalate') and call ctx.reply with an
appropriate response (e.g., send a Card or markdown confirming "Incident
Resolved" and who resolved it using ctx.subscriber?.firstName and timestamp),
ensuring the resolve action path mirrors how 'ack' and 'escalate' use
Card/markdown and Buttons.

---

Outside diff comments:
In `@apps/api/src/app/agents/services/bridge-executor.service.ts`:
- Around line 211-238: The deliveryId built in buildPayload collapses all action
events when message is null because it uses `${conversation._id}:${event}`
(e.g., onAction); change it to include a unique action identifier so each user
action gets a distinct deliveryId—update the deliveryId construction in
buildPayload (used in AgentBridgeRequest) to prefer message?.id, otherwise use
`${conversation._id}:${event}:${action?.id ?? action?.type ?? action?.timestamp
?? Date.now()}` (or another reliable unique field from the
BridgeExecutorParams.action) so downstream systems can correlate/dedupe
correctly.

In `@apps/api/src/app/agents/services/chat-sdk.service.ts`:
- Around line 99-120: In postToConversation, the markdown branch currently drops
content.files and treats empty-string markdown as absent; update the condition
to check content.markdown !== undefined and pass files through to thread.post so
it uses the PostableMarkdown shape (e.g., call thread.post({ markdown:
content.markdown, files: content.files })) while leaving the card branch
(thread.post(content.card)) and fallback text branch unchanged; touch the
postToConversation method and ensure ThreadImpl.fromJSON / thread.post usage is
preserved.

---

Nitpick comments:
In `@apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts`:
- Around line 19-24: FileRef is an interface so class-validator won't validate
entries in files?: FileRef[]; convert FileRef into a class DTO (e.g., class
FileRefDto) and add decorators like `@IsString/`@IsNotEmpty for filename,
`@IsOptional` + `@IsString` for mimeType/url/data and a custom validator to ensure
only one of data or url is present; then update ReplyContentDto to use files?:
FileRefDto[] so Nest/class-validator will validate each file object during DTO
validation (adjust any usages of FileRef to the new FileRefDto).

In
`@libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts`:
- Around line 65-78: Extract the inline parameter object type for the
createAgentActivity method into a named interface (e.g.,
CreateAgentActivityParams) and replace the inline type in the method signature
with that interface; update any internal references to the parameter properties
to use the same param name and export the interface if it may be reused
elsewhere (keep ConversationActivityEntity return type unchanged). Ensure the
new interface includes all fields currently declared inline: identifier,
conversationId, platform, integrationId, platformThreadId, agentId, content,
richContent?, type?, senderName?, environmentId, organizationId.

In
`@libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts`:
- Around line 57-59: The richContent field currently uses Schema.Types.Mixed
which allows primitives/arrays; add a validator on the richContent definition in
conversation-activity.schema.ts (the richContent property) to only accept
object-like values (allow plain objects or null, reject primitives and arrays)
so persisted data matches the Record<string, unknown> contract; implement the
validator function to return true for null or (typeof value === 'object' &&
!Array.isArray(value)) and false otherwise, and include a clear validation
message so invalid saves fail fast.
🪄 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: 830cde7e-45f5-4117-9dbf-5097c8f52a02

📥 Commits

Reviewing files that changed from the base of the PR and between 7598e89 and 956705e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (21)
  • apps/api/src/app/agents/dtos/agent-event.enum.ts
  • apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts
  • apps/api/src/app/agents/e2e/mock-agent-handler.ts
  • apps/api/src/app/agents/services/agent-inbound-handler.service.ts
  • apps/api/src/app/agents/services/bridge-executor.service.ts
  • apps/api/src/app/agents/services/chat-sdk.service.ts
  • apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts
  • apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts
  • libs/dal/src/repositories/conversation-activity/conversation-activity.entity.ts
  • libs/dal/src/repositories/conversation-activity/conversation-activity.repository.ts
  • libs/dal/src/repositories/conversation-activity/conversation-activity.schema.ts
  • packages/framework/jsx-runtime/package.json
  • packages/framework/package.json
  • packages/framework/src/handler.ts
  • packages/framework/src/index.ts
  • packages/framework/src/jsx-runtime.ts
  • packages/framework/src/resources/agent/agent.context.ts
  • packages/framework/src/resources/agent/agent.test.ts
  • packages/framework/src/resources/agent/agent.types.ts
  • packages/framework/src/resources/agent/index.ts
  • packages/framework/tsup.config.ts

Comment on lines +139 to +159
if (actionId === 'ack') {
await ctx.reply(
Card({
title: 'Incident Acknowledged',
children: [
CardText(
`Acknowledged by *${ctx.subscriber?.firstName ?? 'unknown'}* at ${new Date().toLocaleTimeString()}.`
),
Actions([Button({ id: 'resolve', label: 'Resolve Incident', style: 'primary' })]),
],
})
);
} else if (actionId === 'assign') {
await ctx.reply(`On-call assignment updated to *${value}*.`);
} else if (actionId === 'escalate') {
await ctx.reply({
markdown: `**Escalated** — paging the secondary on-call team.\n\n_Triggered by ${ctx.subscriber?.firstName ?? 'unknown'}_`,
});
} else {
await ctx.reply(`Got action: *${actionId}*${value ? ` = ${value}` : ''}`);
}
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 | 🟡 Minor

Handle the resolve action explicitly.

The ack card renders a resolve button, but onAction never matches it, so clicking Resolve Incident falls into the generic fallback instead of exercising the resolve path.

Proposed fix
     if (actionId === 'ack') {
       await ctx.reply(
         Card({
           title: 'Incident Acknowledged',
           children: [
             CardText(
               `Acknowledged by *${ctx.subscriber?.firstName ?? 'unknown'}* at ${new Date().toLocaleTimeString()}.`
             ),
             Actions([Button({ id: 'resolve', label: 'Resolve Incident', style: 'primary' })]),
           ],
         })
       );
+    } else if (actionId === 'resolve') {
+      ctx.resolve('Incident resolved via action');
+      await ctx.reply('Incident resolved.');
     } else if (actionId === 'assign') {
       await ctx.reply(`On-call assignment updated to *${value}*.`);
     } else if (actionId === 'escalate') {
       await ctx.reply({
         markdown: `**Escalated** — paging the secondary on-call team.\n\n_Triggered by ${ctx.subscriber?.firstName ?? 'unknown'}_`,
📝 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
if (actionId === 'ack') {
await ctx.reply(
Card({
title: 'Incident Acknowledged',
children: [
CardText(
`Acknowledged by *${ctx.subscriber?.firstName ?? 'unknown'}* at ${new Date().toLocaleTimeString()}.`
),
Actions([Button({ id: 'resolve', label: 'Resolve Incident', style: 'primary' })]),
],
})
);
} else if (actionId === 'assign') {
await ctx.reply(`On-call assignment updated to *${value}*.`);
} else if (actionId === 'escalate') {
await ctx.reply({
markdown: `**Escalated** — paging the secondary on-call team.\n\n_Triggered by ${ctx.subscriber?.firstName ?? 'unknown'}_`,
});
} else {
await ctx.reply(`Got action: *${actionId}*${value ? ` = ${value}` : ''}`);
}
if (actionId === 'ack') {
await ctx.reply(
Card({
title: 'Incident Acknowledged',
children: [
CardText(
`Acknowledged by *${ctx.subscriber?.firstName ?? 'unknown'}* at ${new Date().toLocaleTimeString()}.`
),
Actions([Button({ id: 'resolve', label: 'Resolve Incident', style: 'primary' })]),
],
})
);
} else if (actionId === 'resolve') {
ctx.resolve('Incident resolved via action');
await ctx.reply('Incident resolved.');
} else if (actionId === 'assign') {
await ctx.reply(`On-call assignment updated to *${value}*.`);
} else if (actionId === 'escalate') {
await ctx.reply({
markdown: `**Escalated** — paging the secondary on-call team.\n\n_Triggered by ${ctx.subscriber?.firstName ?? 'unknown'}_`,
});
} else {
await ctx.reply(`Got action: *${actionId}*${value ? ` = ${value}` : ''}`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/agents/e2e/mock-agent-handler.ts` around lines 139 - 159,
The ack card includes a Button({ id: 'resolve', ... }) but the handler never
checks for actionId === 'resolve', so clicks fall to the fallback; add an
explicit branch checking actionId === 'resolve' (near the existing checks for
'ack', 'assign', 'escalate') and call ctx.reply with an appropriate response
(e.g., send a Card or markdown confirming "Incident Resolved" and who resolved
it using ctx.subscriber?.firstName and timestamp), ensuring the resolve action
path mirrors how 'ack' and 'escalate' use Card/markdown and Buttons.

…nique action deliveryId

- Fix files silently dropped: use `content.markdown !== undefined` check
  and pass `{ markdown, files }` to thread.post() in postToConversation
- Fix collapsing deliveryId for action events: include actionId + timestamp
  to make each button click/select uniquely identifiable
- Add missing `resolve` action handler in mock-agent-handler

Made-with: Cursor
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 15, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@novu/framework@10727
npm i https://pkg.pr.new/novu@10727

commit: 4f67043

@ChmaraX ChmaraX merged commit 2bb0e44 into next Apr 15, 2026
36 checks passed
@ChmaraX ChmaraX deleted the nv-7363-agent-framework-rich-content-support-markdown-cards-onaction branch April 15, 2026 12:08
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