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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/api/src/app/agents/shared/agent-event-mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ import type { EditPayloadDto, ReplyContentDto } from './dtos/agent-reply-payload
/** Pure protocol-shape mappers between `AgentEvent` and the internal reply/Thalamus DTOs. No DI, no side effects. */

/**
* `AgentMessageContent['card']` is `Record<string, unknown>` on the wire — the protocol can't
* depend on the `chat` package's `CardElement` type — while `ReplyContentDto['card']` is the
* validated Chat SDK shape. This is the one place that crosses that trust boundary; the DTO's
* own `@Validate(IsValidReplyContent)` rejects anything that isn't actually card-shaped.
* `AgentMessageContent['card']` is the Novu protocol `CardElement`. `ReplyContentDto['card']`
* is the Chat SDK type. This is the one place that crosses that authoring ↔ wire boundary;
* the DTO's `@Validate(IsValidReplyContent)` still rejects anything that isn't card-shaped.
*/
export function toReplyContent(content: AgentMessageContent, files?: AgentFileRef[]): ReplyContentDto | null {
const base: ReplyContentDto =
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/app/agents/web-chat/activity-to-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,32 @@ describe('activity-to-events run lifecycle', () => {
]);
});

it('falls back to markdown when a stored card has no children array', () => {
const envelopes = mapNewestFirstEventActivities(
[
activity({
type: ConversationActivityTypeEnum.MESSAGE,
identifier: 'msg_card_empty_1',
platformMessageId: 'act_card_empty_1',
sequence: 1,
content: 'fallback markdown',
richContent: { card: { type: 'card' } },
}),
],
context
);

expect(envelopes.map((envelope) => envelope.event)).to.deep.equal([
{
type: 'message',
role: 'assistant',
messageId: 'act_card_empty_1',
content: { markdown: 'fallback markdown' },
files: undefined,
},
]);
});

it('uses immutable activity ids for approval request messages', () => {
const envelopes = mapNewestFirstEventActivities(
[
Expand Down
15 changes: 12 additions & 3 deletions apps/api/src/app/agents/web-chat/activity-to-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type AgentEventEnvelope,
type AgentFileRef,
type AgentMessageContent,
type CardElement,
isDeltaEvent,
} from '@novu/agent-event-protocol';
import {
Expand Down Expand Up @@ -37,8 +38,15 @@ function filesFromRichContent(richContent?: Record<string, unknown>) {
return files as AgentFileRef[];
}

function isCardTree(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && (value as { type?: unknown }).type === 'card';
/** Stored card JSON: `type: 'card'` plus a `children` array. Not a full `CardElement` tree. */
function isCardTree(value: unknown): value is { type: 'card'; children: unknown[] } {
if (typeof value !== 'object' || value === null) {
return false;
}

const card = value as { type?: unknown; children?: unknown };

return card.type === 'card' && Array.isArray(card.children);
}

function isManagedToolApprovalRequest(toolData: ConversationActivityEntity['toolData']): boolean {
Expand Down Expand Up @@ -77,7 +85,8 @@ export function messageContentFromStored(params: {
}): AgentMessageContent {
const card = params.richContent?.card;
if (isCardTree(card)) {
return { card };
// `isCardTree` only proves `type` + `children[]`. Stored trees are trusted here.
return { card: card as CardElement };
}

return { markdown: params.content ?? '' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ export function ChatMessageRow({
key={`${message.id}-card-${index}`}
card={part.card}
disabled={cardActionsDisabled}
onAction={onCardAction ? (action) => onCardAction({ ...action, sourceMessageId: message.id }) : undefined}
onAction={
onCardAction ? (action) => onCardAction({ ...action, sourceMessageId: part.sourceMessageId }) : undefined
}
/>
))}
{visibleTools.length > 0 ? (
Expand Down
Loading
Loading