2026 03 12 S211 Unified Rich Messaging Phase1
Documentation for Plans
S211 — Unified Rich Messaging Architecture (Phase 1) Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a channel-agnostic rich message type system with WhatsApp and Web Chat adapters, enabling list pickers, cards, carousels, CTA deep links, and intelligent response type selection across all channels from a single abstraction.
Architecture: Discriminated union RichMessage types flow from a RichMessageComposerService (heuristic analysis of agent output) → AdapterRegistryService resolves the correct ChannelAdapter → adapter renders to channel-native payload (Twilio Content API for WhatsApp, generative-ui JSON blocks for Web). The frontend already has a GenerativeUIComponentCatalog that dynamically renders registered Angular components from JSON blocks in chat messages — we add 5 new components (list-picker, card, carousel, CTA button, quick-reply) and register them in the catalog seed.
Tech Stack: NestJS (backend-vitto-api), Angular 20+ (standalone, OnPush, signals), Twilio Content API, Jest, TypeScript discriminated unions
Spec/Handoff: docs/english/Plans/S210.5: Vitto Unified Rich Messaging and Interactive WhatsApp Experience.md
Commit tag convention: All commits in this plan use [S211.X] — the implementor MUST substitute X with their actual sub-session number from the session manifest per SAGP v1.2.
Key codebase facts the implementor needs:
TwilioContentApiServiceatlibs/libs-api/messaging-core/src/lib/services/twilio-content-api.service.tsalready supportscreateListPicker(),createCard(),createCarousel()— just not called yet. It IS available globally in DI becauseMessagingCoreModuleis@Global()(line 52 ofmessaging-core.module.ts) — no explicit import needed in EngineModuleChannelGatewayServiceatAPIs/backend-vitto-api/src/app/connectivity/channel-gateway.service.tsalready hassendWhatsAppQuickReply(),sendWhatsAppCarousel(),sendWhatsAppSticker()— use theseGenerativeUIComponentCatalogatlibs/libs-ai/vitto-chat/src/lib/generative-ui/component-catalog.tswithVittoRenderComponentdynamically renders registered components fromgenerative-uifenced code blocks in message contentseedGenerativeUICatalog()atlibs/libs-ai/vitto-chat/src/lib/generative-ui/catalog-seed.tsregisters components at bootstrap — add new ones hereMessageBodyComponentatlibs/libs-ai/vitto-chat/src/lib/components/message/message-body/message-body.component.tsalready parsesgenerative-uiblocks — NO parser changes needed- Engine test pattern: Jest specs in
APIs/backend-vitto-api/src/app/engine/__tests__/, direct service instantiation,jest.mock()for externals - Run backend tests:
yarn nx test backend-vitto-api --testPathPattern="<pattern>" - Angular component pattern: standalone, OnPush,
input()signals, separate or inline template depending on complexity, followSkillPreviewCardComponentas reference
File Structure
New Files (Backend)
| File | Responsibility |
|---|---|
APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.ts |
Discriminated union of all RichMessage types + RichMessageButton |
APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts |
Analyzes agent output text → selects optimal RichMessage type |
APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.ts |
ChannelAdapter interface + ChannelAdapterContext + ChannelAdapterResult |
APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts |
Converts RichMessage → Twilio Content API calls |
APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts |
Converts RichMessage → generative-ui JSON blocks in markdown |
APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts |
Maps channel type string → ChannelAdapter instance |
APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts |
Tests for composer heuristics |
APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts |
Tests for WhatsApp adapter rendering |
APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts |
Tests for Web adapter generative-ui output |
APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.ts |
Tests for adapter resolution |
New Files (Frontend)
| File | Responsibility |
|---|---|
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts |
Interactive list with selectable items grouped in sections |
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.ts |
Card with title, subtitle, optional media, action buttons |
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.ts |
Horizontal scroll of cards |
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.ts |
Call-to-action button that opens a URL |
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.ts |
Row of tappable quick reply chips |
Modified Files
| File | Change |
|---|---|
APIs/backend-vitto-api/src/app/engine/engine.module.ts |
Register AdapterRegistryService, RichMessageComposerService, WhatsAppChannelAdapter, WebChatChannelAdapter |
APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts |
Wire composer + adapter into executeUnifiedAgentLoop() response flow |
libs/libs-ai/vitto-chat/src/lib/generative-ui/catalog-seed.ts |
Register 5 new rich message components |
Chunk 1: RichMessage Types + Channel Adapter Interface + AdapterRegistry
Task 1: Define RichMessage Type System
Files:
Create:
APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.tsStep 1: Create the rich-messages directory and type file
// APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.ts
/**
* S211: Channel-agnostic rich message type system.
* Discriminated union — each type carries structured data without channel-specific formatting.
* Adapters convert these to channel-native payloads (Twilio, generative-ui, Block Kit, etc.)
*/
// ── Button Types ──
export interface RichMessageButton {
type: 'quick_reply' | 'url' | 'phone';
title: string;
url?: string;
phone?: string;
id?: string;
}
// ── Message Types ──
export type RichMessageType =
| 'text'
| 'quick_reply'
| 'list_picker'
| 'card'
| 'carousel'
| 'cta'
| 'media'
| 'location';
interface BaseRichMessage {
type: RichMessageType;
/** Plain text fallback for channels that can't render this rich type */
fallbackText: string;
}
export interface TextMessage extends BaseRichMessage {
type: 'text';
text: string;
}
export interface QuickReplyMessage extends BaseRichMessage {
type: 'quick_reply';
text: string;
/** Max 3 buttons for WhatsApp, max 20 chars per title */
buttons: Array<{ title: string; id?: string }>;
}
export interface ListPickerSection {
title: string;
items: Array<{ id: string; title: string; description?: string }>;
}
export interface ListPickerMessage extends BaseRichMessage {
type: 'list_picker';
body: string;
/** Text shown on the button that opens the list (max 20 chars) */
buttonText: string;
/** WhatsApp: max 10 items total across sections */
sections: ListPickerSection[];
}
export interface CardMessage extends BaseRichMessage {
type: 'card';
title: string;
subtitle?: string;
body?: string;
mediaUrl?: string;
/** Max 3 buttons for WhatsApp */
buttons: RichMessageButton[];
}
export interface CarouselCard {
title: string;
body?: string;
mediaUrl?: string;
/** Max 2 buttons per card for WhatsApp */
buttons: RichMessageButton[];
}
export interface CarouselMessage extends BaseRichMessage {
type: 'carousel';
body: string;
/** WhatsApp: 2-10 cards */
cards: CarouselCard[];
}
export interface CTAMessage extends BaseRichMessage {
type: 'cta';
text: string;
buttons: Array<{ title: string; url: string }>;
}
export interface MediaMessage extends BaseRichMessage {
type: 'media';
mediaUrl: string;
mediaType: 'image' | 'video' | 'audio' | 'document';
caption?: string;
}
export interface LocationMessage extends BaseRichMessage {
type: 'location';
latitude: number;
longitude: number;
label?: string;
address?: string;
}
/** Discriminated union of all rich message types */
export type RichMessage =
| TextMessage
| QuickReplyMessage
| ListPickerMessage
| CardMessage
| CarouselMessage
| CTAMessage
| MediaMessage
| LocationMessage;
// ── Factory helpers ──
export function createTextMessage(text: string): TextMessage {
return { type: 'text', text, fallbackText: text };
}
export function createQuickReplyMessage(
text: string,
buttons: Array<{ title: string; id?: string }>,
): QuickReplyMessage {
return {
type: 'quick_reply',
text,
buttons: buttons.slice(0, 3),
fallbackText: text,
};
}
export function createListPickerMessage(
body: string,
buttonText: string,
sections: ListPickerSection[],
): ListPickerMessage {
return {
type: 'list_picker',
body,
buttonText: buttonText.substring(0, 20),
sections,
fallbackText: body,
};
}
export function createCardMessage(
title: string,
buttons: RichMessageButton[],
opts?: { subtitle?: string; body?: string; mediaUrl?: string },
): CardMessage {
return {
type: 'card',
title,
subtitle: opts?.subtitle,
body: opts?.body,
mediaUrl: opts?.mediaUrl,
buttons: buttons.slice(0, 3),
fallbackText: `${title}${opts?.subtitle ? ` — ${opts.subtitle}` : ''}`,
};
}
export function createCarouselMessage(
body: string,
cards: CarouselCard[],
): CarouselMessage {
return {
type: 'carousel',
body,
cards: cards.slice(0, 10),
fallbackText: body,
};
}
export function createCTAMessage(
text: string,
buttons: Array<{ title: string; url: string }>,
): CTAMessage {
return { type: 'cta', text, buttons, fallbackText: text };
}
- Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -20
Expected: No errors from rich-message.types.ts (other pre-existing errors are OK)
- Step 3: Commit
git add APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message.types.ts
git commit -m "[S211.X] feat: add channel-agnostic RichMessage type system"
Task 2: Define ChannelAdapter Interface
Files:
Create:
APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.tsStep 1: Create the adapters directory and interface file
// APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.ts
/**
* S211: Channel adapter interface.
* Each adapter converts a RichMessage into channel-native format.
*
* WhatsApp adapter: calls Twilio Content API, returns { alreadySent: true }
* Web adapter: returns generative-ui JSON blocks, ChannelProcessing injects into message content
* Slack adapter (future): converts to Block Kit
*/
import { RichMessage } from '../rich-messages/rich-message.types';
export interface ChannelAdapterContext {
channelId: string;
channelType: string;
userId?: string;
spaceId?: string;
metadata?: Record<string, unknown>;
}
export interface ChannelAdapterResult {
/** Whether the adapter successfully handled the message */
success: boolean;
/** Rendered output */
rendered?: {
/** Replacement text content (web: generative-ui blocks prepended to original text) */
text?: string;
/** True if the adapter already sent the message (WhatsApp Content API sends directly) */
alreadySent?: boolean;
};
error?: string;
}
export interface ChannelAdapter {
/** Which channel this adapter handles */
readonly channelType: string;
/**
* Check if this adapter can render the given rich message type.
* Returns false for 'text' — text messages need no special rendering.
*/
supports(message: RichMessage): boolean;
/**
* Render the rich message for this channel.
* For WhatsApp: calls Twilio APIs and returns { alreadySent: true }.
* For Web: returns { text: '<generative-ui block>' } to be injected into response.
*/
render(
message: RichMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult>;
}
- Step 2: Commit
git add APIs/backend-vitto-api/src/app/engine/adapters/channel-adapter.interface.ts
git commit -m "[S211.X] feat: add ChannelAdapter interface for rich message rendering"
Task 3: Build AdapterRegistry Service + Test
Files:
Create:
APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.tsCreate:
APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.tsStep 1: Write the failing test
// APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.ts
import { AdapterRegistryService } from '../adapters/adapter-registry.service';
import { ChannelAdapter, ChannelAdapterContext, ChannelAdapterResult } from '../adapters/channel-adapter.interface';
import { RichMessage, createTextMessage, createQuickReplyMessage } from '../rich-messages/rich-message.types';
class MockWhatsAppAdapter implements ChannelAdapter {
readonly channelType = 'whatsapp';
supports(msg: RichMessage): boolean { return msg.type !== 'text'; }
async render(msg: RichMessage, ctx: ChannelAdapterContext): Promise<ChannelAdapterResult> {
return { success: true, rendered: { alreadySent: true } };
}
}
class MockWebAdapter implements ChannelAdapter {
readonly channelType = 'web';
supports(msg: RichMessage): boolean { return msg.type !== 'text'; }
async render(msg: RichMessage, ctx: ChannelAdapterContext): Promise<ChannelAdapterResult> {
return { success: true, rendered: { text: '```generative-ui\n{}\n```' } };
}
}
describe('AdapterRegistryService', () => {
let registry: AdapterRegistryService;
beforeEach(() => {
registry = new AdapterRegistryService();
registry.register(new MockWhatsAppAdapter());
registry.register(new MockWebAdapter());
});
it('should resolve the correct adapter for a channel type', () => {
const adapter = registry.getAdapter('whatsapp');
expect(adapter).toBeDefined();
expect(adapter!.channelType).toBe('whatsapp');
});
it('should return undefined for unregistered channels', () => {
expect(registry.getAdapter('slack')).toBeUndefined();
});
it('should check if a channel has a registered adapter', () => {
expect(registry.hasAdapter('whatsapp')).toBe(true);
expect(registry.hasAdapter('discord')).toBe(false);
});
it('should list all registered channel types', () => {
const types = registry.getRegisteredChannels();
expect(types).toContain('whatsapp');
expect(types).toContain('web');
expect(types).toHaveLength(2);
});
});
- Step 2: Run test to verify it fails
Run: yarn nx test backend-vitto-api --testPathPattern="adapter-registry" 2>&1 | tail -10
Expected: FAIL — cannot find module ../adapters/adapter-registry.service
- Step 3: Write the implementation
// APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts
import { Injectable, Logger, Optional } from '@nestjs/common';
import { ChannelAdapter } from './channel-adapter.interface';
/**
* S211: Registry that maps channel type → ChannelAdapter.
* Adapters self-register via @Optional() injection + onModuleInit.
*/
@Injectable()
export class AdapterRegistryService {
private readonly logger = new Logger('AdapterRegistry');
private readonly adapters = new Map<string, ChannelAdapter>();
register(adapter: ChannelAdapter): void {
this.adapters.set(adapter.channelType, adapter);
this.logger.log(`Registered adapter: ${adapter.channelType}`);
}
getAdapter(channelType: string): ChannelAdapter | undefined {
return this.adapters.get(channelType);
}
hasAdapter(channelType: string): boolean {
return this.adapters.has(channelType);
}
getRegisteredChannels(): string[] {
return Array.from(this.adapters.keys());
}
}
- Step 4: Run test to verify it passes
Run: yarn nx test backend-vitto-api --testPathPattern="adapter-registry" 2>&1 | tail -10
Expected: PASS — 4 tests passing
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts \
APIs/backend-vitto-api/src/app/engine/__tests__/adapter-registry.spec.ts
git commit -m "[S211.X] feat: add AdapterRegistryService for channel adapter resolution"
Chunk 2: WhatsApp Adapter + RichMessage Composer
Task 4: Build WhatsApp Channel Adapter + Test
Files:
- Create:
APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts - Create:
APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts
Key dependency: ChannelGatewayService already has sendWhatsAppQuickReply(), sendWhatsAppCarousel(), sendWhatsAppSticker(). For list pickers and cards, use the TwilioContentApiService (injected into gateway or used directly).
- Step 1: Write the failing test
// APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts
import { WhatsAppChannelAdapter } from '../adapters/whatsapp-channel.adapter';
import {
createTextMessage,
createQuickReplyMessage,
createListPickerMessage,
createCardMessage,
createCTAMessage,
createCarouselMessage,
} from '../rich-messages/rich-message.types';
describe('WhatsAppChannelAdapter', () => {
let adapter: WhatsAppChannelAdapter;
let mockGateway: any;
let mockContentApi: any;
beforeEach(() => {
mockGateway = {
sendWhatsAppQuickReply: jest.fn().mockResolvedValue(true),
sendWhatsAppCarousel: jest.fn().mockResolvedValue(true),
sendResponse: jest.fn().mockResolvedValue(true),
twilio: {
sendWhatsAppMessage: jest.fn().mockResolvedValue({ success: true }),
getPlatformConfig: jest.fn().mockReturnValue({ accountSid: 'AC123', authToken: 'token123' }),
},
};
mockContentApi = {
createListPicker: jest.fn().mockResolvedValue('HXLP123'),
createCard: jest.fn().mockResolvedValue('HXCARD123'),
// Note: sendContentMessage is private on TwilioContentApiService — the adapter
// sends via gateway.twilio.sendWhatsAppMessage({ contentSid }) instead
};
adapter = new WhatsAppChannelAdapter(mockGateway, mockContentApi);
});
const ctx = { channelId: '+1234567890', channelType: 'whatsapp' };
it('should NOT support text messages (no special rendering needed)', () => {
expect(adapter.supports(createTextMessage('hello'))).toBe(false);
});
it('should support quick_reply messages', () => {
const msg = createQuickReplyMessage('Pick one', [{ title: 'Yes' }, { title: 'No' }]);
expect(adapter.supports(msg)).toBe(true);
});
it('should render quick_reply via gateway.sendWhatsAppQuickReply', async () => {
const msg = createQuickReplyMessage('Pick one', [{ title: 'Yes' }, { title: 'No' }]);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
expect(result.rendered?.alreadySent).toBe(true);
expect(mockGateway.sendWhatsAppQuickReply).toHaveBeenCalledWith(
'+1234567890', 'Pick one', ['Yes', 'No'],
);
});
it('should render list_picker via TwilioContentApiService', async () => {
const msg = createListPickerMessage(
'Choose a module',
'View Options',
[{ title: 'Modules', items: [{ id: 'mod1', title: 'Players', description: 'Player management' }] }],
);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
expect(result.rendered?.alreadySent).toBe(true);
expect(mockContentApi.createListPicker).toHaveBeenCalledWith(
expect.objectContaining({ body: 'Choose a module', buttonText: 'View Options' }),
);
});
it('should render cta as a card with URL buttons', async () => {
const msg = createCTAMessage('Module ready!', [{ title: 'Open in GoGrab', url: 'https://app.gograb.space/modules/123' }]);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
expect(mockContentApi.createCard).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Module ready!',
buttons: expect.arrayContaining([
expect.objectContaining({ type: 'url', title: 'Open in GoGrab', url: 'https://app.gograb.space/modules/123' }),
]),
}),
);
});
it('should fall back to text on Content API failure', async () => {
mockContentApi.createListPicker.mockResolvedValue(null);
const msg = createListPickerMessage('Choose', 'Options', [{ title: 'S', items: [{ id: '1', title: 'A' }] }]);
const result = await adapter.render(msg, ctx);
// Falls back gracefully — sends fallback text
expect(result.success).toBe(true);
expect(mockGateway.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Choose', channelType: 'whatsapp' }),
);
});
});
- Step 2: Run test to verify it fails
Run: yarn nx test backend-vitto-api --testPathPattern="whatsapp-adapter" 2>&1 | tail -10
Expected: FAIL — cannot find module ../adapters/whatsapp-channel.adapter
- Step 3: Write the implementation
// APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts
import { Injectable, Logger, Optional } from '@nestjs/common';
import { ChannelAdapter, ChannelAdapterContext, ChannelAdapterResult } from './channel-adapter.interface';
import {
RichMessage,
QuickReplyMessage,
ListPickerMessage,
CardMessage,
CarouselMessage,
CTAMessage,
} from '../rich-messages/rich-message.types';
import { ChannelGatewayService } from '../../connectivity/channel-gateway.service';
import { TwilioContentApiService } from '@libs-api/messaging-core';
/**
* S211: Converts RichMessage → WhatsApp-native format via Twilio APIs.
*
* - quick_reply → ChannelGateway.sendWhatsAppQuickReply (existing, works)
* - list_picker → TwilioContentApiService.createListPicker + send via Content SID
* - card → TwilioContentApiService.createCard + send via Content SID
* - carousel → ChannelGateway.sendWhatsAppCarousel (existing, works)
* - cta → Rendered as a card with URL-type buttons
* - text → Not handled (adapter.supports returns false)
*
* All methods send directly and return { alreadySent: true }.
* On failure, falls back to sending the fallbackText as plain text.
*/
@Injectable()
export class WhatsAppChannelAdapter implements ChannelAdapter {
private readonly logger = new Logger('WhatsAppAdapter');
readonly channelType = 'whatsapp';
constructor(
private readonly gateway: ChannelGatewayService,
@Optional() private readonly contentApi?: TwilioContentApiService,
) {}
supports(message: RichMessage): boolean {
return message.type !== 'text';
}
async render(
message: RichMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
try {
switch (message.type) {
case 'quick_reply':
return this.renderQuickReply(message, context);
case 'list_picker':
return this.renderListPicker(message, context);
case 'card':
return this.renderCard(message, context);
case 'carousel':
return this.renderCarousel(message, context);
case 'cta':
return this.renderCTA(message, context);
default:
// media, location — fall through to text
return this.sendFallbackText(message, context);
}
} catch (err) {
this.logger.warn(`WhatsApp render failed for ${message.type}: ${(err as Error).message}`);
return this.sendFallbackText(message, context);
}
}
private async renderQuickReply(
message: QuickReplyMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
const titles = message.buttons.map(b => b.title.substring(0, 20));
const success = await this.gateway.sendWhatsAppQuickReply(
context.channelId, message.text, titles,
);
if (!success) return this.sendFallbackText(message, context);
return { success: true, rendered: { alreadySent: true } };
}
private async renderListPicker(
message: ListPickerMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
if (!this.contentApi) return this.sendFallbackText(message, context);
const contentSid = await this.contentApi.createListPicker({
body: message.body,
buttonText: message.buttonText,
sections: message.sections,
});
if (!contentSid) return this.sendFallbackText(message, context);
return this.sendContentSid(contentSid, context);
}
private async renderCard(
message: CardMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
if (!this.contentApi) return this.sendFallbackText(message, context);
const contentSid = await this.contentApi.createCard({
title: message.title,
subtitle: message.subtitle,
mediaUrl: message.mediaUrl,
buttons: message.buttons.map(b => ({
type: b.type,
title: b.title,
url: b.url,
phone: b.phone,
id: b.id,
})),
});
if (!contentSid) return this.sendFallbackText(message, context);
return this.sendContentSid(contentSid, context);
}
private async renderCarousel(
message: CarouselMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
const success = await this.gateway.sendWhatsAppCarousel(
context.channelId,
message.body,
message.cards.map(card => ({
title: card.title,
body: card.body,
mediaUrl: card.mediaUrl,
buttons: card.buttons.map(b => ({
type: b.type === 'url' ? 'url' as const : 'quick_reply' as const,
title: b.title,
url: b.url,
id: b.id,
})),
})),
);
if (!success) return this.sendFallbackText(message, context);
return { success: true, rendered: { alreadySent: true } };
}
private async renderCTA(
message: CTAMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
if (!this.contentApi) return this.sendFallbackText(message, context);
// Render CTA as a card with URL-type buttons
const contentSid = await this.contentApi.createCard({
title: message.text.substring(0, 60),
buttons: message.buttons.slice(0, 3).map(b => ({
type: 'url' as const,
title: b.title,
url: b.url,
})),
});
if (!contentSid) return this.sendFallbackText(message, context);
return this.sendContentSid(contentSid, context);
}
/** Send a Content SID message via Twilio Messages API */
private async sendContentSid(
contentSid: string,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
const result = await this.gateway.twilio.sendWhatsAppMessage(
context.channelId, '', undefined, { contentSid },
);
if (!result.success) {
this.logger.warn(`Content SID send failed: ${result.error}`);
return { success: false, error: result.error };
}
return { success: true, rendered: { alreadySent: true } };
}
/** Send plain text fallback when rich rendering fails */
private async sendFallbackText(
message: RichMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
await this.gateway.sendResponse({
channelType: 'whatsapp',
channelId: context.channelId,
text: message.fallbackText,
metadata: context.metadata,
});
return { success: true, rendered: { alreadySent: true } };
}
}
- Step 4: Run test to verify it passes
Run: yarn nx test backend-vitto-api --testPathPattern="whatsapp-adapter" 2>&1 | tail -15
Expected: PASS — 6 tests passing
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/engine/adapters/whatsapp-channel.adapter.ts \
APIs/backend-vitto-api/src/app/engine/__tests__/whatsapp-adapter.spec.ts
git commit -m "[S211.X] feat: add WhatsAppChannelAdapter with Twilio Content API integration"
Task 5: Build RichMessage Composer Service + Test
Files:
- Create:
APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts - Create:
APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts
This replaces the heuristic logic from WhatsAppResponseComposerService (which remains for backward compat) with a channel-agnostic version that produces RichMessage objects.
- Step 1: Write the failing test
// APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts
import { RichMessageComposerService } from '../rich-messages/rich-message-composer.service';
describe('RichMessageComposerService', () => {
let composer: RichMessageComposerService;
beforeEach(() => {
composer = new RichMessageComposerService();
});
describe('compose()', () => {
it('should return TextMessage for short responses', () => {
const result = composer.compose('OK, done!', []);
expect(result.type).toBe('text');
});
it('should return QuickReplyMessage when response ends with a yes/no question', () => {
const result = composer.compose(
'I found the module configuration. Would you like me to update it?',
[],
);
expect(result.type).toBe('quick_reply');
if (result.type === 'quick_reply') {
expect(result.buttons.length).toBeGreaterThanOrEqual(2);
expect(result.buttons.length).toBeLessThanOrEqual(3);
}
});
it('should return CardMessage when task_complete tool was used with long output', () => {
const longOutput = 'The Players module has been successfully generated with 5 fields: name, age, position, team, and jersey number. The module includes a list view, detail view, and form view with validation. All files have been created and the module is ready for review.';
const result = composer.compose(longOutput, ['task_complete']);
expect(result.type).toBe('card');
});
it('should return ListPickerMessage when response contains 3+ numbered options', () => {
const listOutput = `Here are the available modules you can create:
1. **Player Management** — Track team rosters and stats
2. **Match Scheduler** — Schedule and manage games
3. **Training Log** — Record training sessions
4. **Equipment Inventory** — Track team equipment
5. **Tournament Bracket** — Manage tournament brackets
Which one would you like to create?`;
const result = composer.compose(listOutput, []);
expect(result.type).toBe('list_picker');
if (result.type === 'list_picker') {
expect(result.sections[0].items.length).toBe(5);
}
});
it('should return CTAMessage when task_complete includes a URL-like reference', () => {
const output = 'Module generated successfully! You can view it at /spaces/abc/modules/players';
const result = composer.compose(output, ['task_complete'], {
platformUrl: 'https://app.gograb.space',
});
expect(result.type).toBe('cta');
if (result.type === 'cta') {
expect(result.buttons[0].url).toContain('https://app.gograb.space');
}
});
it('should always include a fallbackText', () => {
const cases = [
{ text: 'short', tools: [] as string[] },
{ text: 'Would you like me to proceed with the changes?', tools: [] as string[] },
{ text: 'A long task result ' + 'x'.repeat(200), tools: ['task_complete'] },
];
for (const c of cases) {
const result = composer.compose(c.text, c.tools);
expect(result.fallbackText).toBeTruthy();
}
});
it('should return TextMessage as default for ambiguous content', () => {
const result = composer.compose(
'The weather in Miami is currently 82°F with partly cloudy skies.',
[],
);
expect(result.type).toBe('text');
});
});
});
- Step 2: Run test to verify it fails
Run: yarn nx test backend-vitto-api --testPathPattern="rich-message-composer" 2>&1 | tail -10
Expected: FAIL — cannot find module
- Step 3: Write the implementation
// APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts
import { Injectable, Logger } from '@nestjs/common';
import {
RichMessage,
createTextMessage,
createQuickReplyMessage,
createListPickerMessage,
createCardMessage,
createCTAMessage,
} from './rich-message.types';
export interface ComposerOptions {
/** Base platform URL for CTA deep links (e.g., 'https://app.gograb.space') */
platformUrl?: string;
}
/**
* S211: Analyzes agent loop output and selects the optimal RichMessage type.
* Heuristic-based only — no LLM call. Must be instant and free.
*
* Decision order (first match wins):
* 1. Short text (<30 chars) → TextMessage
* 2. task_complete + URL path → CTAMessage with deep link
* 3. 3+ numbered items + trailing question → ListPickerMessage
* 4. Ends with yes/no question → QuickReplyMessage
* 5. task_complete + long output → CardMessage
* 6. Ends with any question → QuickReplyMessage (generic)
* 7. Default → TextMessage
*/
@Injectable()
export class RichMessageComposerService {
private readonly logger = new Logger('RichMessageComposer');
compose(
text: string,
toolCallNames: string[],
options?: ComposerOptions,
): RichMessage {
const trimmed = text.trim();
// 1. Very short → plain text
if (trimmed.length < 30) {
return createTextMessage(trimmed);
}
const hasTaskComplete = toolCallNames.includes('task_complete');
// 2. Task complete with URL path → CTA deep link
if (hasTaskComplete && options?.platformUrl) {
const pathMatch = trimmed.match(/(?:\/spaces\/[^\s]+|\/modules\/[^\s]+|\/admin\/[^\s]+|\/vitto\/[^\s]+)/);
if (pathMatch) {
const url = `${options.platformUrl}${pathMatch[0]}`;
return createCTAMessage(trimmed, [
{ title: 'Open in GoGrab', url },
]);
}
}
// 3. Numbered list with 3+ items → ListPicker
const listItems = this.extractNumberedList(trimmed);
if (listItems.length >= 3) {
return createListPickerMessage(
this.extractPreListText(trimmed),
'View Options',
[{
title: 'Options',
items: listItems.slice(0, 10).map((item, i) => ({
id: `opt_${i}`,
title: item.title.substring(0, 24),
description: item.description?.substring(0, 72),
})),
}],
);
}
// 4. Yes/no question → QuickReply
if (this.endsWithQuestion(trimmed)) {
const buttons = this.inferQuestionButtons(trimmed);
return createQuickReplyMessage(trimmed, buttons);
}
// 5. Task complete with long output → Card
if (hasTaskComplete && trimmed.length > 100) {
const title = this.extractTitle(trimmed);
return createCardMessage(title, [
{ type: 'quick_reply', title: 'Thanks!', id: 'ack' },
{ type: 'quick_reply', title: 'Do more', id: 'more' },
], { body: trimmed.substring(0, 300) });
}
// 6. Default → plain text
return createTextMessage(trimmed);
}
// ── Private helpers ──
private endsWithQuestion(text: string): boolean {
return text.slice(-150).trim().endsWith('?');
}
private inferQuestionButtons(text: string): Array<{ title: string; id?: string }> {
const lower = text.toLowerCase();
if (/\b(would you|do you|should i|want me to|shall i|can i|ready)\b/.test(lower)) {
return [{ title: 'Yes, please', id: 'yes' }, { title: 'No thanks', id: 'no' }, { title: 'Tell me more', id: 'more' }];
}
if (/\b(which|what would you prefer|option)\b/.test(lower)) {
return [{ title: 'First option', id: 'first' }, { title: 'Second option', id: 'second' }, { title: 'Not sure', id: 'unsure' }];
}
return [{ title: 'Yes', id: 'yes' }, { title: 'No', id: 'no' }, { title: 'More details', id: 'more' }];
}
/**
* Extract numbered items from text.
* Matches patterns like: "1. **Title** — Description" or "1. Title - Description"
*/
private extractNumberedList(text: string): Array<{ title: string; description?: string }> {
const lines = text.split('\n');
const items: Array<{ title: string; description?: string }> = [];
for (const line of lines) {
const match = line.match(/^\s*\d+\.\s+\*{0,2}([^*\n—–-]+)\*{0,2}\s*(?:[—–-]\s*(.+))?$/);
if (match) {
items.push({
title: match[1].trim(),
description: match[2]?.trim(),
});
}
}
return items;
}
/** Get the text before the first numbered item */
private extractPreListText(text: string): string {
const firstItemIndex = text.search(/^\s*1\.\s+/m);
if (firstItemIndex <= 0) return text.substring(0, 100);
return text.substring(0, firstItemIndex).trim() || text.substring(0, 100);
}
private extractTitle(text: string): string {
const firstSentence = text.match(/^[^.!?]+[.!?]/);
if (firstSentence && firstSentence[0].length <= 60) {
return firstSentence[0].trim();
}
return text.substring(0, 57) + '...';
}
}
- Step 4: Run test to verify it passes
Run: yarn nx test backend-vitto-api --testPathPattern="rich-message-composer" 2>&1 | tail -15
Expected: PASS — 7 tests passing
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/engine/rich-messages/rich-message-composer.service.ts \
APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-composer.spec.ts
git commit -m "[S211.X] feat: add RichMessageComposerService with heuristic type selection"
Chunk 3: Frontend Angular Rich Message Components
Task 6: Build Rich List Picker Component
Files:
- Create:
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts
Follow the same pattern as SkillPreviewCardComponent — standalone, OnPush, input() signals, inline template+styles.
- Step 1: Create the component
// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts
/**
* S211: Rich List Picker — renders selectable options grouped in sections.
* Rendered via generative-ui when Vitto presents a list of choices.
*
* ```generative-ui
* { "type": "generative-ui", "component": "rich-list-picker", "props": {
* "body": "Choose a module to create:",
* "sections": [{ "title": "Modules", "items": [{ "id": "m1", "title": "Players", "description": "Manage team rosters" }] }]
* }}
* ```
*/
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
export interface ListPickerItem {
id: string;
title: string;
description?: string;
}
export interface ListPickerSection {
title: string;
items: ListPickerItem[];
}
@Component({
selector: 'vitto-rich-list-picker',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (body()) {
<p class="rlp__body">{{ body() }}</p>
}
@for (section of sections(); track section.title) {
<div class="rlp__section">
<span class="rlp__section-title">{{ section.title }}</span>
@for (item of section.items; track item.id) {
<button class="rlp__item" (click)="itemSelected.emit(item)">
<span class="rlp__item-title">{{ item.title }}</span>
@if (item.description) {
<span class="rlp__item-desc">{{ item.description }}</span>
}
</button>
}
</div>
}
`,
styles: [`
:host {
display: block;
margin: 6px 0;
}
.rlp__body {
margin: 0 0 8px;
font-size: 0.8125rem;
color: #cbd5e1;
}
.rlp__section {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.rlp__section-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
padding: 0 4px;
}
.rlp__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(99, 102, 241, 0.15);
background: rgba(99, 102, 241, 0.06);
cursor: pointer;
text-align: left;
transition: background 0.15s;
&:hover {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.3);
}
}
.rlp__item-title {
font-size: 0.8125rem;
font-weight: 500;
color: #e2e8f0;
}
.rlp__item-desc {
font-size: 0.75rem;
color: #94a3b8;
}
`],
})
export class RichListPickerComponent {
readonly body = input<string>('');
readonly sections = input<ListPickerSection[]>([]);
readonly itemSelected = output<ListPickerItem>();
}
- Step 2: Commit
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-list-picker.component.ts
git commit -m "[S211.X] feat: add RichListPickerComponent for generative-ui catalog"
Task 7: Build Rich Card Component
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.tsStep 1: Create the component
// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.ts
/**
* S211: Rich Card — renders a card with title, subtitle, body, optional media, and action buttons.
*
* ```generative-ui
* { "type": "generative-ui", "component": "rich-card", "props": {
* "title": "Players Module", "subtitle": "Generated successfully",
* "body": "5 fields, 3 views", "buttons": [{ "title": "Open", "url": "/modules/123" }]
* }}
* ```
*/
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
export interface CardButton {
type?: 'quick_reply' | 'url' | 'phone';
title: string;
url?: string;
}
@Component({
selector: 'vitto-rich-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="rc">
@if (mediaUrl()) {
<img class="rc__media" [src]="mediaUrl()" [alt]="title()" loading="lazy" />
}
<div class="rc__content">
<h4 class="rc__title">{{ title() }}</h4>
@if (subtitle()) {
<span class="rc__subtitle">{{ subtitle() }}</span>
}
@if (body()) {
<p class="rc__body">{{ body() }}</p>
}
@if (buttons().length > 0) {
<div class="rc__actions">
@for (btn of buttons(); track btn.title) {
@if (btn.url) {
<a class="rc__btn rc__btn--url" [href]="btn.url" target="_blank" rel="noopener">{{ btn.title }}</a>
} @else {
<button class="rc__btn rc__btn--reply">{{ btn.title }}</button>
}
}
</div>
}
</div>
</div>
`,
styles: [`
:host { display: block; margin: 6px 0; }
.rc {
border-radius: 10px;
border: 1px solid rgba(99, 102, 241, 0.2);
background: rgba(99, 102, 241, 0.06);
overflow: hidden;
}
.rc__media {
width: 100%;
max-height: 180px;
object-fit: cover;
}
.rc__content { padding: 12px 14px; }
.rc__title {
margin: 0 0 4px;
font-size: 0.875rem;
font-weight: 600;
color: #e2e8f0;
}
.rc__subtitle {
font-size: 0.75rem;
color: #a5b4fc;
display: block;
margin-bottom: 6px;
}
.rc__body {
margin: 0 0 10px;
font-size: 0.8125rem;
color: #94a3b8;
line-height: 1.4;
}
.rc__actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rc__btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: background 0.15s;
}
.rc__btn--url {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.3);
&:hover { background: rgba(99, 102, 241, 0.3); }
}
.rc__btn--reply {
background: rgba(255, 255, 255, 0.06);
color: #cbd5e1;
border: 1px solid rgba(255, 255, 255, 0.1);
&:hover { background: rgba(255, 255, 255, 0.1); }
}
`],
})
export class RichCardComponent {
readonly title = input<string>('');
readonly subtitle = input<string>('');
readonly body = input<string>('');
readonly mediaUrl = input<string>('');
readonly buttons = input<CardButton[]>([]);
}
- Step 2: Commit
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-card.component.ts
git commit -m "[S211.X] feat: add RichCardComponent for generative-ui catalog"
Task 8: Build Rich Carousel Component
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.tsStep 1: Create the component
// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.ts
/**
* S211: Rich Carousel — horizontally scrollable card strip.
*
* ```generative-ui
* { "type": "generative-ui", "component": "rich-carousel", "props": {
* "body": "Here are the available templates:",
* "cards": [{ "title": "Blank", "body": "Start fresh" }, { "title": "CRM", "body": "Customer management" }]
* }}
* ```
*/
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
export interface CarouselCard {
title: string;
body?: string;
mediaUrl?: string;
buttons?: Array<{ title: string; url?: string }>;
}
@Component({
selector: 'vitto-rich-carousel',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (body()) {
<p class="rcr__body">{{ body() }}</p>
}
<div class="rcr__strip">
@for (card of cards(); track card.title) {
<div class="rcr__card">
@if (card.mediaUrl) {
<img class="rcr__media" [src]="card.mediaUrl" [alt]="card.title" loading="lazy" />
}
<h5 class="rcr__title">{{ card.title }}</h5>
@if (card.body) {
<p class="rcr__desc">{{ card.body }}</p>
}
@if (card.buttons?.length) {
<div class="rcr__actions">
@for (btn of card.buttons; track btn.title) {
@if (btn.url) {
<a class="rcr__btn" [href]="btn.url" target="_blank" rel="noopener">{{ btn.title }}</a>
} @else {
<button class="rcr__btn">{{ btn.title }}</button>
}
}
</div>
}
</div>
}
</div>
`,
styles: [`
:host { display: block; margin: 6px 0; }
.rcr__body {
margin: 0 0 8px;
font-size: 0.8125rem;
color: #cbd5e1;
}
.rcr__strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.rcr__card {
flex: 0 0 200px;
scroll-snap-align: start;
border-radius: 10px;
border: 1px solid rgba(99, 102, 241, 0.2);
background: rgba(99, 102, 241, 0.06);
padding: 10px;
overflow: hidden;
}
.rcr__media {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 8px;
}
.rcr__title {
margin: 0 0 4px;
font-size: 0.8125rem;
font-weight: 600;
color: #e2e8f0;
}
.rcr__desc {
margin: 0 0 8px;
font-size: 0.75rem;
color: #94a3b8;
line-height: 1.3;
}
.rcr__actions { display: flex; gap: 4px; flex-wrap: wrap; }
.rcr__btn {
padding: 4px 10px;
border-radius: 5px;
font-size: 0.6875rem;
font-weight: 500;
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.2);
cursor: pointer;
text-decoration: none;
&:hover { background: rgba(99, 102, 241, 0.25); }
}
`],
})
export class RichCarouselComponent {
readonly body = input<string>('');
readonly cards = input<CarouselCard[]>([]);
}
- Step 2: Commit
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-carousel.component.ts
git commit -m "[S211.X] feat: add RichCarouselComponent for generative-ui catalog"
Task 9: Build Rich CTA Button + Quick Reply Components
Files:
Create:
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.tsCreate:
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.tsStep 1: Create CTA button component
// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.ts
/**
* S211: CTA Button — deep link "Open in GoGrab" button.
*
* ```generative-ui
* { "type": "generative-ui", "component": "rich-cta-button", "props": {
* "text": "Module generated!", "buttons": [{ "title": "Open in GoGrab", "url": "/modules/123" }]
* }}
* ```
*/
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
@Component({
selector: 'vitto-rich-cta-button',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (text()) {
<p class="cta__text">{{ text() }}</p>
}
<div class="cta__actions">
@for (btn of buttons(); track btn.title) {
<a class="cta__btn" [href]="btn.url" target="_blank" rel="noopener">
<span class="material-icons cta__icon">open_in_new</span>
{{ btn.title }}
</a>
}
</div>
`,
styles: [`
:host { display: block; margin: 6px 0; }
.cta__text {
margin: 0 0 8px;
font-size: 0.8125rem;
color: #cbd5e1;
}
.cta__actions { display: flex; gap: 8px; flex-wrap: wrap; }
.cta__btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 600;
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.3);
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(99, 102, 241, 0.3);
transform: translateY(-1px);
}
}
.cta__icon { font-size: 16px; }
`],
})
export class RichCTAButtonComponent {
readonly text = input<string>('');
readonly buttons = input<Array<{ title: string; url: string }>>([]);
}
- Step 2: Create quick reply component
// libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.ts
/**
* S211: Quick Reply Chips — tappable reply suggestions rendered inline.
*
* ```generative-ui
* { "type": "generative-ui", "component": "rich-quick-reply", "props": {
* "buttons": [{ "title": "Yes, please" }, { "title": "No thanks" }, { "title": "Tell me more" }]
* }}
* ```
*/
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
@Component({
selector: 'vitto-rich-quick-reply',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (text()) {
<p class="qr__text">{{ text() }}</p>
}
<div class="qr__row">
@for (btn of buttons(); track btn.title) {
<button class="qr__chip" (click)="selected.emit(btn.title)">{{ btn.title }}</button>
}
</div>
`,
styles: [`
:host { display: block; margin: 6px 0; }
.qr__text {
margin: 0 0 6px;
font-size: 0.8125rem;
color: #cbd5e1;
}
.qr__row { display: flex; gap: 6px; flex-wrap: wrap; }
.qr__chip {
padding: 6px 14px;
border-radius: 16px;
font-size: 0.8125rem;
font-weight: 500;
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.25);
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.4);
}
}
`],
})
export class RichQuickReplyComponent {
readonly text = input<string>('');
readonly buttons = input<Array<{ title: string; id?: string }>>([]);
readonly selected = output<string>();
}
- Step 3: Commit
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-cta-button.component.ts \
libs/libs-ai/vitto-chat/src/lib/generative-ui/components/rich-quick-reply.component.ts
git commit -m "[S211.X] feat: add RichCTAButtonComponent and RichQuickReplyComponent"
Task 10: Register All Rich Components in Catalog Seed
Files:
Modify:
libs/libs-ai/vitto-chat/src/lib/generative-ui/catalog-seed.tsStep 1: Update the seed function to register all 5 new components
Add imports and catalog.register() calls for each new component. The existing SkillPreviewCardComponent and AlertBadgeComponent registrations stay untouched.
// Add these imports at the top (after existing imports):
import { RichListPickerComponent } from './components/rich-list-picker.component';
import { RichCardComponent } from './components/rich-card.component';
import { RichCarouselComponent } from './components/rich-carousel.component';
import { RichCTAButtonComponent } from './components/rich-cta-button.component';
import { RichQuickReplyComponent } from './components/rich-quick-reply.component';
// Add these registrations inside seedGenerativeUICatalog() after existing ones:
// S211: Rich messaging components
catalog.register({
id: 'rich-list-picker',
label: 'List Picker',
description: 'Renders a list of selectable options grouped in sections. Use when presenting 3+ choices.',
component: RichListPickerComponent,
expectedProps: ['sections'],
category: 'general',
});
catalog.register({
id: 'rich-card',
label: 'Rich Card',
description: 'Renders a card with title, subtitle, body, optional media, and action buttons. Use for task results, summaries, or structured information.',
component: RichCardComponent,
expectedProps: ['title'],
category: 'general',
});
catalog.register({
id: 'rich-carousel',
label: 'Carousel',
description: 'Renders a horizontal strip of scrollable cards. Use when presenting multiple comparable items (templates, modules, results).',
component: RichCarouselComponent,
expectedProps: ['cards'],
category: 'general',
});
catalog.register({
id: 'rich-cta-button',
label: 'CTA Button',
description: 'Renders prominent call-to-action buttons with URLs. Use for "Open in GoGrab" deep links after task completion.',
component: RichCTAButtonComponent,
expectedProps: ['buttons'],
category: 'general',
});
catalog.register({
id: 'rich-quick-reply',
label: 'Quick Reply',
description: 'Renders a row of tappable quick reply chips with optional prompt text. Use when offering 2-3 follow-up options.',
component: RichQuickReplyComponent,
expectedProps: ['buttons'], // text is optional — renders above chips if provided
category: 'general',
});
- Step 2: Verify it compiles
Run: npx tsc --noEmit --project libs/libs-ai/vitto-chat/tsconfig.lib.json 2>&1 | head -20
Expected: No errors from the new components or catalog-seed.ts
- Step 3: Commit
git add libs/libs-ai/vitto-chat/src/lib/generative-ui/catalog-seed.ts
git commit -m "[S211.X] feat: register 5 rich message components in GenerativeUI catalog"
Chunk 4: Web Chat Adapter + ChannelProcessing Wiring + Engine Registration
Task 11: Build Web Chat Channel Adapter + Test
Files:
- Create:
APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts - Create:
APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts
The web adapter converts RichMessage → generative-ui JSON blocks that MessageBodyComponent on the frontend already knows how to parse and render.
- Step 1: Write the failing test
// APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts
import { WebChatChannelAdapter } from '../adapters/web-chat-channel.adapter';
import {
createQuickReplyMessage,
createListPickerMessage,
createCardMessage,
createCTAMessage,
createTextMessage,
createCarouselMessage,
} from '../rich-messages/rich-message.types';
describe('WebChatChannelAdapter', () => {
let adapter: WebChatChannelAdapter;
beforeEach(() => {
adapter = new WebChatChannelAdapter();
});
const ctx = { channelId: 'web-session-123', channelType: 'web' };
it('should NOT support text messages', () => {
expect(adapter.supports(createTextMessage('hello'))).toBe(false);
});
it('should support quick_reply messages', () => {
const msg = createQuickReplyMessage('Pick one', [{ title: 'Yes' }, { title: 'No' }]);
expect(adapter.supports(msg)).toBe(true);
});
it('should render quick_reply as generative-ui block with component "rich-quick-reply"', async () => {
const msg = createQuickReplyMessage('Pick one?', [
{ title: 'Yes', id: 'yes' },
{ title: 'No', id: 'no' },
]);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
expect(result.rendered?.text).toContain('```generative-ui');
expect(result.rendered?.text).toContain('"component": "rich-quick-reply"');
expect(result.rendered?.text).toContain('Pick one?');
expect(result.rendered?.alreadySent).toBeUndefined();
});
it('should render list_picker as generative-ui block with component "rich-list-picker"', async () => {
const msg = createListPickerMessage(
'Choose a module',
'View',
[{ title: 'Options', items: [{ id: 'm1', title: 'Players' }] }],
);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
const text = result.rendered?.text || '';
expect(text).toContain('"component": "rich-list-picker"');
expect(text).toContain('Choose a module');
expect(text).toContain('Players');
});
it('should render card as generative-ui block with component "rich-card"', async () => {
const msg = createCardMessage('Players Module', [{ type: 'url', title: 'Open', url: '/modules/123' }], {
subtitle: 'Generated',
body: '5 fields',
});
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
const text = result.rendered?.text || '';
expect(text).toContain('"component": "rich-card"');
expect(text).toContain('Players Module');
});
it('should render cta as generative-ui block with component "rich-cta-button"', async () => {
const msg = createCTAMessage('Module ready!', [{ title: 'Open', url: 'https://app.gograb.space/mod/123' }]);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
const text = result.rendered?.text || '';
expect(text).toContain('"component": "rich-cta-button"');
expect(text).toContain('https://app.gograb.space/mod/123');
});
it('should render carousel as generative-ui block', async () => {
const msg = createCarouselMessage('Templates:', [
{ title: 'Blank', buttons: [] },
{ title: 'CRM', buttons: [] },
]);
const result = await adapter.render(msg, ctx);
expect(result.success).toBe(true);
const text = result.rendered?.text || '';
expect(text).toContain('"component": "rich-carousel"');
expect(text).toContain('Blank');
expect(text).toContain('CRM');
});
it('should prepend generative-ui block BEFORE the fallback text', async () => {
const msg = createQuickReplyMessage('What next?', [{ title: 'Yes' }]);
const result = await adapter.render(msg, ctx);
const text = result.rendered?.text || '';
// Split on the closing fence — everything before is the GUI block, after is fallback
const closingFenceIndex = text.indexOf('```\n\n');
expect(closingFenceIndex).toBeGreaterThan(0);
const afterBlock = text.substring(closingFenceIndex + 5);
// The fallback text should appear after the generative-ui block
expect(afterBlock).toContain('What next?');
// And the block itself should start with the opening fence
expect(text.startsWith('```generative-ui')).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: yarn nx test backend-vitto-api --testPathPattern="web-chat-adapter" 2>&1 | tail -10
Expected: FAIL — cannot find module
- Step 3: Write the implementation
// APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts
import { Injectable, Logger } from '@nestjs/common';
import { ChannelAdapter, ChannelAdapterContext, ChannelAdapterResult } from './channel-adapter.interface';
import {
RichMessage,
QuickReplyMessage,
ListPickerMessage,
CardMessage,
CarouselMessage,
CTAMessage,
} from '../rich-messages/rich-message.types';
/**
* S211: Converts RichMessage → generative-ui JSON blocks for web chat.
*
* The frontend MessageBodyComponent already parses ```generative-ui blocks
* and renders them via GenerativeUIComponentCatalog + VittoRenderComponent.
* This adapter just serializes the RichMessage props into that format.
*
* Unlike WhatsApp adapter, this does NOT send anything — it returns rendered.text
* which ChannelProcessingService prepends to the response content.
*/
@Injectable()
export class WebChatChannelAdapter implements ChannelAdapter {
private readonly logger = new Logger('WebChatAdapter');
readonly channelType = 'web';
supports(message: RichMessage): boolean {
return message.type !== 'text';
}
async render(
message: RichMessage,
context: ChannelAdapterContext,
): Promise<ChannelAdapterResult> {
try {
const componentId = this.resolveComponentId(message);
if (!componentId) {
return { success: true, rendered: { text: message.fallbackText } };
}
const props = this.buildProps(message);
const guiBlock = this.formatGenerativeUIBlock(componentId, props);
// Prepend generative-ui block before the fallback text
const fullText = `${guiBlock}\n\n${message.fallbackText}`;
return { success: true, rendered: { text: fullText } };
} catch (err) {
this.logger.warn(`Web render failed for ${message.type}: ${(err as Error).message}`);
return { success: true, rendered: { text: message.fallbackText } };
}
}
private resolveComponentId(message: RichMessage): string | null {
switch (message.type) {
case 'quick_reply': return 'rich-quick-reply';
case 'list_picker': return 'rich-list-picker';
case 'card': return 'rich-card';
case 'carousel': return 'rich-carousel';
case 'cta': return 'rich-cta-button';
default: return null;
}
}
private buildProps(message: RichMessage): Record<string, unknown> {
switch (message.type) {
case 'quick_reply': {
const m = message as QuickReplyMessage;
return { text: m.text, buttons: m.buttons };
}
case 'list_picker': {
const m = message as ListPickerMessage;
return { body: m.body, sections: m.sections };
}
case 'card': {
const m = message as CardMessage;
return {
title: m.title,
subtitle: m.subtitle,
body: m.body,
mediaUrl: m.mediaUrl,
buttons: m.buttons,
};
}
case 'carousel': {
const m = message as CarouselMessage;
return { body: m.body, cards: m.cards };
}
case 'cta': {
const m = message as CTAMessage;
return { text: m.text, buttons: m.buttons };
}
default:
return {};
}
}
private formatGenerativeUIBlock(
componentId: string,
props: Record<string, unknown>,
): string {
const instruction = {
type: 'generative-ui',
component: componentId,
props,
};
return '```generative-ui\n' + JSON.stringify(instruction, null, 2) + '\n```';
}
}
- Step 4: Run test to verify it passes
Run: yarn nx test backend-vitto-api --testPathPattern="web-chat-adapter" 2>&1 | tail -15
Expected: PASS — 7 tests passing
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/engine/adapters/web-chat-channel.adapter.ts \
APIs/backend-vitto-api/src/app/engine/__tests__/web-chat-adapter.spec.ts
git commit -m "[S211.X] feat: add WebChatChannelAdapter with generative-ui serialization"
Task 12: Register Services in Engine Module
Files:
Modify:
APIs/backend-vitto-api/src/app/engine/engine.module.tsStep 1: Read engine.module.ts to find the providers array
Read APIs/backend-vitto-api/src/app/engine/engine.module.ts — find the providers: [...] array.
- Step 2: Add imports and providers
Add at the top:
import { AdapterRegistryService } from './adapters/adapter-registry.service';
import { WhatsAppChannelAdapter } from './adapters/whatsapp-channel.adapter';
import { WebChatChannelAdapter } from './adapters/web-chat-channel.adapter';
import { RichMessageComposerService } from './rich-messages/rich-message-composer.service';
Add to the providers array:
AdapterRegistryService,
WhatsAppChannelAdapter,
WebChatChannelAdapter,
RichMessageComposerService,
- Step 3: Verify the app still compiles
Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -20
Expected: No new errors
- Step 4: Commit
git add APIs/backend-vitto-api/src/app/engine/engine.module.ts
git commit -m "[S211.X] feat: register rich messaging services in EngineModule"
Task 13: Wire Rich Messaging into ChannelProcessingService
Files:
- Modify:
APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts
This is the critical integration point. After the agent loop produces text output, the composer analyzes it, and the adapter renders the rich message for the specific channel.
Integration point: Inside executeUnifiedAgentLoop(), after line ~291 (await this.routeTaskResponse(task, sseSubject)) — but BEFORE routing, we intercept the output to compose a RichMessage and render it.
- Step 1: Read channel-processing.service.ts again to find exact injection point
Read lines around the existing quick reply button logic (lines 305-316) and response routing (line 291).
- Step 2: Add injections to the constructor
Add to constructor parameters (with @Optional() for backward-compatible DI):
@Optional() private readonly richComposer?: RichMessageComposerService,
@Optional() private readonly adapterRegistry?: AdapterRegistryService,
Add imports at the top:
import { RichMessageComposerService } from './rich-messages/rich-message-composer.service';
import { AdapterRegistryService } from './adapters/adapter-registry.service';
- Step 3: Add adapter auto-registration via factory provider
Adapters register themselves via AdapterRegistryService.onModuleInit() using a CHANNEL_ADAPTERS injection token.
NOTE: This requires RE-OPENING adapter-registry.service.ts (already committed in Task 3). Add a constructor and OnModuleInit implementation:
// In adapter-registry.service.ts, add these imports:
import { Inject, OnModuleInit } from '@nestjs/common';
import { ChannelAdapter } from './channel-adapter.interface';
// Change the class declaration to implement OnModuleInit:
export class AdapterRegistryService implements OnModuleInit {
// ...existing fields...
constructor(
@Optional() @Inject('CHANNEL_ADAPTERS') private readonly injectedAdapters?: ChannelAdapter[],
) {}
onModuleInit() {
if (this.injectedAdapters) {
for (const adapter of this.injectedAdapters) {
this.register(adapter);
}
}
}
// ...existing methods stay unchanged...
}
And in engine.module.ts add a factory provider:
{
provide: 'CHANNEL_ADAPTERS',
useFactory: (wa: WhatsAppChannelAdapter, web: WebChatChannelAdapter) => [wa, web],
inject: [WhatsAppChannelAdapter, WebChatChannelAdapter],
},
- Step 4: Add rich message composition in executeUnifiedAgentLoop()
After the agent loop result is captured (line ~281, task.cost.iterations = result.iterations;) and BEFORE await this.routeTaskResponse(task, sseSubject), add:
// S211: Compose rich message and render via channel adapter
const richOutput = await this.composeAndRenderRichMessage(
result.output, result.toolCalls.map((t: { toolName: string }) => t.toolName),
channel, ctx,
);
if (richOutput) {
// Replace the raw output with adapter-rendered output
// For web: generative-ui blocks prepended. For WhatsApp: adapter already sent.
if (richOutput.rendered?.text) {
result.output = richOutput.rendered.text;
}
if (richOutput.rendered?.alreadySent) {
// Skip the main text send in routeTaskResponse — adapter already sent the rich version
// But still update the phase output for SSE/persistence
task.phases[task.phases.length - 1].output = result.output;
}
}
Add the helper method:
/**
* S211: Compose a RichMessage from agent output and render via channel adapter.
* Returns null if no rich rendering is needed (plain text or no adapter).
*/
private async composeAndRenderRichMessage(
output: string,
toolCallNames: string[],
channel: string,
ctx: Record<string, unknown>,
): Promise<{ rendered?: { text?: string; alreadySent?: boolean } } | null> {
if (!this.richComposer || !this.adapterRegistry) return null;
const adapter = this.adapterRegistry.getAdapter(channel);
if (!adapter) return null;
const richMessage = this.richComposer.compose(output, toolCallNames, {
platformUrl: this.config.get<string>('PLATFORM_URL', 'https://app.gograb.space'),
});
// text messages don't need adapter rendering
if (!adapter.supports(richMessage)) return null;
try {
return await adapter.render(richMessage, {
channelId: ctx.channelId as string,
channelType: channel,
userId: ctx.userId as string,
spaceId: ctx.spaceId as string,
metadata: ctx.metadata as Record<string, unknown>,
});
} catch (err) {
this.logger.warn(`Rich message render failed: ${(err as Error).message}`);
return null;
}
}
- Step 5: Adjust existing quick reply logic
The existing quick reply code at lines 306-316 sends WhatsApp quick replies for ALL responses. With the new system, if the RichMessageComposerService already produced a quick_reply type and the WhatsApp adapter already sent it, we should skip the old path. Add a guard:
// Quick reply buttons — skip if rich adapter already sent a rich message
if (channel === 'whatsapp' && ctx.channelId && result.output && !richOutput?.rendered?.alreadySent) {
// ... existing quick reply logic unchanged ...
}
- Step 6: Write integration test for composeAndRenderRichMessage
Create: APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-integration.spec.ts
// APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-integration.spec.ts
import { RichMessageComposerService } from '../rich-messages/rich-message-composer.service';
import { AdapterRegistryService } from '../adapters/adapter-registry.service';
import { WebChatChannelAdapter } from '../adapters/web-chat-channel.adapter';
/**
* S211: Integration test — verifies the full compose → adapt flow.
* Uses the real ComposerService and WebChatAdapter (no mocks).
*/
describe('Rich Message Integration: Compose → Adapt', () => {
let composer: RichMessageComposerService;
let registry: AdapterRegistryService;
beforeEach(() => {
composer = new RichMessageComposerService();
registry = new AdapterRegistryService();
registry.register(new WebChatChannelAdapter());
});
it('should compose a list picker from numbered items and render as generative-ui', async () => {
const output = `Here are your options:\n\n1. Players — Manage rosters\n2. Matches — Schedule games\n3. Training — Log sessions`;
const richMessage = composer.compose(output, []);
expect(richMessage.type).toBe('list_picker');
const adapter = registry.getAdapter('web');
expect(adapter).toBeDefined();
expect(adapter!.supports(richMessage)).toBe(true);
const result = await adapter!.render(richMessage, { channelId: 'test', channelType: 'web' });
expect(result.success).toBe(true);
expect(result.rendered?.text).toContain('"component": "rich-list-picker"');
expect(result.rendered?.text).toContain('Players');
});
it('should compose a quick reply from a question and render as generative-ui', async () => {
const output = 'I found the configuration file. Would you like me to update it?';
const richMessage = composer.compose(output, []);
expect(richMessage.type).toBe('quick_reply');
const adapter = registry.getAdapter('web');
const result = await adapter!.render(richMessage, { channelId: 'test', channelType: 'web' });
expect(result.success).toBe(true);
expect(result.rendered?.text).toContain('"component": "rich-quick-reply"');
});
it('should pass through plain text when no adapter exists for channel', async () => {
const output = 'Hello there!'; // Short — will be TextMessage
const richMessage = composer.compose(output, []);
expect(richMessage.type).toBe('text');
const adapter = registry.getAdapter('web');
// text type is NOT supported by web adapter
expect(adapter!.supports(richMessage)).toBe(false);
});
it('should return null for channels with no registered adapter', () => {
const adapter = registry.getAdapter('slack');
expect(adapter).toBeUndefined();
});
});
- Step 7: Run integration test
Run: yarn nx test backend-vitto-api --testPathPattern="rich-message-integration" 2>&1 | tail -15
Expected: PASS — 4 tests passing
- Step 8: Verify all existing tests still pass
Run: yarn nx test backend-vitto-api --testPathPattern="channel-processing|rich-message|adapter" 2>&1 | tail -20
Expected: All tests pass
- Step 9: Commit
git add APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts \
APIs/backend-vitto-api/src/app/engine/adapters/adapter-registry.service.ts \
APIs/backend-vitto-api/src/app/engine/engine.module.ts \
APIs/backend-vitto-api/src/app/engine/__tests__/rich-message-integration.spec.ts
git commit -m "[S211.X] feat: wire rich message composition into ChannelProcessingService"
Task 14: End-to-End Verification
This is not a code task — it's a manual verification checklist.
- Step 1: Verify backend compiles
Run: yarn nx build backend-vitto-api 2>&1 | tail -10
Expected: Build succeeds
- Step 2: Verify all new tests pass
Run: yarn nx test backend-vitto-api --testPathPattern="rich-message|adapter" 2>&1 | tail -20
Expected: All tests pass (adapter-registry, whatsapp-adapter, web-chat-adapter, rich-message-composer)
- Step 3: Verify frontend compiles
Run: npx tsc --noEmit --project libs/libs-ai/vitto-chat/tsconfig.lib.json 2>&1 | head -20
Expected: No errors from new components
- Step 4: Log architecture decision via MCP
log_decision({
type: 'architecture',
question: 'How should rich messages be rendered across WhatsApp and Web channels?',
chosen: 'Discriminated union RichMessage types → RichMessageComposerService (heuristic analysis) → AdapterRegistryService → per-channel ChannelAdapter. WhatsApp adapter calls Twilio Content API. Web adapter serializes to generative-ui JSON blocks parsed by existing MessageBodyComponent.',
reasoning: 'Leverages existing TwilioContentApiService (already supports list pickers/cards/carousels) and existing GenerativeUIComponentCatalog (already renders dynamic components from JSON). New code is minimal — just the glue layer between agent output and channel-specific rendering.',
confidence: 0.92,
})
- Step 5: Final commit with all files
Verify nothing is unstaged:
git status
If any files were missed, stage and commit them with the appropriate message.
Chunk 5: Fix Escalation Alert Spam (URGENT Production Bug)
The escalation cron in ProactiveMonitorService.escalateUnresolvedCriticalAlerts() sends one WhatsApp message per unresolved alert document instead of grouping by root cause. It also re-escalates every 30 minutes with no dedup, and bypasses the dispatcher safety guards. Result: users get flooded with identical plain text walls.
Task 15: Fix Escalation Grouping and Dedup
Files:
- Modify:
APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.ts(lines 362-422) - Create:
APIs/backend-vitto-api/src/app/proactive/__tests__/escalation-grouping.spec.ts
Three bugs to fix:
- No grouping: sends 1 message per alert doc. Fix: group by
checkName, send 1 consolidated message per root cause - No escalation dedup: re-sends every 30 min. Fix: add
escalationCacheMap trackingcheckNamelast escalation time, skip if escalated within 6h - Bypasses dispatcher: calls
channelGateway.sendResponse()directly. Fix: route throughProactiveDispatcherService.dispatch()
- Step 1: Write the failing test
Create APIs/backend-vitto-api/src/app/proactive/__tests__/escalation-grouping.spec.ts that:
Creates 3 unresolved alert docs with the same
checkName: 'api-health'Calls
escalateUnresolvedCriticalAlerts()Asserts
dispatcher.dispatchcalled exactly ONCE (not 3 times)Asserts the message title includes the alert count "3"
Asserts
channelGateway.sendResponsewas NOT called directlyCalls
escalateUnresolvedCriticalAlerts()a second time and asserts dispatcher was NOT called again (dedup)Step 2: Run test to verify it fails
Run: yarn nx test backend-vitto-api --testPathPattern="escalation-grouping"
Expected: FAIL (current code sends 3 messages, calls gateway directly)
- Step 3: Rewrite
escalateUnresolvedCriticalAlerts()in proactive-monitor.service.ts
Replace lines 362-422 with:
- Add
private readonly escalationCache = new Map<string, number>()field - Group
unresolvedCriticalresults bycheckNameusing aMap<string, alert[]> - For each group: check
escalationCacheskip if escalated within 6h - Build ONE consolidated message with count, oldest alert details, suggested action
- Route through
this.dispatcher.dispatch()withtriggerName: 'escalation',severity: 'critical',bypassGuards: false - Set
escalationCache.set(checkName, Date.now())after dispatching
Key code for the grouping loop:
const grouped = new Map<string, typeof unresolvedCritical>();
for (const alert of unresolvedCritical) {
const key = alert.checkName;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(alert);
}
for (const [checkName, alerts] of grouped) {
const lastEscalated = this.escalationCache.get(checkName);
if (lastEscalated && Date.now() - lastEscalated < ESCALATION_THRESHOLD_MS) {
continue; // Already escalated recently
}
const count = alerts.length;
const oldest = alerts.reduce((a, b) =>
((a as any).createdAt < (b as any).createdAt ? a : b));
const title = count === 1
? `ESCALATION: Unresolved Critical Alert \u2014 ${checkName}`
: `ESCALATION: ${count} Unresolved Critical Alerts \u2014 ${checkName}`;
// Route through dispatcher (dedup + frequency cap + quiet hours)
if (this.dispatcher) {
const prefs = await this.notifPrefsModel.find({ enabled: true }).lean().exec();
for (const pref of prefs) {
await this.dispatcher.dispatch({
userId: pref.userId,
triggerName: 'escalation',
severity: 'critical',
title,
message: /* consolidated details */,
metadata: { checkName, alertCount: count, escalation: true },
bypassGuards: false,
}).catch(() => {});
}
}
this.escalationCache.set(checkName, Date.now());
}
- Step 4: Run test to verify it passes
Run: yarn nx test backend-vitto-api --testPathPattern="escalation-grouping"
Expected: PASS - 3 tests
- Step 5: Run existing proactive tests for regressions
Run: yarn nx test backend-vitto-api --testPathPattern="proactive"
Expected: All pass
- Step 6: Commit
git add APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.ts \
APIs/backend-vitto-api/src/app/proactive/__tests__/escalation-grouping.spec.ts
git commit -m "[S211.X] fix: group escalation alerts by root cause, add dedup, route through dispatcher"
Task 16: Format Dispatcher Alerts as Rich CardMessage
Files:
- Modify:
APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.ts
Upgrade the dispatcher to render alerts as CardMessage with action buttons when a channel adapter is available.
- Step 1: Add AdapterRegistryService injection and RichMessage imports
import { AdapterRegistryService } from '../engine/adapters/adapter-registry.service';
import { createCardMessage, RichMessage } from '../engine/rich-messages/rich-message.types';
// Constructor: add @Optional() private readonly adapterRegistry?: AdapterRegistryService
- Step 2: Add
composeRichAlert()method
private composeRichAlert(severity: string, title: string, message: string): RichMessage {
const emoji = severity === 'critical' ? '\u{1F6A8}' : severity === 'warning' ? '\u{26A0}\u{FE0F}' : '\u{2139}\u{FE0F}';
return createCardMessage(
`${emoji} ${title}`,
[
{ type: 'quick_reply', title: 'Acknowledge', id: 'ack_alert' },
{ type: 'quick_reply', title: 'Mute 1h', id: 'mute_1h' },
],
{ body: message.substring(0, 300), subtitle: severity.toUpperCase() },
);
}
- Step 3: Use adapter in delivery loop for rich rendering with plain text fallback
In the channel delivery loop, try adapter first, fall back to plain text:
let sent = false;
if (this.adapterRegistry) {
const adapter = this.adapterRegistry.getAdapter(channel.type);
if (adapter) {
const richMsg = this.composeRichAlert(severity, title, message);
if (adapter.supports(richMsg)) {
const result = await adapter.render(richMsg, {
channelId: channel.id, channelType: channel.type, userId,
});
sent = result.success && !!result.rendered?.alreadySent;
}
}
}
if (!sent) {
await this.channelGateway.sendResponse({ /* existing plain text path */ });
}
- Step 4: Commit
git add APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.ts
git commit -m "[S211.X] feat: format proactive alerts as rich CardMessage with action buttons"
Summary — What This Plan Produces
| Deliverable | Files | Tests |
|---|---|---|
| RichMessage type system (8 types + factory helpers) | 1 file | Type-safe, tested via composer tests |
| ChannelAdapter interface | 1 file | Interface only |
| AdapterRegistryService | 1 file | 4 tests |
| WhatsAppChannelAdapter | 1 file | 6 tests |
| WebChatChannelAdapter | 1 file | 7 tests |
| RichMessageComposerService | 1 file | 7 tests |
| 5 Angular generative-ui components | 5 files | Compile-verified, catalog-registered |
| Catalog seed registration | 1 modified file | — |
| Integration test (compose → adapt flow) | 1 file | 4 tests |
| ChannelProcessingService wiring | 1 modified file | Existing tests unbroken |
| Engine module registration | 1 modified file | — |
| FIX: Escalation alert grouping + dedup | 1 modified + 1 test file | 3 tests |
| FIX: Dispatcher rich card formatting | 1 modified file | — |
| Total | 15 new + 5 modified | 31 new tests |
What This Plan Does NOT Cover (Deferred to Later Sub-Sessions)
- MediaMessage/LocationMessage adapter handling — Types are defined but no adapter renders them yet (both fall back to plain text). Factory helpers
createMediaMessage()andcreateLocationMessage()are also deferred. These are low-priority because media is already handled by existingsendWhatsAppMedia()and location is rarely used. - Slack/Discord adapters — Block Kit and Embeds conversion (T2.3 from handoff doc)
- Phase 2: Personality & Expression — Stickers, mood detection, adaptive emoji
- Phase 3: WhatsApp Flows — Multi-screen forms (requires WhatsApp Cloud API credentials)
- LLM structured output — Having the LLM directly output RichMessage JSON instead of heuristic parsing (potential T2.1 upgrade)
- User reply routing — Handling when a user taps a list picker item and the reply needs special routing