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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 { 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';
Expand Down Expand Up @@ -932,7 +933,10 @@ export class OutboundGateway {

if (deliveryContent.card) {
return {
card: deliveryContent.card,
card:
branding.platform === AgentPlatformEnum.SLACK
? splitOversizedSlackText(deliveryContent.card)
: deliveryContent.card,
...(deliveryContent.files?.length ? { files: deliveryContent.files } : {}),
} as AdapterPostableMessage;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { WhatsAppAdapter } from '@chat-adapter/whatsapp';
import { BadRequestException, forwardRef, Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { CacheService, PinoLogger } from '@novu/application-generic';
import type { NovuAgentChatAdapter } from '@novu/chat-adapter-agent-chat';
import { stripAgentReplyToken } from '@novu/shared';
import type { Adapter, Chat, Message, ReactionEvent, SlashCommandEvent, Thread } from 'chat';
import { LRUCache } from 'lru-cache';
import { resolveWhatsAppAppSecret } from '../../../integrations/usecases/whatsapp/whatsapp-credentials.utils';
Expand Down Expand Up @@ -465,6 +466,7 @@ export class ChatInstanceRegistry implements OnModuleDestroy {
senderName: resolveAgentEmailSenderName(config),
signingSecret: credentials.secretKey,
sendEmail: this.agentEmailSender.buildSendEmailCallback(config, outboundIntegrationId),
stripAgentReplyToken,
actionUrlBuilder: async ({ threadId, messageId, actionId, value, label, style }) => {
const userIdentifier = extractRecipientFromThreadId(threadId);
const { url } = await this.emailActionTokenService.signActionToken({
Expand Down
9 changes: 6 additions & 3 deletions apps/api/src/app/agents/shared/agent-event-sink.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,9 +652,12 @@ export class AgentEventSink {
}

if (approvals.length === 0) {
this.logger.error({ runId }, 'paused run-finish carried zero approvals — skipping tool approval dispatch');

return;
// Empty when the pending tool_use came from an earlier run (resumed streams are a live
// tail). HandlePendingToolApprovals recovers from the session.
this.logger.warn(
{ runId, sessionId },
'paused run-finish carried zero approvals — recovering pending approvals from the session'
);
}

try {
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/app/agents/shared/util/slack-section-limits.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect } from 'chai';
import type { CardElement } from 'chat';

import { splitForSlackSections, splitOversizedSlackText } from './slack-section-limits';

const LIMIT = 3000;
const LONG_MARKDOWN = Array.from(
{ length: 40 },
(_, index) => `Paragraph ${index}: ${'restaurant details '.repeat(12)}`
).join('\n\n');

describe('slack-section-limits', () => {
describe('splitForSlackSections', () => {
it('leaves text within the section limit untouched', () => {
expect(splitForSlackSections('short reply')).to.deep.equal(['short reply']);
});

it('splits a long reply on paragraph breaks without losing content', () => {
const chunks = splitForSlackSections(LONG_MARKDOWN);

expect(chunks.length).to.be.greaterThan(1);
expect(chunks.every((chunk) => chunk.length <= LIMIT)).to.equal(true);
expect(chunks.join('\n\n')).to.equal(LONG_MARKDOWN);
});

it('splits text that has no paragraph or line breaks to fall back on', () => {
const unbroken = 'x'.repeat(7500);
const chunks = splitForSlackSections(unbroken);

expect(chunks.every((chunk) => chunk.length <= LIMIT)).to.equal(true);
expect(chunks.join('')).to.equal(unbroken);
});

it('prefers a space near the limit over a mid-word hard cut', () => {
const head = `${'a'.repeat(2900)} `;
const tail = 'b'.repeat(200);
const line = `${head}${tail}`;
const chunks = splitForSlackSections(line);

expect(chunks).to.deep.equal([head, tail]);
expect(chunks.join('')).to.equal(line);
});
});

describe('splitOversizedSlackText', () => {
it('returns the same card when every text child fits', () => {
const card: CardElement = { type: 'card', children: [{ type: 'text', content: 'hello' }] };

expect(splitOversizedSlackText(card)).to.equal(card);
});

it('expands an oversized text child and keeps the trailing watermark', () => {
const card: CardElement = {
type: 'card',
children: [
{ type: 'text', content: LONG_MARKDOWN },
{ type: 'text', content: 'Powered by <https://novu.co|Novu>', style: 'muted' },
],
};

const children = splitOversizedSlackText(card).children;
const textChildren = children.filter((child) => child.type === 'text');

expect(children.length).to.be.greaterThan(card.children.length);
expect(textChildren.every((child) => child.content.length <= LIMIT)).to.equal(true);
expect(textChildren[textChildren.length - 1]).to.deep.equal(card.children[1]);
});
});
});
99 changes: 99 additions & 0 deletions apps/api/src/app/agents/shared/util/slack-section-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { CardElement } from 'chat';

/**
* Slack's per-section text limit. Crossing it does not truncate the section — Slack rejects
* the entire `chat.postMessage` payload with `invalid_blocks`, so the reply is lost instead
* of shortened.
*/
const SLACK_SECTION_TEXT_LIMIT = 3000;

function pack(parts: string[], separator: string, limit: number, splitPart: (part: string) => string[]): string[] {
const chunks: string[] = [];
let current = '';

for (const part of parts) {
const candidate = current ? `${current}${separator}${part}` : part;

if (candidate.length <= limit) {
current = candidate;

continue;
}

if (current) {
chunks.push(current);
current = '';
}

if (part.length <= limit) {
current = part;

continue;
}

chunks.push(...splitPart(part));
}

if (current) {
chunks.push(current);
}

return chunks;
}

/** Last resort for a single oversize line: prefer a nearby space, else hard-cut. */
function sliceToLimit(text: string, limit: number): string[] {
const chunks: string[] = [];
let remaining = text;

while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const spaceAt = window.lastIndexOf(' ');
const cut = spaceAt > Math.floor(limit / 2) ? spaceAt + 1 : limit;

chunks.push(remaining.slice(0, cut));
remaining = remaining.slice(cut);
}

if (remaining) {
chunks.push(remaining);
}

return chunks;
}

/** Prefer paragraph breaks, then line breaks, then a space-aware cut at `limit`. */
export function splitForSlackSections(text: string, limit: number = SLACK_SECTION_TEXT_LIMIT): string[] {
if (text.length <= limit) {
return [text];
}

return pack(text.split('\n\n'), '\n\n', limit, (paragraph) =>
pack(paragraph.split('\n'), '\n', limit, (line) => sliceToLimit(line, limit))
);
}

/**
* Expands over-long text children into within-limit ones. The Slack adapter maps one text
* child to one section block; oversize sections are rejected as `invalid_blocks`.
*/
export function splitOversizedSlackText(card: CardElement): CardElement {
const needsSplit = card.children.some(
(child) => child.type === 'text' && child.content.length > SLACK_SECTION_TEXT_LIMIT
);

if (!needsSplit) {
return card;
}

return {
...card,
children: card.children.flatMap((child) => {
if (child.type !== 'text' || child.content.length <= SLACK_SECTION_TEXT_LIMIT) {
return [child];
}

return splitForSlackSections(child.content).map((content) => ({ ...child, content }));
}),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cn } from '@/utils/ui';

type ChannelChipProps = {
label: string;
icon: ReactNode;
icon?: ReactNode;
accent: string;
isSelected: boolean;
onToggle: () => void;
Expand All @@ -21,7 +21,7 @@ export function ChannelChip({ label, icon, accent, isSelected, onToggle }: Chann
)}
style={isSelected ? { backgroundColor: `${accent}1f`, borderColor: `${accent}3d` } : undefined}
>
<span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>
{icon ? <span className="flex size-4 shrink-0 items-center justify-center">{icon}</span> : null}
{label}
</button>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChatProviderIdEnum, EmailProviderIdEnum } from '@novu/shared';
import { Mails } from 'lucide-react';
import { AtSign, Hash, Mails } from 'lucide-react';
import type { ReactNode } from 'react';
import { AGENT_IMESSAGE_LABEL, getAgentChannelIconFileName } from '@/utils/agent-channel-branding';

Expand All @@ -12,6 +12,9 @@ export type AgentReadiness = 'live_in_production' | 'in_development' | 'planned_

export type AgentAudience = 'customers_end_users' | 'employees_internal_teams' | 'both' | 'not_sure_yet';

/** How users and the agent initiate conversations — optional survey answer. */
export type AgentInteraction = 'agent_reaches_out_or_asks' | 'users_message_or_tag' | 'both';

/** Survey channel ids only — real provider ids so they join the rest of the agent funnel. */
export const AGENT_CHANNEL_VALUES = [
EmailProviderIdEnum.NovuAgent,
Expand Down Expand Up @@ -112,3 +115,34 @@ export const AGENT_CHANNEL_OPTIONS: ChannelOption[] = [
accent: '#DC224E',
},
];

export type InteractionOption = {
value: AgentInteraction;
label: string;
icon?: ReactNode;
/** Brand colour the chip tints itself with while selected. */
accent: string;
};

/** Neutral accent shared by interaction chips (no per-option brand colour). */
const INTERACTION_ACCENT = '#525866';

export const AGENT_INTERACTION_OPTIONS: InteractionOption[] = [
{
value: 'agent_reaches_out_or_asks',
label: 'Agent reaches out for input (human-in-the-loop)',
icon: <Hash className="size-4" strokeWidth={1.5} />,
accent: INTERACTION_ACCENT,
},
{
value: 'users_message_or_tag',
label: 'Users message or tag the agent',
icon: <AtSign className="size-4" strokeWidth={1.5} />,
accent: INTERACTION_ACCENT,
},
{
value: 'both',
label: 'Both',
accent: INTERACTION_ACCENT,
},
];
53 changes: 53 additions & 0 deletions apps/dashboard/src/pages/agents-personalize-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { ChannelChip } from '@/components/onboarding/personalize/channel-chip';
import {
AGENT_AUDIENCE_OPTIONS,
AGENT_CHANNEL_OPTIONS,
AGENT_INTERACTION_OPTIONS,
AGENT_READINESS_OPTIONS,
type AgentAudience,
type AgentChannel,
type AgentInteraction,
type AgentReadiness,
type PersonalizeOption,
} from '@/components/onboarding/personalize/personalize-options';
Expand Down Expand Up @@ -128,6 +130,40 @@ function ChannelQuestion({
);
}

/**
* Optional single-select chips. Clicking the selected chip again clears the answer so the
* question stays skippable without a required pick.
*/
function InteractionQuestion({
selected,
onSelect,
}: {
selected: AgentInteraction | undefined;
onSelect: (value: AgentInteraction) => void;
}) {
const labelId = useId();

return (
<fieldset aria-labelledby={labelId} className="flex flex-col gap-2 border-0 p-0">
<Label id={labelId} className="text-text-sub cursor-default font-normal">
How should users interact with your agent?
</Label>
<div className="flex max-w-[400px] flex-wrap gap-2">
{AGENT_INTERACTION_OPTIONS.map((option) => (
<ChannelChip
key={option.value}
label={option.label}
icon={option.icon}
accent={option.accent}
isSelected={selected === option.value}
onToggle={() => onSelect(option.value)}
/>
))}
</div>
</fieldset>
);
}

export function AgentsPersonalizePage() {
const areAgentsAvailable = useAreConversationalAgentsAvailable();
const isLaunchDarklyReady = useLaunchDarklyReady();
Expand All @@ -140,6 +176,7 @@ export function AgentsPersonalizePage() {
const [readiness, setReadiness] = useState<AgentReadiness | undefined>(undefined);
const [audience, setAudience] = useState<AgentAudience | undefined>(undefined);
const [channels, setChannels] = useState<AgentChannel[]>([]);
const [interaction, setInteraction] = useState<AgentInteraction | undefined>(undefined);

// `product_type=agents` signups land here without ever seeing the picker, so sending them "back"
// to it would push them into a screen that defaults to Inbox and reverses their choice. Setup may
Expand Down Expand Up @@ -181,11 +218,23 @@ export function AgentsPersonalizePage() {
});
};

const handleInteractionSelect = (value: AgentInteraction) => {
const next = interaction === value ? undefined : value;

setInteraction(next);
telemetry(TelemetryEvent.ONBOARDING_PERSONALIZE_ANSWERED, {
question: 'agent_interaction',
value,
selected: next !== undefined,
});
};

const handleContinue = () => {
telemetry(TelemetryEvent.ONBOARDING_PERSONALIZE_SUBMITTED, {
agentReadiness: readiness,
agentAudience: audience,
agentChannels: channels,
agentInteraction: interaction,
});
// The agents setup page waits on the org, so the loader plays across that hand-off.
beginOnboardingProvisioning('agents');
Expand Down Expand Up @@ -243,6 +292,10 @@ export function AgentsPersonalizePage() {
<RevealedField isRevealed={Boolean(audience)}>
<ChannelQuestion selected={channels} onToggle={handleChannelToggle} />
</RevealedField>

<RevealedField isRevealed={Boolean(audience)}>
<InteractionQuestion selected={interaction} onSelect={handleInteractionSelect} />
</RevealedField>
</div>

<RevealedField isRevealed={Boolean(audience)}>
Expand Down
Loading
Loading