diff --git a/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.branding.spec.ts b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.branding.spec.ts new file mode 100644 index 00000000000..9401243f372 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.branding.spec.ts @@ -0,0 +1,96 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { SLACK_MARKDOWN_TEXT_LIMIT } from '../../shared/util/slack-section-limits'; +import { OutboundGateway } from './outbound.gateway'; +import { OutboundDeliveryInfo } from './outbound-delivery-info.service'; + +describe('OutboundGateway branding', () => { + function makeGateway() { + const logger = { + setContext: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + }; + + const gateway = new OutboundGateway( + {} as any, + {} as any, + {} as any, + { prepareContentForDelivery: sinon.stub().callsFake(async (content) => content) } as any, + {} as any, + new OutboundDeliveryInfo(), + logger as any + ); + + return gateway; + } + + const brandedSlack = { + removeNovuBranding: false, + agentIdentifier: 'my-agent', + platform: AgentPlatformEnum.SLACK, + }; + + const unbrandedSlack = { + removeNovuBranding: true, + agentIdentifier: 'my-agent', + platform: AgentPlatformEnum.SLACK, + }; + + it('keeps branded text replies on the markdown path instead of converting to a card', () => { + const gateway = makeGateway(); + + const postable = (gateway as any).buildAdapterPostableMessage({ markdown: 'Hello **world**' }, brandedSlack); + + expect(postable.card).to.equal(undefined); + expect(postable.markdown).to.be.a('string'); + expect(postable.markdown).to.include('Hello **world**'); + expect(postable.markdown).to.include('Powered by [Novu]('); + expect(postable.markdown).to.include('go.novu.co/agent-powered'); + }); + + it('leaves cards untouched so approval and connect cards keep their structure', () => { + const gateway = makeGateway(); + const card = { + type: 'card', + children: [ + { type: 'text', content: 'Approve tool?' }, + { + type: 'actions', + children: [{ type: 'button', id: 'approve', label: 'Approve' }], + }, + ], + }; + + const postable = (gateway as any).buildAdapterPostableMessage({ card }, brandedSlack); + + expect(postable.card).to.deep.equal(card); + expect(postable.markdown).to.equal(undefined); + }); + + it('skips the watermark when removeNovuBranding is enabled', () => { + const gateway = makeGateway(); + + const postable = (gateway as any).buildAdapterPostableMessage({ markdown: 'Hello **world**' }, unbrandedSlack); + + expect(postable).to.deep.equal({ markdown: 'Hello **world**', files: undefined }); + }); + + it('falls back to a split card when Slack markdown exceeds the markdown_text limit', () => { + const gateway = makeGateway(); + const oversized = `x`.repeat(SLACK_MARKDOWN_TEXT_LIMIT + 1); + + const postable = (gateway as any).buildAdapterPostableMessage({ markdown: oversized }, brandedSlack); + + expect(postable.markdown).to.equal(undefined); + expect(postable.card).to.be.an('object'); + expect(postable.card.type).to.equal('card'); + expect(postable.card.children.length).to.be.greaterThan(1); + expect(postable.card.children.every((child: { type: string }) => child.type === 'text')).to.equal(true); + expect( + postable.card.children.every((child: { content: string }) => child.content.length <= 3000) + ).to.equal(true); + }); +}); diff --git a/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts index 7c2b2ff8769..f8692e91460 100644 --- a/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts +++ b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts @@ -10,8 +10,8 @@ import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; import { extractCardPlainText } from '../../shared/util/card-plain-text.util'; import { toDeliveryError } from '../../shared/util/delivery-error.util'; import { esmImport } from '../../shared/util/esm-import'; -import { buildBrandedMarkdownReply, contentHasPoweredByWatermark } from '../../shared/util/novu-powered-by-watermark'; -import { splitOversizedSlackText } from '../../shared/util/slack-section-limits'; +import { appendPoweredByWatermark, contentHasPoweredByWatermark } from '../../shared/util/novu-powered-by-watermark'; +import { SLACK_MARKDOWN_TEXT_LIMIT, splitOversizedSlackText } from '../../shared/util/slack-section-limits'; import { type AgentActionTokenBinding, AgentActionTokenService } from '../action-token/agent-action-token.service'; import { AgentConversationService } from '../conversation/agent-conversation.service'; import { ChatInstanceRegistry, type ChatWithAdapters, type PlatformAdapters } from '../ingress/chat-instance.registry'; @@ -899,13 +899,9 @@ export class OutboundGateway { } /** - * Wraps outbound markdown replies with a muted "Powered by Novu" footnote for - * organizations that have not removed Novu branding (free plan). Pro and above - * can disable it via the existing `removeNovuBranding` org setting, resolved - * once per delivery by `AgentConfigResolver`. - * - * Only plain markdown replies are branded — cards/action messages are left - * untouched. + * Appends a "Powered by Novu" markdown footer for orgs that have not removed + * Novu branding. Pro and above can disable it via `removeNovuBranding`. + * Cards and action messages are left untouched. */ private applyOutboundBranding(content: ChatSdkReplyContent, branding: OutboundBrandingContext): ChatSdkReplyContent { if (content.card || !content.markdown || contentHasPoweredByWatermark(content.markdown)) { @@ -916,9 +912,10 @@ export class OutboundGateway { return content; } - const card = buildBrandedMarkdownReply(content.markdown, branding.agentIdentifier, branding.platform); - - return { ...content, card, markdown: undefined }; + return { + ...content, + markdown: appendPoweredByWatermark(content.markdown, branding.agentIdentifier, branding.platform), + }; } /** @@ -941,8 +938,20 @@ export class OutboundGateway { } as AdapterPostableMessage; } + const markdown = deliveryContent.markdown ?? ''; + + if (branding.platform === AgentPlatformEnum.SLACK && markdown.length > SLACK_MARKDOWN_TEXT_LIMIT) { + return { + card: splitOversizedSlackText({ + type: 'card', + children: [{ type: 'text', content: markdown }], + }), + ...(deliveryContent.files?.length ? { files: deliveryContent.files } : {}), + } as AdapterPostableMessage; + } + return { - markdown: deliveryContent.markdown ?? '', + markdown, files: deliveryContent.files, } as AdapterPostableMessage; } diff --git a/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts b/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts index 39843f25384..3d8427b55c4 100644 --- a/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts @@ -250,7 +250,7 @@ describe('Agent Slack Roundtrip - emulate.dev #novu-v2', () => { expect(bridgeStub.calls.length, 'bridge executor invoked').to.be.gte(1); // Plain markdown replies from orgs without `removeNovuBranding` get the - // free-plan "Powered by Novu" watermark appended as the last line, so the + // free-plan "Powered by Novu" watermark appended as a markdown footer, so the // delivered text is no longer exactly the bridge reply. const replyMessage = await pollFor(async () => { const replies = await getThreadReplies(channel.id, threadTs); diff --git a/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.spec.ts b/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.spec.ts index ae89eba0bb5..3bbbf78c022 100644 --- a/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.spec.ts +++ b/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { AgentPlatformEnum } from '../enums/agent-platform.enum'; import { - buildBrandedMarkdownReply, + appendPoweredByWatermark, buildPoweredByWatermark, contentHasPoweredByWatermark, NOVU_AGENT_POWERED_URL, @@ -17,37 +17,35 @@ describe('novu-powered-by-watermark', () => { expect(watermark.length).to.be.greaterThan(NOVU_AGENT_POWERED_WATERMARK_TEXT.length); }); - it('returns attributed Slack mrkdwn link on Slack with only Novu linked', () => { + it('returns italic attributed markdown link on Slack with only Novu linked', () => { const watermark = buildPoweredByWatermark('my-agent', AgentPlatformEnum.SLACK); - expect(watermark.startsWith('Powered by <')).to.equal(true); - expect(watermark).to.include('|Novu>'); - expect(watermark).to.not.include('[Novu]('); + expect(watermark.startsWith('_Powered by [Novu](')).to.equal(true); + expect(watermark.endsWith(')_')).to.equal(true); + expect(watermark).to.not.include('Powered by <'); + expect(watermark).to.not.include('|Novu>'); expect(watermark).to.include(NOVU_AGENT_POWERED_URL); expect(watermark).to.include('utm_source=my-agent'); expect(watermark).to.include('utm_channel=slack'); }); - it('returns attributed markdown link on Teams with only Novu linked', () => { + it('returns italic attributed markdown link on Teams with only Novu linked', () => { const watermark = buildPoweredByWatermark('my-agent', AgentPlatformEnum.TEAMS); - expect(watermark.startsWith('Powered by [Novu](')).to.equal(true); + expect(watermark.startsWith('_Powered by [Novu](')).to.equal(true); expect(watermark).to.not.include('[Powered by Novu]('); expect(watermark).to.include(NOVU_AGENT_POWERED_URL); expect(watermark).to.include('utm_source=my-agent'); expect(watermark).to.include('utm_channel=teams'); }); - it('wraps markdown in a card with a muted watermark footnote', () => { - const card = buildBrandedMarkdownReply('Hello there', 'my-agent', AgentPlatformEnum.SLACK); + it('appends the watermark as a markdown footer', () => { + const branded = appendPoweredByWatermark('Hello there', 'my-agent', AgentPlatformEnum.SLACK); - expect(card.type).to.equal('card'); - expect(card.children).to.have.length(2); - expect(card.children[0]).to.deep.equal({ type: 'text', content: 'Hello there' }); - expect(card.children[1]?.type).to.equal('text'); - expect((card.children[1] as { style?: string }).style).to.equal('muted'); - expect((card.children[1] as { content?: string }).content).to.include('Powered by <'); - expect((card.children[1] as { content?: string }).content).to.include('|Novu>'); + expect(branded.startsWith('Hello there\n\n')).to.equal(true); + expect(branded).to.include('_Powered by [Novu]('); + expect(branded).to.include(NOVU_AGENT_POWERED_URL); + expect(contentHasPoweredByWatermark(branded)).to.equal(true); }); it('detects Slack mrkdwn watermark in markdown', () => { @@ -62,6 +60,12 @@ describe('novu-powered-by-watermark', () => { expect(contentHasPoweredByWatermark(markdown)).to.equal(true); }); + it('detects italic attributed watermark in markdown', () => { + const markdown = `Hello\n\n_Powered by [Novu](${NOVU_AGENT_POWERED_URL}?utm_campaign=agent-powered)_`; + + expect(contentHasPoweredByWatermark(markdown)).to.equal(true); + }); + it('detects legacy attributed watermark in markdown', () => { const markdown = `Hello\n\n[Powered by Novu](${NOVU_AGENT_POWERED_URL}?utm_campaign=agent-powered)`; diff --git a/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.ts b/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.ts index 3e254e53796..7c2473e9726 100644 --- a/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.ts +++ b/apps/api/src/app/agents/shared/util/novu-powered-by-watermark.ts @@ -1,5 +1,4 @@ -import type { CardElement } from 'chat'; -import { AgentPlatformEnum, supportsMarkdownLinks } from '../enums/agent-platform.enum'; +import { supportsMarkdownLinks } from '../enums/agent-platform.enum'; import { buildAttributedNovuUrl } from './novu-attribution-url'; export const NOVU_AGENT_POWERED_URL = 'https://go.novu.co/agent-powered'; @@ -10,18 +9,11 @@ const NOVU_POWERED_WATERMARK_MARKER = '\u200B'; const ATTRIBUTED_POWERED_BY_WATERMARK_PREFIX = `Powered by [Novu](${NOVU_AGENT_POWERED_URL}`; +// Previous Slack card path used mrkdwn (``). Keep detecting it so we don't double-stamp. const SLACK_ATTRIBUTED_POWERED_BY_WATERMARK_PREFIX = `Powered by <${NOVU_AGENT_POWERED_URL}`; const LEGACY_ATTRIBUTED_POWERED_BY_WATERMARK_PREFIX = `[${NOVU_AGENT_POWERED_WATERMARK_TEXT}](${NOVU_AGENT_POWERED_URL}`; -function formatPoweredByLink(label: string, url: string, platform: string): string { - if (platform === AgentPlatformEnum.SLACK) { - return `<${url}|${label}>`; - } - - return `[${label}](${url})`; -} - export function buildPoweredByWatermark(agentIdentifier: string, platform: string): string { if (!supportsMarkdownLinks(platform)) { return `${NOVU_AGENT_POWERED_WATERMARK_TEXT}${NOVU_POWERED_WATERMARK_MARKER}`; @@ -29,19 +21,13 @@ export function buildPoweredByWatermark(agentIdentifier: string, platform: strin const url = buildAttributedNovuUrl(NOVU_AGENT_POWERED_URL, 'agent-powered', agentIdentifier, platform); - return `Powered by ${formatPoweredByLink('Novu', url, platform)}`; + return `_Powered by [Novu](${url})_`; } -export function buildBrandedMarkdownReply(markdown: string, agentIdentifier: string, platform: string): CardElement { +export function appendPoweredByWatermark(markdown: string, agentIdentifier: string, platform: string): string { const watermark = buildPoweredByWatermark(agentIdentifier, platform); - return { - type: 'card', - children: [ - { type: 'text', content: markdown }, - { type: 'text', content: watermark, style: 'muted' }, - ], - }; + return `${markdown}\n\n${watermark}`; } export function contentHasPoweredByWatermark(markdown: string): boolean { diff --git a/apps/api/src/app/agents/shared/util/slack-section-limits.ts b/apps/api/src/app/agents/shared/util/slack-section-limits.ts index 3a6c9e6eac1..d9555ae10eb 100644 --- a/apps/api/src/app/agents/shared/util/slack-section-limits.ts +++ b/apps/api/src/app/agents/shared/util/slack-section-limits.ts @@ -7,6 +7,9 @@ import type { CardElement } from 'chat'; */ const SLACK_SECTION_TEXT_LIMIT = 3000; +/** Slack `markdown_text` limit; oversize payloads are rejected rather than truncated. */ +export const SLACK_MARKDOWN_TEXT_LIMIT = 12_000; + function pack(parts: string[], separator: string, limit: number, splitPart: (part: string) => string[]): string[] { const chunks: string[] = []; let current = ''; diff --git a/packages/chat-adapter-sendblue/src/adapter.spec.ts b/packages/chat-adapter-sendblue/src/adapter.spec.ts index deaa1b0c383..17d17793896 100644 --- a/packages/chat-adapter-sendblue/src/adapter.spec.ts +++ b/packages/chat-adapter-sendblue/src/adapter.spec.ts @@ -59,7 +59,7 @@ describe('SendblueAdapterImpl', () => { }); describe('postMessage', () => { - it('passes plain text and markdown through to the vendor unchanged', async () => { + it('passes plain text through and converts markdown to plain text for iMessage/SMS', async () => { const postMessage = spyOnVendorPostMessage(); const adapter = new SendblueAdapterImpl(CONFIG); @@ -67,7 +67,33 @@ describe('SendblueAdapterImpl', () => { await adapter.postMessage('sendblue:t', { markdown: 'Hello **world**' }); expect(postMessage).toHaveBeenNthCalledWith(1, 'sendblue:t', 'hello'); - expect(postMessage).toHaveBeenNthCalledWith(2, 'sendblue:t', { markdown: 'Hello **world**' }); + expect(postMessage).toHaveBeenNthCalledWith(2, 'sendblue:t', { markdown: 'Hello world' }); + }); + + it('keeps attachments when converting markdown to plain text', async () => { + const postMessage = spyOnVendorPostMessage(); + const adapter = new SendblueAdapterImpl(CONFIG); + const files = [{ name: 'photo.png', mimeType: 'image/png', data: Buffer.from('img') }]; + + await adapter.postMessage('sendblue:t', { markdown: 'See **this**', files } as never); + + expect(postMessage).toHaveBeenCalledWith('sendblue:t', { markdown: 'See this', files }); + }); + + it('converts markdown tables and links before handing off to the vendor', async () => { + const postMessage = spyOnVendorPostMessage(); + const adapter = new SendblueAdapterImpl(CONFIG); + + await adapter.postMessage('sendblue:t', { + markdown: ['See [docs](https://example.com).', '', '| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n'), + }); + + const delivered = postMessage.mock.calls[0]?.[1] as { markdown: string }; + expect(delivered.markdown).toContain('docs (https://example.com)'); + expect(delivered.markdown).toContain('1'); + expect(delivered.markdown).toContain('2'); + expect(delivered.markdown).not.toContain('| ---'); + expect(delivered.markdown).not.toContain('[docs]'); }); it('flattens a bare card to markdown text, since the vendor adapter cannot render cards', async () => { @@ -89,6 +115,19 @@ describe('SendblueAdapterImpl', () => { }); }); + it('keeps attachments posted alongside a card', async () => { + const postMessage = spyOnVendorPostMessage(); + const adapter = new SendblueAdapterImpl(CONFIG); + const files = [{ name: 'receipt.pdf', mimeType: 'application/pdf', data: Buffer.from('pdf') }]; + + await adapter.postMessage('sendblue:t', { + card: { type: 'card', children: [{ type: 'text', content: 'Your order shipped' }] }, + files, + } as never); + + expect(postMessage).toHaveBeenCalledWith('sendblue:t', { markdown: 'Your order shipped', files }); + }); + it('prefers an explicit fallbackText over rendering the card', async () => { const postMessage = spyOnVendorPostMessage(); const adapter = new SendblueAdapterImpl(CONFIG); diff --git a/packages/chat-adapter-sendblue/src/adapter.ts b/packages/chat-adapter-sendblue/src/adapter.ts index aceb360b8c0..0d0ecfdd6c4 100644 --- a/packages/chat-adapter-sendblue/src/adapter.ts +++ b/packages/chat-adapter-sendblue/src/adapter.ts @@ -1,10 +1,11 @@ -import type { Adapter, AdapterPostableMessage, CardElement, RawMessage } from 'chat'; +import type { Adapter, AdapterPostableMessage, CardElement, FileUpload, RawMessage } from 'chat'; import { type SendblueMessagePayload, type SendblueThreadId, SendblueAdapter as VendorSendblueAdapterRuntime, } from 'chat-adapter-sendblue'; import { renderCardAsText } from './card-renderer.js'; +import { markdownToPlainText } from './markdown-to-plain-text.js'; import type { SendblueAdapterConfig } from './types.js'; // iMessage-first with SMS fallback matches Sendblue's own delivery behavior; @@ -78,7 +79,22 @@ export class SendblueAdapterImpl extends VendorSendblueAdapter { threadId: string, message: AdapterPostableMessage ): Promise> { - return super.postMessage(threadId, this.flattenCard(message)); + return super.postMessage(threadId, this.preparePostable(message)); + } + + private preparePostable(message: AdapterPostableMessage): AdapterPostableMessage { + const flattened = this.flattenCard(message); + + if (typeof flattened === 'string') { + return flattened; + } + + const record = flattened as unknown as Record; + if (typeof record.markdown === 'string') { + return { ...flattened, markdown: markdownToPlainText(record.markdown) }; + } + + return flattened; } /** @@ -87,7 +103,8 @@ export class SendblueAdapterImpl extends VendorSendblueAdapter { * its buttons already stripped by `adaptApprovalContentForReplyBasedPlatform`) * renders to an empty string and is silently skipped. Prefer the card's own * `fallbackText` when the caller provided one, otherwise flatten it via - * `renderCardAsText`. + * `renderCardAsText`. Any `files` posted alongside the card are carried over, + * since the vendor adapter uploads them for `{ markdown }` postables too. */ private flattenCard(message: AdapterPostableMessage): AdapterPostableMessage { if (typeof message === 'string') { @@ -102,7 +119,11 @@ export class SendblueAdapterImpl extends VendorSendblueAdapter { } const fallbackText = typeof record.fallbackText === 'string' ? record.fallbackText : undefined; + const files = Array.isArray(record.files) ? (record.files as FileUpload[]) : undefined; - return { markdown: fallbackText ?? renderCardAsText(card) }; + return { + markdown: fallbackText ?? renderCardAsText(card), + ...(files?.length ? { files } : {}), + }; } } diff --git a/packages/chat-adapter-sendblue/src/markdown-to-plain-text.spec.ts b/packages/chat-adapter-sendblue/src/markdown-to-plain-text.spec.ts new file mode 100644 index 00000000000..9e8d4946098 --- /dev/null +++ b/packages/chat-adapter-sendblue/src/markdown-to-plain-text.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { markdownToPlainText } from './markdown-to-plain-text.js'; + +describe('markdownToPlainText', () => { + it('converts a GFM table to an ASCII table', () => { + const plain = markdownToPlainText( + ['| Provider | Count |', '| --- | --- |', '| Slack | 61 |', '| Email | 46 |'].join('\n') + ); + + expect(plain).toContain('Provider'); + expect(plain).toContain('Count'); + expect(plain).toContain('Slack'); + expect(plain).toContain('61'); + expect(plain).toContain('Email'); + expect(plain).toContain('46'); + expect(plain).not.toContain('| ---'); + }); + + it('converts markdown links to label (url) form', () => { + const plain = markdownToPlainText('See [the docs](https://example.com/path) for details.'); + + expect(plain).toBe('See the docs (https://example.com/path) for details.'); + }); + + it('strips common markdown decorations while preserving structure', () => { + const plain = markdownToPlainText('## Report\n\nHello **world** and _team_.\n\nThanks!'); + + expect(plain).toBe('Report\n\nHello world and team.\n\nThanks!'); + }); + + it('returns an empty string for empty input', () => { + expect(markdownToPlainText('')).toBe(''); + }); +}); diff --git a/packages/chat-adapter-sendblue/src/markdown-to-plain-text.ts b/packages/chat-adapter-sendblue/src/markdown-to-plain-text.ts new file mode 100644 index 00000000000..096676e5d37 --- /dev/null +++ b/packages/chat-adapter-sendblue/src/markdown-to-plain-text.ts @@ -0,0 +1,57 @@ +import { + isLinkNode, + isTableNode, + parseMarkdown, + stringifyMarkdown, + tableToAscii, + toPlainText, + walkAst, +} from 'chat'; + +function stripMarkdownDecorators(markdown: string): string { + return markdown + .replace(/^```[^\n]*\n([\s\S]*?)^```/gm, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/(?\s?/gm, '') + .replace(/^\s*[-*+]\s+/gm, '• ') + .replace(/\\([\\`*_{}[\]()#+\-.!:|])/g, '$1') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Convert markdown to iMessage/SMS plain text: ASCII tables, `label (url)` links, no decorations. */ +export function markdownToPlainText(markdown: string): string { + if (!markdown) { + return ''; + } + + const ast = parseMarkdown(markdown); + const transformed = walkAst(structuredClone(ast), (node) => { + if (isTableNode(node)) { + return { + type: 'code', + value: tableToAscii(node), + lang: undefined, + }; + } + + if (isLinkNode(node)) { + const label = toPlainText({ type: 'root', children: node.children ?? [] }).trim(); + + return { + type: 'text', + value: label ? `${label} (${node.url})` : node.url, + }; + } + + return node; + }); + + return stripMarkdownDecorators(stringifyMarkdown(transformed)); +} diff --git a/packages/novu/CHANGELOG.md b/packages/novu/CHANGELOG.md index 701769e80ab..a2dd552003d 100644 --- a/packages/novu/CHANGELOG.md +++ b/packages/novu/CHANGELOG.md @@ -1,3 +1,54 @@ +## v2.21.1 (2026-08-31) + +### 🩹 Fixes + +- **novu:** omit apiUrl in merged Web Chat scaffold for US Cloud fixes NV-8731 ([#12512](https://github.com/novuhq/novu/pull/12512)) + +### ❤️ Thank You + +- Adam Chmara @ChmaraX + +## v2.21.0 (2026-08-31) + +### 🚀 Features + +- **novu,shared:** upgrade connect Agent Chat template to dashboard structure fixes NV-8680 ([#12453](https://github.com/novuhq/novu/pull/12453)) +- **api-service,framework:** agents vision and file support fixes NV-7810 ([#12423](https://github.com/novuhq/novu/pull/12423)) +- **api-service:** structured workflow-origin hydration for agents fixes NV-8608 ([#12371](https://github.com/novuhq/novu/pull/12371)) +- **novu:** add Agent Chat channel to npx novu connect fixes NV-8593 ([#12393](https://github.com/novuhq/novu/pull/12393)) +- **docs:** add Tool channel information in framework fixes DOC-434 ([#12394](https://github.com/novuhq/novu/pull/12394)) +- **shared:** chat provider content overrides with Slack schema fixes NV-8397 ([#12103](https://github.com/novuhq/novu/pull/12103)) +- **dashboard,api-service,js,react,framework:** novu copilot agent for slack fixes NV-8316 ([#11973](https://github.com/novuhq/novu/pull/11973)) +- **dashboard,novu:** add scannable wa.me QR code to WhatsApp inbound test step fixes NV-8330 ([#11996](https://github.com/novuhq/novu/pull/11996)) +- **shared:** add Tool channel with PagerDuty, Opsgenie and Webhook providers fixes NV-8284 ([#11923](https://github.com/novuhq/novu/pull/11923)) +- **api-service,dashboard,novu:** CLI WhatsApp connect via tokenized Meta Embedded Signup fixes NV-8312 ([#11969](https://github.com/novuhq/novu/pull/11969)) +- **api-service:** unify agents Mixpanel activation funnel fixes NV-8322 ([#11977](https://github.com/novuhq/novu/pull/11977)) +- **novu:** add Sendblue (iMessage) to Novu Connect CLI fixes NV-8241 ([#11920](https://github.com/novuhq/novu/pull/11920)) +- **novu:** add novu_docs tool approval demo to connect scaffolds fixes NV-8302 ([#11945](https://github.com/novuhq/novu/pull/11945)) +- **novu:** power scaffolded agents via API key or subscription fixes NV-8289 ([#11938](https://github.com/novuhq/novu/pull/11938)) +- **api-service,dashboard:** agent subscriber-access diagram parity fixes NV-8288 ([#11932](https://github.com/novuhq/novu/pull/11932)) + +### 🩹 Fixes + +- **dashboard,novu,shared:** pin Agent Chat connect flags and skip picker fixes NV-8704 ([#12469](https://github.com/novuhq/novu/pull/12469)) +- **novu,shared:** pin scaffold SDK packages to npm next on staging/local fixes NV-8680 ([#12455](https://github.com/novuhq/novu/pull/12455)) +- **dashboard,novu:** improve novu connect dashboard commands fixes NV-8636 ([#12413](https://github.com/novuhq/novu/pull/12413)) +- **js:** point CLI connect agent links to valid dashboard route fixes NV-8598 ([#12342](https://github.com/novuhq/novu/pull/12342)) +- **docs:** enhance agent communication documentation with new channels and capabilities ([#12188](https://github.com/novuhq/novu/pull/12188)) +- **novu:** resolve LangChain Turbopack dynamic import failure fixes NV-8430 ([#12126](https://github.com/novuhq/novu/pull/12126)) +- **shared:** use logged-in user id instead of connect: subscriber prefix fixes NV-8328 ([#11992](https://github.com/novuhq/novu/pull/11992)) +- **novu:** allow exiting connect LLM auth picker and OAuth screens (fixes NV-8298) ([#11941](https://github.com/novuhq/novu/pull/11941)) +- **novu:** respect --no-studio to skip dashboard open fixes NV-8286 ([#11929](https://github.com/novuhq/novu/pull/11929)) + +### ❤️ Thank You + +- Adam Chmara @ChmaraX +- Dima Grossman @scopsy +- George Djabarov @djabarovgeorge +- Nikita Grossman @nikitagrossman +- Pawan Jain +- Paweł Tymczuk @LetItRock + ## v2.19.0 (2026-07-13) ### 🚀 Features diff --git a/packages/novu/package.json b/packages/novu/package.json index 56366a1891f..d5d5d75a87c 100644 --- a/packages/novu/package.json +++ b/packages/novu/package.json @@ -1,6 +1,6 @@ { "name": "novu", - "version": "2.21.0", + "version": "2.21.1", "description": "Novu CLI. Run Novu Studio and sync workflows with Novu Cloud", "main": "src/index.js", "publishConfig": { diff --git a/packages/react/src/hooks/useWebChat.ts b/packages/react/src/hooks/useWebChat.ts index e2e84970e9b..402d2cfd292 100644 --- a/packages/react/src/hooks/useWebChat.ts +++ b/packages/react/src/hooks/useWebChat.ts @@ -214,26 +214,23 @@ function getCreateFlowKey(agentId: string, agentHash?: string): string { return `${agentId}\0${agentHash ?? ''}`; } -function resolveOwnedRuntime(args: { - novu: ReturnType; - agentId: string; - agentHash?: string; - ownedRuntimeRef: MutableRefObject; -}): AgentConversationRuntime | null { - const key = getCreateFlowKey(args.agentId, args.agentHash); - const current = args.ownedRuntimeRef.current; - - if (current?.novu === args.novu && current.key === key) { - return current.runtime; +function getManagedRuntimeKey( + agentId: string, + agentHash: string | undefined, + conversationIdProp: string | undefined +): string { + if (conversationIdProp) { + return `resume:${agentId}\0${agentHash ?? ''}\0${conversationIdProp}`; } - current?.runtime.dispose(); - - const runtime = args.novu.webChat.conversation({ agentId: args.agentId, agentHash: args.agentHash }); - args.ownedRuntimeRef.current = { key, novu: args.novu, runtime }; - return runtime; + return getCreateFlowKey(agentId, agentHash); } +type ManagedRuntimeEntry = { + key: string; + runtime: AgentConversationRuntime; +}; + type WebChatLoadState = | { novu: ReturnType; status: 'loading' } | { novu: ReturnType; status: 'ready' } @@ -308,53 +305,51 @@ export const useWebChat = (props: UseWebChatProps): UseWebChatResult => { const agentHash = sharedRuntime ? undefined : props.agentHash; const ownedRuntimeRef = useRef(null); + const [managedRuntime, setManagedRuntime] = useState(null); + const managedRuntimeKey = sharedRuntime + ? null + : getManagedRuntimeKey(agentId, agentHash, conversationIdProp); useEffect(() => { - const current = ownedRuntimeRef.current; - if (current && current.novu !== novu) { - current.runtime.dispose(); + if (sharedRuntime) { + ownedRuntimeRef.current?.runtime.dispose(); ownedRuntimeRef.current = null; - } - }, [novu]); + setManagedRuntime(null); - const cachedRuntime = useMemo(() => { - if (!webChatReady) { - return null; + return; } - if (sharedRuntime) { - return sharedRuntime; - } + if (!webChatReady) { + setManagedRuntime(null); - if (!conversationIdProp) { - return null; + return; } - return novu.webChat.conversation({ - agentId, - conversationId: conversationIdProp, - agentHash, - }); - }, [webChatReady, sharedRuntime, novu, agentId, conversationIdProp, agentHash]); - - if (sharedRuntime || conversationIdProp) { - ownedRuntimeRef.current?.runtime.dispose(); - ownedRuntimeRef.current = null; - } + const key = getManagedRuntimeKey(agentId, agentHash, conversationIdProp); + const current = ownedRuntimeRef.current; - const ownedRuntime = - !webChatReady || sharedRuntime || conversationIdProp - ? null - : resolveOwnedRuntime({ novu, agentId, agentHash, ownedRuntimeRef }); + let runtime: AgentConversationRuntime; + if (current?.novu === novu && current.key === key) { + runtime = current.runtime; + } else { + current?.runtime.dispose(); + runtime = conversationIdProp + ? novu.webChat.conversation({ agentId, conversationId: conversationIdProp, agentHash }) + : novu.webChat.conversation({ agentId, agentHash }); + ownedRuntimeRef.current = { key, novu, runtime }; + } - const runtime = sharedRuntime ?? cachedRuntime ?? ownedRuntime; + setManagedRuntime({ key, runtime }); - useEffect(() => { return () => { ownedRuntimeRef.current?.runtime.dispose(); ownedRuntimeRef.current = null; + setManagedRuntime(null); }; - }, []); + }, [webChatReady, sharedRuntime, novu, agentId, conversationIdProp, agentHash]); + + const runtime = + sharedRuntime ?? (managedRuntime?.key === managedRuntimeKey ? managedRuntime.runtime : null); const loadNotifiedRef = useRef(false); const replayedActionsRef = useRef(false); diff --git a/playground/web-chat/src/lib/socket-status.ts b/playground/web-chat/src/lib/socket-status.ts index 112d49c1808..d496910be38 100644 --- a/playground/web-chat/src/lib/socket-status.ts +++ b/playground/web-chat/src/lib/socket-status.ts @@ -21,11 +21,7 @@ export function setSocketStatus(status: SocketStatus): void { if (status === current) return; current = status; - // useWebChat can connect() during render; notify after this turn so - // PlaygroundApp does not setState while WebChat is rendering. - queueMicrotask(() => { - listeners.forEach((listener) => listener(current)); - }); + listeners.forEach((listener) => listener(current)); } export function subscribeSocketStatus(listener: Listener): () => void {