Conversation
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
✅ Deploy Preview for dashboard-v2-novu-staging canceled.
|
📝 WalkthroughWalkthroughThis PR adds support for agent actions and richer reply content structures. It introduces an Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning Review ran into problems🔥 ProblemsTimed 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. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 ?? ''); | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 956705e. Configure here.
There was a problem hiding this comment.
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 | 🟠 MajorMake
deliveryIdunique for action events.When
messageisnull, 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 offdeliveryId.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 | 🟠 MajorPass files through when posting markdown content.
content.filesis accepted by the API contract but silently dropped in the markdown branch. The chat SDK v4.25.0Thread.post()method supports aPostableMarkdownpayload shape that includes an optionalfilesparameter. Additionally, the truthy check oncontent.markdowntreatsmarkdown: ''as absent, which diverges from the DTO validator'sundefined-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 aninterfaceforcreateAgentActivityparams in backend code.Since this signature is being updated, this is a good point to extract the inline object type into a named
interfacefor consistency and reuse.As per coding guidelines: `**/*.{ts,tsx}`: On the backend: use `interface` for type definitions.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> {🤖 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: ConstrainrichContentto object-like values to avoid schema/type drift.
Schema.Types.Mixedis broader thanRecord<string, unknown>and allows primitives/arrays. Adding a validator here will keep persisted data aligned with the entity contract.As per coding guidelines: `libs/dal/**: Review with focus on data integrity and query performance.`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', + }, },🤖 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 toFileRefor converting to a class DTO.
FileRefis defined as an interface, so when used infiles?: FileRef[], the individual file objects won't be validated by class-validator. Malformed file references (e.g., missingfilename, or having bothdataandurl) would only be caught by the customIsValidReplyContentvalidator, which doesn't validatefilenamepresence.♻️ 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (21)
apps/api/src/app/agents/dtos/agent-event.enum.tsapps/api/src/app/agents/dtos/agent-reply-payload.dto.tsapps/api/src/app/agents/e2e/mock-agent-handler.tsapps/api/src/app/agents/services/agent-inbound-handler.service.tsapps/api/src/app/agents/services/bridge-executor.service.tsapps/api/src/app/agents/services/chat-sdk.service.tsapps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.tsapps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.tslibs/dal/src/repositories/conversation-activity/conversation-activity.entity.tslibs/dal/src/repositories/conversation-activity/conversation-activity.repository.tslibs/dal/src/repositories/conversation-activity/conversation-activity.schema.tspackages/framework/jsx-runtime/package.jsonpackages/framework/package.jsonpackages/framework/src/handler.tspackages/framework/src/index.tspackages/framework/src/jsx-runtime.tspackages/framework/src/resources/agent/agent.context.tspackages/framework/src/resources/agent/agent.test.tspackages/framework/src/resources/agent/agent.types.tspackages/framework/src/resources/agent/index.tspackages/framework/tsup.config.ts
| 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}` : ''}`); | ||
| } |
There was a problem hiding this comment.
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.
| 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
commit: |


Summary
Card,Button,Actions,Select,Divider,CardLink), markdown with tables/formatting, and plain text viactx.reply()chat.onAction()→ API inbound handler → bridge executor → frameworkonActionhandler, withctx.action.actionIdandctx.action.valuepopulatedchatSDK into the framework CJS output (noExternal: ['chat']in tsup) to fix ESM-onlyERR_PACKAGE_PATH_NOT_EXPORTEDerror at runtimeReplyContentDtowithIsValidReplyContentcustom validator now lives in the DTO layer, enforcing one-of text/markdown/card constraint and file source validation at the HTTP boundaryData 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 messageChanged files
tsup.config.tsnoExternal: ['chat']bundles chat into CJSagent.types.tsmetadatafromAgentActionindex.ts,agent/index.tsActions+ActionsElementfrom chatagent.test.tsCardTextcall signature (string, not array)agent-event.enum.tsON_ACTION = 'onAction'agent-reply-payload.dto.tsTextContentDtowithReplyContentDtosupporting text/markdown/card/fileshandle-agent-reply.command.tsReplyContentDtofrom DTO, remove re-exporthandle-agent-reply.usecase.tsReplyContentDtodirectly from DTObridge-executor.service.tsBridgeActiontype andactionfield to bridge requestagent-inbound-handler.service.tshandleAction()for routing action eventschat-sdk.service.tschat.onAction(), import DTO directlymock-agent-handler.tsTest plan
check:exportsall green, no circular deps)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
texttoReplyContentDtosupporting exactly-one-oftext/markdown/cardplus 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
onActionevent is added end-to-end: chatonActionevents are routed through a new inbound handler (handleAction), included in bridge payloads (action: { actionId, value }), exposed onctx.action, and dispatched by the framework handler when an agent registersonAction.Conversation history storage is extended for rich messages and SDK packaging is adjusted. Conversation activities gain optional
richContentto persist structured payloads alongside a text fallback, the framework re-exports card helpers and adds a./jsx-runtimeexport, and the CJS build bundleschat(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
onActionflow routes Slack button clicks and select changes through the API to the framework'sonActionhandler, with action context fields (actionId,value) available viactx.action.Affected areas
api: Added
ReplyContentDtowithIsValidReplyContentvalidator to enforce one-of constraints (text/markdown/card) and file validation at the HTTP boundary. ImplementedhandleActionmethod inAgentInboundHandlerto route actions to the bridge executor. ExtendedBridgeActiontype to carry action metadata and updatedChatSdkServiceto dispatchchat.onActionevents. AddedON_ACTIONtoAgentEventEnumand persistedrichContentin conversation activities.framework: Extended
AgentContextto exposeaction: AgentAction | nulland changedreply/updatemethods to acceptMessageContent(string, markdown, or card). AddedonActionhandler toAgentHandlersinterface and event routing inNovuRequestHandler. Re-exported chat SDK card components (Card,Button,Actions,Select,Divider,CardLink) and element types. Addedjsx-runtimeas a subpath export.dal: Added optional
richContent: Record<string, unknown>field toConversationActivityEntityand schema to store structured content (cards, markdown, files).package.json: Added
chatdependency (^4.25.0) andjsx-runtimeexport subpath. Updated tsup config to bundle chat SDK into framework CJS output (noExternal: ['chat']).Key technical decisions
ReplyContentDtowithIsValidReplyContent) to enforce constraints at the HTTP boundary rather than in the executor.MessageContenttype unionsstring(plain text), objects withmarkdownand optionalfiles, andCardElementtypes to support multiple reply formats through a single interface.BridgeActiontype to propagateactionIdand optionalvaluefrom 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
onActionexamples demonstrating action routing. Manual/interactive tests verify card replies, incident cards, markdown tables, and select values. Framework build checks pass; API E2E tests pending.